feat(centralui): TemplateFolderTree.RevealKeys — expand to a preselected node

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
Joseph Doherty
2026-08-21 06:55:03 -04:00
parent 9261fa09fa
commit 8df6bcb6e0
2 changed files with 89 additions and 1 deletions
@@ -67,6 +67,12 @@
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
[Parameter] public string Filter { get; set; } = "";
/// <summary>
/// Keys (<c>f:{id}</c>/<c>t:{id}</c>) whose ancestor folders should start expanded
/// when no <see cref="Filter"/> is active — the picker uses it to open on a
/// preselected template. An active filter's own reveal set takes precedence.
/// </summary>
[Parameter] public IReadOnlyCollection<string>? RevealKeys { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? NodeContent { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? ContextMenu { get; set; }
@@ -165,7 +171,7 @@
{
_visibleRoots = _allRoots;
_filterRevealed = null;
_initiallyExpanded = null;
_initiallyExpanded = BuildRevealPredicate();
return;
}
@@ -185,6 +191,29 @@
_initiallyExpanded = n => revealed.Contains(n.Key);
}
/// <summary>
/// Builds the no-filter expansion predicate from <see cref="RevealKeys"/>: every
/// ancestor of a wanted key (the key's own node stays as-is). Null when nothing
/// was asked for, which keeps existing callers on the previous behaviour.
/// </summary>
private Func<TemplateTreeNode, bool>? BuildRevealPredicate()
{
if (RevealKeys is not { Count: > 0 }) return null;
var wanted = new HashSet<string>(RevealKeys, StringComparer.Ordinal);
var ancestors = new HashSet<string>(StringComparer.Ordinal);
var path = new List<string>();
foreach (var root in _allRoots) CollectAncestors(root, wanted, ancestors, path);
return ancestors.Count == 0 ? null : n => ancestors.Contains(n.Key);
}
private static void CollectAncestors(TemplateTreeNode node, HashSet<string> wanted, HashSet<string> acc, List<string> path)
{
if (wanted.Contains(node.Key)) acc.UnionWith(path);
path.Add(node.Key);
foreach (var c in node.Children) CollectAncestors(c, wanted, acc, path);
path.RemoveAt(path.Count - 1);
}
private static TemplateTreeNode? CopyMatching(TemplateTreeNode node, string needle, HashSet<string> revealed)
{
var selfMatch = node.Name.Contains(needle, StringComparison.OrdinalIgnoreCase);
@@ -0,0 +1,59 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Covers the additive <c>RevealKeys</c> parameter: the tree opens the ancestors of
/// a caller-named node when no filter is active, and an active filter still wins.
/// </summary>
public class TemplateFolderTreeRevealKeysTests : BunitContext
{
public TemplateFolderTreeRevealKeysTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static (List<TemplateFolder> folders, List<Template> templates) Catalogue()
{
var a = new TemplateFolder("A") { Id = 1 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var other = new TemplateFolder("Other") { Id = 3 };
var t7 = new Template("Deep") { Id = 7, FolderId = 2 };
var t8 = new Template("Elsewhere") { Id = 8, FolderId = 3 };
return (new() { a, b, other }, new() { t7, t8 });
}
[Fact]
public void WithoutRevealKeys_AllFoldersStartCollapsed()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Folders, f).Add(x => x.Templates, t));
Assert.DoesNotContain("Deep", cut.Markup);
Assert.DoesNotContain("Elsewhere", cut.Markup);
}
[Fact]
public void RevealKeys_ExpandsOnlyTheAncestorsOfTheListedTemplate()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p
.Add(x => x.Folders, f).Add(x => x.Templates, t)
.Add(x => x.RevealKeys, (IReadOnlyCollection<string>)new[] { "t:7" }));
Assert.Contains("Deep", cut.Markup); // A → B expanded
Assert.DoesNotContain("Elsewhere", cut.Markup); // Other untouched
}
[Fact]
public void ActiveFilter_WinsOverRevealKeys()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p
.Add(x => x.Folders, f).Add(x => x.Templates, t)
.Add(x => x.RevealKeys, (IReadOnlyCollection<string>)new[] { "t:7" })
.Add(x => x.Filter, "Elsewhere"));
Assert.Contains("Elsewhere", cut.Markup);
Assert.DoesNotContain("Deep", cut.Markup);
}
}