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:
Joseph Doherty
2026-07-24 18:11:37 -04:00
parent 51c8c7f30b
commit 115a43dd47
7 changed files with 264 additions and 2 deletions
@@ -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>
@@ -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": "&lt;dataItemId&gt;"}</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]