Replaces the current /design/templates list view with a Wonderware-style template toolbox: nested TemplateFolder entity, FolderId on Template, composition children as inline tree leaves, persistent split-pane with editor on the right, context menus + drag-drop reorg.
13 KiB
Templates Page — Folder & Hierarchy Reorganization
Date: 2026-05-11
Status: Design approved, ready for implementation planning
Scope: /design/templates page in Central UI, plus supporting data model, services, message contracts, and migration.
Goal
Replace the current single-list view at /design/templates with a tree-organized authoring surface modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and edit templates in a persistent split-pane layout.
Reference
The reference image (Wonderware Template Toolbox) shows three distinct concepts that this design carries over:
- Folders (yellow folder glyphs) — purely organizational, can be nested arbitrarily deep.
- Templates (
$Name) — placed inside folders or at the tree root. - Composition children — rendered inline under their owning template (e.g.,
$TestMachineshowsDelmiaReceiverandMESReceiver).
Inheritance is not rendered as tree nesting in the image, and it is not rendered as tree nesting in this design. Inheritance remains metadata on the template node label ("inherits $Parent").
Locked decisions
| Decision | Choice |
|---|---|
| Inheritance in tree | Not shown as nesting; shown as text on the template node label. |
| Folder model | New TemplateFolder entity with self-referencing ParentFolderId. Template.FolderId nullable. |
| Reorganization UX | Context menus + native HTML5 drag-drop. |
| Composition rendering | Read-only leaves with navigation; right-click → Open composed template / Remove composition. |
| Root-level templates | Allowed (FolderId nullable). Existing templates migrate with FolderId = null. |
| Folder delete with contents | Blocked; structured error lists child counts. |
| Page layout | Persistent split pane: tree on left (~25–33% width), template detail/editor on right. |
Data model
New entity in src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs:
public class TemplateFolder
{
public int Id { get; set; }
public string Name { get; set; } // unique among siblings of the same parent (case-insensitive)
public int? ParentFolderId { get; set; } // null = root
public int SortOrder { get; set; } // reserved for future manual ordering; defaults to 0
// Audit fields follow existing entity conventions.
public TemplateFolder(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
Modification to Template:
public int? FolderId { get; set; } // null = root
Invariants (server-enforced):
- Folder name unique among siblings of the same parent (case-insensitive).
ParentFolderIdgraph is acyclic.- A folder cannot be deleted if it has any child folders or child templates.
- Moving a template into a folder is a single FK update; folders carry no semantic meaning to the template engine.
Repository surface (in ITemplateEngineRepository or a new ITemplateFolderRepository):
GetAllFoldersAsync()GetFolderAsync(int id)AddFolderAsync(TemplateFolder)UpdateFolderAsync(TemplateFolder)DeleteFolderAsync(int id)MoveFolderAsync(int folderId, int? newParentId)MoveTemplateAsync(int templateId, int? newFolderId)
Migration: EF Core migration adds a TemplateFolders table and a nullable FolderId column on Templates. Existing templates retain FolderId = null (root). No data movement.
Audit: All folder mutations and template-folder moves go through IAuditService with the same conventions as existing template operations.
Server-side service
TemplateFolderService (new, in src/ScadaLink.TemplateEngine/), mirroring TemplateService:
CreateFolderAsync(name, parentFolderId?, user) → Result<TemplateFolder>RenameFolderAsync(id, newName, user) → Result<TemplateFolder>MoveFolderAsync(id, newParentId?, user) → Result<TemplateFolder>— cycle check: walk parent chain fromnewParentIdupward, reject ifidappears.DeleteFolderAsync(id, user) → Result<Unit>— structured failure with(childFolderCount, childTemplateCount)when non-empty.MoveTemplateAsync(templateId, newFolderId?, user) → Result<Template>— also accessible fromTemplateService.
Validations on all paths: non-empty name, name unique among siblings, parent exists (when not null).
Management Service contracts
In src/ScadaLink.Commons/Messages/Management/:
CreateTemplateFolderRequest/ResponseRenameTemplateFolderRequest/ResponseMoveTemplateFolderRequest/ResponseDeleteTemplateFolderRequest/ResponseListTemplateFoldersRequest/ResponseMoveTemplateToFolderRequest/Response
Additive-only evolution rules apply. Management actor handlers delegate to TemplateFolderService. Required for parity with the rest of the management API and makes future CLI support free (CLI is out of scope here).
Authorization: All folder operations require the Design policy.
Tree model
Page-level tree node (TmplNode) consolidates all three node kinds into one structure for the generic TreeView:
private enum TmplNodeKind { Folder, Template, Composition }
private record TmplNode(
string Key, // "f:{id}" | "t:{id}" | "c:{id}" — uniqueness across kinds
TmplNodeKind Kind,
int EntityId, // FolderId, TemplateId, or CompositionId
string Label,
int? ParentFolderId, // folders + templates
int? OwnerTemplateId, // composition leaves: the template that owns this composition
Template? Template, // populated for Template nodes (for inline metadata)
TemplateComposition? Composition, // populated for Composition nodes
List<TmplNode> Children);
Build order in LoadTreeAsync():
GetAllFoldersAsync()+GetAllTemplatesAsync()(andGetAllCompositionsAsync()if compositions aren't eager-loaded by the list call).- Build folder nodes keyed
f:{id}, attach byParentFolderId. - For each template, build a Template node and attach its compositions as
c:{compositionId}leaves. - Attach each template to its
FolderIdfolder, or to_rootsifFolderId == null. - Sort siblings: folders first (alphabetical by name), then templates (alphabetical by name). Compositions sort alphabetical by
InstanceName.
TreeView wiring:
| Param | Value |
|---|---|
Items |
_roots |
ChildrenSelector |
n => n.Children |
HasChildrenSelector |
n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0 |
KeySelector |
n => (object)n.Key |
StorageKey |
"templates-tree" (preserved from current usage) |
Selectable |
true |
SelectedKeyChanged |
dispatch on key prefix: t: → load template into right pane; f: → no-op; c: → reveal + select composed template |
Inline node labels:
- Folder: glyph + name + child-count badge.
- Template:
<strong>$Name</strong>+ optional "inherits $Parent" muted text + existing attr/alarm/script/comp count badges. - Composition:
<span>InstanceName</span>+ muted→ $ComposedTemplateName.
Search/filter: out of scope for v1; the underlying component supports external filtering (per Component-TreeView.md R8) so it can be added later without component changes.
Page layout
Two-column split inside the existing page container:
+-------------------------------+----------------------------------------+
| Templates | $TestMachine |
| [+Folder][+Template][Expand] | inherits $gMachine |
| | [Properties] [Validate] |
| ▶ 📁 _Default Templates | |
| ▼ 📁 Dev | Tabs: Attributes | Alarms | Scripts |
| $TestMachine | | Compositions |
| DelmiaReceiver → ... | ... |
| MESReceiver → ... | |
| $TestObject | |
| ▶ 📁 System | |
| $UnfiledTemplate | |
+-------------------------------+----------------------------------------+
- Left column: ~25–33% (
col-md-4 col-lg-3), scrollable (max-height: calc(100vh - 160px); overflow-y: auto). - Right column: existing template properties card, validation block, four-tab editor (Attributes / Alarms / Scripts / Compositions) lifted unchanged into
RenderTemplateDetail(). - "Back to List" button is removed — the tree is always visible.
- Empty state in the right column when nothing is selected.
- URL contract preserved:
/design/templates/{id}selects + reveals the template on load viaTreeView.RevealNode("t:" + id, select: true).
Context menus
Per-node-kind ContextMenu fragment driven by node.Kind:
Folder: New Folder · New Template · Rename · Delete Template: Edit · Move to Folder… (modal with folder-only mini-tree, "(Root)" option) · Delete Composition: Open composed template · Remove composition
The "New Template" modal collects name + description and creates with FolderId = thisFolder.Id. Root-level "+Folder" and "+Template" buttons live in the tree-sidebar toolbar above the tree.
Drag-drop
Native HTML5 drag-drop, no library.
Draggability: folders and templates are draggable="true". Composition nodes are not draggable.
Drop targets: folder nodes and the root sidebar wrapper. Template/composition nodes are not drop targets in v1.
Payload: _dragPayload = (kind, id) held in component state on @ondragstart.
Visual feedback: CSS drag-over class toggled via @ondragenter / @ondragleave; compositions dimmed to 0.5 opacity while drag in progress.
Server-side validation (authoritative):
- Folder onto descendant → reject (cycle).
- Folder onto itself → no-op.
- Drop on a composition → ignored.
- Template-onto-template → ignored (no sibling reordering, no surprising "drop into parent's folder").
Edge cases
- Deep-link route reveals ancestors via
RevealNode. - Stale
f:{id}keys insessionStorageafter folder delete are harmless (ignored on next render). - Selected template moved to another folder → tree rebuilds; selection preserved by stable key.
- Selected template deleted → right pane clears to empty state.
- Last-write-wins on concurrent folder edits, matching existing template policy.
- Tree fully rebuilt on every CRUD; expected scale (dozens to low hundreds) makes this trivially cheap.
Validation summary
| Operation | Check | Failure mode |
|---|---|---|
| Create folder | name non-empty, unique among siblings | structured error |
| Rename folder | same as create | structured error |
| Move folder | parent exists or null; no cycle; name still unique in new parent | structured error |
| Delete folder | no child folders, no child templates | error with counts |
| Move template | target folder exists or null | structured error |
Testing
Unit (tests/ScadaLink.TemplateEngine.Tests/):
TemplateFolderServiceTests— create / rename / move (happy + cycle + duplicate) / delete (happy + non-empty).TemplateServiceTests—MoveTemplateAsynchappy + missing target.- Migration test confirming nullable
FolderIdand existing templates retaining null.
bUnit (tests/ScadaLink.CentralUI.Tests/):
- Tree renders folders / templates / compositions in correct nesting.
- Empty state when no selection.
- Selecting a template node loads the detail pane.
- Selecting a composition reveals + selects the composed template.
- Right-click menus differ by node kind.
- Folder-delete-non-empty surfaces structured error toast.
- Deep link selects + reveals.
Manual smoke (per CLAUDE.md): nested folder creation, drag-drop reorg, cycle rejection, refresh persistence, composition navigation.
Documentation updates
docs/requirements/Component-CentralUI.md— describe the templates page tree layout.docs/requirements/Component-TemplateEngine.md— addTemplateFolderentity + folder operations.docs/requirements/Component-ConfigurationDatabase.md— addTemplateFolderstable +Templates.FolderIdcolumn.docs/requirements/Component-ManagementService.md— add new message contracts.README.md— note folder organization in the Template Engine row's responsibilities.
Out of scope (for v1)
- Tree search / filter input (component already supports it; add when needed).
- CLI commands for folder operations (message contracts make this trivial later).
- Sibling reorder via drag-drop (sort stays alphabetical).
- Root context menu (right-click in empty tree area).
- Bootstrap Icons CDN — current pages don't use it, so this design uses Unicode glyphs.