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

8.0 KiB

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.