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