refactor(centralui): extract TemplateFolderTree as shared component

This commit is contained in:
Joseph Doherty
2026-05-24 05:18:12 -04:00
parent e099ed2038
commit 01f4eeaef5
3 changed files with 343 additions and 141 deletions
@@ -2,6 +2,7 @@
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.CentralUI.Components.Shared
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@@ -79,11 +80,12 @@
</div>
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="templates-tree">
<TemplateFolderTree @ref="_tree"
Folders="_folders"
Templates="_templates"
SelectionMode="TreeViewSelectionMode.Single"
ExtraTemplateChildren="BuildCompositionLeavesFor"
StorageKey="templates-tree">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
@@ -93,7 +95,7 @@
<EmptyContent>
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent>
</TreeView>
</TemplateFolderTree>
</div>
}
</div>
@@ -124,7 +126,11 @@
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).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)
{
@@ -133,134 +139,53 @@
_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(
string Key,
TmplNodeKind Kind,
int EntityId,
string Label,
int? ParentFolderId,
int? OwnerTemplateId,
Template? Template,
TemplateComposition? Composition,
List<TmplNode> Children);
private List<TmplNode> _treeRoots = new();
private void BuildTemplateTree()
// Composition-leaf builder for TemplateFolderTree's ExtraTemplateChildren
// hook: walks each template's compositions recursively so cascaded slots
// appear as nested children. The Transport Export wizard intentionally
// does NOT supply this hook — compositions aren't independently exportable.
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
{
// 1. Folder nodes keyed by id
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>();
var result = new List<TemplateTreeNode>();
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
{
var nestedChildren = templatesById.TryGetValue(c.ComposedTemplateId, out var composed)
? BuildCompositionLeaves(composed, templatesById)
: new List<TmplNode>();
var node = new TemplateTreeNode
{
Kind = TemplateTreeNodeKind.Composition,
Id = c.Id,
Name = c.InstanceName,
};
result.Add(new TmplNode(
Key: $"c:{c.Id}",
Kind: TmplNodeKind.Composition,
EntityId: c.Id,
Label: c.InstanceName,
ParentFolderId: null,
OwnerTemplateId: owner.Id,
Template: null,
Composition: c,
Children: nestedChildren));
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
{
foreach (var nested in BuildCompositionLeavesFor(composed))
{
node.Children.Add(nested);
}
}
result.Add(node);
}
return result;
}
private static void SortChildren(List<TmplNode> children)
{
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 TemplateFolderTree _tree = default!;
private void OpenTemplate(int templateId) =>
NavigationManager.NavigateTo($"/design/templates/{templateId}");
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
private RenderFragment RenderNodeLabel(TemplateTreeNode node) => __builder =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
var folderOpen = _tree?.IsExpanded(node.Key) ?? false;
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
case TemplateTreeNodeKind.Folder:
<span class="tv-glyph"><i class="bi bi-folder"></i></span>
<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)
{
<span class="tv-meta">
@@ -269,47 +194,58 @@
}
break;
case TmplNodeKind.Template:
case TemplateTreeNodeKind.Template:
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label"
@ondblclick="() => OpenTemplate(node.EntityId)">@node.Label</span>
title="@node.Name"
@ondblclick="() => OpenTemplate(node.Id)">@node.Name</span>
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-label" title="@node.Label"
@ondblclick="() => OpenTemplate(node.Composition!.ComposedTemplateId)">@node.Label</span>
<span class="tv-label" title="@node.Name"
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
break;
}
};
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button>
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.EntityId}")'>New Template</button>
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button>
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.EntityId, node.Label)">Move to Folder…</button>
case TemplateTreeNodeKind.Folder:
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.Id)">New Folder</button>
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.Id}")'>New Template</button>
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
<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;
case TmplNodeKind.Template:
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Inheriting Template</button>
<button class="dropdown-item" @onclick="() => OpenComposeDialog(node.Template!)">Compose into…</button>
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
case TemplateTreeNodeKind.Template:
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.Id}")'>New Inheriting Template</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>
<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;
case TmplNodeKind.Composition:
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
<button class="dropdown-item" @onclick="() => RenameComposition(node.Composition!)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition!)">Delete</button>
case TemplateTreeNodeKind.Composition:
if (_compositionsById.TryGetValue(node.Id, out var ctx))
{
<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>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
}
break;
}
};