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

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:

  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

[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…":

@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.