Merge feat/instance-picker: area-tree instance picker combobox for Flow Debugger + Debug View
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Instance Picker — area-tree combobox for Flow Debugger and Debug View (design)
|
||||
|
||||
**Date:** 2026-09-08
|
||||
**Status:** approved (user, 2026-09-08: "Approve as designed"; scope: Flow Debugger + Debug View)
|
||||
**Supersedes:** the flat `<select>` instance pickers on `/deployment/flow-debugger` (`flow-instance-select`) and `/deployment/debug-view` (`debug-instance-select`).
|
||||
|
||||
## 1. Change
|
||||
|
||||
Both pages pick an instance from a flat, alphabetical `<select>` of every instance at the chosen site. A site with a few hundred instances across nested areas makes that list useless. Replace it with a **combobox**: a text field the operator can type an instance name into, whose dropdown is the site's **area tree** (all areas collapsed on open) with instances as leaves, so the operator can either drill down or search.
|
||||
|
||||
Alternatives rejected:
|
||||
|
||||
- A modal picker like Create Instance's *Choose…* button (`TemplatePickerDialog`): consistent, but two clicks and nothing typeable in the field. The user asked for a combobox.
|
||||
- A native `<datalist>` typeahead: search only, no area drill-down.
|
||||
|
||||
## 2. Component — `InstancePicker`
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor` (+ `.razor.cs`, `.razor.css`). Bootstrap-only, no third-party UI, no new JavaScript.
|
||||
|
||||
**Field.** A `form-control form-control-sm` text input with a chevron button, sized like the sibling selects. Closed, it shows the selected instance's `UniqueName` (empty when nothing is selected, placeholder *Search or browse instances…*). Focus or typing opens the panel; the chevron toggles it. `role="combobox"`, `aria-expanded`, `aria-controls` → the panel id; `Disabled` disables both.
|
||||
|
||||
**Panel.** A `position-absolute` dropdown under the field (z-index above page content, `max-height: 40vh`, scroll) containing the existing `TreeView<InstanceTreeNode>`:
|
||||
|
||||
- **Forest** = the site's areas nested by `ParentAreaId` (Topology's shape), each area's instances as leaves after its child areas, and instances with no area listed at the root **after** the root areas. Ordinal-ignore-case sort on both.
|
||||
- **All areas collapsed** when the panel opens with an empty filter (no `InitiallyExpanded`, no `StorageKey`).
|
||||
- Clicking an area row toggles it (the TreeView chevron; the label click also toggles). Clicking an instance row selects it and closes the panel.
|
||||
- An empty tree renders *No instances at this site* / *No instances match*.
|
||||
|
||||
**Search.** The field text is a case-insensitive `Contains` on `UniqueName`. With a non-empty filter the forest is rebuilt to the matching instances plus their ancestor areas, and the tree is force-expanded (`ExpandAll` after the rebuild, as the template picker does). Clearing the text restores the full forest and collapses everything (`CollapseAll`).
|
||||
|
||||
**Keyboard.** `Enter` selects the first visible matching instance (document order); `ArrowDown` from the field moves focus into the tree (its roving-tabindex arrow navigation; the picker passes `Selectable` + `SelectedKeyChanged` so `Enter`/`Space` on an instance row selects it and on an area row toggles it — the same handler the mouse uses); `Escape` closes and restores the field text to the current selection. Tabbing out does **not** close the panel (a correct focus-out needs `relatedTarget`, i.e. JS interop, which this design avoids); the next click anywhere lands on the backdrop and closes it, which is acceptable.
|
||||
|
||||
**Outside click.** A transparent fixed backdrop (the same `tv-ctx-overlay` pattern `TreeView` uses for its context menu) sits under the panel while open and closes it on click. No JS interop.
|
||||
|
||||
**Parameters.**
|
||||
|
||||
| Parameter | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Areas` | `IReadOnlyList<Area>` | the site's areas (flat, `ParentAreaId` links) |
|
||||
| `Instances` | `IReadOnlyList<Instance>` | the site's instances |
|
||||
| `SelectedInstanceId` / `SelectedInstanceIdChanged` | `int` / `EventCallback<int>` | two-way bind, `0` = none |
|
||||
| `Disabled` | `bool` | |
|
||||
| `ItemLabel` | `Func<Instance,string>?` | row + field text; default `UniqueName`. Debug View passes `$"{i.UniqueName} ({i.State})"` |
|
||||
| `DataTestPrefix` | `string` | `flow-instance` / `debug-instance` → `{prefix}-picker`, `{prefix}-input`, `{prefix}-toggle`, `{prefix}-panel`, rows `{prefix}-row` with `data-instance-id` |
|
||||
| `Placeholder` | `string` | default *Search or browse instances…* |
|
||||
|
||||
A parent that changes `Areas`/`Instances` (site change) resets the field text and closes the panel; a parent that changes `SelectedInstanceId` (URL / localStorage restore) updates the field text.
|
||||
|
||||
**Pure builder.** `InstanceTreeBuilder.Build(areas, instances, filter)` → `IReadOnlyList<InstanceTreeNode>` (`Components/Shared/InstanceTreeNode.cs`, `InstanceTreeBuilder.cs`; record `InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)` with a read-only `Children` property the builder fills, keys `a:{id}` / `i:{id}`, `IsInstance => Instance != null`). Orphaned areas (parent id pointing nowhere) and orphaned instances (area id pointing nowhere) are treated as roots so a bad row can never hide a subtree; a `ParentAreaId` cycle is unreachable from any root and simply does not render (guarded against looping). `Topology.razor` keeps its own richer `TopoNode` — not refactored (YAGNI).
|
||||
|
||||
## 3. Page wiring
|
||||
|
||||
- **Flow Debugger** (`FlowDebugger.razor` / `.razor.cs`): `LoadInstancesAsync` also loads `_areas` via `ITemplateEngineRepository.GetAreasBySiteIdAsync(siteId)`; the `<select data-test="flow-instance-select">` becomes `<InstancePicker … SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync" Disabled="@(_running || _selectedSiteId == 0 || _instancesLoading)" DataTestPrefix="flow-instance" />` (the handler sets the id then awaits the unchanged `OnInstanceChangedAsync`). **The picker stays disabled while the site's instances load** (`_instancesLoading`, both pages): Blazor renders at the handler's first `await`, so without the flag the field is enabled while the old lists are still bound, and text typed in that window is wiped when the loaded lists arrive (the reference change reads as a site change) — found by Playwright on the rig. `_selectedInstanceId`, `OnInstanceChangedAsync`, the `?instanceId=` deep link, localStorage restore and the site-scope gate are unchanged.
|
||||
- **Debug View** (`DebugView.razor`): `LoadInstancesForSite` also loads the areas; the `<select data-test="debug-instance-select">` becomes the picker with `ItemLabel` = `UniqueName (State)`, `Disabled="@(_connected || _instancesLoading)"`, `DataTestPrefix="debug-instance"`, `SelectedInstanceIdChanged="OnInstancePicked"`. `OnInstanceSelectionChanged`, `?instanceId=` and localStorage reconnect unchanged.
|
||||
|
||||
## 4. Security
|
||||
|
||||
None new. Site scoping stays on the site select (both pages already re-check `SiteScope.IsSiteAllowedAsync` on site change); the picker only ever receives the instances of the already-allowed site. Labels are rendered as text (Blazor-encoded).
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- `CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs`: nesting, root instances after root areas, sort, orphaned area → root, filter keeps ancestors and drops non-matching branches, empty inputs.
|
||||
- `CentralUI.Tests/Shared/InstancePickerTests.cs` (bUnit): opens collapsed (no instance rows visible under a collapsed area); typing reveals matches expanded; clearing collapses; click selects + closes + raises `SelectedInstanceIdChanged`; `Enter` picks the first match; `Escape` restores text; `Disabled`; parent-driven `SelectedInstanceId` updates the field; `ItemLabel`.
|
||||
- `FlowDebuggerPageTests`: the five `flow-instance-select` `.Change("7")` call sites go through a test helper that opens the picker and clicks the row (`data-instance-id`).
|
||||
- Debug View bUnit tests: none drive the select today; add one page test that a picker row click sets the instance and enables *Connect*.
|
||||
- Playwright: `FlowDebuggerTests` (instance pick at line ~209), `DebugViewTests`, `DebugViewTreeTests` switch from `<select>`+option to typing in `{prefix}-input` and clicking the row.
|
||||
|
||||
## 6. Docs
|
||||
|
||||
`docs/requirements/Component-CentralUI.md` (Flow Debugger + Debug View paragraphs), `CLAUDE.md` Flow Debugger bullet (one sentence), this design doc.
|
||||
@@ -0,0 +1,261 @@
|
||||
# Instance Picker Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the flat instance `<select>` on Flow Debugger and Debug View with a shared combobox whose dropdown is the site's collapsed area tree, searchable by typing.
|
||||
|
||||
**Architecture:** A pure `InstanceTreeBuilder` turns `Area`/`Instance` lists (+ filter) into `InstanceTreeNode` forests; a new Bootstrap-only `InstancePicker` component wraps a text field + the existing `TreeView` in an absolutely-positioned panel with a transparent backdrop (no JS). Both pages keep their selection state and only swap the control. Design: `docs/plans/2026-09-08-instance-picker-design.md`.
|
||||
|
||||
**Tech Stack:** Blazor Server, Bootstrap 5, bUnit + NSubstitute + xUnit, Playwright (rig).
|
||||
|
||||
**Branch:** `feat/instance-picker` (from `main` @ `859922ba`). Worktree agents branch from `main`, so every implementer starts with `git reset --hard <current feature sha>` (see memory `agent-worktree-isolation-base`).
|
||||
|
||||
**Conventions:** `docs/plans/...` rules in `CLAUDE.md`; tests run per project (`dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter ...`); commit trailer `Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29`. Never block on a Task from a Blazor lifecycle/event path.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `InstanceTreeNode` + `InstanceTreeBuilder` (pure) with tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (everything else depends on it)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs`
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs`
|
||||
|
||||
**Step 1: Write the failing tests** (`InstanceTreeBuilderTests`, namespace `ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared`; `Area` is `ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Area` — check the namespace with grep — `new Area("Name") { Id, SiteId, ParentAreaId }`; `Instance` is `Commons.Entities.Instances.Instance`, `new Instance("Name") { Id, SiteId, AreaId }`):
|
||||
|
||||
- `Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas`: areas Line1 (1), Cell A (2, parent 1); instances P1 (area 2), Zulu (no area), Alpha (no area) → roots = [`a:1`, `i:<Alpha>`, `i:<Zulu>`]; `a:1` → [`a:2`] → [`i:P1`]. Labels = names; `IsInstance` true only on instance nodes.
|
||||
- `Build_SortsAreasAndInstancesCaseInsensitively`: "beta", "Alpha" areas → Alpha first; same for instances.
|
||||
- `Build_OrphanedArea_BecomesARoot`: area with `ParentAreaId = 99` (absent) appears at root, its instances under it.
|
||||
- `Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches`: filter "p1" (case-insensitive) keeps `a:1 → a:2 → i:P1`, drops Zulu/Alpha and any area with no match; `Build_Filter_WhitespaceMeansNoFilter`.
|
||||
- `Build_NullOrEmptyInputs_ReturnEmpty` (null areas / null instances tolerated).
|
||||
- `Build_AreaWithNoInstancesAndNoFilter_IsStillShown` (empty areas are shown when unfiltered).
|
||||
|
||||
**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~InstanceTreeBuilderTests` → build errors (types missing).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
```csharp
|
||||
// InstanceTreeNode.cs
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>One row of the <see cref="InstancePicker"/> tree: an area branch or an instance leaf. Keys are a:{areaId} / i:{instanceId}.</summary>
|
||||
public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
|
||||
{
|
||||
public List<InstanceTreeNode> Children { get; } = new();
|
||||
public bool IsInstance => Instance is not null;
|
||||
public bool HasChildren => Children.Count > 0;
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
// InstanceTreeBuilder.cs — pure, no Blazor/DI.
|
||||
public static class InstanceTreeBuilder
|
||||
{
|
||||
public static IReadOnlyList<InstanceTreeNode> Build(IReadOnlyList<Area>? areas, IReadOnlyList<Instance>? instances, string? filter)
|
||||
// 1. areas ??= []; instances ??= []; needle = filter?.Trim(); hasFilter = !string.IsNullOrEmpty(needle)
|
||||
// 2. known = set of area ids; children-of lookup: parentId → areas (null parent OR parent not in known ⇒ root)
|
||||
// 3. instancesByArea: AreaId (null ⇒ root) → instances, each sorted OrdinalIgnoreCase by UniqueName
|
||||
// 4. BuildLevel(parentId): child areas sorted by Name OrdinalIgnoreCase, then instances of that level;
|
||||
// with a filter an instance is kept iff UniqueName.Contains(needle, OrdinalIgnoreCase),
|
||||
// an area is kept iff it has any kept child (area or instance); without a filter everything is kept.
|
||||
// 5. return roots
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run the tests → PASS. Step 5: Commit** `feat(central-ui): InstanceTreeBuilder — area/instance forest for the instance picker`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `InstancePicker` component — field, panel, select, outside-click
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor`
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs`
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs`
|
||||
|
||||
Read first: `TreeView.razor` (parameters at ~line 165–190, `ExpandAll`/`CollapseAll`/`RevealNode` at ~600–680, the context-menu overlay markup at ~line 41 for the backdrop pattern), `TemplatePickerDialog.razor` (search box + tree host + `NodeContent` with `tv-glyph`/`tv-label`), `TemplatePickerDialogTests.cs` (bUnit style; `JSInterop.Mode = JSRuntimeMode.Loose`).
|
||||
|
||||
**Step 1: Failing tests** (`InstancePickerTests : BunitContext`, Loose JS interop). Fixture: areas Line1 (1) → Cell A (2); instances P1 (id 7, area 2), Zulu (id 8, root). Render `InstancePicker` with `Areas`, `Instances`, `DataTestPrefix="t"`, `SelectedInstanceIdChanged` capturing into a local.
|
||||
|
||||
- `Closed_ShowsNothingButTheField`: no `[data-test='t-panel']`; input `[data-test='t-input']` has `role="combobox"`, `aria-expanded="false"`.
|
||||
- `Toggle_OpensPanel_WithAreasCollapsed`: click `[data-test='t-toggle']` → panel present; rows `[data-test='t-row']` visible = Line1 (area) and Zulu (root instance) only — P1 is under a collapsed area, so no `[data-instance-id='7']`.
|
||||
- `ClickingAnArea_ExpandsIt`: click the `tv-toggle` of Line1 then Cell A → `[data-instance-id='7']` appears.
|
||||
- `ClickingAnInstance_Selects_Closes_AndRaisesTheCallback`: after expanding, click `[data-instance-id='7']` → callback got 7, panel gone, input value `"P1"`.
|
||||
- `Disabled_NeverOpens`: `Disabled=true` → input disabled, toggle disabled, clicking does nothing.
|
||||
- `Backdrop_ClickClosesThePanel_WithoutChangingSelection`.
|
||||
- `ParentDrivenSelection_UpdatesTheFieldText`: re-render with `SelectedInstanceId=8` → input value "Zulu"; `SelectedInstanceId=0` → "".
|
||||
- `ItemLabel_IsUsedForRowsAndField`: `ItemLabel = i => i.UniqueName + " (x)"`.
|
||||
|
||||
**Step 2: run → build errors. Step 3: Implement.**
|
||||
|
||||
`InstancePicker.razor` (sketch — Bootstrap only):
|
||||
|
||||
```razor
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
|
||||
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" role="combobox" autocomplete="off"
|
||||
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree"
|
||||
aria-expanded="@(_open ? "true" : "false")" aria-controls="@_panelId"
|
||||
placeholder="@Placeholder" disabled="@Disabled"
|
||||
data-test="@($"{DataTestPrefix}-input")"
|
||||
value="@_text" @oninput="OnInput" @onfocus="OpenAsync" @onkeydown="OnInputKeyDown" />
|
||||
<button type="button" class="btn btn-outline-secondary" tabindex="-1" aria-label="Browse instances"
|
||||
disabled="@Disabled" data-test="@($"{DataTestPrefix}-toggle")" @onclick="ToggleAsync">
|
||||
<i class="bi @(_open ? "bi-chevron-up" : "bi-chevron-down")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (_open)
|
||||
{
|
||||
<div class="ip-backdrop" @onclick="CloseAsync"></div>
|
||||
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
|
||||
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
|
||||
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
|
||||
KeySelector="n => n.Key" IndentPx="16">
|
||||
<NodeContent Context="node">
|
||||
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
|
||||
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
|
||||
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)"
|
||||
@onclick="() => OnRowClickAsync(node)">@node.Label</span>
|
||||
</NodeContent>
|
||||
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
|
||||
</TreeView>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
Code-behind essentials:
|
||||
- Parameters per design §2 table (`Areas`, `Instances`, `SelectedInstanceId`, `SelectedInstanceIdChanged`, `Disabled`, `ItemLabel`, `DataTestPrefix` (default `"instance"`), `Placeholder` (default `"Search or browse instances…"`)).
|
||||
- State: `_open`, `_text`, `_filter`, `_roots`, `_tree` (`TreeView<InstanceTreeNode>?`), `_inputId`/`_panelId` from a `Guid` suffix.
|
||||
- `OnParametersSet`: if `Areas`/`Instances` references changed → rebuild `_roots` with the current filter and (if the selection is no longer in `Instances`) clear text; if `SelectedInstanceId` changed and the panel is closed → `_text = LabelOf(selected)`.
|
||||
- `Rebuild()` → `InstanceTreeBuilder.Build(Areas, Instances, _filter)` — the label used for matching is `UniqueName` (the builder's contract), rows display `ItemLabel`.
|
||||
- `OnRowClickAsync(node)`: area → `_tree.ToggleExpand`? (`TreeView` has no public toggle — expose one: add `public void ToggleExpanded(object key)` to `TreeView.razor` next to `IsExpanded`, a one-line wrapper around the existing private `ToggleExpand`); instance → `SelectAsync(node.Instance)`.
|
||||
- `SelectAsync(inst)`: `_text = LabelOf(inst)`, `_filter = ""`, close, `await SelectedInstanceIdChanged.InvokeAsync(inst.Id)`.
|
||||
- `CloseAsync`: `_open = false`, `_filter = ""`, `_text = LabelOf(current)`.
|
||||
- Guard: when `Disabled`, `OpenAsync`/`ToggleAsync` no-op.
|
||||
|
||||
CSS (`InstancePicker.razor.css`): `.ip-backdrop { position: fixed; inset: 0; z-index: 1040; background: transparent; }` `.ip-panel { position: absolute; left: 0; right: 0; top: 100%; z-index: 1041; max-height: 40vh; overflow-y: auto; min-width: 18rem; padding: .25rem; }` `.ip-instance { cursor: pointer; }` `.ip-area { cursor: pointer; }`. Rows render inside `TreeView`, whose `tv-*` classes are already global to that component — check `TreeView.razor.css` whether `tv-label`/`tv-glyph` are `::deep`-reachable from a consumer (TemplatePickerDialog uses them the same way, so follow that).
|
||||
|
||||
**Step 4: tests → PASS. Step 5: Commit** `feat(central-ui): InstancePicker — combobox over the site's area tree`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `InstancePicker` — search filter + keyboard
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor` / `.razor.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs`
|
||||
|
||||
**Step 1: Failing tests**
|
||||
|
||||
- `Typing_Filters_AndExpandsToTheMatches`: open, `Input("p1")` on the input → `[data-instance-id='7']` is rendered (ancestors expanded), Zulu row absent, Line1 row present.
|
||||
- `Clearing_RestoresTheFullTree_Collapsed`: after the above, `Input("")` → Zulu back, P1 hidden again (Line1 collapsed).
|
||||
- `Typing_OpensTheClosedPanel`.
|
||||
- `Enter_PicksTheFirstVisibleMatch`: type "u" (matches Zulu only) + `KeyDown(Key="Enter")` → callback 8, closed, text "Zulu"; with no match Enter does nothing (still open).
|
||||
- `Escape_ClosesAndRestoresTheSelectedText`: select P1, open, type "zzz", Escape → closed, text "P1".
|
||||
- `ArrowDown_MovesFocusIntoTheTree`: after ArrowDown the first visible `[role='treeitem']` has `tabindex="0"` (the tree's roving tabindex target) — assert via the `TreeView` markup; focus itself is JS (Loose mode), so assert the call happened through `JSInterop` if `TreeView` uses `FocusAsync` (an `ElementReference.FocusAsync` shows up as a `Blazor._internal.domWrapper.focus` invocation in bUnit — assert `JSInterop.Invocations` contains it, or expose a `public Task FocusFirstVisibleAsync()` on `TreeView` and assert it doesn't throw).
|
||||
|
||||
**Step 2: run → fail. Step 3: Implement**
|
||||
|
||||
- `OnInput(ChangeEventArgs e)`: `_text = e.Value?.ToString() ?? ""`, `_filter = _text`, `_open = true`, `Rebuild()`; after render, if `_hasFilter` → `_tree?.ExpandAll()` else `_tree?.CollapseAll()` (do it in `OnAfterRender` guarded by a `_pendingExpand` flag so the tree instance exists).
|
||||
- `OnInputKeyDown(KeyboardEventArgs e)`: `Enter` → first instance node in document order of the current (filtered) `_roots` → `SelectAsync`; `Escape` → `CloseAsync`; `ArrowDown` → `_open = true` and focus the tree's first visible item (`TreeView`: add `public async Task FocusFirstVisibleAsync()` that sets `_focusedKey` to the first visible node key and applies focus via the existing `_focusNeedsApply` mechanism).
|
||||
- Typing on a closed picker opens it.
|
||||
|
||||
**Step 4: PASS. Step 5: Commit** `feat(central-ui): InstancePicker search filter + keyboard (Enter/Escape/ArrowDown)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire Flow Debugger to the picker
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (~lines 62–71, the instance `<select>`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` (`_instances` ~114, `LoadInstancesAsync` ~412)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` (five `select[data-test='flow-instance-select']` call sites ~623/637/656/675/717; `WireInstance` ~608)
|
||||
|
||||
**Steps:** (1) Add a test helper `static void PickInstance(IRenderedComponent<FlowDebuggerPage> cut, int id)` = click `[data-test='flow-instance-toggle']`, click `[data-test='flow-instance-row'][data-instance-id='{id}']` (the fixture instance has no area, so it is a root row); replace the five `.Change("7")` lines; make `_templates.GetAreasBySiteIdAsync(1, …)` return `[]` in `WireInstance`. Add `ScriptEntry_InstancePicker_ShowsAreasCollapsed` (areas fixture with the instance under an area: only the area row is visible until expanded). (2) Run the class → fails. (3) Page: `private List<Area> _areas = [];` loaded in `LoadInstancesAsync` alongside instances (`GetAreasBySiteIdAsync`), cleared on site 0; markup:
|
||||
|
||||
```razor
|
||||
<InstancePicker Areas="_areas" Instances="_instances"
|
||||
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
|
||||
Disabled="@(_running || _selectedSiteId == 0)" DataTestPrefix="flow-instance" />
|
||||
```
|
||||
with `private async Task OnInstancePickedAsync(int id) { _selectedInstanceId = id; await OnInstanceChangedAsync(); }`. Everything else (deep link, localStorage, scope) unchanged. (4) `FlowDebuggerPageTests` all pass. (5) Commit `feat(central-ui): Flow Debugger picks the instance through InstancePicker`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire Debug View to the picker
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 4, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor` (instance `<select>` ~lines 83–92; `_siteInstances` ~259; `LoadInstancesForSite` ~430–452)
|
||||
- Test: create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs` (copy the service registration from `DebugViewDisposalTests.cs`)
|
||||
|
||||
**Steps:** (1) Test: substitute repo returns one site, instances [Enabled "Sim01" id 7 in area 2] + areas [Line1 (1) → Cell A (2)]; render `DebugView`, `Change` the site select to the site, open `[data-test='debug-instance-toggle']` → only the Line1 row; expand twice → row `[data-instance-id='7']` text `"Sim01 (Enabled)"`; click it → *Connect* button enabled. (2) fails. (3) Page: `_siteAreas` list loaded in `LoadInstancesForSite` (same try/catch), picker markup with `ItemLabel="@(i => $"{i.UniqueName} ({i.State})")"`, `SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="id => { _selectedInstanceId = id; OnInstanceSelectionChanged(); }"`, `Disabled="@_connected"`, `DataTestPrefix="debug-instance"`. (4) `DebugView*` tests pass. (5) Commit `feat(central-ui): Debug View picks the instance through InstancePicker`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Playwright tests drive the picker
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 4, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` (~lines 209–213)
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs` (~68, ~93–97)
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs` (~79–83)
|
||||
|
||||
Replace `<select>` + `SelectOptionAsync` with: `FillAsync` the `[data-test='{prefix}-input']` with the instance name, then `Expect(row [data-test='{prefix}-row'][data-instance-id='{id}']).ToBeVisibleAsync()` and `ClickAsync` it (Flow Debugger knows the name, not the id → click `[data-test='flow-instance-row']:has-text("<name>")` exactly; `Skip.If` no row appears within 10 s, same skip messages). `DebugViewTests` line 68 asserts the picker container `[data-test='debug-instance-picker']` count 1. Not runnable without the rig — build the project (`dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`) and commit `test(playwright): drive the instance picker on Flow Debugger and Debug View`. The controller runs them on the rig in the gate.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** Task 4, Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/requirements/Component-CentralUI.md` (Flow Debugger v1.3 paragraph + the Debug View section — grep `debug-instance-select` / "Debug View")
|
||||
- Modify: `CLAUDE.md` (Flow Debugger bullet, v1.3 sentence — append one sentence)
|
||||
|
||||
Text: "The instance is picked through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder`, all areas collapsed, un-areaed instances at the root), typed text filters by instance name and expands to the matches, Enter picks the first match; Debug View uses the same component with a `(State)` suffix." Commit `docs: instance picker on Flow Debugger + Debug View`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Gate (controller)
|
||||
|
||||
Build solution (0 warnings), run `CentralUI.Tests` + `Commons.Tests`, `bash docker/deploy.sh`, Playwright `FlowDebuggerTests` + `DebugViewTests` + `DebugViewTreeTests`, browser check of both pages (collapsed areas, typing filter, Enter, Escape, deep link `?instanceId=`), then append gate evidence to this plan and finish via `finishing-a-development-branch`.
|
||||
|
||||
|
||||
## Gate evidence (2026-09-08, branch `feat/instance-picker`)
|
||||
|
||||
- Solution build: 0 warnings. `CentralUI.Tests`: 1335 passed (picker 30 + builder 12 + Flow Debugger page 29 + Debug View picker 7 among them). Playwright project builds.
|
||||
- Rig rebuilt (`docker/deploy.sh`) twice. Playwright `FlowDebuggerTests` + `DebugViewTests` + `DebugViewTreeTests`: **7/7, 0 skipped** on the second build. The first build failed two of them and skipped two: the tests typed into the picker immediately after choosing a site, while the site's instances were still loading; the loaded lists then arrived as a new reference and the picker reset (its "site changed" rule), so the row never appeared. Fixed by keeping the picker **disabled until the load completes** (`_instancesLoading` on both pages, pinned by TCS-based bUnit tests) — a real race for an operator on a slow database, not just a test artefact.
|
||||
- Browser (Claude-in-Chrome, `localhost:9000`): Flow Debugger deep link `?instanceId=3687` shows `DelmiaSim01` in the field without opening the tree; typing `Delmia` opens the panel with the single matching row; Escape restores the selection text and closes. Debug View: after picking Test Plant A the panel lists the three `zztest-area-*` areas first (collapsed) and the Enabled instances after them with the `(State)` suffix.
|
||||
- Whole-branch review findings closed: keyboard activation now routes through `TreeView` selection (mouse + keyboard one handler; `SelectedKey` deliberately unbound to avoid the TreeView reset loop); `aria-label`/`InputId`; `aria-controls` only while open; Debug View's localStorage auto-reconnect guards a stored instance that is no longer Enabled; select-era `OrderBy` dropped; deep-link-in-area test added; design doc corrected (tab-out does not close — needs JS `relatedTarget`; explicit handlers instead of `@bind`).
|
||||
- Untested on the rig: keyboard activation inside the tree in a real browser (pinned by bUnit only); `overflow: hidden` clipping (neither page has such an ancestor today; CSS comment warns integrators).
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-09-08-instance-picker.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: InstanceTreeNode + InstanceTreeBuilder (pure) with tests",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: InstancePicker component \u2014 field, panel, select, outside-click",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: InstancePicker \u2014 search filter + keyboard",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Wire Flow Debugger to the picker",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Wire Debug View to the picker",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Playwright tests drive the picker",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Docs",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: Gate (controller)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-09-08T13:18:02.265057Z"
|
||||
}
|
||||
@@ -175,7 +175,7 @@ Central cluster only. Sites have no user interface.
|
||||
- Track per-site deployment status.
|
||||
|
||||
### Debug View (Deployment Role)
|
||||
- Select a deployed instance and open a live debug view.
|
||||
- Select a deployed instance and open a live debug view — the instance picked through the same shared **`InstancePicker`** combobox as the Flow Debugger, with an `ItemLabel` suffix of `(State)` on each row.
|
||||
- Real-time streaming of all attribute values (with quality and timestamp) and alarm states for that instance.
|
||||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`).
|
||||
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC data stream directly to the bridge actor — they do not travel on the command channel.
|
||||
@@ -220,7 +220,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
|
||||
- **Two modes.** *Live* is a real run — the method script executes on the active central node with tracing on, or a real attribute write goes through the routed-write path and the site's triggered run is polled back — audited like any other call. *Replay* executes nothing: it rebuilds the flow from an existing execution's `ScriptTrace` audit rows plus its `ApiOutbound` / `DbOutbound` / `Notification` rows (`?executionId=` drill-in from the Audit Log drawer's and the Execution Chain page's **Open in Flow Debugger** actions).
|
||||
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a **bolt badge on the connector's riser** — the edge itself keeps an ordinary arrowhead (the bolt was the arrowhead until 2026-09-08, and on the upward Tags→Site hop it read as a broken arrow). The spawn edge starts at the parent's **last write of the attribute the child's `triggerAttribute` names** (`FlowGraphBuilder`); only when the child names no attribute, or the parent never wrote it, does the timestamp rule (last non-async parent step at or before the child) decide — a burst of writes landing in one millisecond used to draw the cascade from the last of them rather than the one that fired it. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<id>`** / **`/notifications/report?correlationId=<id>`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser).
|
||||
- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the three run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` / `?script=` drill-ins; for a non-Deployer the page pins itself to Replay mode.
|
||||
- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for `<attribute>`* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there.
|
||||
- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for `<attribute>`* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. Both entry points pick the target instance through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder` — all areas collapsed on open, un-areaed instances at the root, orphaned rows surface at the root), typed text filters by instance name and expands to the matches, Enter picks the first visible match, Escape restores the selection.
|
||||
- **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed.
|
||||
- **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the three run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same four commands back the CLI's `flow-debug run-method | run-script | run-value-change | replay`. **The client wait is derived from the server's own bound, not a fixed duration (arch-review F4):** `FlowDebugService.RunAskTimeoutFor(timeoutSeconds)` = the method's `TimeoutSeconds` + the server's `FlowDebugRunner.RunGrace` (10 s) + 15 s client slack, floored at 30 s, and `ValueChangeAskTimeoutFor(waitSeconds)` = the trigger-wait window + 15 s (Replay stays a flat 30 s); the CLI mirrors it with `run-method --timeout-seconds` (default 120, set it to the method's timeout + 25 for long methods) and a `run-value-change` wait of `--wait-seconds + 25`. A long-running method used to be cut off client-side before the server's own grace expired. **The script entry point (2026-09-08) reuses the same two timeout functions by kind:** a call-kind script run waits `RunAskTimeoutFor(script.ExecutionTimeoutSeconds ?? 30)`, a write-kind script run waits `ValueChangeAskTimeoutFor(45)`; the page's `?instanceId=` + `?script=` drill-in replaces the former `?attribute=` query parameter.
|
||||
- State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`.
|
||||
|
||||
@@ -82,14 +82,10 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Instance</label>
|
||||
<select class="form-select form-select-sm" data-test="debug-instance-select"
|
||||
@bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
|
||||
<option value="0">Select instance...</option>
|
||||
@foreach (var inst in _siteInstances)
|
||||
{
|
||||
<option value="@inst.Id">@inst.UniqueName (@inst.State)</option>
|
||||
}
|
||||
</select>
|
||||
<InstancePicker Areas="_siteAreas" Instances="_siteInstances"
|
||||
ItemLabel="InstanceLabel"
|
||||
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePicked"
|
||||
Disabled="@(_connected || _instancesLoading)" DataTestPrefix="debug-instance" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end gap-2">
|
||||
@if (!_connected)
|
||||
@@ -257,11 +253,22 @@
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private List<Instance> _siteInstances = new();
|
||||
|
||||
// The site's areas, backing the InstancePicker's tree. Both lists are REPLACED
|
||||
// (never mutated in place) on a site change — the picker compares them by
|
||||
// reference and treats a new object as "the site changed".
|
||||
private List<Area> _siteAreas = new();
|
||||
private int _selectedSiteId;
|
||||
private int _selectedInstanceId;
|
||||
private bool _loading = true;
|
||||
private bool _connected;
|
||||
private bool _connecting;
|
||||
|
||||
// True for the whole of LoadInstancesForSite. Blazor renders as soon as that load hits its
|
||||
// first await, and the page is not _connected then — so without this the picker is enabled
|
||||
// while it is still showing the PREVIOUS site's tree, and anything typed into it is wiped
|
||||
// when the new lists land (the picker reads a new list object as "the site changed").
|
||||
private bool _instancesLoading;
|
||||
private bool _connectedFromStorage;
|
||||
|
||||
// Which tab pane is visible — "attributes" (default) or "alarms".
|
||||
@@ -414,48 +421,93 @@
|
||||
{
|
||||
_selectedSiteId = siteId;
|
||||
await LoadInstancesForSite();
|
||||
|
||||
// Membership guard, mirroring the ?instanceId= path above. A stored instance that
|
||||
// has since been disabled (or deleted, or moved to another site) is not in the list
|
||||
// the picker was handed, so it renders a blank field while the page holds a non-zero
|
||||
// id and auto-connects to it anyway. Forget the stored instance instead — the site
|
||||
// stays selected and loaded, so the operator just picks again.
|
||||
var stored = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
|
||||
if (stored == null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId");
|
||||
_toast.ShowInfo(
|
||||
"The instance from your previous session is no longer available for debug streaming.",
|
||||
autoDismissMs: 8000);
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedInstanceId = instanceId;
|
||||
_connectedFromStorage = true;
|
||||
StateHasChanged();
|
||||
await Connect();
|
||||
|
||||
// Auto-reconnect notice — the user didn't initiate this connection.
|
||||
var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
|
||||
_toast.ShowInfo(
|
||||
$"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.",
|
||||
$"Auto-reconnected to {stored.UniqueName} from previous session.",
|
||||
autoDismissMs: 8000);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the selected site's instances and areas into the picker's two lists, holding
|
||||
/// <c>_instancesLoading</c> for the whole round trip so the field cannot be typed into
|
||||
/// while it is still showing the previous site's tree. The flag is cleared in a
|
||||
/// <c>finally</c>: a scope refusal or a repository fault must not leave the picker
|
||||
/// disabled forever.
|
||||
/// </summary>
|
||||
private async Task LoadInstancesForSite()
|
||||
{
|
||||
_siteInstances.Clear();
|
||||
_selectedInstanceId = 0;
|
||||
if (_selectedSiteId == 0) return;
|
||||
// Site scoping (CentralUI-002): re-check the claim server-side — a query
|
||||
// string or stale localStorage value could name a site outside the grant.
|
||||
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
|
||||
{
|
||||
_selectedSiteId = 0;
|
||||
_toast.ShowError("You are not permitted to debug instances on that site.");
|
||||
return;
|
||||
}
|
||||
_instancesLoading = true;
|
||||
try
|
||||
{
|
||||
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
||||
.Where(i => i.State == InstanceState.Enabled)
|
||||
.ToList();
|
||||
_siteInstances = new();
|
||||
_siteAreas = new();
|
||||
_selectedInstanceId = 0;
|
||||
if (_selectedSiteId == 0) return;
|
||||
// Site scoping (CentralUI-002): re-check the claim server-side — a query
|
||||
// string or stale localStorage value could name a site outside the grant.
|
||||
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
|
||||
{
|
||||
_selectedSiteId = 0;
|
||||
_toast.ShowError("You are not permitted to debug instances on that site.");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
||||
.Where(i => i.State == InstanceState.Enabled)
|
||||
.ToList();
|
||||
_siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Failed to load instances: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
finally
|
||||
{
|
||||
_toast.ShowError($"Failed to load instances: {ex.Message}");
|
||||
_instancesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInstanceSelectionChanged()
|
||||
{
|
||||
// No-op; selection is tracked via _selectedInstanceId binding
|
||||
}
|
||||
/// <summary>
|
||||
/// The label the <c>InstancePicker</c> renders on a row and in its field:
|
||||
/// the instance's unique name with its lifecycle state.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance to label.</param>
|
||||
/// <returns>The display label, e.g. <c>Line1.Mixer (Enabled)</c>.</returns>
|
||||
private static string InstanceLabel(Instance instance) =>
|
||||
$"{instance.UniqueName} ({instance.State})";
|
||||
|
||||
/// <summary>
|
||||
/// Records the instance the operator picked in the <c>InstancePicker</c>.
|
||||
/// Everything downstream (the Connect button's enablement, <c>Connect()</c>
|
||||
/// itself) reads <c>_selectedInstanceId</c>, so there is nothing else to do.
|
||||
/// </summary>
|
||||
/// <param name="id">Id of the picked instance.</param>
|
||||
private void OnInstancePicked(int id) => _selectedInstanceId = id;
|
||||
|
||||
/// <summary>
|
||||
/// When the attribute filter changes, expand the tree so any branches holding
|
||||
@@ -597,7 +649,8 @@
|
||||
await Disconnect();
|
||||
_selectedSiteId = 0;
|
||||
_selectedInstanceId = 0;
|
||||
_siteInstances.Clear();
|
||||
_siteInstances = new();
|
||||
_siteAreas = new();
|
||||
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,16 +59,18 @@
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
@* Wider than the site select beside it: the field carries a whole instance
|
||||
name, not a short site name. The script-entry branch still sums to 8 of 12
|
||||
(site 2 + instance 3 + script 3) alongside the auto-width toolbar columns. *@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm">Instance</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-instance-select"
|
||||
@bind="_selectedInstanceId" @bind:after="OnInstanceChangedAsync" disabled="@(_running || _selectedSiteId == 0)">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var i in _instances)
|
||||
{
|
||||
<option value="@i.Id">@i.UniqueName</option>
|
||||
}
|
||||
</select>
|
||||
@* A site can hold hundreds of instances, so this is the shared area-tree
|
||||
combobox, not a flat list (design 2026-09-08). _areas/_instances are
|
||||
loaded once per site — the picker treats a new list as a site change. *@
|
||||
<InstancePicker Areas="_areas" Instances="_instances"
|
||||
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
|
||||
Disabled="@(_running || _selectedSiteId == 0 || _instancesLoading)"
|
||||
Placeholder="Search instances…" DataTestPrefix="flow-instance" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm">Script</label>
|
||||
|
||||
+55
-3
@@ -111,7 +111,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
private List<ApiMethod> _methods = [];
|
||||
private List<Site> _sites = [];
|
||||
|
||||
/// <summary>
|
||||
/// The selected site's instances, feeding the <c>InstancePicker</c> alongside
|
||||
/// <see cref="_areas"/>. The picker compares both by REFERENCE — a new list object is read
|
||||
/// as "the site changed" and closes its panel — so they are assigned exactly once per site
|
||||
/// load (<see cref="LoadInstancesAsync"/>) and never rebuilt on a render path.
|
||||
/// </summary>
|
||||
private List<Instance> _instances = [];
|
||||
|
||||
/// <summary>The selected site's areas; the picker nests them into its tree. See <see cref="_instances"/>.</summary>
|
||||
private List<Area> _areas = [];
|
||||
|
||||
/// <summary>
|
||||
/// True while <see cref="LoadInstancesAsync"/> is fetching a site's instances and areas, and
|
||||
/// the sole reason the picker is disabled for that window. Blazor renders the moment the load
|
||||
/// hits its first await, and by then <see cref="_selectedSiteId"/> is already the NEW site
|
||||
/// while the two lists are still the OLD ones — so without this the field is enabled over
|
||||
/// stale data, and anything typed into it is wiped when the lists land (the picker reads a new
|
||||
/// list object as "the site changed" and clears its text).
|
||||
/// </summary>
|
||||
private bool _instancesLoading;
|
||||
private List<FlowScriptOption> _scripts = [];
|
||||
|
||||
private int _selectedMethodId;
|
||||
@@ -409,11 +429,43 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
await PredictAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the selected site's instances and areas into the picker's two lists, holding
|
||||
/// <see cref="_instancesLoading"/> for the whole round trip so the field cannot be typed
|
||||
/// into while it is still showing the previous site's tree. The flag is cleared in a
|
||||
/// <c>finally</c>: a repository fault must not leave the picker disabled forever.
|
||||
/// </summary>
|
||||
private async Task LoadInstancesAsync()
|
||||
{
|
||||
_instances = _selectedSiteId > 0
|
||||
? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: [];
|
||||
_instancesLoading = true;
|
||||
try
|
||||
{
|
||||
if (_selectedSiteId <= 0)
|
||||
{
|
||||
_instances = [];
|
||||
_areas = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither list is sorted here: the picker nests and orders both itself
|
||||
// (InstanceTreeBuilder). The OrderBy this replaces dated from the flat <select>.
|
||||
_instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).ToList();
|
||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_instancesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The picker raises the id rather than binding a <select>, so the page applies it
|
||||
/// before running the same change handler the old dropdown used.
|
||||
/// </summary>
|
||||
private async Task OnInstancePickedAsync(int id)
|
||||
{
|
||||
_selectedInstanceId = id;
|
||||
await OnInstanceChangedAsync();
|
||||
}
|
||||
|
||||
/// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
@*
|
||||
Instance Picker — a combobox whose dropdown is the site's area tree (design
|
||||
2026-09-08). The field shows the selected instance; the panel hosts the shared
|
||||
TreeView over InstanceTreeBuilder's forest with every area collapsed. Clicking an
|
||||
area row toggles it, activating an instance row selects it and closes the panel —
|
||||
and "activating" is one path for both input modes, because the tree runs in
|
||||
Selectable mode and the mouse click reaches it through TreeView's own content span.
|
||||
Typing filters the forest to the matching instances and their ancestors; Enter picks
|
||||
the first visible match, ArrowDown hands keyboard navigation to the tree and Escape
|
||||
closes without changing the selection.
|
||||
Bootstrap only, no JavaScript: the transparent fixed backdrop closes the panel on
|
||||
an outside click, the same pattern TreeView uses for its context menu.
|
||||
*@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
|
||||
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
|
||||
@* ip-field lifts the field above the open panel's backdrop — see the .razor.css note. *@
|
||||
<div class="input-group input-group-sm ip-field">
|
||||
<input type="text" class="form-control" role="combobox" autocomplete="off"
|
||||
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree" aria-label="@AriaLabel"
|
||||
aria-expanded="@(_open ? "true" : "false")" aria-controls="@(_open ? _panelId : null)"
|
||||
placeholder="@Placeholder" disabled="@Disabled"
|
||||
data-test="@($"{DataTestPrefix}-input")"
|
||||
value="@_text" @onfocus="Open" @oninput="OnInput" @onkeydown="OnInputKeyDown" />
|
||||
<button type="button" class="btn btn-outline-secondary" tabindex="-1" aria-label="Browse instances"
|
||||
disabled="@Disabled" data-test="@($"{DataTestPrefix}-toggle")" @onclick="Toggle">
|
||||
<i class="bi @(_open ? "bi-chevron-up" : "bi-chevron-down")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (_open)
|
||||
{
|
||||
<div class="ip-backdrop" data-test="@($"{DataTestPrefix}-backdrop")" @onclick="Close"></div>
|
||||
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
|
||||
@* Selectable is what makes the tree's own Enter/Space activation reach us: it
|
||||
routes BOTH a click on TreeView's content span and a key press on the
|
||||
treeitem through SelectedKeyChanged, so mouse and keyboard share one path
|
||||
(the row label below therefore carries no @onclick of its own — a second
|
||||
handler there would fire twice on a click, since the click bubbles into the
|
||||
content span). SelectedKey is deliberately left unbound: a combobox shows
|
||||
its pick in the field, not as a row highlight, and binding it would also arm
|
||||
TreeView's "selected key no longer in the tree" reset — which fires on every
|
||||
render while the pick is filtered out, and this component would never clear
|
||||
it. SelectedCssClass is emptied to match; with no SelectedKey nothing is ever
|
||||
marked selected, so it is belt-and-braces. *@
|
||||
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
|
||||
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
|
||||
KeySelector="n => n.Key" IndentPx="16"
|
||||
Selectable="true" SelectedCssClass="" SelectedKeyChanged="OnTreeKeyActivatedAsync">
|
||||
<NodeContent Context="node">
|
||||
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
|
||||
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
|
||||
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)">@RowLabel(node)</span>
|
||||
</NodeContent>
|
||||
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
|
||||
</TreeView>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,378 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A combobox that picks one instance out of a site's area tree: a text field over a
|
||||
/// dropdown panel hosting <see cref="TreeView{TItem}"/> across the forest that
|
||||
/// <see cref="InstanceTreeBuilder"/> builds from the site's areas and instances.
|
||||
///
|
||||
/// <para>
|
||||
/// It replaces the flat instance <c><select></c> on Flow Debugger and Debug View,
|
||||
/// where a site with a few hundred instances made the list unusable. The panel opens
|
||||
/// with every area collapsed so the operator drills down; an outside click closes it
|
||||
/// through a transparent fixed backdrop (no JavaScript, the same pattern
|
||||
/// <see cref="TreeView{TItem}"/> uses for its context menu).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Typing in the field filters the forest to the instances whose <c>UniqueName</c> contains
|
||||
/// the text plus their ancestor areas, force-expanded so every match is on screen; clearing
|
||||
/// the text restores the full forest, collapsed. <c>Enter</c> picks the first instance the
|
||||
/// operator can actually see (document order, skipping collapsed branches), <c>ArrowDown</c>
|
||||
/// hands keyboard navigation to the tree's own roving tabindex, and <c>Escape</c> closes
|
||||
/// without changing the selection. The filter never survives a close or a pick.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Inside the tree, <c>Enter</c>/<c>Space</c> on the focused row does exactly what a click on
|
||||
/// it does — picks an instance, toggles an area — because the tree runs in
|
||||
/// <c>Selectable</c> mode and both gestures arrive at
|
||||
/// <see cref="OnTreeKeyActivatedAsync"/> through TreeView's single selection callback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A parent that swaps <see cref="Areas"/>/<see cref="Instances"/> (a site change)
|
||||
/// closes the panel and re-derives the field text from the current selection, clearing
|
||||
/// it when the selected instance is not in the new list. A parent that changes
|
||||
/// <see cref="SelectedInstanceId"/> (a deep link or a localStorage restore) updates the
|
||||
/// field text while the panel is closed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class InstancePicker
|
||||
{
|
||||
/// <summary>
|
||||
/// The site's areas, flat and linked by <see cref="Area.ParentAreaId"/>; null is treated
|
||||
/// as empty. Compared by REFERENCE: a new list object means "the site changed" and closes
|
||||
/// the panel, drops the filter and re-derives the field text. Hold a stable reference and
|
||||
/// only hand the picker a new list when the underlying set actually changed — rebuilding
|
||||
/// the list on every parent render would close the panel out from under the operator.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<Area>? Areas { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The site's instances; null is treated as empty. Compared by REFERENCE on the same
|
||||
/// contract as <see cref="Areas"/> — pass a stable reference and only replace it when the
|
||||
/// underlying set changed.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<Instance>? Instances { get; set; }
|
||||
|
||||
/// <summary>Id of the currently selected instance; <c>0</c> means nothing is selected.</summary>
|
||||
[Parameter]
|
||||
public int SelectedInstanceId { get; set; }
|
||||
|
||||
/// <summary>Raised with the instance id when the operator picks a row.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<int> SelectedInstanceIdChanged { get; set; }
|
||||
|
||||
/// <summary>Disables the field and the chevron; the panel can no longer be opened.</summary>
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps an instance to the text shown on its row and, once picked, in the field.
|
||||
/// Defaults to <see cref="Instance.UniqueName"/>. Note that searching always matches
|
||||
/// on <c>UniqueName</c> (the builder's contract), whatever this renders.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<Instance, string>? ItemLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>data-test</c> root for the control: <c>{prefix}-picker</c>, <c>-input</c>,
|
||||
/// <c>-toggle</c>, <c>-panel</c>, <c>-backdrop</c> and <c>-row</c> (instance rows also
|
||||
/// carry <c>data-instance-id</c>).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string DataTestPrefix { get; set; } = "instance";
|
||||
|
||||
/// <summary>Placeholder shown in the field while nothing is selected.</summary>
|
||||
[Parameter]
|
||||
public string Placeholder { get; set; } = "Search or browse instances…";
|
||||
|
||||
/// <summary>
|
||||
/// The combobox's accessible name, rendered as <c>aria-label</c> on the field. A page
|
||||
/// that renders its own visible <c><label for></c> should point it at
|
||||
/// <see cref="InputId"/>; the label wins for a screen reader either way, so this stays
|
||||
/// as the fallback name rather than being suppressed.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string AriaLabel { get; set; } = "Instance";
|
||||
|
||||
/// <summary>
|
||||
/// DOM id for the text field, so a page can address it from a <c><label for></c>.
|
||||
/// Unset means the per-instance generated id — two pickers on one page never collide.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? InputId { get; set; }
|
||||
|
||||
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
private bool _open;
|
||||
private string _text = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The name filter applied to the forest. Empty until the search box lands; kept
|
||||
/// separate from <see cref="_text"/> so a selected label never doubles as a filter.
|
||||
/// </summary>
|
||||
private string _filter = string.Empty;
|
||||
|
||||
private bool _hasFilter;
|
||||
private IReadOnlyList<InstanceTreeNode> _roots = [];
|
||||
private TreeView<InstanceTreeNode>? _tree;
|
||||
|
||||
/// <summary>Bulk expansion the tree owes the current filter, applied after the next render.</summary>
|
||||
private enum PendingTreeAction
|
||||
{
|
||||
/// <summary>Nothing to do.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Open every branch so the filtered matches are all on screen.</summary>
|
||||
ExpandAll,
|
||||
|
||||
/// <summary>Close every branch, back to the drill-down starting point.</summary>
|
||||
CollapseAll,
|
||||
}
|
||||
|
||||
// The TreeView only exists once the panel has rendered, so keyboard/typing work that
|
||||
// targets it is deferred to OnAfterRenderAsync through these two flags.
|
||||
private PendingTreeAction _pendingTreeAction;
|
||||
private bool _pendingTreeFocus;
|
||||
|
||||
// Last parameter values seen, so OnParametersSet can tell a real change from a
|
||||
// parent that simply re-rendered with the same lists and selection.
|
||||
private IReadOnlyList<Area>? _lastAreas;
|
||||
private IReadOnlyList<Instance>? _lastInstances;
|
||||
private int _lastSelectedId;
|
||||
private bool _parametersSeen;
|
||||
|
||||
private string _inputId => InputId is { Length: > 0 } id ? id : $"ip-{_idSuffix}-input";
|
||||
|
||||
private string _panelId => $"ip-{_idSuffix}-panel";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var listsChanged = !_parametersSeen
|
||||
|| !ReferenceEquals(_lastAreas, Areas)
|
||||
|| !ReferenceEquals(_lastInstances, Instances);
|
||||
var selectionChanged = !_parametersSeen || _lastSelectedId != SelectedInstanceId;
|
||||
|
||||
_parametersSeen = true;
|
||||
_lastAreas = Areas;
|
||||
_lastInstances = Instances;
|
||||
_lastSelectedId = SelectedInstanceId;
|
||||
|
||||
if (!listsChanged && !selectionChanged) return;
|
||||
|
||||
var selected = CurrentSelection();
|
||||
|
||||
if (listsChanged)
|
||||
{
|
||||
// A site change: drop any filter, rebuild, and close unconditionally — the panel
|
||||
// is showing the OLD site's tree, so leaving it open would be showing a lie even
|
||||
// when the selected instance happens to exist in the new list too.
|
||||
_filter = string.Empty;
|
||||
Rebuild();
|
||||
_open = false;
|
||||
_text = selected is null ? string.Empty : LabelOf(selected);
|
||||
}
|
||||
else if (!_open)
|
||||
{
|
||||
_text = selected is null ? string.Empty : LabelOf(selected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
var action = _pendingTreeAction;
|
||||
var focus = _pendingTreeFocus;
|
||||
_pendingTreeAction = PendingTreeAction.None;
|
||||
_pendingTreeFocus = false;
|
||||
|
||||
// The panel may have closed again in the same batch (a pick, an Escape), taking
|
||||
// the tree with it — there is nothing left to expand or focus.
|
||||
if (!_open || _tree is null) return;
|
||||
|
||||
// ExpandAll/CollapseAll re-render the TREE, not this component, so consuming the
|
||||
// flag here cannot loop back into another picker render.
|
||||
switch (action)
|
||||
{
|
||||
case PendingTreeAction.ExpandAll:
|
||||
_tree.ExpandAll();
|
||||
break;
|
||||
case PendingTreeAction.CollapseAll:
|
||||
_tree.CollapseAll();
|
||||
break;
|
||||
}
|
||||
|
||||
if (focus) await _tree.FocusFirstVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>The instance <see cref="SelectedInstanceId"/> points at, or null when unresolved.</summary>
|
||||
private Instance? CurrentSelection() =>
|
||||
SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId);
|
||||
|
||||
/// <summary>Rebuilds the forest for the current filter.</summary>
|
||||
private void Rebuild()
|
||||
{
|
||||
_hasFilter = !string.IsNullOrWhiteSpace(_filter);
|
||||
_roots = InstanceTreeBuilder.Build(Areas, Instances, _filter);
|
||||
}
|
||||
|
||||
private string LabelOf(Instance instance) => ItemLabel?.Invoke(instance) ?? instance.UniqueName;
|
||||
|
||||
private string RowLabel(InstanceTreeNode node) =>
|
||||
node.Instance is { } instance ? LabelOf(instance) : node.Label;
|
||||
|
||||
private void Open()
|
||||
{
|
||||
if (Disabled || _open) return;
|
||||
_open = true;
|
||||
}
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
if (Disabled) return;
|
||||
if (_open) Close(); else _open = true;
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
_open = false;
|
||||
if (_hasFilter)
|
||||
{
|
||||
_filter = string.Empty;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
var selected = CurrentSelection();
|
||||
_text = selected is null ? string.Empty : LabelOf(selected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every keystroke retypes the filter: the field text IS the filter while the operator
|
||||
/// is typing, and the panel opens if it was closed. The rebuilt forest is expanded when
|
||||
/// something is being searched for and collapsed back to the drill-down view when the
|
||||
/// text is cleared — both after the next render, once the tree exists.
|
||||
/// </summary>
|
||||
private void OnInput(ChangeEventArgs e)
|
||||
{
|
||||
if (Disabled) return;
|
||||
|
||||
_text = e.Value?.ToString() ?? string.Empty;
|
||||
_filter = _text;
|
||||
_open = true;
|
||||
Rebuild();
|
||||
_pendingTreeAction = _hasFilter ? PendingTreeAction.ExpandAll : PendingTreeAction.CollapseAll;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The field's keyboard surface. Deliberately no <c>preventDefault</c>: Blazor's flag is
|
||||
/// all-or-nothing per element, and neither default is harmful here (Enter in a text input
|
||||
/// outside a form does nothing; ArrowDown just moves the caret) — while suppressing Tab
|
||||
/// would break getting out of the control.
|
||||
/// </summary>
|
||||
private Task OnInputKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (Disabled) return Task.CompletedTask;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case "Enter" when _open:
|
||||
return FirstVisibleInstance() is { } instance ? SelectAsync(instance) : Task.CompletedTask;
|
||||
|
||||
case "Escape" when _open:
|
||||
Close();
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
_open = true;
|
||||
_pendingTreeFocus = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first instance leaf in document order among the rows the operator can actually
|
||||
/// see — a collapsed area's contents are skipped, matching what Enter appears to pick.
|
||||
/// With a filter active every branch is expanded, so this degrades to plain document
|
||||
/// order over the filtered forest.
|
||||
///
|
||||
/// <para>
|
||||
/// Reading <see cref="TreeView{TItem}.IsExpanded"/> is safe here because Blazor Server
|
||||
/// dispatches every UI event on one renderer sync context: the render and the pending
|
||||
/// <c>ExpandAll</c>/<c>CollapseAll</c> that <see cref="OnAfterRenderAsync"/> applies both
|
||||
/// complete before the NEXT keydown is dispatched, so the tree's expansion state can never
|
||||
/// be one keystroke stale. That would stop holding if the pending action ever became
|
||||
/// genuinely async (an await that yields inside <see cref="OnAfterRenderAsync"/>); it would
|
||||
/// then have to be awaited before Enter is allowed to read the tree.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots);
|
||||
|
||||
private Instance? FirstVisibleInstance(IReadOnlyList<InstanceTreeNode> nodes)
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.Instance is { } instance) return instance;
|
||||
if (!node.HasChildren) continue;
|
||||
|
||||
// Before the tree's first render the filter's pending ExpandAll decides visibility.
|
||||
var expanded = _tree?.IsExpanded(node.Key) ?? _hasFilter;
|
||||
if (expanded && FirstVisibleInstance(node.Children) is { } hit) return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single activation path for a tree row, reached from TreeView's
|
||||
/// <c>SelectedKeyChanged</c> — which a mouse click on the row and an
|
||||
/// <c>Enter</c>/<c>Space</c> on the focused treeitem both run through, so the two input
|
||||
/// modes can never diverge. An instance key picks that instance; an area key toggles the
|
||||
/// branch (there is nothing to select on an area, and a keyboard user who cannot open a
|
||||
/// branch cannot reach the instances inside it).
|
||||
///
|
||||
/// <para>
|
||||
/// Keys are the ones <see cref="InstanceTreeBuilder"/> mints: <c>i:{instanceId}</c> and
|
||||
/// <c>a:{areaId}</c>. Anything else — including the null TreeView raises when it clears a
|
||||
/// selection — is ignored rather than guessed at.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private Task OnTreeKeyActivatedAsync(object? key)
|
||||
{
|
||||
if (Disabled || key?.ToString() is not { Length: > 0 } keyStr) return Task.CompletedTask;
|
||||
|
||||
if (keyStr.StartsWith("i:", StringComparison.Ordinal))
|
||||
{
|
||||
return int.TryParse(keyStr.AsSpan(2), out var instanceId)
|
||||
&& Instances?.FirstOrDefault(i => i.Id == instanceId) is { } instance
|
||||
? SelectAsync(instance)
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (keyStr.StartsWith("a:", StringComparison.Ordinal)) _tree?.ToggleExpanded(keyStr);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SelectAsync(Instance instance)
|
||||
{
|
||||
_text = LabelOf(instance);
|
||||
_open = false;
|
||||
if (_hasFilter)
|
||||
{
|
||||
_filter = string.Empty;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
await SelectedInstanceIdChanged.InvokeAsync(instance.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Instance Picker combobox — the field, the dropdown panel and the outside-click
|
||||
backdrop are stacked deliberately: backdrop (1040) < panel (1041) < field (1042). */
|
||||
|
||||
/* The backdrop is a full-viewport fixed layer, so an UNPOSITIONED input-group would be
|
||||
painted underneath it and every click on the field or the chevron would be swallowed
|
||||
by the backdrop (= panel closes, cannot keep typing). Giving the field its own
|
||||
positioned layer above both keeps it clickable while the panel is open; the panel
|
||||
sits at top:100% so being one level lower never hides it. */
|
||||
.ip-field {
|
||||
position: relative;
|
||||
z-index: 1042;
|
||||
}
|
||||
|
||||
/* Transparent full-viewport catcher: a click anywhere outside the panel closes it.
|
||||
Sits just under the panel and above page content (Bootstrap's dropdown z-index
|
||||
band), mirroring TreeView's context-menu overlay. */
|
||||
.ip-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1040;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Dropdown panel anchored under the field (the wrapper is position-relative).
|
||||
Scrolls once the site's tree outgrows 40% of the viewport.
|
||||
|
||||
INTEGRATORS: the panel is absolutely positioned INSIDE the picker's wrapper, so any
|
||||
ancestor with `overflow: hidden`/`auto` (a card body, a scrolling toolbar, a table cell)
|
||||
clips it — the tree appears truncated or invisible while the field still says it is open.
|
||||
Place the picker outside such a container, or let that container overflow visibly. */
|
||||
.ip-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 1041;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
min-width: 18rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* Both row kinds are clickable: an area toggles, an instance selects. */
|
||||
.ip-instance,
|
||||
.ip-area {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Instance Picker's area/instance forest from a site's flat area and
|
||||
/// instance lists. Pure — no Blazor, no DI, no I/O — so the shape is unit-testable
|
||||
/// on its own and the picker component only has to render it.
|
||||
/// </summary>
|
||||
public static class InstanceTreeBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Nests the supplied areas by <see cref="Area.ParentAreaId"/> and hangs each area's
|
||||
/// instances beneath it, mirroring the Topology page's ordering: at every level the
|
||||
/// child areas come first (sorted by name, ordinal-ignore-case), then that level's
|
||||
/// instances (sorted by unique name, ordinal-ignore-case). Instances with no area sit
|
||||
/// at the root after the root areas. A row whose parent points at an area that is not in
|
||||
/// <paramref name="areas"/> is treated as a root — both an orphaned area and an instance
|
||||
/// with an unknown area id — so a stale row can never hide a subtree or an instance.
|
||||
/// </summary>
|
||||
/// <param name="areas">The site's areas (flat, linked by <see cref="Area.ParentAreaId"/>); null is treated as empty.</param>
|
||||
/// <param name="instances">The site's instances; null is treated as empty.</param>
|
||||
/// <param name="filter">
|
||||
/// Optional case-insensitive substring matched against <see cref="Instance.UniqueName"/>.
|
||||
/// When set, only matching instances are kept along with their ancestor areas; areas left
|
||||
/// with nothing beneath them are dropped. Null or whitespace means no filter.
|
||||
/// </param>
|
||||
/// <returns>The root rows of the forest, never null.</returns>
|
||||
public static IReadOnlyList<InstanceTreeNode> Build(
|
||||
IReadOnlyList<Area>? areas,
|
||||
IReadOnlyList<Instance>? instances,
|
||||
string? filter)
|
||||
{
|
||||
areas ??= [];
|
||||
instances ??= [];
|
||||
|
||||
var needle = filter?.Trim();
|
||||
var hasFilter = !string.IsNullOrEmpty(needle);
|
||||
|
||||
var knownAreaIds = areas.Select(a => a.Id).ToHashSet();
|
||||
|
||||
// Child areas by effective parent id — null for a root or an orphaned area.
|
||||
var areasByParent = areas
|
||||
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToLookup(a => a.ParentAreaId is int p && knownAreaIds.Contains(p) ? p : (int?)null);
|
||||
|
||||
var instancesByArea = instances
|
||||
.Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToLookup(i => i.AreaId is int a && knownAreaIds.Contains(a) ? a : (int?)null);
|
||||
|
||||
return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []);
|
||||
}
|
||||
|
||||
private static List<InstanceTreeNode> BuildLevel(
|
||||
int? parentId,
|
||||
ILookup<int?, Area> areasByParent,
|
||||
ILookup<int?, Instance> instancesByArea,
|
||||
bool hasFilter,
|
||||
HashSet<int> ancestors)
|
||||
{
|
||||
var level = new List<InstanceTreeNode>();
|
||||
|
||||
foreach (var area in areasByParent[parentId])
|
||||
{
|
||||
// Defensive: a cycle in ParentAreaId must never recurse forever.
|
||||
if (!ancestors.Add(area.Id)) continue;
|
||||
|
||||
var node = new InstanceTreeNode($"a:{area.Id}", area.Name, area, Instance: null);
|
||||
foreach (var child in BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors))
|
||||
{
|
||||
node.AddChild(child);
|
||||
}
|
||||
|
||||
ancestors.Remove(area.Id);
|
||||
|
||||
// With a filter an area survives only if something matched beneath it.
|
||||
if (!hasFilter || node.HasChildren) level.Add(node);
|
||||
}
|
||||
|
||||
level.AddRange(instancesByArea[parentId].Select(i =>
|
||||
new InstanceTreeNode($"i:{i.Id}", i.UniqueName, Area: null, Instance: i)));
|
||||
|
||||
return level;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the Instance Picker tree: an area branch or an instance leaf.
|
||||
/// Keys are <c>a:{areaId}</c> for areas and <c>i:{instanceId}</c> for instances.
|
||||
/// Children are populated by <see cref="InstanceTreeBuilder"/> and read-only to callers.
|
||||
/// </summary>
|
||||
/// <param name="Key">Stable tree key — <c>a:{areaId}</c> or <c>i:{instanceId}</c>.</param>
|
||||
/// <param name="Label">Text rendered for this row.</param>
|
||||
/// <param name="Area">The area this node represents, or null for an instance leaf.</param>
|
||||
/// <param name="Instance">The instance this node represents, or null for an area branch.</param>
|
||||
public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
|
||||
{
|
||||
private readonly List<InstanceTreeNode> _children = new();
|
||||
|
||||
/// <summary>Gets the child rows nested under this node (child areas first, then instances).</summary>
|
||||
public IReadOnlyList<InstanceTreeNode> Children => _children;
|
||||
|
||||
/// <summary>Gets a value indicating whether this node is a selectable instance leaf.</summary>
|
||||
public bool IsInstance => Instance is not null;
|
||||
|
||||
/// <summary>Gets a value indicating whether this node has any child rows.</summary>
|
||||
public bool HasChildren => _children.Count > 0;
|
||||
|
||||
/// <summary>Appends a child row. Only the builder composes the tree.</summary>
|
||||
/// <param name="child">The child row to append.</param>
|
||||
internal void AddChild(InstanceTreeNode child) => _children.Add(child);
|
||||
}
|
||||
@@ -601,6 +601,35 @@ else
|
||||
/// <summary>Whether the node with the given key is currently expanded.</summary>
|
||||
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
|
||||
|
||||
/// <summary>
|
||||
/// Make the first visible node the roving-tabindex target and pull browser focus to it,
|
||||
/// so a host control above the tree (a combobox field handing over on ArrowDown) can
|
||||
/// enter the tree's own arrow navigation. Focus is applied by the existing
|
||||
/// <c>_focusNeedsApply</c> path after the re-render this schedules. No-op on an empty tree.
|
||||
/// </summary>
|
||||
/// <returns>A completed task once the focus request has been scheduled.</returns>
|
||||
public Task FocusFirstVisibleAsync()
|
||||
{
|
||||
var visible = BuildVisibleNodes();
|
||||
if (visible.Count == 0) return Task.CompletedTask;
|
||||
|
||||
MoveFocusTo(visible[0].Key);
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expand the node with the given key if it is collapsed, collapse it otherwise —
|
||||
/// the same toggle the row chevron performs, for a consumer whose own
|
||||
/// <c>NodeContent</c> handles the click (a label that toggles its branch).
|
||||
/// </summary>
|
||||
/// <param name="key">Key of the node to toggle, as produced by <c>KeySelector</c>.</param>
|
||||
public void ToggleExpanded(object key)
|
||||
{
|
||||
ToggleExpand(key);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Expand every branch node in the tree.</summary>
|
||||
public void ExpandAll()
|
||||
{
|
||||
|
||||
+15
-12
@@ -12,15 +12,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
/// without hanging.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The Enabled-only dropdown + deploy precondition (verified against
|
||||
/// <c>DebugView.razor::LoadInstancesForSite</c>):</b> the instance <c><select></c>
|
||||
/// lists ONLY instances whose config state is <c>Enabled</c>
|
||||
/// <b>The Enabled-only instance picker + deploy precondition (verified against
|
||||
/// <c>DebugView.razor::LoadInstancesForSite</c>):</b> the instance
|
||||
/// <c>InstancePicker</c> combobox (prefix <c>debug-instance</c>) lists ONLY
|
||||
/// instances whose config state is <c>Enabled</c>
|
||||
/// (<c>i.State == InstanceState.Enabled</c>). A freshly-minted instance is
|
||||
/// <c>NotDeployed</c> and would never appear, so Fact B first drives the instance
|
||||
/// to <c>Enabled</c> over the CLI (<c>instance deploy</c>) before it can be picked
|
||||
/// in the dropdown. Both <c><select></c>s carry the <c>data-test</c> hooks
|
||||
/// (<c>debug-site-select</c> / <c>debug-instance-select</c>, option value = entity
|
||||
/// id); Fact A asserts those hooks are served, which doubles as proof the cluster
|
||||
/// in the picker. The site <c><select></c> carries the <c>data-test</c> hook
|
||||
/// <c>debug-site-select</c> (option value = entity id); the instance picker carries
|
||||
/// the shared <c>InstancePicker</c> hooks (<c>debug-instance-picker</c>/<c>-input</c>/
|
||||
/// <c>-toggle</c>/<c>-panel</c>/<c>-row</c>, row <c>data-instance-id</c> = entity id).
|
||||
/// Fact A asserts those hooks are served, which doubles as proof the cluster
|
||||
/// rebuild that added them actually took.
|
||||
/// </para>
|
||||
///
|
||||
@@ -65,7 +68,7 @@ public class DebugViewTests : IClassFixture<DeploymentFixture>
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Debug View')")).ToBeVisibleAsync();
|
||||
// The data-test hooks from Task 4 must be served (proves the rebuild took).
|
||||
await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToHaveCountAsync(1);
|
||||
await Assertions.Expect(page.Locator("[data-test='debug-instance-select']")).ToHaveCountAsync(1);
|
||||
await Assertions.Expect(page.Locator("[data-test='debug-instance-picker']")).ToHaveCountAsync(1);
|
||||
|
||||
// No site/instance selected -> Connect is disabled.
|
||||
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
|
||||
@@ -90,11 +93,11 @@ public class DebugViewTests : IClassFixture<DeploymentFixture>
|
||||
|
||||
await page.Locator("[data-test='debug-site-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
|
||||
var instanceOption = page.Locator("[data-test='debug-instance-select'] option",
|
||||
new() { HasText = uniqueName });
|
||||
await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await page.Locator("[data-test='debug-instance-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() });
|
||||
await page.Locator("[data-test='debug-instance-input']").FillAsync(uniqueName);
|
||||
var instanceRow = page.Locator(
|
||||
$"[data-test='debug-instance-row'][data-instance-id='{instanceId}']");
|
||||
await Assertions.Expect(instanceRow).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await instanceRow.ClickAsync();
|
||||
|
||||
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
|
||||
await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
|
||||
+5
-5
@@ -76,11 +76,11 @@ public class DebugViewTreeTests : IClassFixture<DeploymentFixture>
|
||||
// Select the seeded site + Enabled instance (same hooks as DebugViewTests).
|
||||
await page.Locator("[data-test='debug-site-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
|
||||
var instanceOption = page.Locator("[data-test='debug-instance-select'] option",
|
||||
new() { HasText = uniqueName });
|
||||
await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await page.Locator("[data-test='debug-instance-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() });
|
||||
await page.Locator("[data-test='debug-instance-input']").FillAsync(uniqueName);
|
||||
var instanceRow = page.Locator(
|
||||
$"[data-test='debug-instance-row'][data-instance-id='{instanceId}']");
|
||||
await Assertions.Expect(instanceRow).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await instanceRow.ClickAsync();
|
||||
|
||||
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
|
||||
await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
|
||||
+20
-8
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
@@ -193,7 +194,9 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
||||
/// rig's <see cref="RigInstanceName"/>, skipping the test with a rig-shaped message whenever an
|
||||
/// option is missing — none of these objects is provisioned by the fixture. Site is matched by
|
||||
/// LABEL because its id differs per rig (the seed names it "Test Plant A", identifier
|
||||
/// <c>site-a</c>); the script is matched by option VALUE, which is its canonical name.
|
||||
/// <c>site-a</c>); the instance goes through the shared <c>InstancePicker</c> combobox (typed
|
||||
/// name filters the tree, exact-text row click selects); the script is matched by option VALUE,
|
||||
/// which is its canonical name.
|
||||
/// </summary>
|
||||
private static async Task<ILocator> SelectRigScriptAsync(IPage page, string scriptName)
|
||||
{
|
||||
@@ -206,11 +209,20 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
||||
Skip.If(siteLabel is null, "The rig's site-a ('Test Plant A') is not in the Flow Debugger site list — run docker/seed-sites.sh.");
|
||||
await sites.SelectOptionAsync(new SelectOptionValue { Label = siteLabel! });
|
||||
|
||||
var instances = page.Locator("select[data-test='flow-instance-select']");
|
||||
var instanceLabel = await WaitForOptionLabelAsync(
|
||||
page, instances, l => string.Equals(l, RigInstanceName, StringComparison.Ordinal));
|
||||
Skip.If(instanceLabel is null, $"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh.");
|
||||
await instances.SelectOptionAsync(new SelectOptionValue { Label = instanceLabel! });
|
||||
// Instance selection goes through the shared InstancePicker combobox (prefix
|
||||
// "flow-instance"): typing the name filters the area tree and expands its
|
||||
// ancestors, then the exact-text row is clicked to select and close the panel. The
|
||||
// test only knows the instance NAME (not its id), so the row is matched through the
|
||||
// data-test contract (flow-instance-row) with an exact-text regex rather than a
|
||||
// free-text GetByText scan.
|
||||
var instanceInput = page.Locator("[data-test='flow-instance-input']");
|
||||
await instanceInput.FillAsync(RigInstanceName);
|
||||
var instanceRow = page.Locator("[data-test='flow-instance-row']",
|
||||
new() { HasTextRegex = new Regex($"^\\s*{Regex.Escape(RigInstanceName)}\\s*$") });
|
||||
Skip.IfNot(
|
||||
await WaitForCountAsync(page, instanceRow),
|
||||
$"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh.");
|
||||
await instanceRow.ClickAsync();
|
||||
|
||||
var scripts = page.Locator("select[data-test='flow-script-select']");
|
||||
var option = scripts.Locator($"option[value='{scriptName}']");
|
||||
@@ -223,8 +235,8 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
||||
|
||||
/// <summary>
|
||||
/// Polls a <c><select></c>'s option labels until one matches, returning it (or null on
|
||||
/// timeout). The instance and script lists are filled by a server round-trip after the previous
|
||||
/// picker changes, so the options do not exist at first render.
|
||||
/// timeout). Used for the site select, whose options are filled by a server round-trip after
|
||||
/// the page loads, so the options do not exist at first render.
|
||||
/// </summary>
|
||||
private static async Task<string?> WaitForOptionLabelAsync(
|
||||
IPage page, ILocator select, Func<string, bool> match, int timeoutMs = 15_000)
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for the Debug View's instance selector after it moved from a flat
|
||||
/// <c><select></c> to the shared <c>InstancePicker</c> area-tree combobox
|
||||
/// (design 2026-09-08). Pins that the page hands the picker the site's areas and
|
||||
/// its Enabled-only instance list, that drilling down the tree selects an instance,
|
||||
/// that the selection still enables <em>Connect</em>, and that a live connection
|
||||
/// locks the picker down.
|
||||
/// </summary>
|
||||
public class DebugViewInstancePickerTests : BunitContext
|
||||
{
|
||||
private const int SiteId = 1;
|
||||
|
||||
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
||||
|
||||
private void RegisterServices()
|
||||
{
|
||||
// DebugView touches localStorage on render; let bUnit answer loosely.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List<Instance>
|
||||
{
|
||||
new("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled },
|
||||
new("Off01") { Id = 8, SiteId = SiteId, State = InstanceState.Disabled },
|
||||
});
|
||||
_repo.GetAreasBySiteIdAsync(SiteId).Returns(new List<Area>
|
||||
{
|
||||
new("Line1") { Id = 1, SiteId = SiteId },
|
||||
new("Cell A") { Id = 2, SiteId = SiteId, ParentAreaId = 1 },
|
||||
});
|
||||
Services.AddSingleton(_repo);
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync().Returns(new List<Site>
|
||||
{
|
||||
new("Site One", "site-a") { Id = SiteId },
|
||||
});
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
Services.AddSingleton(comms);
|
||||
|
||||
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
||||
// An empty throwaway provider — these tests never call StartStreamAsync,
|
||||
// so the provider is unused. (Services.BuildServiceProvider() would leak
|
||||
// an undisposed provider.)
|
||||
var debugStream = new DebugStreamService(
|
||||
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
|
||||
NullLogger<DebugStreamService>.Instance);
|
||||
Services.AddSingleton(debugStream);
|
||||
|
||||
// No SiteId claim => system-wide, so site 1 passes the scope re-check.
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
|
||||
var authState = new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
var stubAuth = new StubAuthStateProvider(authState);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
||||
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||
}
|
||||
|
||||
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly AuthenticationState _state;
|
||||
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
=> Task.FromResult(_state);
|
||||
}
|
||||
|
||||
/// <summary>Renders the page, selects the seeded site and opens the picker panel.</summary>
|
||||
private IRenderedComponent<DebugViewPage> RenderWithOpenPicker()
|
||||
{
|
||||
RegisterServices();
|
||||
var cut = Render<DebugViewPage>();
|
||||
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
|
||||
cut.Find("[data-test='debug-instance-toggle']").Click();
|
||||
return cut;
|
||||
}
|
||||
|
||||
private static void ClickRow(IRenderedComponent<DebugViewPage> cut, string text) =>
|
||||
cut.FindAll("[data-test='debug-instance-row']")
|
||||
.First(r => r.TextContent.Trim() == text)
|
||||
.Click();
|
||||
|
||||
[Fact]
|
||||
public void InstanceSelector_IsThePicker_NotAFlatSelect()
|
||||
{
|
||||
RegisterServices();
|
||||
var cut = Render<DebugViewPage>();
|
||||
|
||||
Assert.Single(cut.FindAll("[data-test='debug-instance-picker']"));
|
||||
Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpeningThePicker_ShowsOnlyTheRootArea()
|
||||
{
|
||||
var cut = RenderWithOpenPicker();
|
||||
|
||||
var rows = cut.FindAll("[data-test='debug-instance-row']");
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Line1", rows[0].TextContent.Trim());
|
||||
Assert.False(rows[0].HasAttribute("data-instance-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrillingDown_RevealsTheEnabledInstanceOnly()
|
||||
{
|
||||
var cut = RenderWithOpenPicker();
|
||||
|
||||
ClickRow(cut, "Line1");
|
||||
ClickRow(cut, "Cell A");
|
||||
|
||||
var instanceRow = cut.Find("[data-instance-id='7']");
|
||||
Assert.Equal("Sim01 (Enabled)", instanceRow.TextContent.Trim());
|
||||
|
||||
// Off01 is Disabled, so the page filters it out before the picker sees it.
|
||||
Assert.DoesNotContain(
|
||||
cut.FindAll("[data-test='debug-instance-row']"),
|
||||
r => r.TextContent.Contains("Off01"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickingAnInstance_FillsTheField_AndEnablesConnect()
|
||||
{
|
||||
var cut = RenderWithOpenPicker();
|
||||
|
||||
ClickRow(cut, "Line1");
|
||||
ClickRow(cut, "Cell A");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
|
||||
Assert.Equal(
|
||||
"Sim01 (Enabled)",
|
||||
cut.Find("[data-test='debug-instance-input']").GetAttribute("value"));
|
||||
|
||||
var connect = cut.FindAll("button.btn.btn-primary.btn-sm")
|
||||
.First(b => b.TextContent.Contains("Connect"));
|
||||
Assert.False(connect.HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
|
||||
/// Blazor renders as soon as <c>LoadInstancesForSite</c> hits its first await, and at that point
|
||||
/// the page is not <c>_connected</c> — so the field was already enabled while the site's lists
|
||||
/// were still loading. Typing into it then (an operator, or Playwright's <c>FillAsync</c>, which
|
||||
/// waits only for "enabled") lost the text: the picker reads the freshly-assigned lists as
|
||||
/// "the site changed" and clears the field and closes the panel.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded()
|
||||
{
|
||||
RegisterServices();
|
||||
// Hold the instance load open so the render that follows the first await is observable.
|
||||
var gate = new TaskCompletionSource<IReadOnlyList<Instance>>();
|
||||
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(gate.Task);
|
||||
|
||||
var cut = Render<DebugViewPage>();
|
||||
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
|
||||
|
||||
Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"));
|
||||
Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled"));
|
||||
|
||||
cut.InvokeAsync(() => gate.SetResult(
|
||||
[new Instance("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }]));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.False(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The localStorage auto-reconnect must apply the same membership guard the
|
||||
/// <c>?instanceId=</c> deep link does. Off01 is Disabled, so the page filters it out of the
|
||||
/// list the picker is handed — restoring it anyway left the field blank while the page held
|
||||
/// a non-zero id and auto-connected to it. The stored id is dropped instead, and the site
|
||||
/// stays selected and loaded so the operator can pick again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AutoReconnect_FromStorage_SkipsAnInstanceThatIsNoLongerAvailable()
|
||||
{
|
||||
RegisterServices();
|
||||
JSInterop.Setup<string>("localStorage.getItem", "debugView.siteId").SetResult(SiteId.ToString());
|
||||
JSInterop.Setup<string>("localStorage.getItem", "debugView.instanceId").SetResult("8");
|
||||
|
||||
var cut = Render<DebugViewPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Equal(string.Empty, cut.Find("[data-test='debug-instance-input']").GetAttribute("value")));
|
||||
|
||||
// Nothing is selected, so Connect stays disabled — and the stale id is forgotten.
|
||||
var connect = cut.FindAll("button.btn.btn-primary.btn-sm").First(b => b.TextContent.Contains("Connect"));
|
||||
Assert.True(connect.HasAttribute("disabled"));
|
||||
JSInterop.VerifyInvoke("localStorage.removeItem");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhileConnected_ThePickerIsDisabled()
|
||||
{
|
||||
RegisterServices();
|
||||
var cut = Render<DebugViewPage>();
|
||||
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
|
||||
|
||||
// Mark the page connected the way the other DebugView page tests do —
|
||||
// driving the real gRPC stream is not available to a bUnit render.
|
||||
cut.InvokeAsync(() => typeof(DebugViewPage)
|
||||
.GetField("_connected", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.SetValue(cut.Instance, true));
|
||||
cut.Render();
|
||||
|
||||
Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"));
|
||||
Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled"));
|
||||
}
|
||||
}
|
||||
@@ -603,10 +603,27 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
|
||||
private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Picks an instance through the <c>InstancePicker</c> combobox that replaced the flat
|
||||
/// instance <c><select></c> (design 2026-09-08): open the panel, click the row. The
|
||||
/// fixture instance has no area, so its row sits at the root and needs no expansion.
|
||||
/// </summary>
|
||||
private static void PickInstance(IRenderedComponent<FlowDebuggerPage> cut, int id)
|
||||
{
|
||||
cut.Find("[data-test='flow-instance-toggle']").Click();
|
||||
cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click();
|
||||
}
|
||||
|
||||
/// <summary>Clicks the picker row (an area label toggles it, an instance label selects it).</summary>
|
||||
private static void ClickPickerRow(IRenderedComponent<FlowDebuggerPage> cut, string label) =>
|
||||
cut.FindAll("[data-test='flow-instance-row']").First(r => r.TextContent.Trim() == label).Click();
|
||||
|
||||
private void WireInstance()
|
||||
{
|
||||
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
|
||||
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
|
||||
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
|
||||
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
|
||||
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
|
||||
@@ -620,7 +637,7 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
cut.Find("select[data-test='flow-instance-select']").Change("7");
|
||||
PickInstance(cut, 7);
|
||||
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
|
||||
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
|
||||
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
|
||||
@@ -634,7 +651,7 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
cut.Find("select[data-test='flow-instance-select']").Change("7");
|
||||
PickInstance(cut, 7);
|
||||
cut.Find("select[data-test='flow-script-select']").Change("OnFlag");
|
||||
|
||||
Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
|
||||
@@ -653,7 +670,7 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
cut.Find("select[data-test='flow-instance-select']").Change("7");
|
||||
PickInstance(cut, 7);
|
||||
cut.Find("select[data-test='flow-script-select']").Change("Manual");
|
||||
|
||||
Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
|
||||
@@ -672,7 +689,7 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
cut.Find("select[data-test='flow-instance-select']").Change("7");
|
||||
PickInstance(cut, 7);
|
||||
cut.Find("select[data-test='flow-script-select']").Change("Tick");
|
||||
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
|
||||
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
|
||||
@@ -714,7 +731,7 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
cut.Find("select[data-test='flow-instance-select']").Change("7");
|
||||
PickInstance(cut, 7);
|
||||
cut.Find("select[data-test='flow-script-select']").Change("Manual");
|
||||
cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error
|
||||
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
|
||||
@@ -751,4 +768,139 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
|
||||
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded()
|
||||
{
|
||||
RegisterServices();
|
||||
WireInstance();
|
||||
// Same instance, now filed under Line1 / Cell A so the picker has a tree to drill into.
|
||||
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Area>>(
|
||||
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
|
||||
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(
|
||||
[new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }]));
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
cut.Find("[data-test='flow-instance-toggle']").Click();
|
||||
|
||||
// The panel opens collapsed: only the root area, and the instance is not rendered at all.
|
||||
Assert.Equal(["Line1"], cut.FindAll("[data-test='flow-instance-row']").Select(r => r.TextContent.Trim()).ToList());
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
|
||||
|
||||
ClickPickerRow(cut, "Line1");
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
|
||||
|
||||
ClickPickerRow(cut, "Cell A");
|
||||
cut.Find("[data-test='flow-instance-row'][data-instance-id='7']").Click();
|
||||
|
||||
// Picking the row ran OnInstanceChangedAsync, so the script list is loaded.
|
||||
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
|
||||
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker()
|
||||
{
|
||||
RegisterServices();
|
||||
WireInstance();
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same deep link, but the instance sits two areas deep. The picker resolves the field
|
||||
/// text from <c>SelectedInstanceId</c> against the flat instance list, not by walking the
|
||||
/// tree — so a nested instance must show its name with the panel never opened (and therefore
|
||||
/// with Line1 / Cell A never expanded).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ScriptEntry_DeepLink_InstanceIdInsideAnArea_ShowsItsNameWithoutOpeningThePanel()
|
||||
{
|
||||
RegisterServices();
|
||||
WireInstance();
|
||||
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Area>>(
|
||||
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
|
||||
var nested = new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 };
|
||||
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>([nested]));
|
||||
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(nested);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
|
||||
// The panel was never opened, so nothing of the tree is rendered.
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-instance-panel']"));
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-instance-row']"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
|
||||
/// Blazor renders as soon as <c>OnSiteChangedAsync</c> hits its first await, and at that point
|
||||
/// <c>_selectedSiteId</c> is already non-zero while <c>_instances</c>/<c>_areas</c> are still the
|
||||
/// previous site's lists — so an operator (or Playwright's <c>FillAsync</c>, which waits only for
|
||||
/// "enabled") could type into the field and have the load wipe the text and close the panel a
|
||||
/// moment later, because the picker reads a new list object as "the site changed".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ScriptEntry_PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded()
|
||||
{
|
||||
RegisterServices();
|
||||
WireInstance();
|
||||
// Hold the instance load open so the render that follows the first await is observable.
|
||||
var gate = new TaskCompletionSource<IReadOnlyList<Instance>>();
|
||||
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>()).Returns(gate.Task);
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
|
||||
Assert.True(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled"));
|
||||
Assert.True(cut.Find("[data-test='flow-instance-toggle']").HasAttribute("disabled"));
|
||||
|
||||
cut.InvokeAsync(() => gate.SetResult([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptEntry_SiteChange_ClearsThePickedInstance()
|
||||
{
|
||||
RegisterServices();
|
||||
WireInstance();
|
||||
// A second site the operator can switch to, with no instances of its own.
|
||||
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<Site>>(
|
||||
[new Site("Plant A", "site-a") { Id = 1 }, new Site("Plant B", "site-b") { Id = 2 }]));
|
||||
_templates.GetInstancesBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>([]));
|
||||
_templates.GetAreasBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("button[data-test='flow-entry-script']").Click();
|
||||
cut.Find("select[data-test='flow-site-select']").Change("1");
|
||||
PickInstance(cut, 7);
|
||||
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
|
||||
|
||||
cut.Find("select[data-test='flow-site-select']").Change("2");
|
||||
|
||||
// The pick is dropped on both sides: the page's id and the picker's field text, which
|
||||
// it re-derives because the new site handed it a different instance list.
|
||||
Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
|
||||
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for the <see cref="InstancePicker"/> combobox: the closed field, the
|
||||
/// panel that opens with every area collapsed, area expansion, instance selection
|
||||
/// (callback + close + field text), the disabled guard, the outside-click backdrop,
|
||||
/// parent-driven selection changes, the <c>ItemLabel</c> hook, and the search filter
|
||||
/// with its keyboard surface (Enter / Escape / ArrowDown).
|
||||
/// </summary>
|
||||
public class InstancePickerTests : BunitContext
|
||||
{
|
||||
public InstancePickerTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
|
||||
private static List<Area> Areas() =>
|
||||
[
|
||||
new("Line1") { Id = 1, SiteId = 1 },
|
||||
new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 },
|
||||
];
|
||||
|
||||
private static List<Instance> Instances() =>
|
||||
[
|
||||
new("P1") { Id = 7, SiteId = 1, AreaId = 2 },
|
||||
new("Zulu") { Id = 8, SiteId = 1 },
|
||||
];
|
||||
|
||||
private IRenderedComponent<InstancePicker> RenderPicker(
|
||||
Action<int>? onChanged = null,
|
||||
bool disabled = false,
|
||||
Func<Instance, string>? itemLabel = null,
|
||||
int selectedInstanceId = 0) =>
|
||||
Render<InstancePicker>(ps =>
|
||||
{
|
||||
ps.Add(p => p.Areas, Areas());
|
||||
ps.Add(p => p.Instances, Instances());
|
||||
ps.Add(p => p.DataTestPrefix, "t");
|
||||
ps.Add(p => p.Disabled, disabled);
|
||||
ps.Add(p => p.SelectedInstanceId, selectedInstanceId);
|
||||
if (itemLabel is not null) ps.Add(p => p.ItemLabel, itemLabel);
|
||||
if (onChanged is not null) ps.Add(p => p.SelectedInstanceIdChanged, onChanged);
|
||||
});
|
||||
|
||||
private static void Open(IRenderedComponent<InstancePicker> cut) =>
|
||||
cut.Find("[data-test='t-toggle']").Click();
|
||||
|
||||
private static void ClickRow(IRenderedComponent<InstancePicker> cut, string text) =>
|
||||
cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click();
|
||||
|
||||
private static string InputValue(IRenderedComponent<InstancePicker> cut) =>
|
||||
cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty;
|
||||
|
||||
private static void Type(IRenderedComponent<InstancePicker> cut, string text) =>
|
||||
cut.Find("[data-test='t-input']").Input(text);
|
||||
|
||||
private static void Press(IRenderedComponent<InstancePicker> cut, string key) =>
|
||||
cut.Find("[data-test='t-input']").KeyDown(new KeyboardEventArgs { Key = key });
|
||||
|
||||
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
|
||||
cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
|
||||
|
||||
/// <summary>The nth treeitem in document order - i.e. the nth row the operator can see.</summary>
|
||||
private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent<InstancePicker> cut, int index) =>
|
||||
cut.FindAll("[role='treeitem']")[index];
|
||||
|
||||
[Fact]
|
||||
public void Closed_ShowsNothingButTheField()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
|
||||
var input = cut.Find("[data-test='t-input']");
|
||||
Assert.Equal("combobox", input.GetAttribute("role"));
|
||||
Assert.Equal("false", input.GetAttribute("aria-expanded"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_OpensPanel_WithAreasCollapsed()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
|
||||
Open(cut);
|
||||
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("true", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
|
||||
|
||||
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
|
||||
Assert.Equal(new[] { "Line1", "Zulu" }, rows);
|
||||
|
||||
// P1 sits under a collapsed area, so its row is not rendered at all.
|
||||
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
|
||||
|
||||
// Area rows must not carry a data-instance-id at all (Blazor drops a null attribute).
|
||||
var areaRow = cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == "Line1");
|
||||
Assert.False(areaRow.HasAttribute("data-instance-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickingAnAreaLabel_ExpandsIt()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
ClickRow(cut, "Line1");
|
||||
Assert.Contains(cut.FindAll("[data-test='t-row']"), r => r.TextContent.Trim() == "Cell A");
|
||||
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
|
||||
|
||||
ClickRow(cut, "Cell A");
|
||||
Assert.Single(cut.FindAll("[data-instance-id='7']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
ClickRow(cut, "Line1");
|
||||
ClickRow(cut, "Cell A");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
|
||||
Assert.Equal(7, picked);
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_NeverOpens()
|
||||
{
|
||||
var cut = RenderPicker(disabled: true);
|
||||
|
||||
Assert.True(cut.Find("[data-test='t-input']").HasAttribute("disabled"));
|
||||
Assert.True(cut.Find("[data-test='t-toggle']").HasAttribute("disabled"));
|
||||
|
||||
Open(cut);
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backdrop_ClickClosesThePanel_WithoutChangingSelection()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
cut.Find("[data-test='t-backdrop']").Click();
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Null(picked);
|
||||
Assert.Equal(string.Empty, InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentDrivenSelection_UpdatesTheFieldText()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
|
||||
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 8));
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
|
||||
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 0));
|
||||
Assert.Equal(string.Empty, InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteChange_NewInstancesList_ClearsAStaleSelectionText()
|
||||
{
|
||||
var cut = RenderPicker(selectedInstanceId: 8);
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
|
||||
Open(cut);
|
||||
|
||||
// A different site: neither the areas nor the previously selected instance survive.
|
||||
cut.Render(ps =>
|
||||
{
|
||||
ps.Add(p => p.Areas, new List<Area>());
|
||||
ps.Add(p => p.Instances, new List<Instance> { new("Other") { Id = 99, SiteId = 2 } });
|
||||
});
|
||||
|
||||
Assert.Equal(string.Empty, InputValue(cut));
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemLabel_IsUsedForRowsAndField()
|
||||
{
|
||||
var cut = RenderPicker(itemLabel: i => i.UniqueName + " (x)");
|
||||
|
||||
Open(cut);
|
||||
|
||||
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
|
||||
Assert.Equal(new[] { "Line1", "Zulu (x)" }, rows);
|
||||
|
||||
ClickRow(cut, "Line1");
|
||||
ClickRow(cut, "Cell A");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
|
||||
Assert.Equal("P1 (x)", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_Filters_AndExpandsToTheMatches()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "p1");
|
||||
|
||||
// The match's ancestor areas are force-expanded, so its row renders without a click.
|
||||
Assert.Single(cut.FindAll("[data-instance-id='7']"));
|
||||
|
||||
var rows = Rows(cut);
|
||||
Assert.Contains("Line1", rows);
|
||||
Assert.Contains("Cell A", rows);
|
||||
Assert.DoesNotContain("Zulu", rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_RestoresTheFullTree_Collapsed()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "p1");
|
||||
Assert.Single(cut.FindAll("[data-instance-id='7']"));
|
||||
|
||||
Type(cut, string.Empty);
|
||||
|
||||
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
|
||||
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_OpensTheClosedPanel()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
|
||||
Type(cut, "z");
|
||||
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal(new[] { "Zulu" }, Rows(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_WithNoMatch_DoesNothing()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "zzz");
|
||||
Press(cut, "Enter");
|
||||
|
||||
Assert.Null(picked);
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_PicksTheFirstVisibleMatch()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
Open(cut);
|
||||
|
||||
// "u" matches Zulu only — P1 drops out along with its now-empty ancestor areas.
|
||||
Type(cut, "u");
|
||||
Press(cut, "Enter");
|
||||
|
||||
Assert.Equal(8, picked);
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
Press(cut, "Enter");
|
||||
|
||||
// Areas render before root instances, but every area is collapsed — so the first
|
||||
// instance the operator can actually see is the root-level Zulu, not P1.
|
||||
Assert.Equal(8, picked);
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_ClosesAndRestoresTheSelectedText()
|
||||
{
|
||||
var picked = 0;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
ClickRow(cut, "Line1");
|
||||
ClickRow(cut, "Cell A");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, picked));
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
|
||||
Open(cut);
|
||||
Type(cut, "zzz");
|
||||
Assert.Equal("zzz", InputValue(cut));
|
||||
|
||||
Press(cut, "Escape");
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrowDown_MovesFocusIntoTheTree()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
// The first row owns the tab stop by default, so move it off with the tree's own
|
||||
// arrow navigation first — otherwise the assertion below could pass by accident.
|
||||
ClickRow(cut, "Line1");
|
||||
cut.FindAll("[role='treeitem']")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" });
|
||||
Assert.Equal("-1", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
|
||||
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
var items = cut.FindAll("[role='treeitem']");
|
||||
Assert.Equal("0", items[0].GetAttribute("tabindex"));
|
||||
Assert.All(items.Skip(1), i => Assert.Equal("-1", i.GetAttribute("tabindex")));
|
||||
|
||||
// The tab stop above is the behavioural assertion; this is the belt-and-braces check
|
||||
// that real browser focus was requested too. ElementReference.FocusAsync surfaces as a
|
||||
// JS invocation under Loose mode — drop this line if a bUnit upgrade stops recording it.
|
||||
Assert.Contains(
|
||||
JSInterop.Invocations,
|
||||
i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow()
|
||||
{
|
||||
// The tree does not exist yet when the keydown is handled, so this pins the deferred
|
||||
// path: the panel opens in this render pass and the focus lands in the next one.
|
||||
var cut = RenderPicker();
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_WhenClosed_IsANoOp()
|
||||
{
|
||||
var cut = RenderPicker(selectedInstanceId: 8);
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
|
||||
Press(cut, "Escape");
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterIsNotCarriedIntoSelection()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "p1");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
|
||||
Assert.Equal(7, picked);
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
|
||||
Open(cut);
|
||||
|
||||
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
|
||||
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_WhileOpen_ClosesThePanel()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
|
||||
Open(cut);
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
|
||||
Open(cut);
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("false", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFieldSitsAboveTheBackdrop_SoItStaysClickableWhileOpen()
|
||||
{
|
||||
// The backdrop is a fixed full-viewport layer; the stylesheet lifts the field above
|
||||
// it through this class. Without it a click on the input or the chevron would land on
|
||||
// the backdrop instead and close the panel. Only the class can be pinned here — the
|
||||
// z-index itself is scoped CSS, verified visually.
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
var field = cut.Find("[data-test='t-picker'] .input-group");
|
||||
Assert.Contains("ip-field", field.ClassList);
|
||||
Assert.Single(cut.FindAll("[data-test='t-backdrop']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoPickers_GetDistinctPanelIds()
|
||||
{
|
||||
// The panel id (and the field's aria-controls pointing at it) is minted per component
|
||||
// instance, so two pickers on one page cannot collide on a DOM id.
|
||||
var first = RenderPicker();
|
||||
var second = RenderPicker();
|
||||
|
||||
Open(first);
|
||||
Open(second);
|
||||
|
||||
var firstPanel = first.Find("[data-test='t-panel']").Id;
|
||||
var secondPanel = second.Find("[data-test='t-panel']").Id;
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(firstPanel));
|
||||
Assert.NotEqual(firstPanel, secondPanel);
|
||||
Assert.Equal(firstPanel, first.Find("[data-test='t-input']").GetAttribute("aria-controls"));
|
||||
Assert.Equal(secondPanel, second.Find("[data-test='t-input']").GetAttribute("aria-controls"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameInstancesReference_LeavesTheTypedTextAndOpenPanelAlone()
|
||||
{
|
||||
// Complement of SiteChange_NewInstancesList_ClearsAStaleSelectionText: an unrelated
|
||||
// parameter change is NOT a site change, so a half-typed filter must survive it.
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
Type(cut, "zu");
|
||||
|
||||
cut.Render(ps => ps.Add(p => p.Placeholder, "Pick an instance"));
|
||||
|
||||
Assert.Equal("zu", InputValue(cut));
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal(new[] { "Zulu" }, Rows(cut));
|
||||
}
|
||||
|
||||
// -- Keyboard activation on the tree itself --------------------------------
|
||||
|
||||
[Fact]
|
||||
public void KeyboardActivation_OnATreeRow_SelectsTheInstance()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
// Areas render before root instances, so Zulu is the SECOND treeitem - the first
|
||||
// one is the Line1 area (see KeyboardActivation_OnAnAreaRow_TogglesIt).
|
||||
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
|
||||
Assert.Equal(8, picked);
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardActivation_OnAnAreaRow_TogglesIt()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
|
||||
// An area activates as an expand, not as a pick: its children appear and the
|
||||
// panel stays open with no callback raised.
|
||||
Assert.Contains("Cell A", Rows(cut));
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Null(picked);
|
||||
|
||||
// The same key on the same row closes it again - activation is a toggle.
|
||||
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
Assert.DoesNotContain("Cell A", Rows(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection()
|
||||
{
|
||||
// The label click and Enter both run through TreeView's single selection path,
|
||||
// so neither input mode may double-fire the callback.
|
||||
var picks = new List<int>();
|
||||
var cut = RenderPicker(onChanged: picks.Add);
|
||||
|
||||
Open(cut);
|
||||
ClickRow(cut, "Zulu");
|
||||
Assert.Equal(new[] { 8 }, picks);
|
||||
|
||||
Open(cut);
|
||||
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
Assert.Equal(new[] { 8, 8 }, picks);
|
||||
}
|
||||
|
||||
// -- Accessible name / labelling -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Assert.Equal("Instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
|
||||
|
||||
cut.Render(ps => ps.Add(p => p.AriaLabel, "Target instance"));
|
||||
Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter()
|
||||
{
|
||||
// A page that wants its own <label for> supplies the id; two pickers left to the
|
||||
// generated one must still not collide.
|
||||
var first = RenderPicker();
|
||||
var second = RenderPicker();
|
||||
|
||||
var generated = first.Find("[data-test='t-input']").Id;
|
||||
Assert.False(string.IsNullOrEmpty(generated));
|
||||
Assert.NotEqual(generated, second.Find("[data-test='t-input']").Id);
|
||||
|
||||
first.Render(ps => ps.Add(p => p.InputId, "flow-instance"));
|
||||
Assert.Equal("flow-instance", first.Find("[data-test='t-input']").Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AriaControls_IsRenderedOnlyWhileThePanelIsOpen()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
|
||||
// Closed: the panel element does not exist, so a dangling aria-controls would
|
||||
// point screen readers at nothing.
|
||||
Assert.False(cut.Find("[data-test='t-input']").HasAttribute("aria-controls"));
|
||||
|
||||
Open(cut);
|
||||
Assert.Equal(
|
||||
cut.Find("[data-test='t-panel']").Id,
|
||||
cut.Find("[data-test='t-input']").GetAttribute("aria-controls"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InstanceTreeBuilder"/> — the pure area/instance forest
|
||||
/// behind the Instance Picker combobox (design 2026-09-08).
|
||||
/// </summary>
|
||||
public class InstanceTreeBuilderTests
|
||||
{
|
||||
private static Area MakeArea(int id, string name, int? parentAreaId = null) =>
|
||||
new(name) { Id = id, SiteId = 1, ParentAreaId = parentAreaId };
|
||||
|
||||
private static Instance MakeInstance(int id, string uniqueName, int? areaId = null) =>
|
||||
new(uniqueName) { Id = id, SiteId = 1, AreaId = areaId };
|
||||
|
||||
[Fact]
|
||||
public void Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas()
|
||||
{
|
||||
var line1 = MakeArea(1, "Line1");
|
||||
var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
|
||||
var p1 = MakeInstance(10, "P1", areaId: 2);
|
||||
var zulu = MakeInstance(11, "Zulu");
|
||||
var alpha = MakeInstance(12, "Alpha");
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([line1, cellA], [p1, zulu, alpha], filter: null);
|
||||
|
||||
Assert.Equal(new[] { "a:1", "i:12", "i:11" }, roots.Select(n => n.Key));
|
||||
|
||||
var areaNode = roots[0];
|
||||
Assert.Equal("Line1", areaNode.Label);
|
||||
Assert.False(areaNode.IsInstance);
|
||||
Assert.Same(line1, areaNode.Area);
|
||||
Assert.Null(areaNode.Instance);
|
||||
Assert.True(areaNode.HasChildren);
|
||||
|
||||
var cellNode = Assert.Single(areaNode.Children);
|
||||
Assert.Equal("a:2", cellNode.Key);
|
||||
Assert.Equal("Cell A", cellNode.Label);
|
||||
Assert.Same(cellA, cellNode.Area);
|
||||
|
||||
var p1Node = Assert.Single(cellNode.Children);
|
||||
Assert.Equal("i:10", p1Node.Key);
|
||||
Assert.Equal("P1", p1Node.Label);
|
||||
Assert.True(p1Node.IsInstance);
|
||||
Assert.Same(p1, p1Node.Instance);
|
||||
Assert.Null(p1Node.Area);
|
||||
Assert.False(p1Node.HasChildren);
|
||||
|
||||
Assert.Equal("Alpha", roots[1].Label);
|
||||
Assert.Same(alpha, roots[1].Instance);
|
||||
Assert.Equal("Zulu", roots[2].Label);
|
||||
Assert.Same(zulu, roots[2].Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SortsAreasAndInstancesCaseInsensitively()
|
||||
{
|
||||
var beta = MakeArea(1, "beta");
|
||||
var alphaArea = MakeArea(2, "Alpha");
|
||||
var zebra = MakeInstance(10, "zebra");
|
||||
var apple = MakeInstance(11, "Apple");
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([beta, alphaArea], [zebra, apple], filter: null);
|
||||
|
||||
Assert.Equal(new[] { "Alpha", "beta", "Apple", "zebra" }, roots.Select(n => n.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OrphanedArea_BecomesARoot()
|
||||
{
|
||||
// ParentAreaId points at an area that is not in the supplied list.
|
||||
var orphan = MakeArea(5, "Orphan", parentAreaId: 99);
|
||||
var child = MakeInstance(10, "OrphanChild", areaId: 5);
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([orphan], [child], filter: null);
|
||||
|
||||
var root = Assert.Single(roots);
|
||||
Assert.Equal("a:5", root.Key);
|
||||
var leaf = Assert.Single(root.Children);
|
||||
Assert.Equal("i:10", leaf.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_InstanceWithUnknownArea_SurfacesAtRoot()
|
||||
{
|
||||
// AreaId points at an area that is not in the supplied list — the instance must not
|
||||
// vanish; it is listed at the root like an instance with no area at all.
|
||||
var line1 = MakeArea(1, "Line1");
|
||||
var stranded = MakeInstance(10, "Stranded", areaId: 99);
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([line1], [stranded], filter: null);
|
||||
|
||||
Assert.Equal(new[] { "a:1", "i:10" }, roots.Select(n => n.Key));
|
||||
Assert.Same(stranded, roots[1].Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_InstanceWithUnknownArea_StaysFilterable()
|
||||
{
|
||||
var line1 = MakeArea(1, "Line1");
|
||||
var stranded = MakeInstance(10, "Stranded", areaId: 99);
|
||||
|
||||
var matched = InstanceTreeBuilder.Build([line1], [stranded], filter: "rand");
|
||||
|
||||
Assert.Equal("i:10", Assert.Single(matched).Key);
|
||||
Assert.Empty(InstanceTreeBuilder.Build([line1], [stranded], filter: "nomatch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DeepNesting_WiresChildrenAtEveryLevel()
|
||||
{
|
||||
var plant = MakeArea(1, "Plant");
|
||||
var line = MakeArea(2, "Line", parentAreaId: 1);
|
||||
var cell = MakeArea(3, "Cell", parentAreaId: 2);
|
||||
var deep = MakeInstance(10, "Deep", areaId: 3);
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([plant, line, cell], [deep], filter: null);
|
||||
|
||||
var plantNode = Assert.Single(roots);
|
||||
Assert.Equal("a:1", plantNode.Key);
|
||||
var lineNode = Assert.Single(plantNode.Children);
|
||||
Assert.Equal("a:2", lineNode.Key);
|
||||
var cellNode = Assert.Single(lineNode.Children);
|
||||
Assert.Equal("a:3", cellNode.Key);
|
||||
var leaf = Assert.Single(cellNode.Children);
|
||||
Assert.Equal("i:10", leaf.Key);
|
||||
Assert.False(leaf.HasChildren);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CyclicParentAreaIds_DoNotLoopOrThrow()
|
||||
{
|
||||
// A bad row pair: each area names the other as its parent. Neither is reachable
|
||||
// from a root, so the forest is simply empty — and building it must terminate.
|
||||
var a = MakeArea(1, "A", parentAreaId: 2);
|
||||
var b = MakeArea(2, "B", parentAreaId: 1);
|
||||
var inA = MakeInstance(10, "InA", areaId: 1);
|
||||
var inB = MakeInstance(11, "InB", areaId: 2);
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([a, b], [inA, inB], filter: null);
|
||||
|
||||
Assert.Empty(roots);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Filter_MatchesInstanceNamesOnly_AreaNameMatchIsNotEnough()
|
||||
{
|
||||
// "Line1" contains the needle but holds no matching instance — it is dropped.
|
||||
var line1 = MakeArea(1, "Line1");
|
||||
var line2 = MakeArea(2, "Line2");
|
||||
var other = MakeInstance(10, "Pump", areaId: 1);
|
||||
var hit = MakeInstance(11, "Line1Feeder", areaId: 2);
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([line1, line2], [other, hit], filter: "line1");
|
||||
|
||||
var root = Assert.Single(roots);
|
||||
Assert.Equal("a:2", root.Key);
|
||||
Assert.Equal("i:11", Assert.Single(root.Children).Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches()
|
||||
{
|
||||
var line1 = MakeArea(1, "Line1");
|
||||
var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
|
||||
var emptyArea = MakeArea(3, "Line2");
|
||||
var p1 = MakeInstance(10, "P1", areaId: 2);
|
||||
var zulu = MakeInstance(11, "Zulu");
|
||||
var alpha = MakeInstance(12, "Alpha");
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([line1, cellA, emptyArea], [p1, zulu, alpha], filter: "p1");
|
||||
|
||||
var root = Assert.Single(roots);
|
||||
Assert.Equal("a:1", root.Key);
|
||||
var cellNode = Assert.Single(root.Children);
|
||||
Assert.Equal("a:2", cellNode.Key);
|
||||
var leaf = Assert.Single(cellNode.Children);
|
||||
Assert.Equal("i:10", leaf.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Filter_WhitespaceMeansNoFilter()
|
||||
{
|
||||
var line1 = MakeArea(1, "Line1");
|
||||
var p1 = MakeInstance(10, "P1", areaId: 1);
|
||||
var zulu = MakeInstance(11, "Zulu");
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([line1], [p1, zulu], filter: " ");
|
||||
|
||||
Assert.Equal(new[] { "a:1", "i:11" }, roots.Select(n => n.Key));
|
||||
Assert.Single(roots[0].Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NullOrEmptyInputs_ReturnEmpty()
|
||||
{
|
||||
Assert.Empty(InstanceTreeBuilder.Build(null, null, filter: null));
|
||||
Assert.Empty(InstanceTreeBuilder.Build([], [], filter: null));
|
||||
Assert.Empty(InstanceTreeBuilder.Build(null, [], filter: "anything"));
|
||||
|
||||
var orphanInstances = InstanceTreeBuilder.Build(null, [MakeInstance(1, "Solo")], filter: null);
|
||||
Assert.Equal("i:1", Assert.Single(orphanInstances).Key);
|
||||
|
||||
var areasOnly = InstanceTreeBuilder.Build([MakeArea(1, "Line1")], null, filter: null);
|
||||
Assert.Equal("a:1", Assert.Single(areasOnly).Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AreaWithNoInstancesAndNoFilter_IsStillShown()
|
||||
{
|
||||
var empty = MakeArea(1, "Empty");
|
||||
|
||||
var roots = InstanceTreeBuilder.Build([empty], [], filter: null);
|
||||
|
||||
var root = Assert.Single(roots);
|
||||
Assert.Equal("a:1", root.Key);
|
||||
Assert.False(root.HasChildren);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user