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

253 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.