feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal

This commit is contained in:
Joseph Doherty
2026-06-11 15:01:10 -04:00
parent 773c073d0e
commit 826ffdc1a0
6 changed files with 26 additions and 547 deletions
@@ -4,6 +4,7 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
@inject IUnsTreeService Svc
@inject NavigationManager Nav
<PageTitle>UNS</PageTitle>
@@ -41,7 +42,6 @@
<UnsTree Roots="_roots" Filter="@_filter"
OnToggleExpand="ToggleAsync"
OnAddChild="HandleAddChild"
OnAddVirtualTag="HandleAddVirtualTag"
OnEdit="HandleEdit"
OnDelete="HandleDelete" />
</div>
@@ -64,31 +64,6 @@
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<EquipmentModal Visible="_equipmentModalVisible"
IsNew="_equipmentModalIsNew"
UnsLineId="@_equipmentModalLineId"
Existing="_equipmentModalExisting"
Lines="_equipmentModalLineOptions"
Drivers="_equipmentModalDriverOptions"
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<TagModal Visible="_tagModalVisible"
IsNew="_tagModalIsNew"
EquipmentId="@_tagModalEquipmentId"
Existing="_tagModalExisting"
Drivers="_tagModalDriverOptions"
OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" />
<VirtualTagModal Visible="_vtagModalVisible"
IsNew="_vtagModalIsNew"
EquipmentId="@_vtagModalEquipmentId"
Existing="_vtagModalExisting"
Scripts="_vtagModalScriptOptions"
OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" />
<ImportEquipmentModal Visible="_importModalVisible"
OnImported="OnImportedAsync"
OnCancel="CloseModals" />
@@ -127,7 +102,7 @@
private string? _filter;
private bool _loading = true;
// Guards the async modal openers (HandleAddChild/HandleAddVirtualTag/HandleEdit) so a rapid
// Guards the async modal openers (HandleAddChild/HandleEdit) so a rapid
// double-action can't race two service loads into the same modal state.
private bool _modalBusy;
@@ -144,34 +119,9 @@
private LineEditDto? _lineModalExisting;
private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>();
// --- Equipment modal state ---
private bool _equipmentModalVisible;
private bool _equipmentModalIsNew;
private string? _equipmentModalLineId;
private EquipmentEditDto? _equipmentModalExisting;
private IReadOnlyList<(string Id, string Display)> _equipmentModalLineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = Array.Empty<(string, string)>();
// --- Tag modal state ---
private bool _tagModalVisible;
private bool _tagModalIsNew;
private string? _tagModalEquipmentId;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagModalDriverOptions = Array.Empty<(string, string, string)>();
// --- Virtual-tag modal state ---
private bool _vtagModalVisible;
private bool _vtagModalIsNew;
private string? _vtagModalEquipmentId;
private VirtualTagEditDto? _vtagModalExisting;
private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>();
// --- Import-equipment-CSV modal state ---
private bool _importModalVisible;
// --- Owning equipment to refresh in place after a tag/virtual-tag mutation ---
private string? _childRefreshEquipmentId;
// --- Delete-confirm state ---
private UnsNode? _confirmNode;
private bool _confirmBusy;
@@ -214,43 +164,21 @@
.ToList();
/// <summary>
/// Toggles a node's expansion. For equipment nodes whose children have not yet
/// been loaded, lazily fetches the tag/virtual-tag leaves on first expand.
/// Toggles a structural node's expansion. Equipment nodes are leaves (no expander),
/// so this path only ever fires for Enterprise/Cluster/Area/Line.
/// </summary>
private async Task ToggleAsync(UnsNode node)
private Task ToggleAsync(UnsNode node)
{
if (node.Loading) return;
if (node.Loading) { return Task.CompletedTask; }
node.Expanded = !node.Expanded;
if (node.Kind == UnsNodeKind.Equipment && node.Expanded && !node.Loaded)
{
node.Error = null;
node.Loading = true;
StateHasChanged();
try
{
var kids = await Svc.LoadEquipmentChildrenAsync(node.EntityId!);
node.Children.Clear();
node.Children.AddRange(kids);
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
StateHasChanged();
}
}
return Task.CompletedTask;
}
/// <summary>
/// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster; an equipment
/// gets a new tag scoped to its candidate drivers.
/// Opens the create modal for a structural node's primary child: a cluster gets a new area; an area
/// gets a new line scoped to its cluster. A line's "+ Equipment" navigates to the equipment detail
/// page in create mode rather than opening a modal.
/// </summary>
private async Task HandleAddChild(UnsNode node)
{
@@ -277,21 +205,7 @@
break;
case UnsNodeKind.Line:
_equipmentModalIsNew = true;
_equipmentModalExisting = null;
_equipmentModalLineId = node.EntityId;
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
case UnsNodeKind.Equipment:
_tagModalIsNew = true;
_tagModalExisting = null;
_tagModalEquipmentId = node.EntityId;
_childRefreshEquipmentId = node.EntityId;
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!);
_tagModalVisible = true;
Nav.NavigateTo($"/uns/equipment/new?lineId={node.EntityId}");
break;
}
}
@@ -301,31 +215,10 @@
}
}
/// <summary>Opens the create modal for a new virtual tag scoped to the clicked equipment.</summary>
private async Task HandleAddVirtualTag(UnsNode node)
{
if (_modalBusy) { return; }
_modalBusy = true;
try
{
CloseModals();
_vtagModalIsNew = true;
_vtagModalExisting = null;
_vtagModalEquipmentId = node.EntityId;
_childRefreshEquipmentId = node.EntityId;
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
_vtagModalVisible = true;
}
finally
{
_modalBusy = false;
}
}
/// <summary>
/// Opens the edit modal for the clicked node, loading the entity first to prefill the form and
/// capture its RowVersion. Tag/VirtualTag edits also stash the owning equipment id so a successful
/// save can refresh just that equipment's children in place.
/// capture its RowVersion. Only Area and Line are edited via a modal here; equipment is edited on
/// its own detail page (opened via the tree's "Open" link).
/// </summary>
private async Task HandleEdit(UnsNode node)
{
@@ -354,39 +247,6 @@
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
_lineModalVisible = true;
break;
case UnsNodeKind.Equipment:
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
if (equipment is null) { return; }
_equipmentModalIsNew = false;
_equipmentModalExisting = equipment;
_equipmentModalLineId = equipment.UnsLineId;
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
case UnsNodeKind.Tag:
var tag = await Svc.LoadTagAsync(node.EntityId!);
if (tag is null) { return; }
_tagModalIsNew = false;
_tagModalExisting = tag;
_tagModalEquipmentId = tag.EquipmentId;
_childRefreshEquipmentId = tag.EquipmentId;
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId);
_tagModalVisible = true;
break;
case UnsNodeKind.VirtualTag:
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
if (vtag is null) { return; }
_vtagModalIsNew = false;
_vtagModalExisting = vtag;
_vtagModalEquipmentId = vtag.EquipmentId;
_childRefreshEquipmentId = vtag.EquipmentId;
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
_vtagModalVisible = true;
break;
}
}
finally
@@ -422,8 +282,7 @@
/// <summary>
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
/// Area/Line/Equipment trigger a full structural reload on success; Tag/VirtualTag refresh only the
/// owning equipment's children in place so the rest of the user's expansion is preserved.
/// Area/Line/Equipment trigger a full structural reload on success.
/// </summary>
private async Task ConfirmDeleteAsync()
{
@@ -436,42 +295,8 @@
var node = _confirmNode;
UnsMutationResult result;
// Tag/VirtualTag deletes refresh just the owning equipment's children rather than the
// whole tree, so they're handled separately from the structural Area/Line/Equipment path.
switch (node.Kind)
{
case UnsNodeKind.Tag:
var tag = await Svc.LoadTagAsync(node.EntityId!);
if (tag is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteTagAsync(node.EntityId!, tag.RowVersion);
if (result.Ok)
{
await RefreshEquipmentChildrenAsync(tag.EquipmentId);
CloseModals();
StateHasChanged();
}
else
{
_confirmError = result.Error;
}
return;
case UnsNodeKind.VirtualTag:
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
if (vtag is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteVirtualTagAsync(node.EntityId!, vtag.RowVersion);
if (result.Ok)
{
await RefreshEquipmentChildrenAsync(vtag.EquipmentId);
CloseModals();
StateHasChanged();
}
else
{
_confirmError = result.Error;
}
return;
case UnsNodeKind.Area:
var area = await Svc.LoadAreaAsync(node.EntityId!);
if (area is null) { await ReloadAndCloseAsync(); return; }
@@ -519,70 +344,6 @@
StateHasChanged();
}
/// <summary>
/// Handles a successful Tag/VirtualTag modal save by refreshing only the owning equipment's children
/// in place — never a full structural reload, which would collapse the user's expansion.
/// </summary>
private async Task OnEquipmentChildModalSavedAsync()
{
if (_childRefreshEquipmentId is not null)
{
await RefreshEquipmentChildrenAsync(_childRefreshEquipmentId);
}
CloseModals();
StateHasChanged();
}
/// <summary>
/// Reloads a single equipment node's tag/virtual-tag children in place, leaving the rest of the tree
/// (and the user's expansion) untouched. Falls back to a full structural reload only if the node
/// can no longer be found in the current tree. Either branch only mutates state — the caller is
/// responsible for calling StateHasChanged() afterwards (every current caller does).
/// </summary>
private async Task RefreshEquipmentChildrenAsync(string equipmentId)
{
var node = FindEquipmentNode(equipmentId);
if (node is null)
{
// Fallback: the equipment node is no longer in the current tree — reload the whole structure.
_roots = await Svc.LoadStructureAsync();
return;
}
var kids = await Svc.LoadEquipmentChildrenAsync(equipmentId);
node.Children.Clear();
node.Children.AddRange(kids);
node.ChildCount = node.Children.Count;
node.Loaded = true;
node.Expanded = true;
}
/// <summary>Recursively walks the current tree for the Equipment node with the given id, or null.</summary>
private UnsNode? FindEquipmentNode(string equipmentId)
{
foreach (var root in _roots)
{
var found = FindEquipmentNode(root, equipmentId);
if (found is not null) { return found; }
}
return null;
}
private static UnsNode? FindEquipmentNode(UnsNode node, string equipmentId)
{
if (node.Kind == UnsNodeKind.Equipment && node.EntityId == equipmentId)
{
return node;
}
foreach (var child in node.Children)
{
var found = FindEquipmentNode(child, equipmentId);
if (found is not null) { return found; }
}
return null;
}
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
private async Task ReloadAndCloseAsync()
{
@@ -603,24 +364,7 @@
_lineModalAreaId = null;
_lineModalExisting = null;
_lineModalAreaOptions = Array.Empty<(string, string)>();
_equipmentModalVisible = false;
_equipmentModalIsNew = false;
_equipmentModalLineId = null;
_equipmentModalExisting = null;
_equipmentModalLineOptions = Array.Empty<(string, string)>();
_equipmentModalDriverOptions = Array.Empty<(string, string)>();
_tagModalVisible = false;
_tagModalIsNew = false;
_tagModalEquipmentId = null;
_tagModalExisting = null;
_tagModalDriverOptions = Array.Empty<(string, string, string)>();
_vtagModalVisible = false;
_vtagModalIsNew = false;
_vtagModalEquipmentId = null;
_vtagModalExisting = null;
_vtagModalScriptOptions = Array.Empty<(string, string)>();
_importModalVisible = false;
_childRefreshEquipmentId = null;
_confirmNode = null;
_confirmError = null;
}
@@ -1,252 +0,0 @@
@* Create/edit modal for an equipment, wired straight into IUnsTreeService. The host page owns
visibility and supplies the parent line id (create) or the loaded EquipmentEditDto (edit), plus
the cluster-scoped UNS-line and driver lists. The EquipmentId is system-generated (decision #125)
so it is never an editable field — only shown read-only on edit. On a successful save it raises
OnSaved so the host can reload the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New equipment" : "Edit equipment")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<h6 class="text-muted">Identity</h6>
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">EquipmentId</label>
<input class="form-control form-control-sm mono" value="@Existing?.EquipmentId" disabled />
<div class="form-text">System-generated; never operator-edited.</div>
</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-name">Name</label>
<InputText id="eq-name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
placeholder="machine-01" />
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
<ValidationMessage For="@(() => _form.Name)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-machinecode">MachineCode</label>
<InputText id="eq-machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
placeholder="machine_001" />
<ValidationMessage For="@(() => _form.MachineCode)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-line">UNS line</label>
<InputSelect id="eq-line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
<option value="">— pick a line —</option>
@foreach (var (id, display) in Lines)
{
<option value="@id">@display</option>
}
</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 Drivers)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
<InputText id="eq-ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-sap">SAPID</label>
<InputText id="eq-sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Surface in deployments</label>
</div>
</div>
<hr />
<h6 class="text-muted">OPC 40010 identification (optional)</h6>
<div class="row">
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new equipment; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The parent line id used to default the UNS-line select on create.</summary>
[Parameter] public string? UnsLineId { get; set; }
/// <summary>The equipment being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public EquipmentEditDto? Existing { get; set; }
/// <summary>The selectable UNS lines — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Lines { get; set; } = Array.Empty<(string, string)>();
/// <summary>The selectable drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel { UnsLineId = UnsLineId ?? "" };
}
else if (Existing is not null)
{
_form = new FormModel
{
Name = Existing.Name,
MachineCode = Existing.MachineCode,
UnsLineId = Existing.UnsLineId,
DriverInstanceId = Existing.DriverInstanceId,
ZTag = Existing.ZTag,
SAPID = Existing.SAPID,
Manufacturer = Existing.Manufacturer,
Model = Existing.Model,
SerialNumber = Existing.SerialNumber,
HardwareRevision = Existing.HardwareRevision,
SoftwareRevision = Existing.SoftwareRevision,
YearOfConstruction = Existing.YearOfConstruction,
AssetLocation = Existing.AssetLocation,
ManufacturerUri = Existing.ManufacturerUri,
DeviceManualUri = Existing.DeviceManualUri,
Enabled = Existing.Enabled,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new EquipmentInput(
_form.Name,
_form.MachineCode,
_form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag,
_form.SAPID,
_form.Manufacturer,
_form.Model,
_form.SerialNumber,
_form.HardwareRevision,
_form.SoftwareRevision,
_form.YearOfConstruction,
_form.AssetLocation,
_form.ManufacturerUri,
_form.DeviceManualUri,
_form.Enabled);
var result = IsNew
? await Svc.CreateEquipmentAsync(input)
: await Svc.UpdateEquipmentAsync(Existing!.EquipmentId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
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; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public bool Enabled { get; set; } = true;
}
}
@@ -13,13 +13,10 @@
/// <summary>The top-level UNS nodes to render (typically the enterprise roots).</summary>
[Parameter, EditorRequired] public IReadOnlyList<UnsNode> Roots { get; set; } = default!;
/// <summary>Raised for the primary "add child" action of a node (e.g. "+ Area" on a cluster, "+ Tag" on equipment).</summary>
/// <summary>Raised for the primary "add child" action of a structural node (e.g. "+ Area" on a cluster, "+ Equipment" on a line).</summary>
[Parameter] public EventCallback<UnsNode> OnAddChild { get; set; }
/// <summary>Raised for the equipment-only "+ Virtual tag" action, kept distinct from OnAddChild ("+ Tag").</summary>
[Parameter] public EventCallback<UnsNode> OnAddVirtualTag { get; set; }
/// <summary>Raised when the user edits a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
/// <summary>Raised when the user edits a node (Area/Line).</summary>
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
/// <summary>Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
@@ -117,20 +114,7 @@
break;
case UnsNodeKind.Equipment:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Tag</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddVirtualTag.InvokeAsync(node))">+ Virtual tag</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
case UnsNodeKind.Tag:
case UnsNodeKind.VirtualTag:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/uns/equipment/{node.EntityId}")">Open</a>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
@@ -241,7 +241,7 @@ public static class UnsTreeAssembly
ClusterId = clusterId,
EntityId = equipment.EquipmentId,
ChildCount = childCount,
HasLazyChildren = childCount > 0,
HasLazyChildren = false,
};
}
}
@@ -68,7 +68,7 @@ public sealed class UnsTreeAssemblyTests
}
[Fact]
public void Build_sets_equipment_child_count_and_lazy_flag()
public void Build_sets_equipment_child_count_and_leaf_flag()
{
var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
@@ -82,9 +82,12 @@ public sealed class UnsTreeAssemblyTests
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
var line = tree.Single().Children.Single().Children.Single().Children.Single();
// Equipment is a tree leaf that links to its own detail page, so it never carries an
// expander — HasLazyChildren is always false regardless of its tag/virtual-tag count.
// The ChildCount badge still reflects tags + virtual tags.
var withChildren = line.Children.Single(e => e.Key == "eq:e1");
withChildren.ChildCount.ShouldBe(3);
withChildren.HasLazyChildren.ShouldBeTrue();
withChildren.HasLazyChildren.ShouldBeFalse();
var empty = line.Children.Single(e => e.Key == "eq:e2");
empty.ChildCount.ShouldBe(0);
@@ -54,8 +54,8 @@ public sealed class UnsTreeServiceStructureTests
equipment.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId);
}
/// <summary>The seeded equipment node's badge count equals tags + virtual tags and it is
/// flagged as lazily expandable.</summary>
/// <summary>The seeded equipment node's badge count equals tags + virtual tags. Equipment is a
/// tree leaf (no expander), so HasLazyChildren is always false.</summary>
[Fact]
public async Task LoadStructure_counts_tags_and_vtags_per_equipment()
{
@@ -72,7 +72,7 @@ public sealed class UnsTreeServiceStructureTests
// Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded).
equipment.ChildCount.ShouldBe(3);
equipment.HasLazyChildren.ShouldBeTrue();
equipment.HasLazyChildren.ShouldBeFalse();
}
/// <summary>An empty cluster (no areas) is still rendered as a Cluster node with no children.</summary>