Compare commits

...

11 Commits

Author SHA1 Message Date
Joseph Doherty afb27e7780 docs: CLAUDE.md — template picker on Create Instance (RevealKeys, ?templateId, Deployer-gated New Instance…) + the Single-mode SelectedKey/SelectedKeys trap
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 08:24:05 -04:00
Joseph Doherty 7a77d38abf test(playwright): key the Configure bulk-Apply click by data-test (strict-mode collision with the grouped 'Apply to n' button)
The grouped Connection Bindings panel (77f4af0d) added a per-group
'Apply to n' button, so InstanceConfigureTests' Name = "Apply" role
locator started matching two elements and failing Playwright's strict
mode. Adds data-test="binding-bulk-apply" alongside the existing
binding-bulk-select / binding-group-apply keys and clicks that instead.

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 08:22:03 -04:00
Joseph Doherty d6a31f1cd0 docs(plans): template picker — live rig gate PASS (New Instance…, ?templateId prefill, picker reveal/search/cancel, Disable verb) + task status
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 08:07:22 -04:00
Joseph Doherty 4bb77cf0f7 docs(centralui): template picker on Create Instance, New Instance… from the Templates tree
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 08:01:37 -04:00
Joseph Doherty 1790f67ebd test(centralui): pin Topology Disable/Delete confirm verbs (register row 28)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:45:46 -04:00
Joseph Doherty 7c2582d105 feat(centralui): Templates tree context menu — "New Instance…" (Deployer-gated)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:44:15 -04:00
Joseph Doherty 5b4a27b23e feat(centralui): Create Instance picks its template from the folder tree (+ ?templateId preselect)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:42:14 -04:00
Joseph Doherty 4bd2651925 feat(centralui): TemplatePickerDialog — folder-tree template picker hosted by IDialogService
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 07:08:37 -04:00
Joseph Doherty 8df6bcb6e0 feat(centralui): TemplateFolderTree.RevealKeys — expand to a preselected node
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:55:03 -04:00
Joseph Doherty 9261fa09fa feat(centralui): TemplatePathFormatter — folder-path label for template pickers
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:53:40 -04:00
Joseph Doherty 22468c1548 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
2026-08-21 06:51:27 -04:00
19 changed files with 1976 additions and 23 deletions
+1
View File
@@ -127,6 +127,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- Deployment identity: unique deployment ID + revision hash for idempotency.
- Per-instance operation lock covers all mutating commands (deploy, disable, enable, delete).
- **Bulk site deployment (`DeploymentService.DeploySiteAsync`, arch-review WP2.5)** redeploys every out-of-date instance at a site through one flatten session (a template chain shared by N instances is walked once) in three phases — serial Prepare, bounded-parallel Send (`SiteDeploymentMaxParallelism`, default 4), serial Finalize — still **not all-or-nothing**: any one instance's failure doesn't block the rest. **Disabled instances are SKIPPED by bulk deploy as of 2026-08-19 (arch-review P7-2)** — they are reported in the summary's additive `SkippedInstances` list, never flattened/sent/re-enabled (single-instance deploy remains the explicit operator path that deploys+enables a Disabled instance); **Up-to-date instances are ALSO skipped as of 2026-08-20 (arch-review P7-6)** — a redeploy is not idempotent at the site (it resets static overrides and clears native alarm state), so `DeploySiteAsync` pre-filters through `IStaleInstanceProbe` (trustworthy since P7-4 fixed its memo timing) and reports unchanged instances in `SkippedInstances` with reason `up-to-date`; the filter is conservative (every uncertainty deploys: missing snapshot, `ShouldQuerySiteBeforeRedeploy`, null/faulting probe). Additive `MgmtDeploySiteCommand.Force` / CLI `deploy site --force` restores the unconditional push for what central cannot see (restored volume, rebuilt node) — **Disabled still wins under `--force`**. Also P7-5: the three phases share one scoped DbContext and a failed `SaveChanges` leaves the entity tracked, so one permanent failure used to cascade to every later instance and strand records `InProgress`; `MarkDeploymentFailedAsync` now detaches pending changes and retries the `Failed` write once on the clean tracker; Prepare's per-instance reconcile also runs under a short `SiteDeploymentReconcileTimeout` (5s) with a once-per-batch unreachable-site circuit-breaker, so a down site no longer costs 30s × N serial before the first send (P7-3). The `PendingDeployment` staging row (5-minute TTL) is written per-instance immediately before that instance's round-trip in the Send phase, not upfront in Prepare, so a large batch's tail instances don't have their TTL clock already half-spent by the time they're reached.
- **Create Instance picks its template from the Templates folder tree** (2026-08-21): the flat `<select>` is gone. The Template field is a read-only folder-qualified path box (`TemplatePathFormatter`, "Folder / Sub / Name") + a **Choose…** button, both opening `TemplatePickerDialog` — an `IDialogService.ShowAsync` body wrapping the **same shared `TemplateFolderTree`** the Templates page renders, with its own search, expand/collapse-all, templates-only selection and double-click-to-pick. It opens on the current pick via the tree's additive **`RevealKeys`** parameter (ancestors of the named `f:`/`t:` keys start expanded when no `Filter` is active; an active filter's reveal set still wins; callers passing nothing are byte-identical). `InstanceCreate` gained `[SupplyParameterFromQuery] TemplateId`, which the Templates tree's new **Deployer-gated "New Instance…"** context-menu item navigates with; combined with `?siteId=` it populates the Connection Bindings card on first render. **The trap for anyone extending the picker:** in `TreeViewSelectionMode.Single` the inner `TreeView` reads the *singular* `SelectedKey`/`SelectedKeyChanged` pair, but `TemplateFolderTree` forwards only the plural (Checkbox-mode) `SelectedKeys` pair — so a Single-mode caller cannot get a row highlight through the shared tree and must own selection in its own `NodeContent`, as the picker does. Design: `docs/plans/2026-08-21-template-picker-design.md`.
- **Create Instance binds module groups at creation** (2026-08-21): the create page's Connection Bindings card flattens template+site through the additive `IFlatteningPipeline.PreviewAsync` (an unsaved instance — no validation, no hash, nothing persisted, instance repo never touched) and reuses `BindingGroupBuilder`; `NodeBrowserDialog.SelectableNodeClasses` lets it pick **Object** nodes (default stays Variable-only for every existing caller). Create = `CreateInstanceAsync` → `SetConnectionBindingsAsync` (the existing upsert, no new server surface) → always navigate to Configure with `?created=1&bound=N|&bindError=…`, because the row exists whether or not the second call succeeded. Partial binding is legal: a group missing a connection or an Object is skipped and stays unbound. Design: `docs/plans/2026-08-21-create-instance-bindings-design.md`.
- Site-side apply is all-or-nothing per instance.
- System-wide artifact version skew across sites is supported.
@@ -42,8 +42,12 @@ Same `FlattenSession` memo, so it is one template-chain walk per call.
### 3.2 Create page (CentralUI `InstanceCreate.razor` + new code-behind)
- **Superseded 2026-08-21 (`docs/plans/2026-08-21-template-picker.md`):** the Template `<select>`
described below is now a read-only folder-path field + `TemplatePickerDialog`, and the page takes
a `?templateId=` query preselect (what the Templates tree's "New Instance…" navigates with). The
card's trigger is unchanged — a pick still calls `OnTemplateOrSiteChangedAsync()`.
- The **Connection Bindings** card appears once Template *and* Site are set; changing either
re-runs the preview (debounced by the select's change event — no typing involved) and resets
re-runs the preview (a pick or a site-select change — no typing involved) and resets
the card. Rows = `PreviewAsync(...).Attributes.Where(DefaultDataSourceReference ?? DataSourceReference != null)`
→ `BindingRow(canonicalName, default, null, null)` → `BindingGroupBuilder.Build(rows, siteConnectionIds)`.
- **Group rows** are the Configure page's group-header row (connection select, Object input,
@@ -0,0 +1,142 @@
# Template picker for Create Instance + "New Instance…" from the Templates tree (design)
**Date:** 2026-08-21 · **Status:** **implemented** 2026-08-21 — 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.
+798
View File
@@ -0,0 +1,798 @@
# Template Picker + "New Instance…" Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** The Create Instance page picks its template from the Templates-page folder tree (modal picker, `?templateId=` preselect); a template's right-click menu on the Templates page offers "New Instance…"; the Topology Disable confirm's "Disable" verb is pinned by a test and checked live.
**Architecture:** Central UI only — no server, repo, CLI, proto or migration change. A new `TemplatePickerDialog` body component (hosted by the existing `IDialogService.ShowAsync<T>` pattern) wraps the shared `TemplateFolderTree`, which gains one additive `RevealKeys` parameter. `InstanceCreate` swaps its `<select>` for a read-only path field + Choose… button and a `TemplateId` query parameter. `Templates.razor` adds one Deployer-gated menu item. The Disable label was fixed by `0db253b5`; this plan only pins it.
**Tech Stack:** .NET 10, Blazor Server + Bootstrap 5, bUnit + xUnit + NSubstitute (`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`).
**Design:** `docs/plans/2026-08-21-template-picker-design.md` · mockup https://claude.ai/code/artifact/71fea4e0-eafc-4b84-8157-66b450126330
**Branch:** `feat/template-picker` in its own worktree (`git worktree add ../ScadaBridge-template-picker -b feat/template-picker main`). Never `git stash` in agent prompts (shared across worktrees).
**Test command (filtered, per task):**
`dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~<ClassName>" --no-restore -v q`
---
### Task 1: `TemplatePathFormatter` (pure helper)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2, Task 5, Task 6
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/TemplatePathFormatter.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/TemplatePathFormatterTests.cs`
**Step 1: Write the failing tests**
```csharp
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class TemplatePathFormatterTests
{
private static Dictionary<int, TemplateFolder> Folders(params TemplateFolder[] f) => f.ToDictionary(x => x.Id);
[Fact]
public void RootTemplate_IsNameOnly()
{
var t = new Template("Motor Controller") { Id = 1, FolderId = null };
Assert.Equal("Motor Controller", TemplatePathFormatter.Format(t, Folders()));
}
[Fact]
public void NestedTemplate_JoinsFolderChainWithSlashes()
{
var controllers = new TemplateFolder("Controllers") { Id = 10, ParentFolderId = null };
var conveyor = new TemplateFolder("Conveyor") { Id = 11, ParentFolderId = 10 };
var t = new Template("MES Line Controller") { Id = 1, FolderId = 11 };
Assert.Equal("Controllers / Conveyor / MES Line Controller",
TemplatePathFormatter.Format(t, Folders(controllers, conveyor)));
}
[Fact]
public void UnknownFolderId_FallsBackToNameOnly_WithoutThrowing()
{
var t = new Template("Orphan") { Id = 1, FolderId = 999 };
Assert.Equal("Orphan", TemplatePathFormatter.Format(t, Folders()));
}
[Fact]
public void FolderCycle_Terminates()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = 2 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var t = new Template("T") { Id = 1, FolderId = 1 };
var s = TemplatePathFormatter.Format(t, Folders(a, b));
Assert.EndsWith("T", s); // no hang, no exception
}
}
```
(Check the `Template`/`TemplateFolder` constructors in `Commons/Entities/Templates/` — if they are parameterless with `Name { get; set; }`, use object initialisers instead.)
**Step 2: Run — expect compile failure (`TemplatePathFormatter` missing).**
**Step 3: Implement**
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Renders a template's location as "Folder / Subfolder / Name" for the Create
/// Instance template field and the picker footer. Pure; tolerates dangling or
/// cyclic folder ids (stops after 32 hops) so a half-migrated catalogue never
/// breaks the create page.
/// </summary>
public static class TemplatePathFormatter
{
public const string Separator = " / ";
public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById)
{
var parts = new List<string> { template.Name };
var next = template.FolderId;
var hops = 0;
while (next is int id && foldersById.TryGetValue(id, out var folder) && hops++ < 32)
{
parts.Add(folder.Name);
next = folder.ParentFolderId;
}
parts.Reverse();
return string.Join(Separator, parts);
}
}
```
**Step 4: Run — expect 4 PASS.**
**Step 5: Commit** — `feat(centralui): TemplatePathFormatter — folder-path label for template pickers`
---
### Task 2: `TemplateFolderTree.RevealKeys` (additive)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TemplateFolderTree.razor` (parameters ~L64-85, `ApplyFilter` ~L162-186)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplateFolderTreeRevealKeysTests.cs`
**Step 1: Failing tests** (bUnit; the tree renders `li[role='treeitem']` rows; an expanded folder renders its children — see `TreeViewTests.cs` for the expand/collapse assertions to copy)
```csharp
public class TemplateFolderTreeRevealKeysTests : BunitContext
{
private static (List<TemplateFolder> folders, List<Template> templates) Catalogue()
{
var a = new TemplateFolder("A") { Id = 1 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var other = new TemplateFolder("Other") { Id = 3 };
var t7 = new Template("Deep") { Id = 7, FolderId = 2 };
var t8 = new Template("Elsewhere") { Id = 8, FolderId = 3 };
return (new() { a, b, other }, new() { t7, t8 });
}
[Fact]
public void WithoutRevealKeys_AllFoldersStartCollapsed()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Folders, f).Add(x => x.Templates, t));
Assert.DoesNotContain("Deep", cut.Markup);
Assert.DoesNotContain("Elsewhere", cut.Markup);
}
[Fact]
public void RevealKeys_ExpandsOnlyTheAncestorsOfTheListedTemplate()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p
.Add(x => x.Folders, f).Add(x => x.Templates, t)
.Add(x => x.RevealKeys, new[] { "t:7" }));
Assert.Contains("Deep", cut.Markup); // A → B expanded
Assert.DoesNotContain("Elsewhere", cut.Markup); // Other untouched
}
[Fact]
public void ActiveFilter_WinsOverRevealKeys()
{
var (f, t) = Catalogue();
var cut = Render<TemplateFolderTree>(p => p
.Add(x => x.Folders, f).Add(x => x.Templates, t)
.Add(x => x.RevealKeys, new[] { "t:7" })
.Add(x => x.Filter, "Elsewhere"));
Assert.Contains("Elsewhere", cut.Markup);
Assert.DoesNotContain("Deep", cut.Markup);
}
}
```
**Step 2: Run — expect compile failure on `RevealKeys`.**
**Step 3: Implement**
Add the parameter next to `Filter`:
```csharp
/// <summary>
/// Keys (<c>f:{id}</c>/<c>t:{id}</c>) whose ancestor folders should start expanded
/// when no <see cref="Filter"/> is active — the picker uses it to open on a
/// preselected template. An active filter's own reveal set takes precedence.
/// </summary>
[Parameter] public IReadOnlyCollection<string>? RevealKeys { get; set; }
```
In `ApplyFilter`, replace the empty-filter branch:
```csharp
if (string.IsNullOrWhiteSpace(Filter))
{
_visibleRoots = _allRoots;
_filterRevealed = null;
_initiallyExpanded = BuildRevealPredicate();
return;
}
```
and add:
```csharp
private Func<TemplateTreeNode, bool>? BuildRevealPredicate()
{
if (RevealKeys is not { Count: > 0 }) return null;
var wanted = new HashSet<string>(RevealKeys, StringComparer.Ordinal);
var ancestors = new HashSet<string>(StringComparer.Ordinal);
foreach (var root in _allRoots) CollectAncestors(root, wanted, ancestors, new List<string>());
return ancestors.Count == 0 ? null : n => ancestors.Contains(n.Key);
}
private static void CollectAncestors(TemplateTreeNode node, HashSet<string> wanted, HashSet<string> acc, List<string> path)
{
if (wanted.Contains(node.Key)) acc.UnionWith(path);
path.Add(node.Key);
foreach (var c in node.Children) CollectAncestors(c, wanted, acc, path);
path.RemoveAt(path.Count - 1);
}
```
Note `TreeView.InitiallyExpanded` is applied once (`_initialExpansionApplied`) and skipped when a `StorageKey` has saved state — both fine for the picker (no key). Existing callers pass no `RevealKeys` ⇒ `null` predicate ⇒ unchanged.
**Step 4: Run — 3 PASS; also run `TreeViewTests` + `TemplatesPageTests` (unchanged callers).**
**Step 5: Commit** — `feat(centralui): TemplateFolderTree.RevealKeys — expand to a preselected node`
---
### Task 3: `TemplatePickerDialog` component
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6 (after Tasks 1–2)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TemplatePickerDialog.razor`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TemplatePickerDialogTests.cs`
**Step 1: Failing tests** — render the body directly with a `DialogContext` obtained through a real `DialogService` + `DialogHost` (copy the fixture from `DialogHostShowAsyncTests`: `Services.AddSingleton<IDialogService>(_service); JSInterop.Mode = Loose;`):
```csharp
public class TemplatePickerDialogTests : BunitContext
{
private readonly DialogService _service = new();
public TemplatePickerDialogTests()
{
Services.AddSingleton<IDialogService>(_service);
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static (List<TemplateFolder>, List<Template>) Catalogue() { /* same as Task 2 */ }
private async Task<(IRenderedComponent<DialogHost> cut, Task<TemplatePickerDialog.TemplatePickResult?> pending)> Open(int? initial = null)
{
var (f, t) = Catalogue();
var cut = Render<DialogHost>();
Task<TemplatePickerDialog.TemplatePickResult?> pending = null!;
await cut.InvokeAsync(() => pending = _service.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
"Choose template",
ctx => b => { b.OpenComponent<TemplatePickerDialog>(0);
b.AddAttribute(1, nameof(TemplatePickerDialog.Context), ctx);
b.AddAttribute(2, nameof(TemplatePickerDialog.Folders), f);
b.AddAttribute(3, nameof(TemplatePickerDialog.Templates), t);
b.AddAttribute(4, nameof(TemplatePickerDialog.InitialTemplateId), initial);
b.CloseComponent(); },
size: "modal-lg"));
cut.Render();
return (cut, pending);
}
[Fact] public async Task Select_IsDisabled_UntilATemplateIsClicked()
{ var (cut, _) = await Open(); Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled")); }
[Fact] public async Task ClickingTemplate_ThenSelect_ResolvesItsId()
{
var (cut, pending) = await Open();
// expand A, then B, then click "Deep"
ExpandFolder(cut, "A"); ExpandFolder(cut, "B");
cut.FindAll(".tv-label").First(l => l.TextContent == "Deep").Click();
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("A / B / Deep", cut.Find("[data-test='picker-status']").TextContent);
cut.Find("button[data-test='picker-select']").Click();
Assert.Equal(7, (await pending)!.TemplateId);
}
[Fact] public async Task DoubleClickingTemplate_Resolves()
{ /* same, but .DoubleClick() on the label; await pending == 7 */ }
[Fact] public async Task ClickingFolder_SelectsNothing()
{ /* click "A" label; Select still disabled */ }
[Fact] public async Task Cancel_ResolvesNull()
{ var (cut, pending) = await Open(); cut.Find("button[data-test='picker-cancel']").Click(); Assert.Null(await pending); }
[Fact] public async Task InitialTemplateId_IsRevealedAndHighlighted()
{
var (cut, _) = await Open(initial: 7);
Assert.Contains("Deep", cut.Markup);
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
}
[Fact] public async Task Search_FiltersTree()
{
var (cut, _) = await Open();
cut.Find("input[data-test='picker-search']").Input("Else");
Assert.Contains("Elsewhere", cut.Markup); Assert.DoesNotContain("Deep", cut.Markup);
}
private static void ExpandFolder(IRenderedComponent<DialogHost> cut, string label) =>
cut.FindAll("li[role='treeitem']").First(li => li.TextContent.Contains(label))
.QuerySelector(".tv-toggle, .tv-caret, button")!.Click(); // use the selector TreeViewTests uses
}
```
**Step 2: Run — compile failure.**
**Step 3: Implement** `TemplatePickerDialog.razor`:
```razor
@*
Body component for the Create Instance template picker, hosted by
IDialogService.ShowAsync. Wraps the shared TemplateFolderTree (the Templates
page's tree) in Single mode with its own search box. Templates are the only
selectable nodes; double-click selects. Closes with TemplatePickResult, a
record so Cancel (null) is distinguishable from a pick — same contract as
MoveTemplateDialog.
*@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
<div class="d-flex gap-2 align-items-center mb-2">
<div class="input-group input-group-sm">
<input type="text" class="form-control" data-test="picker-search" placeholder="Search templates..."
value="@_search" @oninput="e => _search = e.Value?.ToString() ?? string.Empty" />
@if (!string.IsNullOrEmpty(_search))
{
<button class="btn btn-outline-secondary" type="button" title="Clear search" @onclick="() => _search = string.Empty">✕</button>
}
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" title="Expand all folders" @onclick="() => _tree?.ExpandAll()"><i class="bi bi-arrows-expand"></i></button>
<button class="btn btn-sm btn-outline-secondary" type="button" title="Collapse all folders" @onclick="() => _tree?.CollapseAll()"><i class="bi bi-arrows-collapse"></i></button>
</div>
<div class="border rounded p-1" style="max-height: 50vh; min-height: 160px; overflow-y: auto;">
<TemplateFolderTree @ref="_tree"
Folders="Folders" Templates="Templates"
SelectionMode="TreeViewSelectionMode.Single"
SelectedKeys="_selectedKeys" SelectedKeysChanged="OnSelectedKeysChanged"
Filter="@_search"
RevealKeys="_revealKeys"
OnNodeClick="OnNodeClick">
<NodeContent Context="node">
<span class="tv-glyph"><i class="bi @(node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text")"></i></span>
<span class="tv-label @(node.Kind == TemplateTreeNodeKind.Folder ? "fw-semibold" : "")" title="@node.Name"
@onclick="() => OnNodeClick(node)"
@ondblclick="() => OnNodeDoubleClick(node)">@node.Name</span>
@if (node.Kind == TemplateTreeNodeKind.Folder && node.Children.Count > 0)
{
<span class="tv-meta"><span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span></span>
}
</NodeContent>
<EmptyContent><span class="text-muted fst-italic">No templates match.</span></EmptyContent>
</TemplateFolderTree>
</div>
<div class="modal-footer px-0 pb-0 mt-3 d-flex justify-content-between">
<span class="small text-muted" data-test="picker-status">
@if (_selectedId > 0 && _templatesById.TryGetValue(_selectedId, out var sel))
{ <text>Selected: <span class="text-body">@TemplatePathFormatter.Format(sel, _foldersById)</span></text> }
else { <text>Select a template</text> }
</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" data-test="picker-cancel" @onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" data-test="picker-select" disabled="@(_selectedId <= 0)" @onclick="Submit">Select</button>
</div>
</div>
@code {
[Parameter] public DialogContext<TemplatePickResult> Context { get; set; } = default!;
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
[Parameter] public int? InitialTemplateId { get; set; }
private TemplateFolderTree? _tree;
private string _search = string.Empty;
private int _selectedId;
private HashSet<object> _selectedKeys = new();
private string[]? _revealKeys;
private Dictionary<int, Template> _templatesById = new();
private Dictionary<int, TemplateFolder> _foldersById = new();
protected override void OnParametersSet()
{
_templatesById = Templates.ToDictionary(t => t.Id);
_foldersById = Folders.ToDictionary(f => f.Id);
if (_selectedId == 0 && InitialTemplateId is int id && _templatesById.ContainsKey(id))
{
_selectedId = id;
_selectedKeys = new HashSet<object> { $"t:{id}" };
_revealKeys = new[] { $"t:{id}" };
}
}
private void OnNodeClick(TemplateTreeNode node)
{
if (node.Kind != TemplateTreeNodeKind.Template) return; // folders only toggle
_selectedId = node.Id;
_selectedKeys = new HashSet<object> { node.Key };
}
private void OnNodeDoubleClick(TemplateTreeNode node)
{
if (node.Kind != TemplateTreeNodeKind.Template) return;
_selectedId = node.Id;
Submit();
}
private void OnSelectedKeysChanged(HashSet<object> keys)
{
// The TreeView's own row selection: accept template keys, ignore folder keys.
var key = keys.FirstOrDefault() as string;
if (key is not null && key.StartsWith("t:", StringComparison.Ordinal) && int.TryParse(key.AsSpan(2), out var id))
{ _selectedId = id; _selectedKeys = keys; }
else { _selectedKeys = _selectedId > 0 ? new HashSet<object> { $"t:{_selectedId}" } : new(); }
}
private void Submit() { if (_selectedId > 0) Context.Close(new TemplatePickResult(_selectedId)); }
/// <summary>Resolved result of the picker; <c>null</c> from ShowAsync means Cancel.</summary>
public sealed record TemplatePickResult(int TemplateId);
}
```
Check `TreeView`'s row selection contract (`Selectable` + `SelectedKeysChanged`) while implementing — if the row click already routes through `SelectedKeysChanged`, the `OnNodeClick` label handler is belt-and-braces; keep whichever the tests prove. Derived templates render as children of their base (the tree's `BuildTree` nests `IsDerived`), so a "template" node can have children — that is expected.
**Step 4: Run — 7 PASS.**
**Step 5: Commit** — `feat(centralui): TemplatePickerDialog — folder-tree template picker hosted by IDialogService`
---
### Task 4: Create page — picker field + `?templateId=` preselect
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6 (after Task 3)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor` (template field L40-51; query params L231-232; `OnInitializedAsync` L245-280)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs` (add `OpenTemplatePickerAsync`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs`
No bUnit test renders `InstanceCreate` today; this task adds the first. Its DI graph = `ITemplateEngineRepository`, `ISiteRepository`, `IFlatteningPipeline`, `DeploymentService` (copy the `DeploymentService` registrations verbatim from `TopologyPageTests`' constructor), `SiteScopeService`, `IDialogService` (real `DialogService` + render a `DialogHost` alongside), auth with a `Deployer` claim (`AddTestAuth` from `TopologyPageTests`), `JSInterop.Mode = Loose`. Open the page's `@inject` list and satisfy every entry.
**Step 1: Failing tests**
```csharp
[Fact] public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
{
SeedCatalogue();
var cut = RenderPage();
var field = cut.Find("input[data-test='create-template']");
Assert.True(field.HasAttribute("readonly"));
Assert.Equal("Choose a template…", field.GetAttribute("placeholder"));
Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
}
[Fact] public void TemplateIdQuery_PrefillsFieldWithFolderPath()
{
SeedCatalogue(); // template 7 "Deep" in A/B
var cut = RenderPage(templateId: 7);
Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
}
[Fact] public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
{
SeedCatalogue();
var cut = RenderPage(templateId: 999);
Assert.Equal("", cut.Find("input[data-test='create-template']").GetAttribute("value") ?? "");
Assert.Contains("Template 999 no longer exists", cut.Markup);
}
[Fact] public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
{
SeedCatalogue(empty: true);
var cut = RenderPage();
Assert.True(cut.Find("button[data-test='create-template-choose']").HasAttribute("disabled"));
Assert.Contains("/design/templates", cut.Markup);
}
[Fact] public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
{
SeedCatalogue(); // + one site id 1; _pipeline.PreviewAsync returns an empty-attribute success
var cut = RenderPage(siteId: 1);
cut.Find("button[data-test='create-template-choose']").Click();
// picker is open in the DialogHost rendered alongside
ExpandFolder(cut, "A"); ExpandFolder(cut, "B");
cut.FindAll(".tv-label").First(l => l.TextContent == "Deep").Click();
cut.Find("button[data-test='picker-select']").Click();
cut.WaitForAssertion(() => Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value")));
await _pipeline.Received(1).PreviewAsync(7, 1, Arg.Any<CancellationToken>());
}
```
(Query params: render with `Render<InstanceCreate>(p => p.Add(x => x.TemplateId, 7))` — `[SupplyParameterFromQuery]` properties are settable as ordinary parameters under bUnit.)
**Step 2: Run — fails (select still rendered, `TemplateId` missing).**
**Step 3: Implement**
`InstanceCreate.razor` template field (replace L40-51):
```razor
<div class="mb-2">
<label class="form-label small">Template</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" data-test="create-template" readonly
placeholder="Choose a template…" value="@TemplatePathLabel"
style="cursor: pointer; background-color: var(--bs-body-bg);"
@onclick="OpenTemplatePickerAsync" />
<button class="btn btn-outline-secondary" type="button" data-test="create-template-choose"
disabled="@(_templates.Count == 0)" @onclick="OpenTemplatePickerAsync">Choose…</button>
</div>
@if (_templates.Count == 0 && !_loading)
{
<div class="form-text">No templates exist yet — <a href="/design/templates">create one on the Templates page</a>.</div>
}
else if (_templateHint is not null)
{
<div class="form-text text-warning-emphasis">@_templateHint</div>
}
</div>
```
Query parameter (next to `SiteId`/`AreaId`): `[SupplyParameterFromQuery] public int? TemplateId { get; set; }`
Fields: `private List<TemplateFolder> _folders = new(); private string? _templateHint;`
`private string TemplatePathLabel => _templates.FirstOrDefault(t => t.Id == _createTemplateId) is { } t ? TemplatePathFormatter.Format(t, _foldersById) : string.Empty;`
`private Dictionary<int, TemplateFolder> _foldersById = new();`
In `OnInitializedAsync`, after `_templates` loads:
```csharp
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
_foldersById = _folders.ToDictionary(f => f.Id);
if (TemplateId is int tid)
{
if (_templates.Any(t => t.Id == tid)) _createTemplateId = tid;
else _templateHint = $"Template {tid} no longer exists — choose another.";
}
```
and fix the stale comment above the trailing `OnTemplateOrSiteChangedAsync()` call: *"Query-string preselects (siteId + templateId) make this a real preview when both are present."*
`InstanceCreate.razor.cs`:
```csharp
/// <summary>Opens the folder-tree template picker; a pick re-runs the bindings preview.</summary>
private async Task OpenTemplatePickerAsync()
{
if (_templates.Count == 0) return;
var picked = await Dialog.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
"Choose template",
ctx => @<TemplatePickerDialog Context="ctx" Folders="_folders" Templates="_templates"
InitialTemplateId="@(_createTemplateId > 0 ? _createTemplateId : null)" />,
size: "modal-lg");
if (picked is null || picked.TemplateId == _createTemplateId) return;
_createTemplateId = picked.TemplateId;
_templateHint = null;
await OnTemplateOrSiteChangedAsync();
}
```
(The inline `@<…>` fragment syntax only compiles in `.razor`; if `Dialog` is injected only in the `.razor`, put this method in the `@code` block of `InstanceCreate.razor` instead, or use `RenderTreeBuilder` in the `.cs` as the Task 3 test does. Add `@inject IDialogService Dialog` if the page lacks it.)
**Step 4: Run — 5 PASS.** Also `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`.
**Step 5: Commit** — `feat(centralui): Create Instance picks its template from the folder tree (+ ?templateId preselect)`
---
### Task 5: Templates page — "New Instance…" menu item (Deployer-gated)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor` (`LoadTemplatesAsync` ~L140; `RenderNodeContextMenu` Template case ~L298-310)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs` (append)
**Step 1: Failing tests** (the file's `AddTestAuth()` seeds a `Designer` claim; add an overload `AddTestAuth(params string[] roles)` or a second fixture method)
```csharp
private static AngleSharp.Dom.IElement OpenTemplateContextMenu(IRenderedComponent<TemplatesPage> cut, string label)
{
var row = cut.FindAll("li[role='treeitem']").First(li => li.TextContent.Contains(label)).QuerySelector(".tv-row")!;
row.ContextMenu();
return cut.Find(".dropdown-menu.show");
}
[Fact]
public void TemplateContextMenu_OffersNewInstance_ForDeployer()
{
// arrange: auth with Designer + Deployer, one root template "Pump"
var menu = OpenTemplateContextMenu(cut, "Pump");
var btn = menu.QuerySelector("button[data-test='ctx-new-instance']");
Assert.NotNull(btn);
Assert.Equal("New Instance…", btn!.TextContent.Trim());
btn.Click();
var nav = Services.GetRequiredService<NavigationManager>();
Assert.EndsWith("/deployment/instances/create?templateId=" + pumpId, nav.Uri);
}
[Fact]
public void TemplateContextMenu_HidesNewInstance_ForDesignerOnly()
{
var menu = OpenTemplateContextMenu(cut, "Pump");
Assert.Null(menu.QuerySelector("button[data-test='ctx-new-instance']"));
}
```
**Step 2: Run — fail (no such button).**
**Step 3: Implement**
Field + resolution in `LoadTemplatesAsync` (or `OnInitializedAsync`):
```csharp
// "New Instance…" leaves the Design area for a RequireDeployment page; only offer
// it to users who hold that role so the menu never leads to a 403.
private bool _canCreateInstances;
...
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_canCreateInstances = state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
```
(`Roles` lives in `ZB.MOM.WW.ScadaBridge.Security`; `JwtTokenService.RoleClaimType` likewise — add the `@using` if missing.)
Menu (after "Compose into…" block, before "Move to Folder…"):
```razor
@if (_canCreateInstances)
{
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-test="ctx-new-instance"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?templateId={node.Id}")'>New Instance…</button>
<div class="dropdown-divider"></div>
}
```
**Step 4: Run — `TemplatesPageTests` all PASS.**
**Step 5: Commit** — `feat(centralui): Templates tree context menu — "New Instance…" (Deployer-gated)`
---
### Task 6: Pin the Topology Disable confirm label
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4, Task 5
**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/TopologyDisableConfirmLabelTests.cs`
No production change expected — `Topology.razor:932-938` already passes `confirmLabel: "Disable"` (commit `0db253b5`, register row 28). This pins it the way `Monitoring/FailoverConfirmLabelTests.cs` pins the failover button.
**Step 1: Write the test** — copy `TopologyPageTests`' constructor DI block verbatim (it is long; that is the cost of rendering `Topology`), register `Services.AddSingleton<IDialogService>(_dialog)` with a shared `DialogService`, render `Topology` **and** a `DialogHost` in one `Render` via a wrapper fragment (see `FailoverConfirmLabelTests.RenderControlWithHost` for the pattern), seed one site with one `Deployed`/`Enabled` instance (copy the seed from the staleness tests ~L300-355, state `InstanceState.Deployed`), expand to the row, `.ContextMenu()`, click the `Disable` item, then:
```csharp
var confirm = cut.FindAll(".modal .btn-group button").Last();
Assert.Equal("Disable", confirm.TextContent.Trim());
Assert.Contains("btn-danger", confirm.ClassName);
Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent);
```
Also add the negative twin for completeness: clicking **Delete** yields a button reading "Delete".
**Step 2: Run — expect PASS on the first run** (the fix already exists). If it FAILS, stop: that means the label path regressed — investigate before touching anything.
**Step 3: Commit** — `test(centralui): pin Topology Disable/Delete confirm verbs (register row 28)`
---
### Task 7: Docs
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (after Tasks 4–6)
**Files:**
- Modify: `docs/requirements/Component-CentralUI.md` (the Create-Instance bullet near L121; the Templates page section — grep "Templates page" / "context menu")
- Modify: `docs/plans/2026-08-21-template-picker-design.md` (Status → implemented)
- Modify: `docs/plans/2026-08-21-create-instance-bindings-design.md` §3.2 (one line: template is now chosen via the picker; `?templateId=` preselect)
Content: one paragraph under the Create-Instance bullet — template chosen through `TemplatePickerDialog` (shared `TemplateFolderTree`, Single mode, templates-only selection, `RevealKeys` opens on the current pick), `?templateId=` query preselect; one sentence in the Templates-page section — "New Instance…" on Template nodes, Deployer-gated, navigates with `templateId`. Note the Disable label pin.
**Commit** — `docs(centralui): template picker on Create Instance, New Instance… from the Templates tree`
---
### Task 8: Solution build + CentralUI test sweep + live check on the rig
**Classification:** standard
**Estimated implement time:** ~5 min (+ image build)
**Parallelizable with:** none (last)
**Steps**
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx -v q` → 0 errors.
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests -v q` → green (the 8 Playwright E2E live in a separate project and are environment-gated; not part of this run).
3. `bash docker/deploy.sh`, wait for `http://localhost:9000/health/ready`.
4. Live check (multi-role / password at `http://localhost:9000`):
- `/design/templates` → right-click a composed template (e.g. the rig's "MES Line Controller") → **New Instance…** → lands on `/deployment/instances/create?templateId=N` with the field showing its folder path.
- Pick a site → the Connection Bindings card appears (the `?templateId` preselect now feeds `OnTemplateOrSiteChangedAsync`).
- **Choose…** → picker opens expanded to and highlighting that template; search narrows; Cancel leaves the field as it was; pick another → field + card update.
- `/deployment/topology` → **+ Instance** → empty field, Choose… opens collapsed.
- Topology → right-click a Deployed instance → **Disable** → confirm button reads **Disable** (red). Cancel.
5. Record the outcome in the plan's Status section; commit.
**Commit** — `docs(plans): template picker — live rig gate + task status`
---
## Status
**DONE — all 8 tasks complete, 2026-08-21.** Branch `feat/template-picker` (worktree
`~/Desktop/ScadaBridge-template-picker`), 8 commits.
| # | Task | Result |
|---|------|--------|
| 1 | `TemplatePathFormatter` | done — 4 tests |
| 2 | `TemplateFolderTree.RevealKeys` | done — 3 tests; `TreeViewTests` / `TemplatesPageTests` / `TransportExportPageTests` unaffected |
| 3 | `TemplatePickerDialog` | done — 7 tests |
| 4 | Create page picker field + `?templateId=` | done — 5 tests (first bUnit render of `InstanceCreate`) |
| 5 | Templates tree "New Instance…" | done — 2 tests (Deployer shows, Designer-only hides) |
| 6 | Topology Disable confirm pin | done — 3 tests, **PASS on first run** (no production change; `0db253b5` intact) |
| 7 | Docs | done — `Component-CentralUI.md` (Create-Instance + Templates-page + dialog-label bullets), this design doc → implemented, bindings design §3.2 superseded note |
| 8 | Build + sweep + live rig | **PASS** — see below |
### Deviations from the plan, and why
1. **Selection is tracked inside `TemplatePickerDialog`, not through `SelectedKeys`/`SelectedKeysChanged`.**
In `TreeViewSelectionMode.Single` the inner `TreeView` uses the *singular* `SelectedKey` /
`SelectedKeyChanged` pair; `TemplateFolderTree` forwards only the plural (Checkbox-mode) pair,
which `TreeView` reads for tri-state checkbox aggregation. The plan's wiring would therefore
never have highlighted a row and `OnSelectedKeysChanged` would never have fired. The dialog's own
`NodeContent` click/dblclick handlers own selection and the `text-primary` highlight — no change
to the shared tree's public surface.
2. **`[SupplyParameterFromQuery]` cannot be set as an ordinary bUnit parameter** ("only accepts
cascading values"). The Task-4 tests seed `BunitNavigationManager.NavigateTo(".../create?templateId=7")`
instead, matching `Pages/QueryStringDrillInTests.cs`.
3. **`&#8230;` in a Razor *attribute* is emitted literally** — the placeholder uses the real `…`.
4. **The Task-3 `Open()` helper needs a block-bodied `InvokeAsync` lambda.** An expression body
returning the pending `Task` binds to `InvokeAsync(Func<Task>)` and awaits the dialog itself;
every test hung until it was changed (comment left in the file).
### Task 8 — build, sweep, live rig gate
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx -v q` → **0 warnings, 0 errors**.
- `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` → **1154 / 1154 PASS**.
- Rig: `docker/build.sh` run from the worktree (tags `scadabridge:latest`), then
`docker compose -f docker/docker-compose.yml up -d --force-recreate` from the **primary
checkout** — deploying `deploy.sh` wholesale from a worktree would have rehomed every node's
bind-mounted appsettings to the worktree copy. `/health/ready` Healthy on all 8 nodes.
- Live check at `http://localhost:9000` as `multi-role`, **all PASS**:
1. `/design/templates` → right-click **Motor Controller** (inside folder `Test`) → the menu shows
**New Instance…** between its own dividers → lands on
`/deployment/instances/create?templateId=4` with the field reading **"Test / Motor Controller"**.
2. Site = *Test Plant A* → Connection Bindings card appears (30 attributes, 4 groups) — the
`?templateId` preselect does feed `OnTemplateOrSiteChangedAsync`.
3. **Choose…** → picker opens **expanded to and highlighting** Motor Controller, footer
"Selected: Test / Motor Controller", Select enabled. Search "Tank" narrows to one row.
**Cancel** left the field, the site and the card untouched.
4. Re-open → **double-click "Tank Monitor"** → dialog closes, field reads "Tank Monitor"
(root template ⇒ no folder prefix), card re-previews to 7 attributes / 2 groups.
5. `/deployment/instances/create` with no query → field blank with the placeholder, picker opens
**collapsed**, footer "Select a template", **Select disabled**; clicking a *folder* label leaves
it disabled.
6. `/deployment/topology` → right-click `rig-probe-1` (Enabled) → **Disable** → confirm button reads
**Disable** in red, body "Disable instance 'rig-probe-1'? …". Cancelled; instance still Enabled.
### Known limitation (pre-existing, not introduced here)
`TreeView.InitiallyExpanded` is applied **once** (`_initialExpansionApplied`). So in the picker, if a
preselected template reveals on first render and the operator *then* types a search, the filter's own
ancestor-expand does not re-apply — matches under a still-collapsed folder stay hidden until the
folder is opened by hand. Opening the picker with no preselect (the common case) is unaffected,
because nothing consumed the one-shot. Fixing it means resetting `_initialExpansionApplied` when
`InitiallyExpanded` changes identity, which touches every `TreeView` caller — out of scope here.
@@ -0,0 +1,61 @@
{
"planPath": "docs/plans/2026-08-21-template-picker.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: TemplatePathFormatter (pure helper)",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: TemplateFolderTree.RevealKeys (additive)",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: TemplatePickerDialog component",
"status": "completed",
"blockedBy": [
1,
2
]
},
{
"id": 4,
"subject": "Task 4: Create page \u2014 picker field + ?templateId= preselect",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 5,
"subject": "Task 5: Templates page \u2014 New Instance\u2026 menu item (Deployer-gated)",
"status": "completed"
},
{
"id": 6,
"subject": "Task 6: Pin the Topology Disable confirm label",
"status": "completed"
},
{
"id": 7,
"subject": "Task 7: Docs",
"status": "completed",
"blockedBy": [
4,
5,
6
]
},
{
"id": 8,
"subject": "Task 8: Solution build + CentralUI test sweep + live rig check",
"status": "completed",
"blockedBy": [
7
]
}
],
"lastUpdated": "2026-08-21T00:00:00Z"
}
+4 -2
View File
@@ -13,7 +13,7 @@ Central cluster only. Sites have no user interface.
- **Framework**: Blazor Server (ASP.NET Core). UI logic executes on the server, updates pushed to the browser via SignalR.
- Keeps the entire stack in C#/.NET, consistent with the rest of the system (Akka.NET, EF Core).
- SignalR provides built-in support for real-time UI updates.
- **Dialog service confirm labels.** Every confirmation goes through `IDialogService.ConfirmAsync(title, message, danger, confirmLabel)`, rendered by the single `DialogHost` in `MainLayout`. `danger` selects **styling only** (`btn-danger`); `confirmLabel` names the action. When no label is supplied the button falls back to "Delete" for a danger confirm and "Confirm" otherwise — a convenience for the ~20 genuine delete call sites, and the reason the Health dashboard's *Trigger failover* dialog used to offer a red **Delete** button (deferred-work register row 28). **Any destructive action that is not literally a delete must pass `confirmLabel`**: failover → *Fail over*, secured-write approve/reject → *Approve* / *Reject*, instance disable → *Disable*, and every parked-message / notification / cached-call discard → *Discard*.
- **Dialog service confirm labels.** Every confirmation goes through `IDialogService.ConfirmAsync(title, message, danger, confirmLabel)`, rendered by the single `DialogHost` in `MainLayout`. `danger` selects **styling only** (`btn-danger`); `confirmLabel` names the action. When no label is supplied the button falls back to "Delete" for a danger confirm and "Confirm" otherwise — a convenience for the ~20 genuine delete call sites, and the reason the Health dashboard's *Trigger failover* dialog used to offer a red **Delete** button (deferred-work register row 28). **Any destructive action that is not literally a delete must pass `confirmLabel`**: failover → *Fail over*, secured-write approve/reject → *Approve* / *Reject*, instance disable → *Disable*, and every parked-message / notification / cached-call discard → *Discard*. The two most-cited verbs are pinned by test: `Monitoring/FailoverConfirmLabelTests` (failover, plus that an unlabelled danger confirm still defaults to "Delete") and `Deployment/TopologyDisableConfirmLabelTests` (Topology's instance **Disable** reads "Disable" and stays red, while its **Delete** still reads "Delete"). Both drive a real `DialogService` behind a real `DialogHost`, so they pin what the operator reads, not the argument a substitute received.
- **Dialog confirm severity — three rungs, not two** (deferred-work register row 75). `DialogSeverity` drives the confirm button's colour: `Normal` → `btn-primary`, `Caution` → `btn-warning`, `Destructive` → `btn-danger`. Only `Destructive` inherits the "Delete" label fallback above; an unlabelled `Caution` reads *Confirm*, because a caution action is by definition not a delete.
- **Why a middle rung exists.** The boolean was a two-state flag for a three-state concept, so a consequential-but-not-destructive action had to choose between looking like a delete and looking like the safe default. The **Secured Writes** page is where that bit: *Approve* and *Reject* both rendered as identical red buttons, so at the moment of a two-person-control decision the colour carried **no discriminating information at all** — and the affirmative half of the control wore the delete affordance.
- **The decision.** *Approve* is `Caution` (amber): approving executes a real write to live plant equipment, so it is not a benign primary action, but it is not destroying anything either. *Reject* **keeps** `Destructive` (red): it is terminal and irreversible for the request — the Operator must resubmit — and holding it red is what keeps the two opposite decisions visually distinct. Dropping *Reject* to a neutral button was considered and rejected: it would simply invert the bias, making declining look like the safe default.
@@ -45,7 +45,7 @@ Central cluster only. Sites have no user interface.
### Template Authoring (Design Role)
- The `/design/templates` page uses a **split-pane layout**: a folder/template tree sidebar on the left and the editor on the right.
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline beneath their owning template (right-click "Open composed template" reveals and selects the target). Compositions are shown by **effective set** (own + inherited): a derived/composed member surfaces the slots it inherits from its base — e.g. `LeakTest` composed onto base `ReactorSide` appears under each derived `…ReactorSide` member — badged **"inherited"** and read-only (Rename/Delete are offered on the base's own slot, not the inherited copy).
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). Root-level folders also carry a context menu. **Folder sibling reorder** is done via **Move up / Move down** menu items (M9/T23, `ReorderTemplateFolderCommand`); drag-drop is **not implemented** (permanently deferred). Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). A Template node also offers **"New Instance…"**, which navigates to `/deployment/instances/create?templateId={id}` with the picker field prefilled; it is **gated on the `Deployer` role claim** (exactly what `RequireDeployment` requires) so the item never leads a Designer-only user to a 403. Root-level folders also carry a context menu. **Folder sibling reorder** is done via **Move up / Move down** menu items (M9/T23, `ReorderTemplateFolderCommand`); drag-drop is **not implemented** (permanently deferred). Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
- A **search box** above the tree (M9/T22) filters visible nodes by substring match; it is wired to `TemplateFolderTree.Filter`.
- The `TemplateEdit` page shows a read-only **"Inherited members" panel** (M9/T26) listing the full multi-level effective inherited member set (origin, locked state, merged HiLo config) resolved by `GetResolvedTemplateMembersCommand` / `TemplateInheritanceResolver`. A **"Base changed" banner** appears when the resolver's staleness summary indicates the parent template has changed since the child was last edited. This is read-only — no "update-derived" mutation is exposed; the child is redeployed through the normal flow to pick up base changes.
- Create, edit, and delete templates.
@@ -118,6 +118,8 @@ Central cluster only. Sites have no user interface.
### Instance Management (Deployment Role)
- Create instances from templates at a specific site.
- **The template is chosen from the Templates folder tree, not a flat dropdown** (`InstanceCreate`, design `docs/plans/2026-08-21-template-picker.md`). The Template field is a **read-only, folder-qualified path box** ("Controllers / Conveyor / MES Line Controller", rendered by `TemplatePathFormatter`) with a **Choose…** button; both open `TemplatePickerDialog`, a `IDialogService.ShowAsync` body wrapping the **same shared `TemplateFolderTree`** the Templates page renders — its own search box, expand/collapse all, templates-only selection (folders just toggle), double-click to pick, and a footer showing the pending path. It opens **expanded to and highlighting the current pick** via the tree's additive `RevealKeys` parameter (ancestors of the named `f:`/`t:` keys start expanded when no filter is active; an active filter's own reveal set still wins, and callers that pass nothing are unaffected). A flat `<select>` was unusable once the catalogue grew past a screenful and showed no folder context at all.
- **`?templateId=` preselects.** The page takes a `TemplateId` query parameter, which is what the Templates tree's "New Instance…" navigates with; combined with the existing `?siteId=`, the Connection Bindings card below is populated on first render. An id that no longer exists leaves the field blank and shows *"Template N no longer exists — choose another."* rather than failing the page. With no templates at all, **Choose…** is disabled and the field points at the Templates page.
- **Connection Bindings at creation** (`InstanceCreate`, design `docs/plans/2026-08-21-create-instance-bindings-design.md`). Once a template AND a site are chosen, the create form grows a Connection Bindings card showing the SAME module groups the Configure page shows, so one Create can yield a deployable instance instead of always landing on Configure with 100% of the rows unbound. The groups come from `IFlatteningPipeline.PreviewAsync(templateId, siteId)` — a flatten of an *unsaved* instance with no overrides and no bindings — fed through the same `BindingGroupBuilder` Configure uses. **Group headers only**: per-member editing stays on Configure.
- **Bind all to** — a connection + root object, then **Prefill groups** sets the connection on every group and fills *blank* Objects only: the `(instance)` group gets the root verbatim; a module group whose template defaults already carry a shared prefix keeps it (pill: *from template*); any other module group is suggested `{root}.{groupKey}` (pill: *suggested*). An Object the operator typed is never overwritten. Provenance pills exist because a suggestion is a guess about the address space — a sibling-object layout needs correcting before Create.
- **Browse…** per group (and for the root) opens the shared `NodeBrowserDialog` with `SelectableNodeClasses = {Object, Variable}` — the create page binds a module to its **container** node, which the Variable-only default could not select. Picking a leaf *under* the module still works: `CreateBindingPlanner.ObjectFromPick` strips a trailing `.{memberTail}` matching any member of that group. Enabled only for browsable protocols (OPC UA, MxGateway), the same gate Configure applies.
@@ -142,7 +142,8 @@
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
<button class="btn btn-outline-primary btn-sm" data-test="binding-bulk-apply"
@onclick="ApplyBulkBinding"
disabled="@(_bulkConnectionId == 0)">Apply connection</button>
</label>
}
@@ -9,6 +9,7 @@
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@@ -18,6 +19,7 @@
@inject IFlatteningPipeline FlatteningPipeline
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
@@ -39,15 +41,22 @@
</div>
<div class="mb-2">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" data-test="create-template"
value="@_createTemplateId"
@onchange="async e => { _createTemplateId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; await OnTemplateOrSiteChangedAsync(); }">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
<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)
{
<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>
<div class="mb-2">
<label class="form-label small">Site</label>
@@ -228,11 +237,21 @@
</div>
@code {
/// <summary>Query preselect: the template the operator picked elsewhere (e.g. the Templates tree's "New Instance&#8230;").</summary>
[SupplyParameterFromQuery] public int? TemplateId { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? AreaId { get; set; }
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
private Dictionary<int, TemplateFolder> _foldersById = new();
private string? _templateHint;
/// <summary>Folder-qualified label shown in the read-only template field; blank when nothing is chosen.</summary>
private string TemplatePathLabel => _templates.FirstOrDefault(t => t.Id == _createTemplateId) is { } t
? TemplatePathFormatter.Format(t, _foldersById)
: string.Empty;
private List<Area> _allAreas = new();
private bool _loading = true;
@@ -247,6 +266,13 @@
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_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.";
}
// Site scoping (CentralUI-002): a scoped Deployment user may only
// create instances on sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
@@ -268,8 +294,8 @@
}
// The query string can preselect a site; a template never is, so this
// no-ops today — kept so a future preselect gets the card for free.
// Query-string preselects (siteId + templateId) make this a real preview
// when both are present; it no-ops when either is missing.
await OnTemplateOrSiteChangedAsync();
}
catch (Exception ex)
@@ -353,4 +379,22 @@
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
/// <summary>
/// Opens the folder-tree template picker. A pick clears any dangling-id hint and
/// re-runs the bindings preview; Cancel (or re-picking the same template) is a no-op.
/// </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();
}
}
@@ -130,10 +130,16 @@
private bool _loading = true;
private string? _errorMessage;
// "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;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_canCreateInstances = state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
await LoadTemplatesAsync();
}
@@ -302,6 +308,13 @@
{
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
}
@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>
}
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
<div class="dropdown-divider"></div>
@if (tmpl != null)
@@ -67,6 +67,12 @@
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
[Parameter] public string Filter { get; set; } = "";
/// <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; }
[Parameter] public RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? NodeContent { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? ContextMenu { get; set; }
@@ -165,7 +171,7 @@
{
_visibleRoots = _allRoots;
_filterRevealed = null;
_initiallyExpanded = null;
_initiallyExpanded = BuildRevealPredicate();
return;
}
@@ -185,6 +191,29 @@
_initiallyExpanded = n => revealed.Contains(n.Key);
}
/// <summary>
/// Builds the no-filter expansion predicate from <see cref="RevealKeys"/>: every
/// ancestor of a wanted key (the key's own node stays as-is). Null when nothing
/// was asked for, which keeps existing callers on the previous behaviour.
/// </summary>
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);
var path = new List<string>();
foreach (var root in _allRoots) CollectAncestors(root, wanted, ancestors, path);
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);
}
private static TemplateTreeNode? CopyMatching(TemplateTreeNode node, string needle, HashSet<string> revealed)
{
var selfMatch = node.Name.Contains(needle, StringComparison.OrdinalIgnoreCase);
@@ -0,0 +1,138 @@
@*
Body component for the Create Instance template picker, hosted by
IDialogService.ShowAsync. Wraps the shared TemplateFolderTree (the same tree
the Templates page renders) with its own search box. Templates are the only
selectable nodes — folders just toggle — and a double-click selects. Closes
with TemplatePickResult, a record so Cancel (null) is distinguishable from a
pick, the same contract MoveTemplateDialog uses.
Selection is tracked here rather than through TreeView's SelectedKey: in
Single mode TemplateFolderTree forwards only the Checkbox-mode SelectedKeys
pair, so the row highlight has to come from this component's own NodeContent.
*@
@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="OnSearchInput" />
@if (!string.IsNullOrEmpty(_search))
{
<button class="btn btn-outline-secondary" type="button" title="Clear search"
data-test="picker-search-clear" @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"
Filter="@_search"
RevealKeys="_revealKeys">
<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 @LabelClass(node)" 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" type="button" data-test="picker-cancel"
@onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" type="button" data-test="picker-select"
disabled="@(_selectedId <= 0)" @onclick="Submit">Select</button>
</div>
</div>
@code {
/// <summary>Supplied by <c>IDialogService.ShowAsync</c>; closes/cancels the modal.</summary>
[Parameter] public DialogContext<TemplatePickResult> Context { get; set; } = default!;
/// <summary>Folder catalogue backing the tree.</summary>
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
/// <summary>Template catalogue backing the tree.</summary>
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
/// <summary>Template to open on: preselected, highlighted, and revealed in the tree.</summary>
[Parameter] public int? InitialTemplateId { get; set; }
private TemplateFolderTree? _tree;
private string _search = string.Empty;
private int _selectedId;
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;
_revealKeys = new[] { $"t:{id}" };
}
}
private void OnSearchInput(ChangeEventArgs e) => _search = e.Value?.ToString() ?? string.Empty;
private string LabelClass(TemplateTreeNode node)
{
if (node.Kind == TemplateTreeNodeKind.Folder) return "fw-semibold";
return node.Kind == TemplateTreeNodeKind.Template && node.Id == _selectedId
? "text-primary fw-semibold"
: string.Empty;
}
private void OnNodeClick(TemplateTreeNode node)
{
// Folders and composition slots are structure, not pickable targets.
if (node.Kind != TemplateTreeNodeKind.Template) return;
_selectedId = node.Id;
}
private void OnNodeDoubleClick(TemplateTreeNode node)
{
if (node.Kind != TemplateTreeNodeKind.Template) return;
_selectedId = node.Id;
Submit();
}
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>
/// <param name="TemplateId">Id of the template the operator picked.</param>
public sealed record TemplatePickResult(int TemplateId);
}
@@ -0,0 +1,33 @@
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
{
/// <summary>Separator placed between each folder segment and the template name.</summary>
public const string Separator = " / ";
/// <summary>
/// Builds the display path for <paramref name="template"/> by walking its
/// folder chain upward through <paramref name="foldersById"/>.
/// </summary>
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);
}
}
@@ -55,7 +55,10 @@ public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixt
// Bulk-assign every bindable attribute to the fixture connection, then Apply + Save.
// Select by VALUE (the connection id) — most robust, since the select binds _bulkConnectionId.
await bulkSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.ConnectionId.ToString() });
await page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
// Keyed, not by accessible name: the grouped-bindings panel (77f4af0d) added a
// per-group "Apply to n" button, so a Name = "Apply" role locator now matches two
// elements and fails Playwright's strict mode.
await page.Locator("[data-test='binding-bulk-apply']").ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Save Bindings" }).ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
@@ -0,0 +1,215 @@
using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using InstanceCreatePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceCreate;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// bUnit tests for the Create Instance template field: a read-only path box plus a
/// Choose… button that opens <c>TemplatePickerDialog</c>, and the <c>?templateId=</c>
/// query preselect (including the dangling-id hint and the no-templates empty state).
/// </summary>
public class InstanceCreateTemplateFieldTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
public InstanceCreateTemplateFieldTests()
{
Services.AddSingleton(_repo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_pipeline);
Services.AddSingleton(_audit);
// The Connection Bindings card renders NodeBrowserDialog once a site is chosen.
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.CentralUI.Services.IBrowseService>());
// DeploymentService / InstanceService have non-mockable concrete deps;
// instantiate them directly (same block TopologyPageTests uses).
Services.AddSingleton(new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance));
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton(Options.Create(new CommunicationOptions()));
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
Services.AddSingleton<IDeploymentStatusNotifier>(
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance));
Services.AddSingleton(Substitute.For<IStaleInstanceProbe>());
Services.AddScoped<DeploymentService>();
Services.AddScoped<AreaService>();
Services.AddScoped<InstanceService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
// A real DialogService behind a real DialogHost so Choose… actually opens.
Services.AddSingleton<IDialogService>(_dialog);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer")
};
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Folders A → B with template 7 "Deep"; folder Other with template 8 "Elsewhere".</summary>
private void SeedCatalogue(bool empty = false, bool withSite = false)
{
var folders = empty
? new List<TemplateFolder>()
: new List<TemplateFolder>
{
new("A") { Id = 1 },
new("B") { Id = 2, ParentFolderId = 1 },
new("Other") { Id = 3 },
};
var templates = empty
? new List<Template>()
: new List<Template>
{
new("Deep") { Id = 7, FolderId = 2 },
new("Elsewhere") { Id = 8, FolderId = 3 },
};
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(folders));
var sites = withSite
? new List<Site> { new("Site A", "site-a") { Id = 1 } }
: new List<Site>();
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(new List<Area>()));
_pipeline.PreviewAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result<FlattenedConfiguration>.Success(new FlattenedConfiguration())));
}
/// <summary>
/// Renders the page and a DialogHost side by side (as MainLayout does) after seeding
/// the query string. <c>[SupplyParameterFromQuery]</c> properties are cascading-only,
/// so they cannot be Add()-ed as ordinary parameters — the URL is the only way in.
/// </summary>
private IRenderedComponent<TestHostWrapper> RenderPage(int? templateId = null, int? siteId = null)
{
var query = new List<string>();
if (templateId is int t) query.Add($"templateId={t}");
if (siteId is int s) query.Add($"siteId={s}");
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/instances/create" + (query.Count > 0 ? "?" + string.Join("&", query) : ""));
return Render<TestHostWrapper>();
}
/// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary>
public sealed class TestHostWrapper : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<InstanceCreatePage>(0);
builder.CloseComponent();
builder.OpenComponent<DialogHost>(1);
builder.CloseComponent();
}
}
private static void ExpandFolder(IRenderedComponent<TestHostWrapper> cut, string label) =>
cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
.QuerySelector(".tv-toggle")!.Click();
[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();
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(withSite: true);
var cut = RenderPage(siteId: 1);
cut.Find("button[data-test='create-template-choose']").Click();
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("button[data-test='picker-select']")));
ExpandFolder(cut, "A");
ExpandFolder(cut, "B");
cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == "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>());
}
}
@@ -0,0 +1,179 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using TopologyPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Topology;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Pins the verbs on the Topology instance-action confirmations (deferred-work
/// register row 28): <em>Disable</em> must read "Disable", not the red button's
/// historical "Delete" default, while a genuine Delete must still read "Delete".
/// Like <c>FailoverConfirmLabelTests</c> these run the real <see cref="DialogService"/>
/// behind a real <c>DialogHost</c>, so they pin what the user reads rather than
/// the argument a substitute received.
/// </summary>
public class TopologyDisableConfirmLabelTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
public TopologyDisableConfirmLabelTests()
{
Services.AddSingleton(_repo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_pipeline);
Services.AddSingleton(_audit);
// DeploymentService has non-mockable concrete deps; instantiate them directly
// (the same DI block TopologyPageTests uses to render this page).
Services.AddSingleton(new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance));
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton(Options.Create(new CommunicationOptions()));
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
Services.AddSingleton<IDeploymentStatusNotifier>(
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance));
Services.AddSingleton(Substitute.For<IStaleInstanceProbe>());
Services.AddScoped<DeploymentService>();
Services.AddScoped<AreaService>();
Services.AddScoped<InstanceService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
// The concrete service registered as the interface, so the page and the
// host share one instance (DialogHost injects IDialogService and casts).
Services.AddSingleton<IDialogService>(_dialog);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, Roles.Deployer)
};
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>One site with one enabled instance, so both Disable and Delete are offered.</summary>
private void SeedOneEnabledInstance()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(
new List<Site> { new("Plant-A", "plant-a") { Id = 1 } }));
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>
{
new("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.Enabled }
}));
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(new List<Area>()));
}
/// <summary>Renders Topology next to a live DialogHost, as MainLayout does.</summary>
private IRenderedComponent<TestHostWrapper> RenderPageWithHost() => Render<TestHostWrapper>();
/// <summary>Wrapper so the page and the dialog host share one render tree.</summary>
public sealed class TestHostWrapper : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<TopologyPage>(0);
builder.CloseComponent();
builder.OpenComponent<DialogHost>(1);
builder.CloseComponent();
}
}
/// <summary>
/// Expands to the instance row, opens its context menu and clicks the named item,
/// then returns the confirmation modal's footer buttons.
/// </summary>
/// <remarks>
/// The menu click is deliberately not awaited: with a real DialogService the page's
/// handler parks on <c>await ConfirmAsync(...)</c> until the dialog is answered.
/// </remarks>
private (IRenderedComponent<TestHostWrapper> cut, IReadOnlyList<AngleSharp.Dom.IElement> footer)
OpenInstanceConfirm(string menuItem)
{
SeedOneEnabledInstance();
var cut = RenderPageWithHost();
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
.QuerySelector(".tv-toggle")!.Click();
var row = cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Pump-001");
row.ContextMenu();
_ = cut.Find(".dropdown-menu.show")
.QuerySelectorAll("button.dropdown-item")
.First(b => b.TextContent.Trim() == menuItem)
.ClickAsync(new());
cut.WaitForState(() => cut.FindAll("div.modal-footer button").Count > 0);
return (cut, cut.FindAll("div.modal-footer button").ToList());
}
[Fact]
public void Disable_confirm_button_says_disable_not_delete()
{
var (_, footer) = OpenInstanceConfirm("Disable");
var confirm = footer.Last();
Assert.Equal("Disable", confirm.TextContent.Trim());
Assert.Contains("btn-danger", confirm.ClassList);
Assert.Contains("Cancel", footer.Select(b => b.TextContent.Trim()));
}
[Fact]
public void Disable_confirm_body_names_the_instance()
{
var (cut, _) = OpenInstanceConfirm("Disable");
Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent);
}
[Fact]
public void Delete_confirm_button_still_says_delete()
{
// The negative twin: relabelling Disable must not have changed the genuine
// delete path, which relies on the danger-confirm default.
var confirm = OpenInstanceConfirm("Delete").footer.Last();
Assert.Equal("Delete", confirm.TextContent.Trim());
Assert.Contains("btn-danger", confirm.ClassList);
}
}
@@ -0,0 +1,43 @@
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
}
}
@@ -0,0 +1,59 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Covers the additive <c>RevealKeys</c> parameter: the tree opens the ancestors of
/// a caller-named node when no filter is active, and an active filter still wins.
/// </summary>
public class TemplateFolderTreeRevealKeysTests : BunitContext
{
public TemplateFolderTreeRevealKeysTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
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, (IReadOnlyCollection<string>)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, (IReadOnlyCollection<string>)new[] { "t:7" })
.Add(x => x.Filter, "Elsewhere"));
Assert.Contains("Elsewhere", cut.Markup);
Assert.DoesNotContain("Deep", cut.Markup);
}
}
@@ -0,0 +1,136 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for the Create Instance template picker: templates are the only
/// selectable nodes, Select stays disabled until one is picked, double-click
/// selects, Cancel resolves null, and an InitialTemplateId opens the tree on
/// that template.
/// </summary>
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()
{
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 });
}
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!;
// Block body, not an expression body: an expression-bodied lambda returning the
// pending Task binds to InvokeAsync(Func<Task>) and would await the dialog itself.
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);
}
private static void ExpandFolder(IRenderedComponent<DialogHost> cut, string label) =>
cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
.QuerySelector(".tv-toggle")!.Click();
private static AngleSharp.Dom.IElement Label(IRenderedComponent<DialogHost> cut, string text) =>
cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == text);
[Fact]
public async Task Select_IsDisabled_UntilATemplateIsClicked()
{
var (cut, _) = await Open();
Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("Select a template", cut.Find("[data-test='picker-status']").TextContent);
}
[Fact]
public async Task ClickingTemplate_ThenSelect_ResolvesItsId()
{
var (cut, pending) = await Open();
ExpandFolder(cut, "A");
ExpandFolder(cut, "B");
Label(cut, "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()
{
var (cut, pending) = await Open();
ExpandFolder(cut, "A");
ExpandFolder(cut, "B");
Label(cut, "Deep").DoubleClick();
Assert.Equal(7, (await pending)!.TemplateId);
}
[Fact]
public async Task ClickingFolder_SelectsNothing()
{
var (cut, _) = await Open();
Label(cut, "A").Click();
Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("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.DoesNotContain("Elsewhere", cut.Markup);
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("A / B / Deep", cut.Find("[data-test='picker-status']").TextContent);
Assert.Contains("text-primary", Label(cut, "Deep").ClassName);
}
[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);
}
}
@@ -47,15 +47,16 @@ public class TemplatesPageTests : BunitContext
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
private void AddTestAuth(params string[] roles)
{
// The page resolves the current user via the "Username" claim in
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Designer")
};
// Calling this again from a test replaces the constructor's Designer-only
// default (MS DI resolves the LAST registration of a service), which is how
// a test renders the page as a Deployer.
if (roles.Length == 0) roles = new[] { Roles.Designer };
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
var identity = new ClaimsIdentity(claims, "TestAuth");
var user = new ClaimsPrincipal(identity);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -396,6 +397,57 @@ public class TemplatesPageTests : BunitContext
Assert.EndsWith("/design/templates/create", nav.Uri);
}
// ---------------------------------------------------------------
// "New Instance…" context-menu item (Deployer-gated)
// ---------------------------------------------------------------
private void SeedOneTemplate(int id, string name)
{
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { new(name) { Id = id } }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
}
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()
{
AddTestAuth(Roles.Designer, Roles.Deployer);
SeedOneTemplate(42, "Pump");
var cut = Render<TemplatesPage>();
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=42", nav.Uri);
}
[Fact]
public void TemplateContextMenu_HidesNewInstance_ForDesignerOnly()
{
// The constructor already registered the Designer-only principal.
SeedOneTemplate(42, "Pump");
var cut = Render<TemplatesPage>();
var menu = OpenTemplateContextMenu(cut, "Pump");
Assert.Null(menu.QuerySelector("button[data-test='ctx-new-instance']"));
}
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider