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
@@ -69,6 +69,28 @@
/// </summary>
[Parameter] public EventCallback OnRootsChanged { get; set; }
// ---------------------------------------------------------------------------
// v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
// In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
// checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
// page's "+ Add reference" modal drives this against a single cluster-scoped root.
// ---------------------------------------------------------------------------
/// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
/// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
[Parameter] public bool PickerMode { get; set; }
/// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
/// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
[Parameter] public HashSet<string>? SelectedTagIds { get; set; }
/// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
[Parameter] public EventCallback OnSelectionChanged { get; set; }
/// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
/// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
[Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }
// --- Configure-driver modal (edit) ---
private bool _driverCfgVisible;
private string? _driverCfgId;
@@ -198,6 +220,12 @@
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
{
<span class="d-inline-flex align-items-center gap-1">
@if (PickerMode && node.Kind == RawNodeKind.Tag)
{
<input type="checkbox" class="form-check-input me-1"
checked="@IsTagSelected(node)"
@onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
}
<span aria-hidden="true">@KindIcon(node.Kind)</span>
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
@if (muted)
@@ -270,17 +298,67 @@
// named handlers below are the seam Wave B/C replaces with the real modals.
// ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
// Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
// affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
if (PickerMode)
{
return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
? PickerContainerMenu(node)
: Array.Empty<ContextMenuItem>();
}
return node.Kind switch
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
};
}
// --- Picker-mode menu + selection helpers ---
private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
{
new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
};
private bool IsTagSelected(RawNode node) =>
node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);
private async Task ToggleTagSelection(RawNode node, bool selected)
{
if (SelectedTagIds is null || node.EntityId is null) { return; }
if (selected) { SelectedTagIds.Add(node.EntityId); }
else { SelectedTagIds.Remove(node.EntityId); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task SelectAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Add(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task ClearAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Remove(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },