feat(mtconnect): typed AdminUI tag-config editor + browse round-trip (Task 18 Part A)
Registers the MTConnect typed tag editor in TagConfigEditorMap /
TagConfigValidator (keyed off DriverTypeNames.MTConnect, never a literal) and
adds the razor shell over Task 17's MTConnectTagConfigModel.
The shell stays trivial: the /probe-sourced mtCategory/mtType/mtSubType/units
row is rendered disabled + readonly with NO binding of any kind, and no
inferred-type hint is shown — MTConnectDataTypeInference.Infer() also keys on
the DataItem `representation`, which the tag-config model does not carry, so
calling it here would agree with the browse picker for ordinary items and
silently disagree for exactly the DATA_SET/TABLE/TIME_SERIES ones.
Also fixes the picker -> editor round trip, which was broken in both
directions:
- RawBrowseCommitMapper.BuildTagConfig had no MTConnect branch, so a committed
tag landed as the generic {"address": "<dataItemId>"} while the model read
only `fullName`. The data plane kept working (the driver accepts all three id
spellings), so the only symptom was an empty DataItem-id field in the editor
— and saving from that state stamped fullName:"" over the tag.
- MTConnectTagConfigModel.FromJson now accepts dataItemId/address as fallbacks
(fixing tags committed before this change) and ToJson normalises onto
`fullName`, dropping the aliases so the two spellings cannot drift.
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
@* Typed tag-config editor for an MTConnect Agent tag. A thin shell over MTConnectTagConfigModel — every
|
||||
rule (defaults, unknown-key preservation, the numeric-dataType trap) lives in that model, not here.
|
||||
|
||||
MTConnect is READ-ONLY (the driver is deliberately not IWritable), so unlike Modbus there is no
|
||||
"Writable" toggle to author.
|
||||
|
||||
The mtCategory/mtType/mtSubType/units row is /probe-sourced device-model metadata: rendered disabled +
|
||||
readonly with NO binding of any kind, so nothing an operator can do in this UI writes to it. The model
|
||||
still round-trips the values so a browse-committed stamp survives an edit of the data type.
|
||||
|
||||
NOTE: no inferred-type hint is shown. MTConnectDataTypeInference.Infer() also keys on the DataItem's
|
||||
`representation` (DATA_SET/TABLE demote to String even on a SAMPLE; TIME_SERIES is honoured only on a
|
||||
SAMPLE), and the tag-config model does not carry `representation`. Calling Infer() from here would
|
||||
therefore agree with the browse picker for ordinary items and silently DISAGREE for exactly the
|
||||
representation-driven ones — the failure that never shows up in the obvious test. The picker already
|
||||
stamped the inferred type into `dataType` on commit; this editor is the override surface for it. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="mtc-fullname">DataItem id</label>
|
||||
<input id="mtc-fullname" type="text" class="form-control form-control-sm mono"
|
||||
value="@_m.FullName" @onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? ""))"
|
||||
placeholder="Xact" />
|
||||
<div class="form-text">The Agent DataItem's <span class="mono">id</span> attribute — the driver's read/subscribe key.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="mtc-datatype">Data type</label>
|
||||
<select id="mtc-datatype" class="form-select form-select-sm" value="@_m.DataType"
|
||||
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.String)))">
|
||||
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
|
||||
</select>
|
||||
<div class="form-text">Overrides the type the browse picker inferred. MTConnect is text on the wire — a wrong numeric type reads as Bad quality.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Probe-sourced device-model metadata — display only. *@
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="mtc-category">Category</label>
|
||||
<input id="mtc-category" type="text" class="form-control form-control-sm mono"
|
||||
value="@Display(_m.MtCategory)" disabled readonly />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="mtc-type">Type</label>
|
||||
<input id="mtc-type" type="text" class="form-control form-control-sm mono"
|
||||
value="@Display(_m.MtType)" disabled readonly />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="mtc-subtype">Sub-type</label>
|
||||
<input id="mtc-subtype" type="text" class="form-control form-control-sm mono"
|
||||
value="@Display(_m.MtSubType)" disabled readonly />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="mtc-units">Units</label>
|
||||
<input id="mtc-units" type="text" class="form-control form-control-sm mono"
|
||||
value="@Display(_m.Units)" disabled readonly />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-text">From the Agent's <span class="mono">/probe</span> device model — not editable here. Re-pick the tag in the browser to refresh it.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Author's own notes — not consumed by the driver or the runtime. *@
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="mtc-device">Device (note)</label>
|
||||
<input id="mtc-device" type="text" class="form-control form-control-sm"
|
||||
value="@_m.MtDevice" @onchange="@(e => Update(() => _m.MtDevice = e.Value?.ToString()))" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="mtc-component">Component (note)</label>
|
||||
<input id="mtc-component" type="text" class="form-control form-control-sm"
|
||||
value="@_m.MtComponent" @onchange="@(e => Update(() => _m.MtComponent = e.Value?.ToString()))" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_m.Validate() is { } error)
|
||||
{
|
||||
<div class="text-danger small mt-2">@error</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
/// <summary>Two-way config callback.</summary>
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
/// <summary>DriverType of the selected driver — supplied by the hosting modal for browse-capable pickers.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private MTConnectTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = MTConnectTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
// Placeholder for absent probe metadata: an empty read-only box reads as "the field is broken".
|
||||
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value;
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,11 @@ public static class RawBrowseCommitMapper
|
||||
return new FocasTagConfigModel { Address = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.S7))
|
||||
return new S7TagConfigModel { Address = address }.ToJson();
|
||||
// MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed
|
||||
// editor's canonical spelling is fullName — committing under the generic key below would open
|
||||
// in that editor with an EMPTY id field and blank it on save.
|
||||
if (Is(driverType, DriverTypeNames.MTConnect))
|
||||
return new MTConnectTagConfigModel { FullName = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||
return WriteSingleKey("attributeRef", address);
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// </remarks>
|
||||
public sealed class MTConnectTagConfigModel
|
||||
{
|
||||
/// <summary>The MTConnect DataItem's <c>id</c> attribute — the driver's read/subscribe key. Required.</summary>
|
||||
/// <summary>The MTConnect DataItem's <c>id</c> attribute — the driver's read/subscribe key. Required.
|
||||
/// Loaded from <c>fullName</c>, or from the driver's alternate spellings <c>dataItemId</c> /
|
||||
/// <c>address</c> (see <see cref="IdKeys"/>); always saved as <c>fullName</c>.</summary>
|
||||
public string FullName { get; set; } = "";
|
||||
|
||||
/// <summary>Logical data type override for the DataItem's value. Defaults to <see cref="DriverDataType.String"/>
|
||||
@@ -65,6 +67,18 @@ public sealed class MTConnectTagConfigModel
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>
|
||||
/// The accepted spellings of the DataItem id, in precedence order, mirroring the driver's own
|
||||
/// <c>MTConnectRawTagConfigDto</c>: <c>fullName</c> (this model's canonical key),
|
||||
/// <c>dataItemId</c> (what an operator hand-authoring the blob reaches for), and <c>address</c>
|
||||
/// (what <c>RawBrowseCommitMapper</c> wrote for MTConnect before it grew a typed-editor branch).
|
||||
/// Reading only <c>fullName</c> made every already-committed tag open with an EMPTY id field and
|
||||
/// get blanked on save, while the data plane kept working — the symptom looked cosmetic.
|
||||
/// <see cref="ToJson"/> normalises onto <c>fullName</c> and drops the aliases, so the two
|
||||
/// spellings can never drift apart on a later edit.
|
||||
/// </summary>
|
||||
private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||
@@ -74,7 +88,7 @@ public sealed class MTConnectTagConfigModel
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new MTConnectTagConfigModel
|
||||
{
|
||||
FullName = TagConfigJson.GetString(o, "fullName") ?? "",
|
||||
FullName = ReadId(o),
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
|
||||
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
|
||||
MtType = TagConfigJson.GetString(o, "mtType"),
|
||||
@@ -94,6 +108,10 @@ public sealed class MTConnectTagConfigModel
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
// Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
|
||||
// two spellings drift on the next edit and the driver silently reads the stale one.
|
||||
TagConfigJson.Set(_bag, "dataItemId", null);
|
||||
TagConfigJson.Set(_bag, "address", null);
|
||||
TagConfigJson.Set(_bag, "fullName", FullName.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
|
||||
@@ -127,4 +145,19 @@ public sealed class MTConnectTagConfigModel
|
||||
// Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
|
||||
// persisting an empty string.
|
||||
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
// First non-blank id spelling wins (see IdKeys). A present-but-blank "fullName" must not shadow a
|
||||
// populated alias — that is exactly the state a save from the broken editor left behind.
|
||||
private static string ReadId(JsonObject o)
|
||||
{
|
||||
foreach (var key in IdKeys)
|
||||
{
|
||||
if (Blank(TagConfigJson.GetString(o, key)) is { } id)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class TagConfigEditorMap
|
||||
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
||||
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
||||
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class TagConfigValidator
|
||||
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
|
||||
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the browse-picker → typed-editor round trip for MTConnect, the driver's <b>primary</b>
|
||||
/// authoring path (a tag is committed from the universal browser, then confirmed/overridden in the
|
||||
/// typed editor).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The defect this guards: <see cref="RawBrowseCommitMapper.BuildTagConfig"/> had no MTConnect
|
||||
/// branch, so a committed tag landed as the generic <c>{"address": "<dataItemId>"}</c>
|
||||
/// while <see cref="MTConnectTagConfigModel"/> read only <c>fullName</c>. The data plane kept
|
||||
/// working (the driver accepts all three id spellings), so the ONLY symptom was an empty
|
||||
/// DataItem-id field in the editor — and saving from that state stamped <c>fullName: ""</c>
|
||||
/// over the tag. Razor is exactly what this repo cannot unit-test, so both halves of the fix
|
||||
/// are pinned here in plain C#.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both halves are asserted: the mapper now writes the canonical <c>fullName</c> (fixes new
|
||||
/// commits), and the model accepts <c>dataItemId</c>/<c>address</c> as fallbacks (fixes tags
|
||||
/// already committed before the fix).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MTConnectBrowseCommitRoundTripTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildTagConfig_writes_the_canonical_fullName_key_for_MTConnect()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "avail");
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["fullName"]!.GetValue<string>().ShouldBe("avail");
|
||||
o.ContainsKey("address").ShouldBeFalse(
|
||||
"the generic fallback key must not be used now that MTConnect has a typed editor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_browse_committed_tag_round_trips_through_the_typed_editor_model()
|
||||
{
|
||||
var committed = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "Xact");
|
||||
|
||||
var model = MTConnectTagConfigModel.FromJson(committed);
|
||||
|
||||
model.FullName.ShouldBe("Xact");
|
||||
model.Validate().ShouldBeNull("a browse-committed tag must open in the editor already valid");
|
||||
MTConnectTagConfigModel.FromJson(model.ToJson()).FullName.ShouldBe("Xact");
|
||||
}
|
||||
|
||||
// ---- legacy blobs committed before the mapper fix -------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("address")]
|
||||
[InlineData("dataItemId")]
|
||||
public void FromJson_accepts_the_drivers_alternate_id_spellings(string key)
|
||||
{
|
||||
var model = MTConnectTagConfigModel.FromJson($$"""{"{{key}}":"Ypos"}""");
|
||||
|
||||
model.FullName.ShouldBe("Ypos");
|
||||
model.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullName_wins_over_dataItemId_which_wins_over_address()
|
||||
{
|
||||
MTConnectTagConfigModel
|
||||
.FromJson("""{"fullName":"a","dataItemId":"b","address":"c"}""")
|
||||
.FullName.ShouldBe("a");
|
||||
|
||||
MTConnectTagConfigModel
|
||||
.FromJson("""{"dataItemId":"b","address":"c"}""")
|
||||
.FullName.ShouldBe("b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blank_alias_does_not_shadow_a_populated_one()
|
||||
=> MTConnectTagConfigModel
|
||||
.FromJson("""{"fullName":" ","address":"c"}""")
|
||||
.FullName.ShouldBe("c");
|
||||
|
||||
[Fact]
|
||||
public void Saving_a_legacy_blob_normalises_it_onto_fullName_and_drops_the_aliases()
|
||||
{
|
||||
var model = MTConnectTagConfigModel.FromJson("""{"address":"Zpos","units":"MILLIMETER"}""");
|
||||
|
||||
var o = JsonNode.Parse(model.ToJson())!.AsObject();
|
||||
|
||||
o["fullName"]!.GetValue<string>().ShouldBe("Zpos");
|
||||
o.ContainsKey("address").ShouldBeFalse(
|
||||
"leaving a second id spelling behind lets the two drift apart on the next edit");
|
||||
o.ContainsKey("dataItemId").ShouldBeFalse();
|
||||
// Unrelated keys are still preserved — normalisation is not a purge.
|
||||
o["units"]!.GetValue<string>().ShouldBe("MILLIMETER");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
|
||||
DriverTypeNames.FOCAS,
|
||||
DriverTypeNames.OpcUaClient,
|
||||
DriverTypeNames.Calculation,
|
||||
DriverTypeNames.MTConnect,
|
||||
];
|
||||
|
||||
[Theory]
|
||||
@@ -46,6 +47,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
|
||||
DriverTypeNames.TwinCAT,
|
||||
DriverTypeNames.FOCAS,
|
||||
DriverTypeNames.OpcUaClient,
|
||||
DriverTypeNames.MTConnect,
|
||||
];
|
||||
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user