refactor(centralui): extract TemplateFolderTree as shared component
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
@using ScadaLink.Commons.Entities.Templates
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@using ScadaLink.CentralUI.Components.Shared
|
||||||
@using ScadaLink.TemplateEngine
|
@using ScadaLink.TemplateEngine
|
||||||
@using ScadaLink.TemplateEngine.Services
|
@using ScadaLink.TemplateEngine.Services
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@@ -79,10 +80,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
||||||
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
|
<TemplateFolderTree @ref="_tree"
|
||||||
ChildrenSelector="n => n.Children"
|
Folders="_folders"
|
||||||
HasChildrenSelector="n => n.Children.Count > 0"
|
Templates="_templates"
|
||||||
KeySelector="n => (object)n.Key"
|
SelectionMode="TreeViewSelectionMode.Single"
|
||||||
|
ExtraTemplateChildren="BuildCompositionLeavesFor"
|
||||||
StorageKey="templates-tree">
|
StorageKey="templates-tree">
|
||||||
<NodeContent Context="node">
|
<NodeContent Context="node">
|
||||||
@RenderNodeLabel(node)
|
@RenderNodeLabel(node)
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
|
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</TreeView>
|
</TemplateFolderTree>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +126,11 @@
|
|||||||
{
|
{
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||||
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
||||||
BuildTemplateTree();
|
_templatesById = _templates.ToDictionary(t => t.Id);
|
||||||
|
_compositionsById = _templates
|
||||||
|
.SelectMany(t => t.Compositions)
|
||||||
|
.GroupBy(c => c.Id)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -133,134 +139,53 @@
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TmplNodeKind { Folder, Template, Composition }
|
// ID lookups so RenderNodeLabel / RenderNodeContextMenu can resolve the
|
||||||
|
// entity behind a TemplateTreeNode (whose payload is just Id+Kind+Name).
|
||||||
|
private Dictionary<int, Template> _templatesById = new();
|
||||||
|
private Dictionary<int, TemplateComposition> _compositionsById = new();
|
||||||
|
|
||||||
private record TmplNode(
|
// Composition-leaf builder for TemplateFolderTree's ExtraTemplateChildren
|
||||||
string Key,
|
// hook: walks each template's compositions recursively so cascaded slots
|
||||||
TmplNodeKind Kind,
|
// appear as nested children. The Transport Export wizard intentionally
|
||||||
int EntityId,
|
// does NOT supply this hook — compositions aren't independently exportable.
|
||||||
string Label,
|
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
|
||||||
int? ParentFolderId,
|
|
||||||
int? OwnerTemplateId,
|
|
||||||
Template? Template,
|
|
||||||
TemplateComposition? Composition,
|
|
||||||
List<TmplNode> Children);
|
|
||||||
|
|
||||||
private List<TmplNode> _treeRoots = new();
|
|
||||||
|
|
||||||
private void BuildTemplateTree()
|
|
||||||
{
|
{
|
||||||
// 1. Folder nodes keyed by id
|
var result = new List<TemplateTreeNode>();
|
||||||
var folderNodes = _folders.ToDictionary(
|
|
||||||
f => f.Id,
|
|
||||||
f => new TmplNode(
|
|
||||||
Key: $"f:{f.Id}",
|
|
||||||
Kind: TmplNodeKind.Folder,
|
|
||||||
EntityId: f.Id,
|
|
||||||
Label: f.Name,
|
|
||||||
ParentFolderId: f.ParentFolderId,
|
|
||||||
OwnerTemplateId: null,
|
|
||||||
Template: null,
|
|
||||||
Composition: null,
|
|
||||||
Children: new List<TmplNode>()));
|
|
||||||
|
|
||||||
// 2. Attach folder nodes by ParentFolderId
|
|
||||||
var roots = new List<TmplNode>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Template nodes with composition leaves. Derived templates are
|
|
||||||
// slot-owned and reached via their parent's composition leaf — never
|
|
||||||
// shown as standalone tree nodes. Composition leaves recurse so a
|
|
||||||
// composite slot (e.g. Pump composed with TempSensor) reveals its own
|
|
||||||
// child slots when expanded.
|
|
||||||
var templatesById = _templates.ToDictionary(t => t.Id);
|
|
||||||
foreach (var t in _templates.Where(t => !t.IsDerived).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var compChildren = BuildCompositionLeaves(t, templatesById);
|
|
||||||
|
|
||||||
var tNode = new TmplNode(
|
|
||||||
Key: $"t:{t.Id}",
|
|
||||||
Kind: TmplNodeKind.Template,
|
|
||||||
EntityId: t.Id,
|
|
||||||
Label: t.Name,
|
|
||||||
ParentFolderId: t.FolderId,
|
|
||||||
OwnerTemplateId: null,
|
|
||||||
Template: t,
|
|
||||||
Composition: null,
|
|
||||||
Children: compChildren);
|
|
||||||
|
|
||||||
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
|
|
||||||
parentFolder.Children.Add(tNode);
|
|
||||||
else
|
|
||||||
roots.Add(tNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Sort each level: folders before templates, alphabetical
|
|
||||||
SortChildren(roots);
|
|
||||||
foreach (var node in folderNodes.Values)
|
|
||||||
SortChildren(node.Children);
|
|
||||||
|
|
||||||
_treeRoots = roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursive: each composition leaf's children are the composed-template's
|
|
||||||
// own composition leaves. Cascaded derived templates carry their slot
|
|
||||||
// compositions, so walking ComposedTemplateId surfaces the full nested
|
|
||||||
// structure.
|
|
||||||
private static List<TmplNode> BuildCompositionLeaves(Template owner, IReadOnlyDictionary<int, Template> templatesById)
|
|
||||||
{
|
|
||||||
var result = new List<TmplNode>();
|
|
||||||
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var nestedChildren = templatesById.TryGetValue(c.ComposedTemplateId, out var composed)
|
var node = new TemplateTreeNode
|
||||||
? BuildCompositionLeaves(composed, templatesById)
|
{
|
||||||
: new List<TmplNode>();
|
Kind = TemplateTreeNodeKind.Composition,
|
||||||
|
Id = c.Id,
|
||||||
|
Name = c.InstanceName,
|
||||||
|
};
|
||||||
|
|
||||||
result.Add(new TmplNode(
|
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
|
||||||
Key: $"c:{c.Id}",
|
{
|
||||||
Kind: TmplNodeKind.Composition,
|
foreach (var nested in BuildCompositionLeavesFor(composed))
|
||||||
EntityId: c.Id,
|
{
|
||||||
Label: c.InstanceName,
|
node.Children.Add(nested);
|
||||||
ParentFolderId: null,
|
}
|
||||||
OwnerTemplateId: owner.Id,
|
}
|
||||||
Template: null,
|
|
||||||
Composition: c,
|
result.Add(node);
|
||||||
Children: nestedChildren));
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SortChildren(List<TmplNode> children)
|
private TemplateFolderTree _tree = default!;
|
||||||
{
|
|
||||||
children.Sort((a, b) =>
|
|
||||||
{
|
|
||||||
var kindOrder = (int)a.Kind - (int)b.Kind;
|
|
||||||
if (kindOrder != 0) return kindOrder;
|
|
||||||
return string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private TreeView<TmplNode> _tree = default!;
|
|
||||||
|
|
||||||
private void OpenTemplate(int templateId) =>
|
private void OpenTemplate(int templateId) =>
|
||||||
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
||||||
|
|
||||||
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
|
private RenderFragment RenderNodeLabel(TemplateTreeNode node) => __builder =>
|
||||||
{
|
{
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
{
|
{
|
||||||
case TmplNodeKind.Folder:
|
case TemplateTreeNodeKind.Folder:
|
||||||
var folderOpen = _tree?.IsExpanded(node.Key) ?? false;
|
<span class="tv-glyph"><i class="bi bi-folder"></i></span>
|
||||||
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
|
|
||||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||||
title="@node.Label">@node.Label</span>
|
title="@node.Name">@node.Name</span>
|
||||||
@if (node.Children.Count > 0)
|
@if (node.Children.Count > 0)
|
||||||
{
|
{
|
||||||
<span class="tv-meta">
|
<span class="tv-meta">
|
||||||
@@ -269,47 +194,58 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
case TemplateTreeNodeKind.Template:
|
||||||
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
||||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||||
title="@node.Label"
|
title="@node.Name"
|
||||||
@ondblclick="() => OpenTemplate(node.EntityId)">@node.Label</span>
|
@ondblclick="() => OpenTemplate(node.Id)">@node.Name</span>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Composition:
|
case TemplateTreeNodeKind.Composition:
|
||||||
|
var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0;
|
||||||
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
||||||
<span class="tv-label" title="@node.Label"
|
<span class="tv-label" title="@node.Name"
|
||||||
@ondblclick="() => OpenTemplate(node.Composition!.ComposedTemplateId)">@node.Label</span>
|
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
|
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
|
||||||
{
|
{
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
{
|
{
|
||||||
case TmplNodeKind.Folder:
|
case TemplateTreeNodeKind.Folder:
|
||||||
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button>
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.Id)">New Folder</button>
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.EntityId}")'>New Template</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.Id}")'>New Template</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button>
|
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button>
|
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
case TemplateTreeNodeKind.Template:
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Inheriting Template</button>
|
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
|
||||||
<button class="dropdown-item" @onclick="() => OpenComposeDialog(node.Template!)">Compose into…</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.Id}")'>New Inheriting Template</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
@if (tmpl != null)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
|
||||||
|
}
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
|
@if (tmpl != null)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(tmpl)">Delete</button>
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Composition:
|
case TemplateTreeNodeKind.Composition:
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
|
if (_compositionsById.TryGetValue(node.Id, out var ctx))
|
||||||
<button class="dropdown-item" @onclick="() => RenameComposition(node.Composition!)">Rename…</button>
|
{
|
||||||
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition!)">Delete</button>
|
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
@* 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 ScadaLink.Commons.Entities.Templates
|
||||||
|
@using ScadaLink.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">
|
||||||
|
<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; } = "";
|
||||||
|
[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>
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
BuildTree();
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ExtraTemplateChildren(t))
|
||||||
|
{
|
||||||
|
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 = null;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
public enum TemplateTreeNodeKind
|
||||||
|
{
|
||||||
|
Folder,
|
||||||
|
Template,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composition slot under a parent Template — produced only by callers that
|
||||||
|
/// supply <c>TemplateFolderTree.ExtraTemplateChildren</c>. The Transport
|
||||||
|
/// Export wizard intentionally never emits this kind (compositions aren't
|
||||||
|
/// independently exportable); the Templates page uses it to surface slots.
|
||||||
|
/// </summary>
|
||||||
|
Composition,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter node used by <c>TemplateFolderTree</c> to model the template-folder
|
||||||
|
/// hierarchy in a TreeView. Folder nodes carry sub-folders + their templates as
|
||||||
|
/// children; template nodes are leaves unless the caller injects extras via
|
||||||
|
/// <c>TemplateFolderTree.ExtraTemplateChildren</c> (e.g. composition slots).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TemplateTreeNode
|
||||||
|
{
|
||||||
|
public required TemplateTreeNodeKind Kind { get; init; }
|
||||||
|
public required int Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public List<TemplateTreeNode> Children { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
|
||||||
|
public string Key => Kind switch
|
||||||
|
{
|
||||||
|
TemplateTreeNodeKind.Folder => $"f:{Id}",
|
||||||
|
TemplateTreeNodeKind.Template => $"t:{Id}",
|
||||||
|
TemplateTreeNodeKind.Composition => $"c:{Id}",
|
||||||
|
_ => $"x:{Id}",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user