diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor
index 9deb928f..4b872e2e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor
@@ -4,9 +4,9 @@
Children and raises callbacks; the host page owns expansion + lazy loading. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
-@foreach (var root in Roots)
+@foreach (var root in Roots.Where(r => VisibleUnder(r, false)))
{
- @RenderNode(root, 0)
+ @RenderNode(root, 0, false)
}
@code {
@@ -28,19 +28,28 @@
/// Raised when the user toggles a node's expansion; the host owns the resulting state + lazy load.
[Parameter] public EventCallback OnToggleExpand { get; set; }
- /// Optional case-insensitive substring filter applied to visible children by DisplayName.
+ ///
+ /// Optional case-insensitive substring filter on DisplayName. A node is shown when it matches,
+ /// sits under a matching ancestor, or has a matching descendant, and the path to matches
+ /// auto-expands. Lazy tag children are only considered once their equipment has been loaded.
+ ///
[Parameter] public string? Filter { get; set; }
- private RenderFragment RenderNode(UnsNode node, int depth) => __builder =>
+ private RenderFragment RenderNode(UnsNode node, int depth, bool ancestorMatched) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.Children.Count > 0 || node.HasLazyChildren;
+ // Under an active filter the matched path auto-expands, so the chevron and the child block
+ // follow `childrenShown` (derived from the filter) rather than the user's node.Expanded flag.
+ var matched = ancestorMatched || SelfMatches(node);
+ var visibleChildren = HasFilter ? VisibleChildren(node, matched).ToList() : node.Children;
+ var childrenShown = HasFilter ? visibleChildren.Count > 0 : node.Expanded;
}
- else if (node.Expanded)
+ else if (childrenShown)
{
- @foreach (var child in FilterChildren(node))
+ @foreach (var child in visibleChildren)
{
- @RenderNode(child, depth + 1)
+ @RenderNode(child, depth + 1, matched)
}
}
};
@@ -128,15 +137,34 @@
}
};
- private IEnumerable FilterChildren(UnsNode node)
+ /// Whether a non-empty filter is currently active.
+ private bool HasFilter => !string.IsNullOrWhiteSpace(Filter);
+
+ /// Whether the node's own DisplayName matches the active filter (case-insensitive substring).
+ private bool SelfMatches(UnsNode node)
{
var f = Filter?.Trim();
- if (string.IsNullOrEmpty(f))
- {
- return node.Children;
- }
-
- return node.Children.Where(c =>
- c.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase));
+ return !string.IsNullOrEmpty(f)
+ && node.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase);
}
+
+ ///
+ /// Whether the node should appear under the active filter: it self-matches, sits under a matched
+ /// ancestor, or has a (loaded) descendant that matches. Unloaded lazy tag children can't be
+ /// inspected, so an unexpanded equipment matches on its own name only. The structural tree is
+ /// bounded, so this recursive walk is cheap.
+ ///
+ private bool VisibleUnder(UnsNode node, bool ancestorMatched)
+ {
+ if (!HasFilter) { return true; }
+ if (ancestorMatched || SelfMatches(node)) { return true; }
+ return node.Children.Any(c => VisibleUnder(c, false));
+ }
+
+ ///
+ /// The children to render under the active filter: the whole child list when the node (or an
+ /// ancestor) matched, otherwise only the branches that still contain a match.
+ ///
+ private IEnumerable VisibleChildren(UnsNode node, bool matched) =>
+ matched ? node.Children : node.Children.Where(c => VisibleUnder(c, false));
}