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

799 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
**DONE — all 8 tasks complete, 2026-08-21.** Branch `feat/template-picker` (worktree
`~/Desktop/ScadaBridge-template-picker`), 8 commits.
| # | Task | Result |
|---|------|--------|
| 1 | `TemplatePathFormatter` | done — 4 tests |
| 2 | `TemplateFolderTree.RevealKeys` | done — 3 tests; `TreeViewTests` / `TemplatesPageTests` / `TransportExportPageTests` unaffected |
| 3 | `TemplatePickerDialog` | done — 7 tests |
| 4 | Create page picker field + `?templateId=` | done — 5 tests (first bUnit render of `InstanceCreate`) |
| 5 | Templates tree "New Instance…" | done — 2 tests (Deployer shows, Designer-only hides) |
| 6 | Topology Disable confirm pin | done — 3 tests, **PASS on first run** (no production change; `0db253b5` intact) |
| 7 | Docs | done — `Component-CentralUI.md` (Create-Instance + Templates-page + dialog-label bullets), this design doc → implemented, bindings design §3.2 superseded note |
| 8 | Build + sweep + live rig | **PASS** — see below |
### Deviations from the plan, and why
1. **Selection is tracked inside `TemplatePickerDialog`, not through `SelectedKeys`/`SelectedKeysChanged`.**
In `TreeViewSelectionMode.Single` the inner `TreeView` uses the *singular* `SelectedKey` /
`SelectedKeyChanged` pair; `TemplateFolderTree` forwards only the plural (Checkbox-mode) pair,
which `TreeView` reads for tri-state checkbox aggregation. The plan's wiring would therefore
never have highlighted a row and `OnSelectedKeysChanged` would never have fired. The dialog's own
`NodeContent` click/dblclick handlers own selection and the `text-primary` highlight — no change
to the shared tree's public surface.
2. **`[SupplyParameterFromQuery]` cannot be set as an ordinary bUnit parameter** ("only accepts
cascading values"). The Task-4 tests seed `BunitNavigationManager.NavigateTo(".../create?templateId=7")`
instead, matching `Pages/QueryStringDrillInTests.cs`.
3. **`&#8230;` in a Razor *attribute* is emitted literally** — the placeholder uses the real `…`.
4. **The Task-3 `Open()` helper needs a block-bodied `InvokeAsync` lambda.** An expression body
returning the pending `Task` binds to `InvokeAsync(Func<Task>)` and awaits the dialog itself;
every test hung until it was changed (comment left in the file).
### Task 8 — build, sweep, live rig gate
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx -v q` → **0 warnings, 0 errors**.
- `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` → **1154 / 1154 PASS**.
- Rig: `docker/build.sh` run from the worktree (tags `scadabridge:latest`), then
`docker compose -f docker/docker-compose.yml up -d --force-recreate` from the **primary
checkout** — deploying `deploy.sh` wholesale from a worktree would have rehomed every node's
bind-mounted appsettings to the worktree copy. `/health/ready` Healthy on all 8 nodes.
- Live check at `http://localhost:9000` as `multi-role`, **all PASS**:
1. `/design/templates` → right-click **Motor Controller** (inside folder `Test`) → the menu shows
**New Instance…** between its own dividers → lands on
`/deployment/instances/create?templateId=4` with the field reading **"Test / Motor Controller"**.
2. Site = *Test Plant A* → Connection Bindings card appears (30 attributes, 4 groups) — the
`?templateId` preselect does feed `OnTemplateOrSiteChangedAsync`.
3. **Choose…** → picker opens **expanded to and highlighting** Motor Controller, footer
"Selected: Test / Motor Controller", Select enabled. Search "Tank" narrows to one row.
**Cancel** left the field, the site and the card untouched.
4. Re-open → **double-click "Tank Monitor"** → dialog closes, field reads "Tank Monitor"
(root template ⇒ no folder prefix), card re-previews to 7 attributes / 2 groups.
5. `/deployment/instances/create` with no query → field blank with the placeholder, picker opens
**collapsed**, footer "Select a template", **Select disabled**; clicking a *folder* label leaves
it disabled.
6. `/deployment/topology` → right-click `rig-probe-1` (Enabled) → **Disable** → confirm button reads
**Disable** in red, body "Disable instance 'rig-probe-1'? …". Cancelled; instance still Enabled.
### Known limitation (pre-existing, not introduced here)
`TreeView.InitiallyExpanded` is applied **once** (`_initialExpansionApplied`). So in the picker, if a
preselected template reveals on first render and the operator *then* types a search, the filter's own
ancestor-expand does not re-apply — matches under a still-collapsed folder stay hidden until the
folder is opened by hand. Opening the picker with no preselect (the common case) is unaffected,
because nothing consumed the one-shot. Fixing it means resetting `_initialExpansionApplied` when
`InitiallyExpanded` changes identity, which touches every `TreeView` caller — out of scope here.