Files
ScadaBridge/docs/plans/2026-08-21-template-picker-design.md
T

143 lines
9.8 KiB
Markdown

# Template picker for Create Instance + "New Instance…" from the Templates tree (design)
**Date:** 2026-08-21 · **Status:** **implemented** 2026-08-21 — plan `docs/plans/2026-08-21-template-picker.md`
**Mockup:** Claude artifact "Template Picker" (https://claude.ai/code/artifact/71fea4e0-eafc-4b84-8157-66b450126330)
**Builds on:** `docs/plans/2026-08-21-create-instance-bindings-design.md` (the create page this changes), the shared `TemplateFolderTree` (Templates page, Transport export wizard)
## 1. Problem
Three operator reports against the 2026-08-21 build:
1. **Topology → "+ Instance"** lands on `/deployment/instances/create`, whose Template field is a flat
`<select>` of every template in the system, alphabetical, no folders, no search. The Templates
page organises the same set as a folder tree; the create page should pick from that tree.
2. **Templates page** has no way to go from a template to a new instance of it — the menu offers
"New Inheriting Template" and "Compose into…" but the most common next step (make one) means
leaving for Topology and re-finding the template in the flat select.
3. **Topology → right-click → Disable** shows a confirm whose button reads "Delete".
Item 3 is **already fixed on `main`**: commit `0db253b5` (2026-08-15, deferred-work register
row 28) made `DisableInstance` pass `confirmLabel: "Disable"` and `DialogHost` only fall back to
"Delete" for an *unlabelled* destructive confirm. That commit is an ancestor of `691a2f2f`, the
build on the docker rig, QA and vd03 as of 2026-08-21. This design pins it with a bUnit test (the
failover button has one — `FailoverConfirmLabelTests` — the Disable button does not) and adds a
live check; no code change is expected.
## 2. Decisions
| Decision | Choice | Alternatives considered |
|---|---|---|
| Shape of the template field | **Read-only field showing `Folder / Subfolder / Template` + a "Choose…" button** that opens a modal picker. The field itself is clickable. | Inline tree embedded in the form (pushes Site/Area/Bindings below the fold for a 60-template tree); a custom dropdown-tree (new widget, no keyboard story). |
| Picker implementation | **New `TemplatePickerDialog` body component hosted by `IDialogService.ShowAsync<TemplatePickResult>`**, wrapping the existing `TemplateFolderTree` in Single mode with its own search box, Expand/Collapse all, Select/Cancel. Same pattern as `MoveTemplateDialog`/`ComposeIntoDialog`. | Reusing the Templates page itself in "pick mode" (drags its context menus, composition leaves and storage key along). |
| What is selectable | **Templates only.** Clicking a folder toggles it; clicking a template highlights it; double-click = Select. | Allowing folder selection (meaningless for create). |
| Preselect | **`?templateId=` query parameter** on the create page — the existing `SiteId`/`AreaId` pattern. Picker opens expanded to and highlighting the current template. | None; the Templates-page menu item needs it. |
| Templates-page menu item | **"New Instance…"** on Template nodes only, in its own group between the design verbs and the housekeeping verbs, **shown only when the user holds the Deployer role** (the create page is `RequireDeployment`). Navigates to `/deployment/instances/create?templateId={id}`. | Always show and let the 403 explain; a "New Instance" toolbar button (no template context). |
| Expansion persistence | **None** in the picker (no `StorageKey`). The picker is transient; the Templates page keeps its own `templates-tree` key. | Sharing the Templates page key (a filter in one place would surprise the other). |
| Disable confirm | **Pin only** — bUnit test through the real `DialogService` + `DialogHost`, plus a live check on the rig. | — |
## 3. Design
### 3.1 `TemplateFolderTree` — reveal a key (Shared, additive)
New parameter
```csharp
[Parameter] public IReadOnlyCollection<string>? RevealKeys { get; set; }
```
When set and no `Filter` is active, `ApplyFilter` builds `_initiallyExpanded` from the ancestor
keys of every listed node key (a `t:{id}` leaf reveals its `f:{id}` chain). With a filter active the
filter's own reveal set wins (it already expands ancestors of matches). Existing callers pass
nothing and are unchanged. This is what lets the picker open on the preselected template.
### 3.2 `TemplatePickerDialog` (Shared, new)
Body component for `IDialogService.ShowAsync<TemplatePickerDialog.TemplatePickResult>`:
- Parameters: `DialogContext<TemplatePickResult> Context`, `IReadOnlyList<TemplateFolder> Folders`,
`IReadOnlyList<Template> Templates`, `int? InitialTemplateId`.
- Layout (mockup §2): search input (`Filter`) with clear ✕ · Expand all / Collapse all buttons ·
`TemplateFolderTree` in `Single` mode, `SelectedKeys` bound to `{ "t:{id}" }`, `RevealKeys` = the
initial template's key, `OnNodeClick` → folder: no-op (the row's caret handles toggling),
template: set `_selectedId`; `@ondblclick` on template rows (via `NodeContent`) → `Submit()`.
- Footer: status text "Selected: Folder / … / Name" or "Select a template"; Cancel →
`Context.Cancel()`; Select (disabled until `_selectedId > 0`) → `Context.Close(new TemplatePickResult(_selectedId))`.
- `public sealed record TemplatePickResult(int TemplateId)` — a record so Cancel (`null`) is
distinguishable from a pick, mirroring `MoveTemplateResult`.
- Path rendering is a small pure helper `TemplatePathFormatter.Format(template, foldersById)` in
`Services/` (walks `FolderId` → parent chain, joins with " / "); shared with the create page's
field and unit-pinned.
Size: `modal-lg`. No `StorageKey`, no `ContextMenu`, no `ExtraTemplateChildren`.
### 3.3 Create page (`InstanceCreate.razor` / `.razor.cs`)
- Replace the `<select data-test="create-template">` with an input-group: a read-only
`<input data-test="create-template" readonly value="@TemplatePathLabel">` (placeholder
"Choose a template…") and a `Choose…` button; both open the picker. The `data-test` hook is kept
so any future Playwright selector keeps resolving to the template control.
- `OpenTemplatePickerAsync()`: loads `_folders` (once, `ITemplateEngineRepository.GetAllFoldersAsync`,
alongside the existing `_templates` load), calls `ShowAsync`, and on a non-null result sets
`_createTemplateId` and calls the existing `OnTemplateOrSiteChangedAsync()` — so the Connection
Bindings card re-previews exactly as a `<select>` change did.
- New `[SupplyParameterFromQuery] public int? TemplateId { get; set; }`. In `OnInitializedAsync`,
after templates load: if it names an existing template, set `_createTemplateId` (the existing
"kept so a future preselect gets the card for free" call to `OnTemplateOrSiteChangedAsync` now
does real work when `SiteId` was also supplied); if it names nothing, leave the field empty and
show a small muted hint "Template {id} no longer exists — choose another."
- Empty-catalogue state: when `_templates` is empty the button is disabled and the hint links to
`/design/templates`.
- Nothing else on the page changes; `CreateInstanceAsync`/bindings flow is untouched.
### 3.4 Templates page (`Templates.razor`)
In `RenderNodeContextMenu`, `case TemplateTreeNodeKind.Template`, after "Compose into…":
```razor
@if (_canCreateInstances)
{
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-test="ctx-new-instance"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?templateId={node.Id}")'>New Instance…</button>
<div class="dropdown-divider"></div>
}
```
`_canCreateInstances` is resolved once in `OnInitializedAsync` from the authentication state:
`user.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer)` — the same claim
`AuthorizationPolicies.RequireDeployment` checks, evaluated in code rather than through an
`AuthorizeView` so the context-menu render fragment stays free of cascading-auth plumbing.
### 3.5 Disable confirm
No change. Add `TopologyDisableConfirmLabelTests` modelled on `FailoverConfirmLabelTests`: render
`Topology` + a live `DialogHost` sharing one `DialogService`, right-click a Deployed instance, click
Disable, assert the confirm button text is "Disable" and its class is `btn-danger`. Live check on the
rig is part of the plan's final task.
## 4. Out of scope
- Multi-select / creating several instances at once.
- Remembering the last-picked template or folder between visits.
- A template-level "preferred connection" default (still the follow-up noted in the bindings design).
- CLI changes — `instance create --template <id|name>` already takes the template directly.
## 5. Testing
- `TemplateFolderTreeRevealKeysTests`: `RevealKeys = ["t:7"]` expands the folder chain above
template 7 and nothing else; with a `Filter` set the filter's reveal set wins; no `RevealKeys` ⇒
behaviour unchanged (all collapsed).
- `TemplatePickerDialogTests` (bUnit, real `DialogService` + `DialogHost`): renders folders/templates
at nesting; Select disabled until a template row is clicked; clicking a template then Select
resolves `TemplatePickResult(id)`; double-click resolves; Cancel resolves null; `InitialTemplateId`
highlights and reveals; folder click selects nothing; search filters and reveals.
- `TemplatePathFormatterTests`: root template ⇒ name only; nested ⇒ `A / B / Name`; orphan folder id
⇒ name only (no throw).
- `TemplatesPageTests`: Template context menu contains "New Instance…" for a Deployer, omits it for
a Designer-only user; clicking it navigates to `/deployment/instances/create?templateId={id}`
(bUnit `NavigationManager`).
- `TopologyDisableConfirmLabelTests` (§3.5).
- Live check on the docker rig: Templates → right-click a composed template → New Instance… →
create page arrives with the template pre-filled → pick a site → bindings card appears → Choose…
reopens the picker expanded to that template; Topology → Disable → button reads Disable.