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

38 KiB
Raw Blame History

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

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

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)

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:

/// <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:

if (string.IsNullOrWhiteSpace(Filter))
{
    _visibleRoots = _allRoots;
    _filterRevealed = null;
    _initiallyExpanded = BuildRevealPredicate();
    return;
}

and add:

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;):

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:

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

[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):

<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:

_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:

/// <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)

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):

// "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…"):

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

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.