feat(adminui): editable S7 tag list via CollectionEditor
This commit is contained in:
+99
-50
@@ -145,23 +145,38 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@* Tags — read-only JSON view *@
|
@* Tags *@
|
||||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
<CollectionEditor TRow="S7TagRow" Items="_tags" Title="Tags" ItemNoun="tag"
|
||||||
<div class="panel-head">Tags</div>
|
AnimationDelay=".11s"
|
||||||
<div style="padding:1rem">
|
NewRow="@(() => new S7TagRow())" Clone="@(r => r.Clone())"
|
||||||
<div class="form-text mb-2">
|
Validate="S7TagRow.ValidateRow">
|
||||||
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.
|
<HeaderTemplate>
|
||||||
|
<tr><th>Name</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
|
||||||
|
</HeaderTemplate>
|
||||||
|
<RowTemplate Context="t">
|
||||||
|
<td class="mono">@t.Name</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-6"><label class="form-label">Address</label>
|
||||||
|
<input class="form-control form-control-sm mono" @bind="t.Address"
|
||||||
|
placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" /></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<S7DataType>()) { <option value="@e">@e</option> }
|
||||||
|
</select></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">String length</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" />
|
||||||
|
<div class="form-text">Only for String type. Max 254.</div></div>
|
||||||
|
<div class="col-md-3"><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>
|
</div>
|
||||||
@if (_form.TagsJson is not null)
|
</EditTemplate>
|
||||||
{
|
</CollectionEditor>
|
||||||
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-muted"><em>No tags configured.</em></p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||||
</DriverFormShell>
|
</DriverFormShell>
|
||||||
@@ -196,6 +211,9 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
|
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
|
||||||
|
private List<S7TagRow> _tags = [];
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
@@ -226,6 +244,7 @@ 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.Select(S7TagRow.FromDefinition).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
@@ -236,7 +255,7 @@ else
|
|||||||
_busy = true; _error = null;
|
_busy = true; _error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var opts = _form.ToOptions();
|
var opts = _form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList());
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
if (IsNew)
|
if (IsNew)
|
||||||
@@ -307,7 +326,8 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
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)
|
private static S7DriverOptions? TryDeserialize(string json)
|
||||||
{
|
{
|
||||||
@@ -315,6 +335,53 @@ else
|
|||||||
catch { return null; }
|
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<S7TagRow> 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
|
public sealed class FormModel
|
||||||
{
|
{
|
||||||
// Connection
|
// Connection
|
||||||
@@ -331,43 +398,25 @@ else
|
|||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
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<S7TagDefinition> _tags = [];
|
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
public string? ResilienceConfig { get; set; }
|
public string? ResilienceConfig { get; set; }
|
||||||
public byte[] RowVersion { 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
|
Host = o.Host,
|
||||||
: System.Text.Json.JsonSerializer.Serialize(o.Tags,
|
Port = o.Port,
|
||||||
new System.Text.Json.JsonSerializerOptions
|
CpuType = o.CpuType,
|
||||||
{
|
Rack = o.Rack,
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
Slot = o.Slot,
|
||||||
WriteIndented = true,
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
});
|
ProbeEnabled = o.Probe.Enabled,
|
||||||
return new FormModel
|
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||||
{
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
Host = o.Host,
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public S7DriverOptions ToOptions() => new()
|
public S7DriverOptions ToOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||||
{
|
{
|
||||||
Host = Host,
|
Host = Host,
|
||||||
Port = Port,
|
Port = Port,
|
||||||
@@ -382,7 +431,7 @@ else
|
|||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
},
|
},
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
Tags = _tags,
|
Tags = tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.S7;
|
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
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
|
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
.S7DriverPage.FormModel.FromOptions(opts);
|
.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.Host.ShouldBe("192.168.1.50");
|
||||||
roundTripped.Port.ShouldBe(102);
|
roundTripped.Port.ShouldBe(102);
|
||||||
@@ -117,4 +121,94 @@ public sealed class S7DriverPageFormSerializationTests
|
|||||||
roundTripped.Tags[1].Name.ShouldBe("Status");
|
roundTripped.Tags[1].Name.ShouldBe("Status");
|
||||||
roundTripped.Tags[1].Writable.ShouldBeFalse();
|
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>
|
||||||
|
{
|
||||||
|
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<S7DriverOptions>(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user