feat(adminui): editable Modbus tag list via CollectionEditor

This commit is contained in:
Joseph Doherty
2026-05-29 09:14:06 -04:00
parent c3fec1426c
commit c4116e54c9
2 changed files with 128 additions and 17 deletions
@@ -273,16 +273,44 @@ else
</div>
</section>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<p class="form-text mb-2">
Tag list — full list-editor coming in a follow-up phase. Edit tags via the Tag editor pages or by exporting/importing the driver config JSON.
</p>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre>
</div>
</section>
<CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
Validate="ModbusTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-3"><label class="form-label">Region</label>
<select class="form-select form-select-sm" @bind="t.Region">
@foreach (var e in Enum.GetValues<ModbusRegion>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" @bind="t.Address" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<ModbusDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" @bind="t.ByteOrder">
@foreach (var e in Enum.GetValues<ModbusByteOrder>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" @bind="t.BitIndex" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" /></div>
<div class="col-md-2"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -318,9 +346,8 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — rendered as read-only JSON.
private IReadOnlyList<ModbusTagDefinition> _tags = [];
private string _tagsJson = "[]";
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<ModbusTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
@@ -360,10 +387,9 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags;
_tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();
}
}
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true;
}
@@ -372,7 +398,7 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts);
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -443,7 +469,7 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json)
{
@@ -451,6 +477,44 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — ModbusTagDefinition is an immutable record.
public sealed class ModbusTagRow
{
public string Name { get; set; } = "";
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
public int Address { get; set; }
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
public bool Writable { get; set; } = true;
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
public int BitIndex { get; set; }
public int StringLength { get; set; }
public bool WriteIdempotent { get; set; }
public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone();
public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new()
{
Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex,
StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent,
};
public ModbusTagDefinition ToDefinition() => new(
Name: Name.Trim(), Region: Region, Address: (ushort)Math.Clamp(Address, 0, 65535),
DataType: DataType, Writable: Writable, ByteOrder: ByteOrder,
BitIndex: (byte)Math.Clamp(BitIndex, 0, 255), StringLength: (ushort)Math.Clamp(StringLength, 0, 65535),
WriteIdempotent: WriteIdempotent);
public static string? ValidateRow(ModbusTagRow row, IReadOnlyList<ModbusTagRow> 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 scalars exposed as settable properties so Blazor @bind-Value works.
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
public sealed class FormModel
@@ -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.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -14,6 +15,12 @@ public sealed class ModbusDriverPageFormSerializationTests
WriteIndented = false,
};
private static readonly JsonSerializerOptions TestJsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -104,4 +111,44 @@ public sealed class ModbusDriverPageFormSerializationTests
back.ShouldNotBeNull();
back.ProbeTimeoutSeconds.ShouldBe(10);
}
[Fact]
public void TagRow_round_trips_through_definition()
{
var row = new ModbusDriverPage.ModbusTagRow
{
Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegisters, Address = 40001,
DataType = ModbusDataType.Int16, Writable = true,
};
var def = row.ToDefinition();
var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def);
back.Name.ShouldBe("Pump1_Speed");
back.Address.ShouldBe(40001);
back.DataType.ShouldBe(ModbusDataType.Int16);
back.Writable.ShouldBeTrue();
}
[Fact]
public void Tag_list_survives_options_serialize_round_trip()
{
var tags = new List<ModbusTagDefinition>
{
new("A", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16),
new("B", ModbusRegion.Coils, 2, ModbusDataType.Bool),
};
var opts = new ModbusDriverPage.FormModel().ToOptions(tags);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<ModbusDriverOptions>(json, TestJsonOpts)!;
back.Tags.Count.ShouldBe(2);
back.Tags[0].Name.ShouldBe("A");
}
[Fact]
public void ValidateRow_rejects_duplicate_name()
{
var rows = new List<ModbusDriverPage.ModbusTagRow> { new() { Name = "A" } };
ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null)
.ShouldNotBeNull();
}
}