docs(plans): template picker design + 8-task plan — Create Instance picks from the Templates folder tree (TemplatePickerDialog, ?templateId preselect), Templates tree gains Deployer-gated 'New Instance…', Topology Disable confirm verb pinned (already fixed by 0db253b5)

Claude-Session: https://claude.ai/code/session_01Rs5tk49eMrNcwmi8FAohHX
This commit is contained in:
Joseph Doherty
2026-08-21 06:51:27 -04:00
parent a3e2df325c
commit 22468c1548
3 changed files with 891 additions and 0 deletions
@@ -0,0 +1,142 @@
# Template picker for Create Instance + "New Instance…" from the Templates tree (design)
**Date:** 2026-08-21 · **Status:** designed — 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.
+735
View File
@@ -0,0 +1,735 @@
# Template Picker + "New Instance…" Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** The Create Instance page picks its template from the Templates-page folder tree (modal picker, `?templateId=` preselect); a template's right-click menu on the Templates page offers "New Instance…"; the Topology Disable confirm's "Disable" verb is pinned by a test and checked live.
**Architecture:** Central UI only — no server, repo, CLI, proto or migration change. A new `TemplatePickerDialog` body component (hosted by the existing `IDialogService.ShowAsync<T>` pattern) wraps the shared `TemplateFolderTree`, which gains one additive `RevealKeys` parameter. `InstanceCreate` swaps its `<select>` for a read-only path field + Choose… button and a `TemplateId` query parameter. `Templates.razor` adds one Deployer-gated menu item. The Disable label was fixed by `0db253b5`; this plan only pins it.
**Tech Stack:** .NET 10, Blazor Server + Bootstrap 5, bUnit + xUnit + NSubstitute (`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`).
**Design:** `docs/plans/2026-08-21-template-picker-design.md` · mockup https://claude.ai/code/artifact/71fea4e0-eafc-4b84-8157-66b450126330
**Branch:** `feat/template-picker` in its own worktree (`git worktree add ../ScadaBridge-template-picker -b feat/template-picker main`). Never `git stash` in agent prompts (shared across worktrees).
**Test command (filtered, per task):**
`dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~<ClassName>" --no-restore -v q`
---
### Task 1: `TemplatePathFormatter` (pure helper)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2, Task 5, Task 6
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/TemplatePathFormatter.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/TemplatePathFormatterTests.cs`
**Step 1: Write the failing tests**
```csharp
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class TemplatePathFormatterTests
{
private static Dictionary<int, TemplateFolder> Folders(params TemplateFolder[] f) => f.ToDictionary(x => x.Id);
[Fact]
public void RootTemplate_IsNameOnly()
{
var t = new Template("Motor Controller") { Id = 1, FolderId = null };
Assert.Equal("Motor Controller", TemplatePathFormatter.Format(t, Folders()));
}
[Fact]
public void NestedTemplate_JoinsFolderChainWithSlashes()
{
var controllers = new TemplateFolder("Controllers") { Id = 10, ParentFolderId = null };
var conveyor = new TemplateFolder("Conveyor") { Id = 11, ParentFolderId = 10 };
var t = new Template("MES Line Controller") { Id = 1, FolderId = 11 };
Assert.Equal("Controllers / Conveyor / MES Line Controller",
TemplatePathFormatter.Format(t, Folders(controllers, conveyor)));
}
[Fact]
public void UnknownFolderId_FallsBackToNameOnly_WithoutThrowing()
{
var t = new Template("Orphan") { Id = 1, FolderId = 999 };
Assert.Equal("Orphan", TemplatePathFormatter.Format(t, Folders()));
}
[Fact]
public void FolderCycle_Terminates()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = 2 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var t = new Template("T") { Id = 1, FolderId = 1 };
var s = TemplatePathFormatter.Format(t, Folders(a, b));
Assert.EndsWith("T", s); // no hang, no exception
}
}
```
(Check the `Template`/`TemplateFolder` constructors in `Commons/Entities/Templates/` — if they are parameterless with `Name { get; set; }`, use object initialisers instead.)
**Step 2: Run — expect compile failure (`TemplatePathFormatter` missing).**
**Step 3: Implement**
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Renders a template's location as "Folder / Subfolder / Name" for the Create
/// Instance template field and the picker footer. Pure; tolerates dangling or
/// cyclic folder ids (stops after 32 hops) so a half-migrated catalogue never
/// breaks the create page.
/// </summary>
public static class TemplatePathFormatter
{
public const string Separator = " / ";
public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById)
{
var parts = new List<string> { template.Name };
var next = template.FolderId;
var hops = 0;
while (next is int id && foldersById.TryGetValue(id, out var folder) && hops++ < 32)
{
parts.Add(folder.Name);
next = folder.ParentFolderId;
}
parts.Reverse();
return string.Join(Separator, parts);
}
}
```
**Step 4: Run — expect 4 PASS.**
**Step 5: Commit** — `feat(centralui): TemplatePathFormatter — folder-path label for template pickers`
---
### Task 2: `TemplateFolderTree.RevealKeys` (additive)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TemplateFolderTree.razor` (parameters ~L64-85, `ApplyFilter` ~L162-186)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplateFolderTreeRevealKeysTests.cs`
**Step 1: Failing tests** (bUnit; the tree renders `li[role='treeitem']` rows; an expanded folder renders its children — see `TreeViewTests.cs` for the expand/collapse assertions to copy)
```csharp
public class TemplateFolderTreeRevealKeysTests : BunitContext
{
private static (List<TemplateFolder> folders, List<Template> templates) Catalogue()
{
var a = new TemplateFolder("A") { Id = 1 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var other = new TemplateFolder("Other") { Id = 3 };
var t7 = new Template("Deep") { Id = 7, FolderId = 2 };
var t8 = new Template("Elsewhere") { Id = 8, FolderId = 3 };
return (new() { a, b, other }, new() { t7, t8 });
}
[Fact]
public void WithoutRevealKeys_AllFoldersStartCollapsed()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Folders, f).Add(x => x.Templates, t));
Assert.DoesNotContain("Deep", cut.Markup);
Assert.DoesNotContain("Elsewhere", cut.Markup);
}
[Fact]
public void RevealKeys_ExpandsOnlyTheAncestorsOfTheListedTemplate()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p
.Add(x => x.Folders, f).Add(x => x.Templates, t)
.Add(x => x.RevealKeys, new[] { "t:7" }));
Assert.Contains("Deep", cut.Markup); // A → B expanded
Assert.DoesNotContain("Elsewhere", cut.Markup); // Other untouched
}
[Fact]
public void ActiveFilter_WinsOverRevealKeys()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p
.Add(x => x.Folders, f).Add(x => x.Templates, t)
.Add(x => x.RevealKeys, new[] { "t:7" })
.Add(x => x.Filter, "Elsewhere"));
Assert.Contains("Elsewhere", cut.Markup);
Assert.DoesNotContain("Deep", cut.Markup);
}
}
```
**Step 2: Run — expect compile failure on `RevealKeys`.**
**Step 3: Implement**
Add the parameter next to `Filter`:
```csharp
/// <summary>
/// Keys (<c>f:{id}</c>/<c>t:{id}</c>) whose ancestor folders should start expanded
/// when no <see cref="Filter"/> is active — the picker uses it to open on a
/// preselected template. An active filter's own reveal set takes precedence.
/// </summary>
[Parameter] public IReadOnlyCollection<string>? RevealKeys { get; set; }
```
In `ApplyFilter`, replace the empty-filter branch:
```csharp
if (string.IsNullOrWhiteSpace(Filter))
{
_visibleRoots = _allRoots;
_filterRevealed = null;
_initiallyExpanded = BuildRevealPredicate();
return;
}
```
and add:
```csharp
private Func<TemplateTreeNode, bool>? BuildRevealPredicate()
{
if (RevealKeys is not { Count: > 0 }) return null;
var wanted = new HashSet<string>(RevealKeys, StringComparer.Ordinal);
var ancestors = new HashSet<string>(StringComparer.Ordinal);
foreach (var root in _allRoots) CollectAncestors(root, wanted, ancestors, new List<string>());
return ancestors.Count == 0 ? null : n => ancestors.Contains(n.Key);
}
private static void CollectAncestors(TemplateTreeNode node, HashSet<string> wanted, HashSet<string> acc, List<string> path)
{
if (wanted.Contains(node.Key)) acc.UnionWith(path);
path.Add(node.Key);
foreach (var c in node.Children) CollectAncestors(c, wanted, acc, path);
path.RemoveAt(path.Count - 1);
}
```
Note `TreeView.InitiallyExpanded` is applied once (`_initialExpansionApplied`) and skipped when a `StorageKey` has saved state — both fine for the picker (no key). Existing callers pass no `RevealKeys` ⇒ `null` predicate ⇒ unchanged.
**Step 4: Run — 3 PASS; also run `TreeViewTests` + `TemplatesPageTests` (unchanged callers).**
**Step 5: Commit** — `feat(centralui): TemplateFolderTree.RevealKeys — expand to a preselected node`
---
### Task 3: `TemplatePickerDialog` component
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6 (after Tasks 1–2)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TemplatePickerDialog.razor`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplatePickerDialogTests.cs`
**Step 1: Failing tests** — render the body directly with a `DialogContext` obtained through a real `DialogService` + `DialogHost` (copy the fixture from `DialogHostShowAsyncTests`: `Services.AddSingleton<IDialogService>(_service); JSInterop.Mode = Loose;`):
```csharp
public class TemplatePickerDialogTests : BunitContext
{
private readonly DialogService _service = new();
public TemplatePickerDialogTests()
{
Services.AddSingleton<IDialogService>(_service);
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static (List<TemplateFolder>, List<Template>) Catalogue() { /* same as Task 2 */ }
private async Task<(IRenderedComponent<DialogHost> cut, Task<TemplatePickerDialog.TemplatePickResult?> pending)> Open(int? initial = null)
{
var (f, t) = Catalogue();
var cut = Render<DialogHost>();
Task<TemplatePickerDialog.TemplatePickResult?> pending = null!;
await cut.InvokeAsync(() => pending = _service.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
"Choose template",
ctx => b => { b.OpenComponent<TemplatePickerDialog>(0);
b.AddAttribute(1, nameof(TemplatePickerDialog.Context), ctx);
b.AddAttribute(2, nameof(TemplatePickerDialog.Folders), f);
b.AddAttribute(3, nameof(TemplatePickerDialog.Templates), t);
b.AddAttribute(4, nameof(TemplatePickerDialog.InitialTemplateId), initial);
b.CloseComponent(); },
size: "modal-lg"));
cut.Render();
return (cut, pending);
}
[Fact] public async Task Select_IsDisabled_UntilATemplateIsClicked()
{ var (cut, _) = await Open(); Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled")); }
[Fact] public async Task ClickingTemplate_ThenSelect_ResolvesItsId()
{
var (cut, pending) = await Open();
// expand A, then B, then click "Deep"
ExpandFolder(cut, "A"); ExpandFolder(cut, "B");
cut.FindAll(".tv-label").First(l => l.TextContent == "Deep").Click();
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("A / B / Deep", cut.Find("[data-test='picker-status']").TextContent);
cut.Find("button[data-test='picker-select']").Click();
Assert.Equal(7, (await pending)!.TemplateId);
}
[Fact] public async Task DoubleClickingTemplate_Resolves()
{ /* same, but .DoubleClick() on the label; await pending == 7 */ }
[Fact] public async Task ClickingFolder_SelectsNothing()
{ /* click "A" label; Select still disabled */ }
[Fact] public async Task Cancel_ResolvesNull()
{ var (cut, pending) = await Open(); cut.Find("button[data-test='picker-cancel']").Click(); Assert.Null(await pending); }
[Fact] public async Task InitialTemplateId_IsRevealedAndHighlighted()
{
var (cut, _) = await Open(initial: 7);
Assert.Contains("Deep", cut.Markup);
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
}
[Fact] public async Task Search_FiltersTree()
{
var (cut, _) = await Open();
cut.Find("input[data-test='picker-search']").Input("Else");
Assert.Contains("Elsewhere", cut.Markup); Assert.DoesNotContain("Deep", cut.Markup);
}
private static void ExpandFolder(IRenderedComponent<DialogHost> cut, string label) =>
cut.FindAll("li[role='treeitem']").First(li => li.TextContent.Contains(label))
.QuerySelector(".tv-toggle, .tv-caret, button")!.Click(); // use the selector TreeViewTests uses
}
```
**Step 2: Run — compile failure.**
**Step 3: Implement** `TemplatePickerDialog.razor`:
```razor
@*
Body component for the Create Instance template picker, hosted by
IDialogService.ShowAsync. Wraps the shared TemplateFolderTree (the Templates
page's tree) in Single mode with its own search box. Templates are the only
selectable nodes; double-click selects. Closes with TemplatePickResult, a
record so Cancel (null) is distinguishable from a pick — same contract as
MoveTemplateDialog.
*@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
<div class="d-flex gap-2 align-items-center mb-2">
<div class="input-group input-group-sm">
<input type="text" class="form-control" data-test="picker-search" placeholder="Search templates..."
value="@_search" @oninput="e => _search = e.Value?.ToString() ?? string.Empty" />
@if (!string.IsNullOrEmpty(_search))
{
<button class="btn btn-outline-secondary" type="button" title="Clear search" @onclick="() => _search = string.Empty">✕</button>
}
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" title="Expand all folders" @onclick="() => _tree?.ExpandAll()"><i class="bi bi-arrows-expand"></i></button>
<button class="btn btn-sm btn-outline-secondary" type="button" title="Collapse all folders" @onclick="() => _tree?.CollapseAll()"><i class="bi bi-arrows-collapse"></i></button>
</div>
<div class="border rounded p-1" style="max-height: 50vh; min-height: 160px; overflow-y: auto;">
<TemplateFolderTree @ref="_tree"
Folders="Folders" Templates="Templates"
SelectionMode="TreeViewSelectionMode.Single"
SelectedKeys="_selectedKeys" SelectedKeysChanged="OnSelectedKeysChanged"
Filter="@_search"
RevealKeys="_revealKeys"
OnNodeClick="OnNodeClick">
<NodeContent Context="node">
<span class="tv-glyph"><i class="bi @(node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text")"></i></span>
<span class="tv-label @(node.Kind == TemplateTreeNodeKind.Folder ? "fw-semibold" : "")" title="@node.Name"
@onclick="() => OnNodeClick(node)"
@ondblclick="() => OnNodeDoubleClick(node)">@node.Name</span>
@if (node.Kind == TemplateTreeNodeKind.Folder && node.Children.Count > 0)
{
<span class="tv-meta"><span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span></span>
}
</NodeContent>
<EmptyContent><span class="text-muted fst-italic">No templates match.</span></EmptyContent>
</TemplateFolderTree>
</div>
<div class="modal-footer px-0 pb-0 mt-3 d-flex justify-content-between">
<span class="small text-muted" data-test="picker-status">
@if (_selectedId > 0 && _templatesById.TryGetValue(_selectedId, out var sel))
{ <text>Selected: <span class="text-body">@TemplatePathFormatter.Format(sel, _foldersById)</span></text> }
else { <text>Select a template</text> }
</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" data-test="picker-cancel" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" data-test="picker-select" disabled="@(_selectedId <= 0)" @onclick="Submit">Select</button>
</div>
</div>
@code {
[Parameter] public DialogContext<TemplatePickResult> Context { get; set; } = default!;
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
[Parameter] public int? InitialTemplateId { get; set; }
private TemplateFolderTree? _tree;
private string _search = string.Empty;
private int _selectedId;
private HashSet<object> _selectedKeys = new();
private string[]? _revealKeys;
private Dictionary<int, Template> _templatesById = new();
private Dictionary<int, TemplateFolder> _foldersById = new();
protected override void OnParametersSet()
{
_templatesById = Templates.ToDictionary(t => t.Id);
_foldersById = Folders.ToDictionary(f => f.Id);
if (_selectedId == 0 && InitialTemplateId is int id && _templatesById.ContainsKey(id))
{
_selectedId = id;
_selectedKeys = new HashSet<object> { $"t:{id}" };
_revealKeys = new[] { $"t:{id}" };
}
}
private void OnNodeClick(TemplateTreeNode node)
{
if (node.Kind != TemplateTreeNodeKind.Template) return; // folders only toggle
_selectedId = node.Id;
_selectedKeys = new HashSet<object> { node.Key };
}
private void OnNodeDoubleClick(TemplateTreeNode node)
{
if (node.Kind != TemplateTreeNodeKind.Template) return;
_selectedId = node.Id;
Submit();
}
private void OnSelectedKeysChanged(HashSet<object> keys)
{
// The TreeView's own row selection: accept template keys, ignore folder keys.
var key = keys.FirstOrDefault() as string;
if (key is not null && key.StartsWith("t:", StringComparison.Ordinal) && int.TryParse(key.AsSpan(2), out var id))
{ _selectedId = id; _selectedKeys = keys; }
else { _selectedKeys = _selectedId > 0 ? new HashSet<object> { $"t:{_selectedId}" } : new(); }
}
private void Submit() { if (_selectedId > 0) Context.Close(new TemplatePickResult(_selectedId)); }
/// <summary>Resolved result of the picker; <c>null</c> from ShowAsync means Cancel.</summary>
public sealed record TemplatePickResult(int TemplateId);
}
```
Check `TreeView`'s row selection contract (`Selectable` + `SelectedKeysChanged`) while implementing — if the row click already routes through `SelectedKeysChanged`, the `OnNodeClick` label handler is belt-and-braces; keep whichever the tests prove. Derived templates render as children of their base (the tree's `BuildTree` nests `IsDerived`), so a "template" node can have children — that is expected.
**Step 4: Run — 7 PASS.**
**Step 5: Commit** — `feat(centralui): TemplatePickerDialog — folder-tree template picker hosted by IDialogService`
---
### Task 4: Create page — picker field + `?templateId=` preselect
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6 (after Task 3)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor` (template field L40-51; query params L231-232; `OnInitializedAsync` L245-280)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs` (add `OpenTemplatePickerAsync`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs`
No bUnit test renders `InstanceCreate` today; this task adds the first. Its DI graph = `ITemplateEngineRepository`, `ISiteRepository`, `IFlatteningPipeline`, `DeploymentService` (copy the `DeploymentService` registrations verbatim from `TopologyPageTests`' constructor), `SiteScopeService`, `IDialogService` (real `DialogService` + render a `DialogHost` alongside), auth with a `Deployer` claim (`AddTestAuth` from `TopologyPageTests`), `JSInterop.Mode = Loose`. Open the page's `@inject` list and satisfy every entry.
**Step 1: Failing tests**
```csharp
[Fact] public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
{
SeedCatalogue();
var cut = RenderPage();
var field = cut.Find("input[data-test='create-template']");
Assert.True(field.HasAttribute("readonly"));
Assert.Equal("Choose a template…", field.GetAttribute("placeholder"));
Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
}
[Fact] public void TemplateIdQuery_PrefillsFieldWithFolderPath()
{
SeedCatalogue(); // template 7 "Deep" in A/B
var cut = RenderPage(templateId: 7);
Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
}
[Fact] public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
{
SeedCatalogue();
var cut = RenderPage(templateId: 999);
Assert.Equal("", cut.Find("input[data-test='create-template']").GetAttribute("value") ?? "");
Assert.Contains("Template 999 no longer exists", cut.Markup);
}
[Fact] public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
{
SeedCatalogue(empty: true);
var cut = RenderPage();
Assert.True(cut.Find("button[data-test='create-template-choose']").HasAttribute("disabled"));
Assert.Contains("/design/templates", cut.Markup);
}
[Fact] public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
{
SeedCatalogue(); // + one site id 1; _pipeline.PreviewAsync returns an empty-attribute success
var cut = RenderPage(siteId: 1);
cut.Find("button[data-test='create-template-choose']").Click();
// picker is open in the DialogHost rendered alongside
ExpandFolder(cut, "A"); ExpandFolder(cut, "B");
cut.FindAll(".tv-label").First(l => l.TextContent == "Deep").Click();
cut.Find("button[data-test='picker-select']").Click();
cut.WaitForAssertion(() => Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value")));
await _pipeline.Received(1).PreviewAsync(7, 1, Arg.Any<CancellationToken>());
}
```
(Query params: render with `Render<InstanceCreate>(p => p.Add(x => x.TemplateId, 7))` — `[SupplyParameterFromQuery]` properties are settable as ordinary parameters under bUnit.)
**Step 2: Run — fails (select still rendered, `TemplateId` missing).**
**Step 3: Implement**
`InstanceCreate.razor` template field (replace L40-51):
```razor
<div class="mb-2">
<label class="form-label small">Template</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" data-test="create-template" readonly
placeholder="Choose a template…" value="@TemplatePathLabel"
style="cursor: pointer; background-color: var(--bs-body-bg);"
@onclick="OpenTemplatePickerAsync" />
<button class="btn btn-outline-secondary" type="button" data-test="create-template-choose"
disabled="@(_templates.Count == 0)" @onclick="OpenTemplatePickerAsync">Choose…</button>
</div>
@if (_templates.Count == 0 && !_loading)
{
<div class="form-text">No templates exist yet — <a href="/design/templates">create one on the Templates page</a>.</div>
}
else if (_templateHint is not null)
{
<div class="form-text text-warning-emphasis">@_templateHint</div>
}
</div>
```
Query parameter (next to `SiteId`/`AreaId`): `[SupplyParameterFromQuery] public int? TemplateId { get; set; }`
Fields: `private List<TemplateFolder> _folders = new(); private string? _templateHint;`
`private string TemplatePathLabel => _templates.FirstOrDefault(t => t.Id == _createTemplateId) is { } t ? TemplatePathFormatter.Format(t, _foldersById) : string.Empty;`
`private Dictionary<int, TemplateFolder> _foldersById = new();`
In `OnInitializedAsync`, after `_templates` loads:
```csharp
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
_foldersById = _folders.ToDictionary(f => f.Id);
if (TemplateId is int tid)
{
if (_templates.Any(t => t.Id == tid)) _createTemplateId = tid;
else _templateHint = $"Template {tid} no longer exists — choose another.";
}
```
and fix the stale comment above the trailing `OnTemplateOrSiteChangedAsync()` call: *"Query-string preselects (siteId + templateId) make this a real preview when both are present."*
`InstanceCreate.razor.cs`:
```csharp
/// <summary>Opens the folder-tree template picker; a pick re-runs the bindings preview.</summary>
private async Task OpenTemplatePickerAsync()
{
if (_templates.Count == 0) return;
var picked = await Dialog.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
"Choose template",
ctx => @<TemplatePickerDialog Context="ctx" Folders="_folders" Templates="_templates"
InitialTemplateId="@(_createTemplateId > 0 ? _createTemplateId : null)" />,
size: "modal-lg");
if (picked is null || picked.TemplateId == _createTemplateId) return;
_createTemplateId = picked.TemplateId;
_templateHint = null;
await OnTemplateOrSiteChangedAsync();
}
```
(The inline `@<…>` fragment syntax only compiles in `.razor`; if `Dialog` is injected only in the `.razor`, put this method in the `@code` block of `InstanceCreate.razor` instead, or use `RenderTreeBuilder` in the `.cs` as the Task 3 test does. Add `@inject IDialogService Dialog` if the page lacks it.)
**Step 4: Run — 5 PASS.** Also `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`.
**Step 5: Commit** — `feat(centralui): Create Instance picks its template from the folder tree (+ ?templateId preselect)`
---
### Task 5: Templates page — "New Instance…" menu item (Deployer-gated)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor` (`LoadTemplatesAsync` ~L140; `RenderNodeContextMenu` Template case ~L298-310)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs` (append)
**Step 1: Failing tests** (the file's `AddTestAuth()` seeds a `Designer` claim; add an overload `AddTestAuth(params string[] roles)` or a second fixture method)
```csharp
private static AngleSharp.Dom.IElement OpenTemplateContextMenu(IRenderedComponent<TemplatesPage> cut, string label)
{
var row = cut.FindAll("li[role='treeitem']").First(li => li.TextContent.Contains(label)).QuerySelector(".tv-row")!;
row.ContextMenu();
return cut.Find(".dropdown-menu.show");
}
[Fact]
public void TemplateContextMenu_OffersNewInstance_ForDeployer()
{
// arrange: auth with Designer + Deployer, one root template "Pump"
var menu = OpenTemplateContextMenu(cut, "Pump");
var btn = menu.QuerySelector("button[data-test='ctx-new-instance']");
Assert.NotNull(btn);
Assert.Equal("New Instance…", btn!.TextContent.Trim());
btn.Click();
var nav = Services.GetRequiredService<NavigationManager>();
Assert.EndsWith("/deployment/instances/create?templateId=" + pumpId, nav.Uri);
}
[Fact]
public void TemplateContextMenu_HidesNewInstance_ForDesignerOnly()
{
var menu = OpenTemplateContextMenu(cut, "Pump");
Assert.Null(menu.QuerySelector("button[data-test='ctx-new-instance']"));
}
```
**Step 2: Run — fail (no such button).**
**Step 3: Implement**
Field + resolution in `LoadTemplatesAsync` (or `OnInitializedAsync`):
```csharp
// "New Instance…" leaves the Design area for a RequireDeployment page; only offer
// it to users who hold that role so the menu never leads to a 403.
private bool _canCreateInstances;
...
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_canCreateInstances = state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
```
(`Roles` lives in `ZB.MOM.WW.ScadaBridge.Security`; `JwtTokenService.RoleClaimType` likewise — add the `@using` if missing.)
Menu (after "Compose into…" block, before "Move to Folder…"):
```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>
}
```
**Step 4: Run — `TemplatesPageTests` all PASS.**
**Step 5: Commit** — `feat(centralui): Templates tree context menu — "New Instance…" (Deployer-gated)`
---
### Task 6: Pin the Topology Disable confirm label
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4, Task 5
**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/TopologyDisableConfirmLabelTests.cs`
No production change expected — `Topology.razor:932-938` already passes `confirmLabel: "Disable"` (commit `0db253b5`, register row 28). This pins it the way `Monitoring/FailoverConfirmLabelTests.cs` pins the failover button.
**Step 1: Write the test** — copy `TopologyPageTests`' constructor DI block verbatim (it is long; that is the cost of rendering `Topology`), register `Services.AddSingleton<IDialogService>(_dialog)` with a shared `DialogService`, render `Topology` **and** a `DialogHost` in one `Render` via a wrapper fragment (see `FailoverConfirmLabelTests.RenderControlWithHost` for the pattern), seed one site with one `Deployed`/`Enabled` instance (copy the seed from the staleness tests ~L300-355, state `InstanceState.Deployed`), expand to the row, `.ContextMenu()`, click the `Disable` item, then:
```csharp
var confirm = cut.FindAll(".modal .btn-group button").Last();
Assert.Equal("Disable", confirm.TextContent.Trim());
Assert.Contains("btn-danger", confirm.ClassName);
Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent);
```
Also add the negative twin for completeness: clicking **Delete** yields a button reading "Delete".
**Step 2: Run — expect PASS on the first run** (the fix already exists). If it FAILS, stop: that means the label path regressed — investigate before touching anything.
**Step 3: Commit** — `test(centralui): pin Topology Disable/Delete confirm verbs (register row 28)`
---
### Task 7: Docs
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (after Tasks 4–6)
**Files:**
- Modify: `docs/requirements/Component-CentralUI.md` (the Create-Instance bullet near L121; the Templates page section — grep "Templates page" / "context menu")
- Modify: `docs/plans/2026-08-21-template-picker-design.md` (Status → implemented)
- Modify: `docs/plans/2026-08-21-create-instance-bindings-design.md` §3.2 (one line: template is now chosen via the picker; `?templateId=` preselect)
Content: one paragraph under the Create-Instance bullet — template chosen through `TemplatePickerDialog` (shared `TemplateFolderTree`, Single mode, templates-only selection, `RevealKeys` opens on the current pick), `?templateId=` query preselect; one sentence in the Templates-page section — "New Instance…" on Template nodes, Deployer-gated, navigates with `templateId`. Note the Disable label pin.
**Commit** — `docs(centralui): template picker on Create Instance, New Instance… from the Templates tree`
---
### Task 8: Solution build + CentralUI test sweep + live check on the rig
**Classification:** standard
**Estimated implement time:** ~5 min (+ image build)
**Parallelizable with:** none (last)
**Steps**
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx -v q` → 0 errors.
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests -v q` → green (the 8 Playwright E2E live in a separate project and are environment-gated; not part of this run).
3. `bash docker/deploy.sh`, wait for `http://localhost:9000/health/ready`.
4. Live check (multi-role / password at `http://localhost:9000`):
- `/design/templates` → right-click a composed template (e.g. the rig's "MES Line Controller") → **New Instance…** → lands on `/deployment/instances/create?templateId=N` with the field showing its folder path.
- Pick a site → the Connection Bindings card appears (the `?templateId` preselect now feeds `OnTemplateOrSiteChangedAsync`).
- **Choose…** → picker opens expanded to and highlighting that template; search narrows; Cancel leaves the field as it was; pick another → field + card update.
- `/deployment/topology` → **+ Instance** → empty field, Choose… opens collapsed.
- Topology → right-click a Deployed instance → **Disable** → confirm button reads **Disable** (red). Cancel.
5. Record the outcome in the plan's Status section; commit.
**Commit** — `docs(plans): template picker — live rig gate + task status`
---
## Status
_(filled in by the executing session)_
@@ -0,0 +1,14 @@
{
"planPath": "docs/plans/2026-08-21-template-picker.md",
"tasks": [
{"id": 1, "subject": "Task 1: TemplatePathFormatter (pure helper)", "status": "pending"},
{"id": 2, "subject": "Task 2: TemplateFolderTree.RevealKeys (additive)", "status": "pending"},
{"id": 3, "subject": "Task 3: TemplatePickerDialog component", "status": "pending", "blockedBy": [1, 2]},
{"id": 4, "subject": "Task 4: Create page — picker field + ?templateId= preselect", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Templates page — New Instance… menu item (Deployer-gated)", "status": "pending"},
{"id": 6, "subject": "Task 6: Pin the Topology Disable confirm label", "status": "pending"},
{"id": 7, "subject": "Task 7: Docs", "status": "pending", "blockedBy": [4, 5, 6]},
{"id": 8, "subject": "Task 8: Solution build + CentralUI test sweep + live rig check", "status": "pending", "blockedBy": [7]}
],
"lastUpdated": "2026-08-21T12:00:00Z"
}