Right-click a template now offers "New Derived Template" — opens TemplateCreate with the parent pre-selected via a new ?parentId query parameter. Composition rows in the tree drop the trailing "→ TargetName" muted text; the kind glyph plus the instance name carry enough meaning, and the composed template is one click away from the row's right-click menu.
249 lines
15 KiB
Markdown
249 lines
15 KiB
Markdown
# 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; composition rows also name-only — the glyph signals the kind), folder child-count pill. |
|
||
|
||
## 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:` → `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 only. The composed template name is intentionally omitted from the tree — open the owning template's edit page to see/manage compositions.
|
||
|
||
**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 |
|
||
| ↪ MESReceiver |
|
||
| 📄 $TestObject |
|
||
| ▶ 📁 System |
|
||
| 📄 $UnfiledTemplate |
|
||
+--------------------------------------------+
|
||
```
|
||
|
||
- Tree scrollable region: `max-height: calc(100vh - 160px); overflow-y: auto`. The 25–33% 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).
|
||
- `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 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.
|