Files
scadalink-design/docs/plans/2026-05-11-templates-folder-hierarchy-design.md
Joseph Doherty daa01261f3 design(templates-page): folder hierarchy and split-pane tree layout
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.
2026-05-11 10:20:50 -04:00

13 KiB
Raw Blame History

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., $TestMachine shows DelmiaReceiver and MESReceiver).

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 (~2533% 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).
  • ParentFolderId graph 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 from newParentId upward, reject if id appears.
  • DeleteFolderAsync(id, user) → Result<Unit> — structured failure with (childFolderCount, childTemplateCount) when non-empty.
  • MoveTemplateAsync(templateId, newFolderId?, user) → Result<Template> — also accessible from TemplateService.

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 / Response
  • RenameTemplateFolderRequest / Response
  • MoveTemplateFolderRequest / Response
  • DeleteTemplateFolderRequest / Response
  • ListTemplateFoldersRequest / Response
  • MoveTemplateToFolderRequest / 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():

  1. GetAllFoldersAsync() + GetAllTemplatesAsync() (and GetAllCompositionsAsync() if compositions aren't eager-loaded by the list call).
  2. Build folder nodes keyed f:{id}, attach by ParentFolderId.
  3. For each template, build a Template node and attach its compositions as c:{compositionId} leaves.
  4. Attach each template to its FolderId folder, or to _roots if FolderId == null.
  5. 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: ~2533% (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 via TreeView.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 in sessionStorage after 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).
  • TemplateServiceTestsMoveTemplateAsync happy + missing target.
  • Migration test confirming nullable FolderId and 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 — add TemplateFolder entity + folder operations.
  • docs/requirements/Component-ConfigurationDatabase.md — add TemplateFolders table + Templates.FolderId column.
  • 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.