Compare commits
11 Commits
a3e2df325c
...
afb27e7780
| Author | SHA1 | Date | |
|---|---|---|---|
| afb27e7780 | |||
| 7a77d38abf | |||
| d6a31f1cd0 | |||
| 4bb77cf0f7 | |||
| 1790f67ebd | |||
| 7c2582d105 | |||
| 5b4a27b23e | |||
| 4bd2651925 | |||
| 8df6bcb6e0 | |||
| 9261fa09fa | |||
| 22468c1548 |
@@ -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.
|
||||
@@ -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. **`…` 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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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>
|
||||
}
|
||||
|
||||
+55
-11
@@ -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…").</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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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 });
|
||||
|
||||
+215
@@ -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>());
|
||||
}
|
||||
}
|
||||
+179
@@ -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
|
||||
}
|
||||
}
|
||||
+59
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user