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:
OpenTemplate(node.EntityId)">@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;
- OpenTemplate(node.Composition!.ComposedTemplateId)">@node.Label
+ OpenTemplate(composedId)">@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
+ {
+
+
+
+ InvokeNodeClick(node)">@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 Templates { get; set; } = Array.Empty();
+ [Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
+ [Parameter] public HashSet