From 861f510427cd0a780ca4564e574162367a238bc5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 09:15:57 -0400 Subject: [PATCH 01/22] =?UTF-8?q?docs(plans):=20Instance=20Picker=20design?= =?UTF-8?q?=20=E2=80=94=20area-tree=20combobox=20for=20Flow=20Debugger=20+?= =?UTF-8?q?=20Debug=20View=20instance=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../2026-09-08-instance-picker-design.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/plans/2026-09-08-instance-picker-design.md diff --git a/docs/plans/2026-09-08-instance-picker-design.md b/docs/plans/2026-09-08-instance-picker-design.md new file mode 100644 index 00000000..e820e4e0 --- /dev/null +++ b/docs/plans/2026-09-08-instance-picker-design.md @@ -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 `` 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 `` 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`: + +- **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 and `Enter`/`Space` activation already exist); `Escape` closes and restores the field text to the current selection. Tabbing out closes as well. + +**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` | the site's areas (flat, `ParentAreaId` links) | +| `Instances` | `IReadOnlyList` | the site's instances | +| `SelectedInstanceId` / `SelectedInstanceIdChanged` | `int` / `EventCallback` | two-way bind, `0` = none | +| `Disabled` | `bool` | | +| `ItemLabel` | `Func?` | 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` (`Components/Shared/InstanceTreeNode.cs`, `InstanceTreeBuilder.cs`; record `InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance, List Children)`, keys `a:{id}` / `i:{id}`, `IsInstance => Instance != null`). Orphaned areas (parent id pointing nowhere) are treated as roots so a bad row can never hide a subtree. `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 `` becomes the picker with `ItemLabel` = `UniqueName (State)`, `Disabled="@_connected"`, `DataTestPrefix="debug-instance"`. `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 `` 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 ` (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:`, `i:`]; `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; + +/// One row of the tree: an area branch or an instance leaf. Keys are a:{areaId} / i:{instanceId}. +public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance) +{ + public List 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 Build(IReadOnlyList? areas, IReadOnlyList? 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 + +
+
+ + +
+ @if (_open) + { +
+
+ + + + @node.Label + + @(_hasFilter ? "No instances match." : "No instances at this site.") + +
+ } +
+``` + +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?`), `_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 `` ~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 ` on Flow Debugger and Debug View (design docs/plans/2026-09-08-instance-picker-design.md, plan task 2): a text field over a dropdown panel hosting TreeView across the InstanceTreeBuilder forest, every area collapsed on open. Clicking an area row toggles it, clicking an instance row selects it, closes the panel and raises SelectedInstanceIdChanged. Outside clicks close through a transparent fixed backdrop — the same pattern TreeView uses for its context menu, so no JavaScript. A site change (new Areas/Instances references) closes the panel and clears the field when the selection no longer resolves; a parent-driven SelectedInstanceId change re-derives the field text while closed. ItemLabel customises row and field text (Debug View wants a "(State)" suffix) while search still matches UniqueName. TreeView gains one additive public ToggleExpanded(key) — the chevron's existing private toggle, exposed so a consumer whose own NodeContent owns the label click can toggle the branch. Search filtering and keyboard handling land in task 3; _filter/Rebuild() are in place for them. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../Components/Shared/InstancePicker.razor | 42 ++++ .../Components/Shared/InstancePicker.razor.cs | 187 +++++++++++++++++ .../Shared/InstancePicker.razor.css | 32 +++ .../Components/Shared/TreeView.razor | 12 ++ .../Shared/InstancePickerTests.cs | 194 ++++++++++++++++++ 5 files changed, 467 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor new file mode 100644 index 00000000..4e32178c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor @@ -0,0 +1,42 @@ +@* + 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, clicking an instance row selects it and closes the panel. + 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 +@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances + +
+
+ + +
+ @if (_open) + { +
+
+ + + + @RowLabel(node) + + @(_hasFilter ? "No instances match." : "No instances at this site.") + +
+ } +
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs new file mode 100644 index 00000000..55b00584 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs @@ -0,0 +1,187 @@ +using Microsoft.AspNetCore.Components; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// A combobox that picks one instance out of a site's area tree: a text field over a +/// dropdown panel hosting across the forest that +/// builds from the site's areas and instances. +/// +/// +/// It replaces the flat instance <select> 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 +/// uses for its context menu). +/// +/// +/// +/// A parent that swaps / (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 +/// (a deep link or a localStorage restore) updates the +/// field text while the panel is closed. +/// +/// +public partial class InstancePicker +{ + /// The site's areas, flat and linked by ; null is treated as empty. + [Parameter] + public IReadOnlyList? Areas { get; set; } + + /// The site's instances; null is treated as empty. + [Parameter] + public IReadOnlyList? Instances { get; set; } + + /// Id of the currently selected instance; 0 means nothing is selected. + [Parameter] + public int SelectedInstanceId { get; set; } + + /// Raised with the instance id when the operator picks a row. + [Parameter] + public EventCallback SelectedInstanceIdChanged { get; set; } + + /// Disables the field and the chevron; the panel can no longer be opened. + [Parameter] + public bool Disabled { get; set; } + + /// + /// Maps an instance to the text shown on its row and, once picked, in the field. + /// Defaults to . Note that searching always matches + /// on UniqueName (the builder's contract), whatever this renders. + /// + [Parameter] + public Func? ItemLabel { get; set; } + + /// + /// data-test root for the control: {prefix}-picker, -input, + /// -toggle, -panel, -backdrop and -row (instance rows also + /// carry data-instance-id). + /// + [Parameter] + public string DataTestPrefix { get; set; } = "instance"; + + /// Placeholder shown in the field while nothing is selected. + [Parameter] + public string Placeholder { get; set; } = "Search or browse instances…"; + + private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8]; + + private bool _open; + private string _text = string.Empty; + + /// + /// The name filter applied to the forest. Empty until the search box lands; kept + /// separate from so a selected label never doubles as a filter. + /// + private string _filter = string.Empty; + + private bool _hasFilter; + private IReadOnlyList _roots = []; + private TreeView? _tree; + + // 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? _lastAreas; + private IReadOnlyList? _lastInstances; + private int _lastSelectedId; + private bool _parametersSeen; + + private string _inputId => $"ip-{_idSuffix}-input"; + + private string _panelId => $"ip-{_idSuffix}-panel"; + + /// + 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 if the selection went away. + _filter = string.Empty; + Rebuild(); + if (selected is null) _open = false; + _text = selected is null ? string.Empty : LabelOf(selected); + } + else if (!_open) + { + _text = selected is null ? string.Empty : LabelOf(selected); + } + } + + /// The instance points at, or null when unresolved. + private Instance? CurrentSelection() => + SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId); + + /// Rebuilds the forest for the current filter. + 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); + } + + private Task OnRowClickAsync(InstanceTreeNode node) + { + if (node.Instance is { } instance) return SelectAsync(instance); + + _tree?.ToggleExpanded(node.Key); + 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); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css new file mode 100644 index 00000000..cb7575e9 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css @@ -0,0 +1,32 @@ +/* Instance Picker combobox — the field is a plain input-group; only the dropdown + panel and its outside-click backdrop need styling. */ + +/* 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. */ +.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; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor index e5f52320..19b84920 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor @@ -601,6 +601,18 @@ else /// Whether the node with the given key is currently expanded. public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key)); + /// + /// 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 + /// NodeContent handles the click (a label that toggles its branch). + /// + /// Key of the node to toggle, as produced by KeySelector. + public void ToggleExpanded(object key) + { + ToggleExpand(key); + StateHasChanged(); + } + /// Expand every branch node in the tree. public void ExpandAll() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs new file mode 100644 index 00000000..75669dc2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs @@ -0,0 +1,194 @@ +using Bunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// +/// bUnit tests for the 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 and the ItemLabel hook. +/// +public class InstancePickerTests : BunitContext +{ + public InstancePickerTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private static List Areas() => + [ + new("Line1") { Id = 1, SiteId = 1 }, + new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }, + ]; + + private static List Instances() => + [ + new("P1") { Id = 7, SiteId = 1, AreaId = 2 }, + new("Zulu") { Id = 8, SiteId = 1 }, + ]; + + private IRenderedComponent RenderPicker( + Action? onChanged = null, + bool disabled = false, + Func? itemLabel = null, + int selectedInstanceId = 0) => + Render(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 cut) => + cut.Find("[data-test='t-toggle']").Click(); + + private static void ClickRow(IRenderedComponent cut, string text) => + cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click(); + + private static string InputValue(IRenderedComponent cut) => + cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty; + + [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()); + ps.Add(p => p.Instances, new List { 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)); + } +} From 621d6bfd946b37c985b402f7285c9146c622f41c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 09:37:44 -0400 Subject: [PATCH 07/22] feat(central-ui): InstancePicker search filter + keyboard (Enter/Escape/ArrowDown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the field is the filter: the forest is rebuilt to the matching instances plus their ancestor areas and force-expanded so every match is on screen; clearing the text restores the full forest, collapsed. Both bulk expansions are deferred to OnAfterRenderAsync, because the TreeView only exists once the panel has rendered. Enter picks the first instance the operator can actually SEE — document order, descending into a branch only when the tree reports it expanded — so with areas collapsed it picks the first root instance, and with a filter (every branch expanded) it degrades to plain document order. Escape closes and restores the field text from the current selection; ArrowDown hands keyboard navigation to the tree via a new additive TreeView.FocusFirstVisibleAsync(), which reuses the existing roving-tabindex _focusNeedsApply path rather than adding a second focus mechanism. No preventDefault: Blazor's flag is all-or-nothing per element, and neither default is harmful (Enter in a text input outside a form does nothing, ArrowDown just moves the caret) while suppressing Tab would trap the control. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../Components/Shared/InstancePicker.razor | 5 +- .../Components/Shared/InstancePicker.razor.cs | 123 +++++++++++++ .../Components/Shared/TreeView.razor | 17 ++ .../Shared/InstancePickerTests.cs | 166 +++++++++++++++++- 4 files changed, 309 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor index 4e32178c..0e5144ca 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor @@ -3,6 +3,9 @@ 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, clicking an instance row selects it and closes the panel. + 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. *@ @@ -16,7 +19,7 @@ aria-expanded="@(_open ? "true" : "false")" aria-controls="@_panelId" placeholder="@Placeholder" disabled="@Disabled" data-test="@($"{DataTestPrefix}-input")" - value="@_text" @onfocus="Open" /> + value="@_text" @onfocus="Open" @oninput="OnInput" @onkeydown="OnInputKeyDown" />