From 01f4eeaef50cbcce2938c50bcb6444924e1c250d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 05:18:12 -0400 Subject: [PATCH] refactor(centralui): extract TemplateFolderTree as shared component --- .../Components/Pages/Design/Templates.razor | 218 ++++++----------- .../Shared/TemplateFolderTree.razor | 228 ++++++++++++++++++ .../Components/Shared/TemplateTreeNode.cs | 38 +++ 3 files changed, 343 insertions(+), 141 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/TemplateTreeNode.cs diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor index 431d8f8..ae358bb 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor @@ -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 @@
- + @RenderNodeLabel(node) @@ -93,7 +95,7 @@ No templates yet. Use the buttons above to create a folder or template. - +
} @@ -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 _templatesById = new(); + private Dictionary _compositionsById = new(); - private record TmplNode( - string Key, - TmplNodeKind Kind, - int EntityId, - string Label, - int? ParentFolderId, - int? OwnerTemplateId, - Template? Template, - TemplateComposition? Composition, - List Children); - - private List _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 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())); - - // 2. Attach folder nodes by ParentFolderId - var roots = new List(); - 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 BuildCompositionLeaves(Template owner, IReadOnlyDictionary templatesById) - { - var result = new List(); + var result = new List(); 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(); + 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 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 _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; - + case TemplateTreeNodeKind.Folder: + @node.Label + title="@node.Name">@node.Name @if (node.Children.Count > 0) { @@ -269,47 +194,58 @@ } break; - case TmplNodeKind.Template: + case TemplateTreeNodeKind.Template: @node.Label + title="@node.Name" + @ondblclick="() => OpenTemplate(node.Id)">@node.Name break; - case TmplNodeKind.Composition: + case TemplateTreeNodeKind.Composition: + var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0; - @node.Label + @node.Name break; } }; - private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder => + private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder => { switch (node.Kind) { - case TmplNodeKind.Folder: - - - - + case TemplateTreeNodeKind.Folder: + + + + - + break; - case TmplNodeKind.Template: - - - + case TemplateTreeNodeKind.Template: + var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null; + + @if (tmpl != null) + { + + } + - + @if (tmpl != null) + { + + } break; - case TmplNodeKind.Composition: - - - - + case TemplateTreeNodeKind.Composition: + if (_compositionsById.TryGetValue(node.Id, out var ctx)) + { + + + + + } break; } }; diff --git a/src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor b/src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor new file mode 100644 index 0000000..4493965 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor @@ -0,0 +1,228 @@ +@* Shared template folder/template tree. + + Wraps TreeView 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 + + + + @if (NodeContent != null) + { + @NodeContent(node) + } + else + { + + + + @node.Name + @if (NodeExtras != null) + { + @NodeExtras(node) + } + } + + + @if (ContextMenu != null) + { + @ContextMenu(node) + } + + + @if (EmptyContent != null) + { + @EmptyContent + } + else + { + No templates. + } + + + +@code { + [Parameter] public IReadOnlyList Folders { get; set; } = Array.Empty(); + [Parameter] public IReadOnlyList