Files
scadalink-design/docs/plans/2026-05-11-templates-folder-hierarchy-design.md
Joseph Doherty 8e388a89c5 feat(ui/templates): adopt TreeView design guide; split editor to /design/templates/{id}
Templates page is now a tree-only browser; editing happens on a dedicated
TemplateEdit page. Drag-drop is replaced by context-menu Move-to-Folder.
TreeView gains Bootstrap Icons (chevron + per-kind glyphs), ancestor guide
lines, defined hover/selected/focus tokens, and Escape-dismisses-menu per
the new Visual Design Guide (V1-V7) in Component-TreeView.md.
2026-05-11 20:52:34 -04:00

14 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 browser modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and navigate to a dedicated edit page (/design/templates/{id}) when authoring a specific template. The tree page itself does not host the editor.

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; not shown on the node label either (label is name only). Inheritance is visible in the TemplateEdit page when a template is selected.
Folder model New TemplateFolder entity with self-referencing ParentFolderId. Template.FolderId nullable.
Reorganization UX Right-click context menus only (no drag-drop). Modal dialog pickers for move targets.
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 Tree browser only — no split-pane editor. Selecting a template navigates to /design/templates/{id} (TemplateEdit page); creating navigates to /design/templates/create.
Tree node visuals Per Component-TreeView.md Visual Design Guide V7: Bootstrap Icons (bi-folder / bi-folder2-open / bi-file-earmark-text / bi-arrow-return-right), name-only labels (no count/inherit badges on template nodes), folder child-count pill, composition → $Target muted secondary text.

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:NavigationManager.NavigateTo($"/design/templates/{id}") (TemplateEdit page); f: → no-op; c:NavigateTo the composed template's edit page

Inline node labels (see Component-TreeView.md V7 for the canonical recipe):

  • Folder: <i class="bi bi-folder"> (closed) or <i class="bi bi-folder2-open"> (expanded) + name (semibold when has children) + count-pill badge of direct children.
  • Template: <i class="bi bi-file-earmark-text"> + $Name (semibold when has compositions). No inheritance hint, no attr/alarm/script count, no composition count on the node.
  • Composition: <i class="bi bi-arrow-return-right"> + composition instance name + muted → $ComposedTemplateName secondary text.

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

/design/templates is a single-column tree browser — no inline editor, no split pane.

+--------------------------------------------+
| Templates                                  |
| [+Folder] [+Template] [Expand] [Collapse]  |
|                                            |
| ▶ 📁 _Default Templates                    |
| ▼ 📂 Dev                                   |
|     📄 $TestMachine                        |
|        ↪ DelmiaReceiver → $DelmiaSvc       |
|        ↪ MESReceiver   → $MesSvc           |
|     📄 $TestObject                         |
| ▶ 📁 System                                |
| 📄 $UnfiledTemplate                        |
+--------------------------------------------+
  • Tree scrollable region: max-height: calc(100vh - 160px); overflow-y: auto. The 2533% sidebar width constraint is removed; the tree uses the page's main container width.
  • Selecting a template node navigates to /design/templates/{id} (TemplateEdit page).
  • Selecting a composition node navigates to the composed template's edit page.
  • Selecting a folder node is a no-op (still allowed; expansion and context-menu still work).
  • Creating a template: toolbar "+ Template" button (or folder context-menu "New Template") navigates to /design/templates/create?folderId={id}. After successful create, the create page navigates to /design/templates/{newId}.
  • URL contract for deep links: /design/templates/{id} resolves to the TemplateEdit page directly — the browser doesn't need to be on the tree page first.

Context menus

The context menu is the only reorganization mechanism. Per-node-kind ContextMenu fragment driven by node.Kind:

Folder: New Folder · New Template · Rename · Move to Folder… · Delete Template: Edit · Move to Folder… · Delete Composition: Open composed template · Remove composition

  • Move to Folder… opens a modal (MoveFolderDialog / MoveTemplateDialog) with a flat folder picker. The list includes "(Root)" as the first entry. For folder-move, the dialog client-side prunes the folder being moved and its descendants from the candidate list to prevent obvious cycles; the server still validates (authoritative). For template-move, all folders are valid targets.
  • Edit on a template navigates to /design/templates/{id} (TemplateEdit page) — equivalent to clicking the node, kept in the menu for discoverability.
  • Root-level "+ Folder" and "+ Template" buttons live in the toolbar above the tree.

Server-side validation (authoritative):

  • Folder onto descendant → reject (cycle).
  • Folder onto itself → no-op (client prunes).
  • Template-onto-template → not a valid target (templates aren't shown in the folder picker).

Edge cases

  • Deep-link route /design/templates/{id} resolves directly to the TemplateEdit page; the tree page is not involved. If the user navigates back, the tree's sessionStorage-persisted expansion state is restored.
  • 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.
  • Template deleted from the TemplateEdit page → page navigates back to /design/templates; the tree rebuilds without the deleted node.
  • 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 roots exist (no folders, no root templates).
  • Selecting a template node invokes NavigationManager.NavigateTo($"/design/templates/{id}").
  • Selecting a composition node invokes NavigateTo for the composed template's edit page.
  • Selecting a folder node is a no-op (no navigation).
  • Right-click menus differ by node kind (Folder / Template / Composition each have distinct items).
  • Folder context menu includes "Move to Folder…"; the dialog excludes the folder being moved and its descendants from candidates.
  • Folder-delete-non-empty surfaces a structured error toast.
  • Bootstrap Icons render in the glyph slot for each node kind (bi-folder / bi-folder2-open / bi-file-earmark-text / bi-arrow-return-right).

Manual smoke (per CLAUDE.md): nested folder creation, context-menu reorg (folder + template Move-to-Folder dialogs), cycle rejection, refresh persistence, composition navigation, navigation from tree to TemplateEdit and back.

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 (sort stays alphabetical).
  • Root context menu (right-click in empty tree area).
  • (Removed from out-of-scope.) Bootstrap Icons are now adopted (static files at wwwroot/lib/bootstrap-icons/) — see Component-TreeView.md V4.