docs(plans): Instance Picker design — area-tree combobox for Flow Debugger + Debug View instance selection

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 09:15:57 -04:00
parent 859922ba17
commit 861f510427
@@ -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 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<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, List<InstanceTreeNode> 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 `<select data-test="flow-instance-select">` becomes `<InstancePicker … @bind-SelectedInstanceId="_selectedInstanceId" @bind-SelectedInstanceId:after="OnInstanceChangedAsync" Disabled="@(_running || _selectedSiteId == 0)" DataTestPrefix="flow-instance" />`. `_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"`, `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 `<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.