304 lines
12 KiB
Plaintext
304 lines
12 KiB
Plaintext
@* Shared template folder/template tree.
|
|
|
|
Wraps TreeView<TemplateTreeNode> with template-folder-specific layout: folder
|
|
nodes carry their child folders + templates, leaves are templates. Used by:
|
|
- Templates page (Single mode, navigation on click)
|
|
- Transport Export wizard (Checkbox mode, bulk selection)
|
|
|
|
Optional ExtraTemplateChildren lets callers nest extra leaves (e.g. composition
|
|
slots) under a template without forking the component. *@
|
|
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
|
|
|
<TreeView @ref="_tree" TItem="TemplateTreeNode"
|
|
Items="_visibleRoots"
|
|
ChildrenSelector="n => n.Children"
|
|
HasChildrenSelector="n => n.Children.Count > 0"
|
|
KeySelector="n => (object)n.Key"
|
|
Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
|
|
SelectionMode="SelectionMode"
|
|
SelectedKeys="SelectedKeys"
|
|
SelectedKeysChanged="SelectedKeysChanged"
|
|
InitiallyExpanded="@(_initiallyExpanded)"
|
|
StorageKey="@StorageKey"
|
|
OnNodeContextMenuOpened="OnNodeContextMenuOpened">
|
|
<NodeContent Context="node">
|
|
@if (NodeContent != null)
|
|
{
|
|
@NodeContent(node)
|
|
}
|
|
else
|
|
{
|
|
<span class="tv-glyph">
|
|
<i class="bi @(NodeGlyph(node))"></i>
|
|
</span>
|
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
|
title="@node.Name"
|
|
@onclick="() => InvokeNodeClick(node)">@node.Name</span>
|
|
@if (NodeExtras != null)
|
|
{
|
|
@NodeExtras(node)
|
|
}
|
|
}
|
|
</NodeContent>
|
|
<ContextMenu Context="node">
|
|
@if (ContextMenu != null)
|
|
{
|
|
@ContextMenu(node)
|
|
}
|
|
</ContextMenu>
|
|
<EmptyContent>
|
|
@if (EmptyContent != null)
|
|
{
|
|
@EmptyContent
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted fst-italic">No templates.</span>
|
|
}
|
|
</EmptyContent>
|
|
</TreeView>
|
|
|
|
@code {
|
|
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
|
|
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
|
|
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
|
|
[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; }
|
|
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
|
[Parameter] public EventCallback<TemplateTreeNode> OnNodeClick { get; set; }
|
|
[Parameter] public string? StorageKey { get; set; }
|
|
/// <summary>Raised when a node context menu opens; forwarded from the inner TreeView.</summary>
|
|
[Parameter] public EventCallback OnNodeContextMenuOpened { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional: caller-supplied extra leaves to nest under a template. Used by
|
|
/// the Templates page to surface composition slots; left null by the
|
|
/// Transport Export wizard (compositions aren't exportable as standalone
|
|
/// items, so the wizard's checkbox tree intentionally hides them).
|
|
/// </summary>
|
|
[Parameter] public Func<Template, IReadOnlyList<TemplateTreeNode>>? ExtraTemplateChildren { get; set; }
|
|
|
|
private TreeView<TemplateTreeNode>? _tree;
|
|
private List<TemplateTreeNode> _allRoots = new();
|
|
private List<TemplateTreeNode> _visibleRoots = new();
|
|
private HashSet<string>? _filterRevealed;
|
|
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
|
|
|
|
// Compare immutable value snapshots, rather than entity/list reference identity:
|
|
// callers may rename, move, or edit composition children in place.
|
|
private sealed record TreeInput(string Kind, int Id, int? ParentId, string Name, bool Derived = false, bool Inherited = false, string? KeyOverride = null);
|
|
private List<TreeInput>? _treeInputs;
|
|
private Dictionary<int, IReadOnlyList<TemplateTreeNode>> _extraChildren = new();
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
var inputs = new List<TreeInput>();
|
|
foreach (var folder in Folders)
|
|
inputs.Add(new("folder", folder.Id, folder.ParentFolderId, folder.Name));
|
|
_extraChildren = new();
|
|
foreach (var template in Templates)
|
|
{
|
|
inputs.Add(new("template", template.Id, template.FolderId, template.Name, template.IsDerived));
|
|
if (!template.IsDerived && ExtraTemplateChildren is not null)
|
|
{
|
|
var children = ExtraTemplateChildren(template);
|
|
_extraChildren[template.Id] = children;
|
|
CaptureChildren(children, inputs);
|
|
}
|
|
}
|
|
if (_treeInputs is null || !inputs.SequenceEqual(_treeInputs))
|
|
{
|
|
_treeInputs = inputs;
|
|
BuildTree();
|
|
}
|
|
// Filter and RevealKeys are independent and may also be changed in place.
|
|
ApplyFilter();
|
|
}
|
|
|
|
private static void CaptureChildren(IReadOnlyList<TemplateTreeNode> children, List<TreeInput> inputs)
|
|
{
|
|
foreach (var child in children)
|
|
{
|
|
inputs.Add(new(child.Kind.ToString(), child.Id, null, child.Name, Inherited: child.IsInherited, KeyOverride: child.KeyOverride));
|
|
CaptureChildren(child.Children, inputs);
|
|
inputs.Add(new("end", 0, null, ""));
|
|
}
|
|
}
|
|
|
|
private void BuildTree()
|
|
{
|
|
var folderNodes = Folders.ToDictionary(
|
|
f => f.Id,
|
|
f => new TemplateTreeNode
|
|
{
|
|
Kind = TemplateTreeNodeKind.Folder,
|
|
Id = f.Id,
|
|
Name = f.Name,
|
|
});
|
|
|
|
var roots = new List<TemplateTreeNode>();
|
|
foreach (var f in Folders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var node = folderNodes[f.Id];
|
|
if (f.ParentFolderId is int pid && folderNodes.TryGetValue(pid, out var parent))
|
|
parent.Children.Add(node);
|
|
else
|
|
roots.Add(node);
|
|
}
|
|
|
|
foreach (var t in Templates
|
|
.Where(t => !t.IsDerived)
|
|
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var tNode = new TemplateTreeNode
|
|
{
|
|
Kind = TemplateTreeNodeKind.Template,
|
|
Id = t.Id,
|
|
Name = t.Name,
|
|
};
|
|
|
|
if (ExtraTemplateChildren != null)
|
|
{
|
|
foreach (var extra in _extraChildren[t.Id])
|
|
{
|
|
tNode.Children.Add(extra);
|
|
}
|
|
}
|
|
|
|
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
|
|
parentFolder.Children.Add(tNode);
|
|
else
|
|
roots.Add(tNode);
|
|
}
|
|
|
|
SortChildren(roots);
|
|
foreach (var node in folderNodes.Values)
|
|
SortChildren(node.Children);
|
|
|
|
_allRoots = roots;
|
|
}
|
|
|
|
private static void SortChildren(List<TemplateTreeNode> children)
|
|
{
|
|
children.Sort((a, b) =>
|
|
{
|
|
var kindOrder = (int)a.Kind - (int)b.Kind;
|
|
if (kindOrder != 0) return kindOrder;
|
|
return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
|
|
});
|
|
}
|
|
|
|
private void ApplyFilter()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(Filter))
|
|
{
|
|
_visibleRoots = _allRoots;
|
|
_filterRevealed = null;
|
|
_initiallyExpanded = BuildRevealPredicate();
|
|
return;
|
|
}
|
|
|
|
var needle = Filter.Trim();
|
|
var revealed = new HashSet<string>(StringComparer.Ordinal);
|
|
var filtered = new List<TemplateTreeNode>();
|
|
foreach (var root in _allRoots)
|
|
{
|
|
if (CopyMatching(root, needle, revealed) is { } copy)
|
|
filtered.Add(copy);
|
|
}
|
|
|
|
_visibleRoots = filtered;
|
|
_filterRevealed = revealed;
|
|
// Force every ancestor of a match to be expanded so the matched leaf is
|
|
// visible without the user clicking through.
|
|
_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);
|
|
var keptChildren = new List<TemplateTreeNode>();
|
|
foreach (var child in node.Children)
|
|
{
|
|
var copy = CopyMatching(child, needle, revealed);
|
|
if (copy != null) keptChildren.Add(copy);
|
|
}
|
|
|
|
if (!selfMatch && keptChildren.Count == 0) return null;
|
|
|
|
var clone = new TemplateTreeNode
|
|
{
|
|
Kind = node.Kind,
|
|
Id = node.Id,
|
|
Name = node.Name,
|
|
IsInherited = node.IsInherited,
|
|
KeyOverride = node.KeyOverride,
|
|
};
|
|
foreach (var k in keptChildren) clone.Children.Add(k);
|
|
if (keptChildren.Count > 0)
|
|
{
|
|
// Mark this node as an ancestor on the match path so it auto-expands.
|
|
revealed.Add(clone.Key);
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
private static string NodeGlyph(TemplateTreeNode node) =>
|
|
node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text";
|
|
|
|
private async Task InvokeNodeClick(TemplateTreeNode node)
|
|
{
|
|
if (OnNodeClick.HasDelegate)
|
|
{
|
|
await OnNodeClick.InvokeAsync(node);
|
|
}
|
|
}
|
|
|
|
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
|
public void ExpandAll() => _tree?.ExpandAll();
|
|
|
|
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
|
public void CollapseAll() => _tree?.CollapseAll();
|
|
|
|
/// <summary>
|
|
/// Close any open node context menu. Called by the host page when it opens the
|
|
/// root context menu so both menus are never visible simultaneously.
|
|
/// </summary>
|
|
public void DismissNodeContextMenu() => _tree?.DismissContextMenu();
|
|
}
|