From 861f510427cd0a780ca4564e574162367a238bc5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 09:15:57 -0400 Subject: [PATCH] =?UTF-8?q?docs(plans):=20Instance=20Picker=20design=20?= =?UTF-8?q?=E2=80=94=20area-tree=20combobox=20for=20Flow=20Debugger=20+=20?= =?UTF-8?q?Debug=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 `