feat(uns): recursive UnsTree renderer

This commit is contained in:
Joseph Doherty
2026-06-08 12:21:38 -04:00
parent 3e8941bce4
commit 0f286a70b8
@@ -0,0 +1,142 @@
@* Recursive, read-only UNS browse-tree renderer. Modelled on DriverBrowseTree:
per-node indent, expand chevron and Bootstrap/theme styling. This component
owns no state and calls no service — it reads node.Expanded/Loading/Error/
Children and raises callbacks; the host page owns expansion + lazy loading. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
@code {
/// <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>
[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>
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
/// <summary>Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
[Parameter] public EventCallback<UnsNode> OnDelete { get; set; }
/// <summary>Raised when the user toggles a node's expansion; the host owns the resulting state + lazy load.</summary>
[Parameter] public EventCallback<UnsNode> OnToggleExpand { get; set; }
/// <summary>Optional case-insensitive substring filter applied to visible children by DisplayName.</summary>
[Parameter] public string? Filter { get; set; }
private RenderFragment RenderNode(UnsNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.Children.Count > 0 || node.HasLazyChildren;
<div class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0"
@onclick="@(() => OnToggleExpand.InvokeAsync(node))" style="width:18px">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
<span class="mono small">@node.DisplayName</span>
@if (node.ChildCount > 0)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
}
@RenderActions(node)
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in FilterChildren(node))
{
@RenderNode(child, depth + 1)
}
}
};
private RenderFragment RenderActions(UnsNode node) => __builder =>
{
switch (node.Kind)
{
case UnsNodeKind.Enterprise:
// No actions on the enterprise root.
break;
case UnsNodeKind.Cluster:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Area</button>
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/clusters/{node.ClusterId}")">⚙ settings</a>
break;
case UnsNodeKind.Area:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Line</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.Line:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Equipment</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.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>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
}
};
private IEnumerable<UnsNode> FilterChildren(UnsNode node)
{
var f = Filter?.Trim();
if (string.IsNullOrEmpty(f))
{
return node.Children;
}
return node.Children.Where(c =>
c.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase));
}
}