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());
}
}