4bb77cf0f7
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
143 lines
9.8 KiB
Markdown
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.
|