Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
9.8 KiB
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:
- 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. - 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.
- 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
[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 ·TemplateFolderTreeinSinglemode,SelectedKeysbound to{ "t:{id}" },RevealKeys= the initial template's key,OnNodeClick→ folder: no-op (the row's caret handles toggling), template: set_selectedId;@ondblclickon template rows (viaNodeContent) →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, mirroringMoveTemplateResult.- Path rendering is a small pure helper
TemplatePathFormatter.Format(template, foldersById)inServices/(walksFolderId→ 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 aChoose…button; both open the picker. Thedata-testhook is kept so any future Playwright selector keeps resolving to the template control. OpenTemplatePickerAsync(): loads_folders(once,ITemplateEngineRepository.GetAllFoldersAsync, alongside the existing_templatesload), callsShowAsync, and on a non-null result sets_createTemplateIdand calls the existingOnTemplateOrSiteChangedAsync()— so the Connection Bindings card re-previews exactly as a<select>change did.- New
[SupplyParameterFromQuery] public int? TemplateId { get; set; }. InOnInitializedAsync, after templates load: if it names an existing template, set_createTemplateId(the existing "kept so a future preselect gets the card for free" call toOnTemplateOrSiteChangedAsyncnow does real work whenSiteIdwas 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
_templatesis 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…":
@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 aFilterset the filter's reveal set wins; noRevealKeys⇒ behaviour unchanged (all collapsed).TemplatePickerDialogTests(bUnit, realDialogService+DialogHost): renders folders/templates at nesting; Select disabled until a template row is clicked; clicking a template then Select resolvesTemplatePickResult(id); double-click resolves; Cancel resolves null;InitialTemplateIdhighlights 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}(bUnitNavigationManager).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.