diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor index 3cccc933..33b25652 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor @@ -145,23 +145,38 @@ else - @* Tags — read-only JSON view *@ -
-
Tags
-
-
- Tag list editor coming in a follow-up phase. To add/remove tags, edit the JSON directly in the raw driver config via the generic editor, or deploy via the import tooling. + @* Tags *@ + + + NameAddressTypeWritable + + + @t.Name@t.Address + @t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+ +
Only for String type. Max 254.
+
+ +
- @if (_form.TagsJson is not null) - { -
@_form.TagsJson
- } - else - { -

No tags configured.

- } -
-
+ + @@ -196,6 +211,9 @@ else private void OnAddressPicked(string address) => _pickedAddress = address; + // Held separately because Tags is a collection — edited via the CollectionEditor modal. + private List _tags = []; + protected override async Task OnInitializedAsync() { await using var db = await DbFactory.CreateDbContextAsync(); @@ -226,6 +244,7 @@ else _form = FormModel.FromOptions(opts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; + _tags = opts.Tags.Select(S7TagRow.FromDefinition).ToList(); } } _loaded = true; @@ -236,7 +255,7 @@ else _busy = true; _error = null; try { - var opts = _form.ToOptions(); + var opts = _form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()); var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) @@ -307,7 +326,8 @@ else } private string SerializeCurrentConfig() - => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + => System.Text.Json.JsonSerializer.Serialize( + _form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts); private static S7DriverOptions? TryDeserialize(string json) { @@ -315,6 +335,53 @@ else catch { return null; } } + // Mutable VM for the modal editor — S7TagDefinition is an immutable record. + public sealed class S7TagRow + { + public string Name { get; set; } = ""; + public string Address { get; set; } = ""; + public S7DataType DataType { get; set; } = S7DataType.Int16; + public bool Writable { get; set; } = true; + public int StringLength { get; set; } = 254; + + // Original record (null for newly-added rows). Preserves fields the editor doesn't expose + // (WriteIdempotent) across a load→save. + private S7TagDefinition? _source; + + public S7TagRow Clone() => (S7TagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static S7TagRow FromDefinition(S7TagDefinition d) => new() + { + Name = d.Name, Address = d.Address, DataType = d.DataType, + Writable = d.Writable, StringLength = d.StringLength, + _source = d, + }; + + public S7TagDefinition ToDefinition() + { + var baseDef = _source ?? new S7TagDefinition(Name.Trim(), Address.Trim(), DataType); + return baseDef with + { + Name = Name.Trim(), + Address = Address.Trim(), + DataType = DataType, + Writable = Writable, + StringLength = StringLength, + }; + } + + public static string? ValidateRow(S7TagRow 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. + // Collection (Tags) is kept on the component (_tags) and passed in on ToOptions(). public sealed class FormModel { // Connection @@ -331,43 +398,25 @@ else public int ProbeTimeoutSeconds { get; set; } = 2; public int AdminProbeTimeoutSeconds { get; set; } = 5; - // Tags JSON view (read-only) - public string? TagsJson { get; set; } - - // Preserved originals (round-tripped unchanged from original options) - private IReadOnlyList _tags = []; - // Common public string? ResilienceConfig { get; set; } public byte[] RowVersion { get; set; } = []; - public static FormModel FromOptions(S7DriverOptions o) + public static FormModel FromOptions(S7DriverOptions o) => new() { - string? tagsJson = o.Tags.Count == 0 ? null - : System.Text.Json.JsonSerializer.Serialize(o.Tags, - new System.Text.Json.JsonSerializerOptions - { - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, - WriteIndented = true, - }); - return new FormModel - { - Host = o.Host, - Port = o.Port, - CpuType = o.CpuType, - Rack = o.Rack, - Slot = o.Slot, - TimeoutSeconds = (int)o.Timeout.TotalSeconds, - ProbeEnabled = o.Probe.Enabled, - ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, - ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, - AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, - TagsJson = tagsJson, - _tags = o.Tags, - }; - } + Host = o.Host, + Port = o.Port, + CpuType = o.CpuType, + Rack = o.Rack, + Slot = o.Slot, + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; - public S7DriverOptions ToOptions() => new() + public S7DriverOptions ToOptions(IReadOnlyList tags) => new() { Host = Host, Port = Port, @@ -382,7 +431,7 @@ else Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), }, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, - Tags = _tags, + Tags = tags, }; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs index 4f446670..2e50a9d9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.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.S7; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -95,7 +96,10 @@ public sealed class S7DriverPageFormSerializationTests var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers .S7DriverPage.FormModel.FromOptions(opts); - var roundTripped = form.ToOptions(); + var tagRows = opts.Tags + .Select(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers.S7DriverPage.S7TagRow.FromDefinition) + .ToList(); + var roundTripped = form.ToOptions(tagRows.Select(r => r.ToDefinition()).ToList()); roundTripped.Host.ShouldBe("192.168.1.50"); roundTripped.Port.ShouldBe(102); @@ -117,4 +121,94 @@ public sealed class S7DriverPageFormSerializationTests roundTripped.Tags[1].Name.ShouldBe("Status"); roundTripped.Tags[1].Writable.ShouldBeFalse(); } + + [Fact] + public void S7TagRow_RoundTrip_PreservesEditableFields() + { + var def = new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true, StringLength: 80); + + var row = S7DriverPage.S7TagRow.FromDefinition(def); + var back = row.ToDefinition(); + + back.Name.ShouldBe("Speed"); + back.Address.ShouldBe("DB1.DBD0"); + back.DataType.ShouldBe(S7DataType.Float32); + back.Writable.ShouldBeTrue(); + back.StringLength.ShouldBe(80); + } + + [Fact] + public void S7TagRow_CarriesThroughUneditedFields() + { + // WriteIdempotent is not exposed by the editor; it must survive FromDefinition→edit→ToDefinition. + var def = new S7TagDefinition("Setpoint", "DB10.DBD0", S7DataType.Float32, Writable: true, WriteIdempotent: true); + + var row = S7DriverPage.S7TagRow.FromDefinition(def); + row.Name = "SetpointRenamed"; + row.Writable = false; + var back = row.ToDefinition(); + + back.Name.ShouldBe("SetpointRenamed"); + back.Writable.ShouldBeFalse(); + // Un-edited field carried through via _source. + back.WriteIdempotent.ShouldBeTrue(); + } + + [Fact] + public void S7TagRow_ValidateRow_RejectsDuplicateNames() + { + var all = new List + { + S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32)), + S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Status", "DB1.DBW4", S7DataType.Int16)), + }; + + // Editing index 1 to a name that case-insensitively collides with index 0. + var edited = all[1].Clone(); + edited.Name = "speed"; + S7DriverPage.S7TagRow.ValidateRow(edited, all, editIndex: 1) + .ShouldBe("Duplicate tag name 'speed'."); + + // Required-name guard. + var blank = new S7DriverPage.S7TagRow(); + S7DriverPage.S7TagRow.ValidateRow(blank, all, editIndex: null) + .ShouldBe("Name is required."); + + // Unique name passes. + var ok = all[1].Clone(); + ok.Name = "Torque"; + S7DriverPage.S7TagRow.ValidateRow(ok, all, editIndex: 1).ShouldBeNull(); + } + + [Fact] + public void TagList_SerializeRoundTrip_PreservesTags() + { + var opts = new S7DriverOptions + { + Host = "10.1.1.1", + Tags = + [ + new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true), + new S7TagDefinition("Name", "DB2.DBB0", S7DataType.String, Writable: false, StringLength: 32), + ], + }; + + var optsSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + var json = JsonSerializer.Serialize(opts, optsSkip); + var back = JsonSerializer.Deserialize(json, optsSkip); + + back.ShouldNotBeNull(); + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("Speed"); + back.Tags[0].Address.ShouldBe("DB1.DBD0"); + back.Tags[0].DataType.ShouldBe(S7DataType.Float32); + back.Tags[0].Writable.ShouldBeTrue(); + back.Tags[1].Name.ShouldBe("Name"); + back.Tags[1].DataType.ShouldBe(S7DataType.String); + back.Tags[1].StringLength.ShouldBe(32); + back.Tags[1].Writable.ShouldBeFalse(); + } }