Files
ScadaBridge/docs/plans/2026-09-08-instance-picker-design.md
T

71 lines
8.0 KiB
Markdown

# 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.