d6a31f1cd0
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
799 lines
38 KiB
Markdown
799 lines
38 KiB
Markdown
# 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. **`…` 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.
|