v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker

Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 06:15:51 -04:00
parent 6dc5af7aa2
commit 77bc010ba9
12 changed files with 1161 additions and 1060 deletions
@@ -78,17 +78,9 @@ else
</InputSelect>
<ValidationMessage For="@(() => _form.UnsLineId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-driver">Driver instance</label>
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var (id, display) in _driverOptions)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div>
@* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced
here by reference on the Tags tab. The old "Driver instance" select was removed. *@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
@@ -138,38 +130,48 @@ else
}
else if (_activeTab == "tags")
{
@* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at
raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name
override is the only per-reference editable field. *@
<div class="d-flex justify-content-end align-items-center gap-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddReference">+ Add reference</button>
</div>
@if (!string.IsNullOrWhiteSpace(_tagError))
@if (!string.IsNullOrWhiteSpace(_refError))
{
<div class="text-danger small mb-2">@_tagError</div>
<div class="text-danger small mb-2">@_refError</div>
}
@if (_tags is null)
@if (_refs is null)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (_tags.Count == 0)
else if (_refs.Count == 0)
{
<p class="text-muted">No tags yet.</p>
<p class="text-muted">No tag references yet.</p>
}
else
{
<table class="table table-sm">
<table class="table table-sm align-middle">
<thead>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
<tr>
<th>Effective name</th><th>Raw path</th><th>Data type</th><th>Access</th>
<th>Display-name override</th><th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in _tags)
@foreach (var r in _refs)
{
<tr @key="t.TagId">
<td>@t.Name</td>
<td class="mono">@t.DriverInstanceId</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</td>
<td class="text-end">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
<tr @key="r.UnsTagReferenceId">
<td>@r.EffectiveName</td>
<td class="mono small">@r.RawPath</td>
<td>@r.DataType</td>
<td>@r.AccessLevel</td>
<td>
<input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
placeholder="(uses raw name)" />
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => SaveOverride(r)">Save name</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
</td>
</tr>
}
@@ -177,9 +179,8 @@ else
</table>
}
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
<AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
}
else if (_activeTab == "vtags")
{
@@ -290,15 +291,13 @@ else
private EquipmentEditDto? _equipment;
private FormModel _form = new();
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). ---
private IReadOnlyList<EquipmentTagRow>? _tags;
private string? _tagError;
private bool _tagModalVisible;
private bool _tagModalIsNew;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
// --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
// spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
private IReadOnlyList<EquipmentReferenceRow>? _refs;
private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
private string? _refError;
private bool _refModalVisible;
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
@@ -329,54 +328,48 @@ else
{
_activeTab = tab;
if (IsNew) { return; }
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); }
if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); }
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
}
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) ---
// --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) ---
private async Task ReloadTagsAsync()
private async Task ReloadReferencesAsync()
{
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
_refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!);
// Seed the per-row override edit buffer so each row's <input> binds to a live value.
_overrideEdits.Clear();
foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; }
}
private async Task OpenAddTag()
private void OpenAddReference()
{
_tagError = null;
_tagModalIsNew = true;
_tagModalExisting = null;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
_refError = null;
_refModalVisible = true;
}
private async Task OpenEditTag(string tagId)
private async Task OnReferencesCommittedAsync()
{
_tagError = null;
var dto = await Svc.LoadTagAsync(tagId);
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
_tagModalIsNew = false;
_tagModalExisting = dto;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
_refModalVisible = false;
await ReloadReferencesAsync();
}
private async Task OnTagSavedAsync()
private async Task SaveOverride(EquipmentReferenceRow r)
{
_tagModalVisible = false;
await ReloadTagsAsync();
_refError = null;
var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId);
var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion);
if (res.Ok) { await ReloadReferencesAsync(); }
else { _refError = res.Error; }
}
private async Task DeleteTag(string tagId)
private async Task RemoveReference(EquipmentReferenceRow r)
{
_tagModalVisible = false;
_tagError = null;
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete.
var dto = await Svc.LoadTagAsync(tagId);
if (dto is null) { await ReloadTagsAsync(); return; }
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
if (r.Ok) { await ReloadTagsAsync(); }
else { _tagError = r.Error; }
_refError = null;
var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
if (res.Ok) { await ReloadReferencesAsync(); }
else { _refError = res.Error; }
}
// --- Virtual Tags tab handlers ---
@@ -478,7 +471,7 @@ else
// path lands on Details because the field initializes to "details" and a fresh page instance
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
_tags = null;
_refs = null;
_vtags = null;
_alarms = null;
if (!IsNew)
@@ -489,7 +482,6 @@ else
LoadFormFrom(_equipment);
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
_lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
}
}
else
@@ -497,7 +489,6 @@ else
_form = new FormModel { UnsLineId = LineId ?? "" };
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
_lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
}
_loading = false;
}
@@ -510,7 +501,6 @@ else
Name = e.Name,
MachineCode = e.MachineCode,
UnsLineId = e.UnsLineId,
DriverInstanceId = e.DriverInstanceId,
ZTag = e.ZTag,
SAPID = e.SAPID,
Manufacturer = e.Manufacturer,
@@ -536,7 +526,6 @@ else
_form.Name,
_form.MachineCode,
_form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag,
_form.SAPID,
_form.Manufacturer,
@@ -582,7 +571,6 @@ else
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }