diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor index 3b3ee667..bf566480 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -146,27 +146,65 @@ else - @* Devices — read-only JSON view *@ -
-
Devices
-
-

- Device list (host addresses, PLC family, packing overrides) — full list-editor coming in a follow-up phase. Each entry: { "hostAddress": "ab://gateway/1,0", "plcFamily": "ControlLogix" }. -

-
@_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. -

-
@_tagsJson
-
-
+ @* Tags *@ + + + NameDeviceTag pathTypeWritable + + + @t.Name@t.DeviceHostAddress + @t.TagPath@t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
@@ -202,11 +240,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() { @@ -246,12 +282,10 @@ else _form = FormModel.FromOptions(opts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; - _devices = opts.Devices; - _tags = opts.Tags; + _devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList(); + _tags = opts.Tags.Select(AbCipTagRow.FromDefinition).ToList(); } } - _devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts); - _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); _loaded = true; } @@ -260,7 +294,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) { @@ -331,7 +369,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 AbCipDriverOptions? TryDeserialize(string json) { @@ -339,6 +381,91 @@ else catch { return null; } } + // Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record. + public sealed class AbCipDeviceRow + { + public string HostAddress { get; set; } = ""; + public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix; + public string? DeviceName { get; set; } + + // Original record (null for newly-added rows). Preserves fields the editor doesn't expose + // (AllowPacking, ConnectionSize) across a load→save. + private AbCipDeviceOptions? _source; + + public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new() + { + HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName, + _source = d, + }; + + public AbCipDeviceOptions ToDefinition() + { + var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily); + return baseDef with + { + HostAddress = HostAddress.Trim(), + PlcFamily = PlcFamily, + DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(), + }; + } + + public static string? ValidateRow(AbCipDeviceRow 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 — AbCipTagDefinition is an immutable record. + public sealed class AbCipTagRow + { + public string Name { get; set; } = ""; + public string DeviceHostAddress { get; set; } = ""; + public string TagPath { get; set; } = ""; + public AbCipDataType DataType { get; set; } = AbCipDataType.DInt; + public bool Writable { get; set; } = true; + + // Original record (null for newly-added rows). Preserves fields the editor doesn't expose + // (WriteIdempotent, Members, SafetyTag) across a load→save. + private AbCipTagDefinition? _source; + + public AbCipTagRow Clone() => (AbCipTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static AbCipTagRow FromDefinition(AbCipTagDefinition d) => new() + { + Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, TagPath = d.TagPath, + DataType = d.DataType, Writable = d.Writable, + _source = d, + }; + + public AbCipTagDefinition ToDefinition() + { + var baseDef = _source ?? new AbCipTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), TagPath.Trim(), DataType); + return baseDef with + { + Name = Name.Trim(), + DeviceHostAddress = DeviceHostAddress.Trim(), + TagPath = TagPath.Trim(), + DataType = DataType, + Writable = Writable, + }; + } + + public static string? ValidateRow(AbCipTagRow 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/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 1bb34cee..060ca94a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.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.AbCip; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -14,6 +15,12 @@ public sealed class AbCipDriverPageFormSerializationTests WriteIndented = false, }; + private static readonly JsonSerializerOptions TestJsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + [Fact] public void RoundTrip_PreservesKnownFields() { @@ -78,4 +85,110 @@ public sealed class AbCipDriverPageFormSerializationTests back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(10); } + + [Fact] + public void DeviceRow_round_trips_through_definition() + { + var row = new AbCipDriverPage.AbCipDeviceRow + { + HostAddress = "ab://10.0.0.1/1,0", PlcFamily = AbCipPlcFamily.CompactLogix, DeviceName = "PLC-A", + }; + var def = row.ToDefinition(); + var back = AbCipDriverPage.AbCipDeviceRow.FromDefinition(def); + + back.HostAddress.ShouldBe("ab://10.0.0.1/1,0"); + back.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix); + back.DeviceName.ShouldBe("PLC-A"); + } + + [Fact] + public void DeviceRow_preserves_unedited_fields() + { + var original = new AbCipDeviceOptions( + "ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-A", + AllowPacking: true, ConnectionSize: 4002); + var row = AbCipDriverPage.AbCipDeviceRow.FromDefinition(original); + row.HostAddress = "ab://10.0.0.2/1,0"; + + var back = row.ToDefinition(); + back.HostAddress.ShouldBe("ab://10.0.0.2/1,0"); + back.AllowPacking.ShouldBe(true); + back.ConnectionSize.ShouldBe(4002); + } + + [Fact] + public void TagRow_round_trips_through_definition() + { + var row = new AbCipDriverPage.AbCipTagRow + { + Name = "Speed", DeviceHostAddress = "ab://10.0.0.1/1,0", TagPath = "Motor1.Speed", + DataType = AbCipDataType.Real, Writable = true, + }; + var def = row.ToDefinition(); + var back = AbCipDriverPage.AbCipTagRow.FromDefinition(def); + + back.Name.ShouldBe("Speed"); + back.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0"); + back.TagPath.ShouldBe("Motor1.Speed"); + back.DataType.ShouldBe(AbCipDataType.Real); + back.Writable.ShouldBeTrue(); + } + + [Fact] + public void TagRow_preserves_unedited_fields() + { + var original = new AbCipTagDefinition( + "Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Structure, + Writable: true, WriteIdempotent: true, + Members: [new AbCipStructureMember("Sub", AbCipDataType.DInt)], + SafetyTag: true); + var row = AbCipDriverPage.AbCipTagRow.FromDefinition(original); + row.Name = "Renamed"; + + var back = row.ToDefinition(); + back.Name.ShouldBe("Renamed"); + back.WriteIdempotent.ShouldBeTrue(); + back.SafetyTag.ShouldBeTrue(); + back.Members.ShouldNotBeNull(); + back.Members!.Count.ShouldBe(1); + back.Members[0].Name.ShouldBe("Sub"); + } + + [Fact] + public void ValidateDeviceRow_rejects_duplicate_host() + { + var rows = new List { new() { HostAddress = "ab://10.0.0.1/1,0" } }; + AbCipDriverPage.AbCipDeviceRow.ValidateRow(new() { HostAddress = "ab://10.0.0.1/1,0" }, rows, null) + .ShouldNotBeNull(); + } + + [Fact] + public void ValidateTagRow_rejects_duplicate_name() + { + var rows = new List { new() { Name = "Speed" } }; + AbCipDriverPage.AbCipTagRow.ValidateRow(new() { Name = "Speed" }, rows, null) + .ShouldNotBeNull(); + } + + [Fact] + public void Device_and_tag_lists_survive_options_serialize_round_trip() + { + var devices = new List + { + new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"), + new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"), + }; + var tags = new List + { + new("Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Real), + new("Run", "ab://10.0.0.2/1,0", "Motor2.Run", AbCipDataType.Bool), + }; + var opts = new AbCipDriverPage.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("ab://10.0.0.1/1,0"); + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("Speed"); + } }