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:
@@ -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.
|
||||
Reference in New Issue
Block a user