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.
This commit is contained in:
Joseph Doherty
2026-05-11 10:20:50 -04:00
parent 872d358ad3
commit daa01261f3

View File

@@ -0,0 +1,252 @@
# 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`:
```csharp
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`:**
```csharp
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`:
```csharp
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).
- `TemplateServiceTests``MoveTemplateAsync` 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.