feat(adminui): editable Modbus tag list via CollectionEditor
This commit is contained in:
+81
-17
@@ -273,16 +273,44 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@* Tags — read-only JSON view *@
|
<CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
|
||||||
<section class="panel rise mt-3" style="animation-delay:.18s">
|
NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
|
||||||
<div class="panel-head">Tags</div>
|
Validate="ModbusTagRow.ValidateRow">
|
||||||
<div style="padding:1rem">
|
<HeaderTemplate>
|
||||||
<p class="form-text mb-2">
|
<tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
|
||||||
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.
|
</HeaderTemplate>
|
||||||
</p>
|
<RowTemplate Context="t">
|
||||||
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre>
|
<td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
|
||||||
</div>
|
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
|
||||||
</section>
|
</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" />
|
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||||
</DriverFormShell>
|
</DriverFormShell>
|
||||||
@@ -318,9 +346,8 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
// Held separately because Tags is a collection — rendered as read-only JSON.
|
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
|
||||||
private IReadOnlyList<ModbusTagDefinition> _tags = [];
|
private List<ModbusTagRow> _tags = [];
|
||||||
private string _tagsJson = "[]";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -360,10 +387,9 @@ else
|
|||||||
_form = FormModel.FromOptions(opts);
|
_form = FormModel.FromOptions(opts);
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||||
_form.RowVersion = _existing.RowVersion;
|
_form.RowVersion = _existing.RowVersion;
|
||||||
_tags = opts.Tags;
|
_tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
|
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +398,7 @@ else
|
|||||||
_busy = true; _error = null;
|
_busy = true; _error = null;
|
||||||
try
|
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();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
if (IsNew)
|
if (IsNew)
|
||||||
{
|
{
|
||||||
@@ -443,7 +469,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
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)
|
private static ModbusDriverOptions? TryDeserialize(string json)
|
||||||
{
|
{
|
||||||
@@ -451,6 +477,44 @@ else
|
|||||||
catch { return null; }
|
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.
|
// 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.
|
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
|
||||||
public sealed class FormModel
|
public sealed class FormModel
|
||||||
|
|||||||
+47
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -14,6 +15,12 @@ public sealed class ModbusDriverPageFormSerializationTests
|
|||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions TestJsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -104,4 +111,44 @@ public sealed class ModbusDriverPageFormSerializationTests
|
|||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user