feat(central-ui): InstancePicker — combobox over the site's area tree

A shared Bootstrap-only combobox that replaces the flat instance <select> on
Flow Debugger and Debug View (design docs/plans/2026-09-08-instance-picker-design.md,
plan task 2): a text field over a dropdown panel hosting TreeView across the
InstanceTreeBuilder forest, every area collapsed on open. Clicking an area row
toggles it, clicking an instance row selects it, closes the panel and raises
SelectedInstanceIdChanged. Outside clicks close through a transparent fixed
backdrop — the same pattern TreeView uses for its context menu, so no JavaScript.

A site change (new Areas/Instances references) closes the panel and clears the
field when the selection no longer resolves; a parent-driven SelectedInstanceId
change re-derives the field text while closed. ItemLabel customises row and field
text (Debug View wants a "(State)" suffix) while search still matches UniqueName.

TreeView gains one additive public ToggleExpanded(key) — the chevron's existing
private toggle, exposed so a consumer whose own NodeContent owns the label click
can toggle the branch.

Search filtering and keyboard handling land in task 3; _filter/Rebuild() are in
place for them.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 09:29:21 -04:00
parent f12ba2f9eb
commit ba1ac38333
5 changed files with 467 additions and 0 deletions
@@ -0,0 +1,42 @@
@*
Instance Picker — a combobox whose dropdown is the site's area tree (design
2026-09-08). The field shows the selected instance; the panel hosts the shared
TreeView over InstanceTreeBuilder's forest with every area collapsed. Clicking an
area row toggles it, clicking an instance row selects it and closes the panel.
Bootstrap only, no JavaScript: the transparent fixed backdrop closes the panel on
an outside click, the same pattern TreeView uses for its context menu.
*@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
<div class="input-group input-group-sm">
<input type="text" class="form-control" role="combobox" autocomplete="off"
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree"
aria-expanded="@(_open ? "true" : "false")" aria-controls="@_panelId"
placeholder="@Placeholder" disabled="@Disabled"
data-test="@($"{DataTestPrefix}-input")"
value="@_text" @onfocus="Open" />
<button type="button" class="btn btn-outline-secondary" tabindex="-1" aria-label="Browse instances"
disabled="@Disabled" data-test="@($"{DataTestPrefix}-toggle")" @onclick="Toggle">
<i class="bi @(_open ? "bi-chevron-up" : "bi-chevron-down")"></i>
</button>
</div>
@if (_open)
{
<div class="ip-backdrop" data-test="@($"{DataTestPrefix}-backdrop")" @onclick="Close"></div>
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key" IndentPx="16">
<NodeContent Context="node">
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)"
@onclick="() => OnRowClickAsync(node)">@RowLabel(node)</span>
</NodeContent>
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
</TreeView>
</div>
}
</div>
@@ -0,0 +1,187 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// A combobox that picks one instance out of a site's area tree: a text field over a
/// dropdown panel hosting <see cref="TreeView{TItem}"/> across the forest that
/// <see cref="InstanceTreeBuilder"/> builds from the site's areas and instances.
///
/// <para>
/// It replaces the flat instance <c>&lt;select&gt;</c> on Flow Debugger and Debug View,
/// where a site with a few hundred instances made the list unusable. The panel opens
/// with every area collapsed so the operator drills down; an outside click closes it
/// through a transparent fixed backdrop (no JavaScript, the same pattern
/// <see cref="TreeView{TItem}"/> uses for its context menu).
/// </para>
///
/// <para>
/// A parent that swaps <see cref="Areas"/>/<see cref="Instances"/> (a site change)
/// closes the panel and re-derives the field text from the current selection, clearing
/// it when the selected instance is not in the new list. A parent that changes
/// <see cref="SelectedInstanceId"/> (a deep link or a localStorage restore) updates the
/// field text while the panel is closed.
/// </para>
/// </summary>
public partial class InstancePicker
{
/// <summary>The site's areas, flat and linked by <see cref="Area.ParentAreaId"/>; null is treated as empty.</summary>
[Parameter]
public IReadOnlyList<Area>? Areas { get; set; }
/// <summary>The site's instances; null is treated as empty.</summary>
[Parameter]
public IReadOnlyList<Instance>? Instances { get; set; }
/// <summary>Id of the currently selected instance; <c>0</c> means nothing is selected.</summary>
[Parameter]
public int SelectedInstanceId { get; set; }
/// <summary>Raised with the instance id when the operator picks a row.</summary>
[Parameter]
public EventCallback<int> SelectedInstanceIdChanged { get; set; }
/// <summary>Disables the field and the chevron; the panel can no longer be opened.</summary>
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Maps an instance to the text shown on its row and, once picked, in the field.
/// Defaults to <see cref="Instance.UniqueName"/>. Note that searching always matches
/// on <c>UniqueName</c> (the builder's contract), whatever this renders.
/// </summary>
[Parameter]
public Func<Instance, string>? ItemLabel { get; set; }
/// <summary>
/// <c>data-test</c> root for the control: <c>{prefix}-picker</c>, <c>-input</c>,
/// <c>-toggle</c>, <c>-panel</c>, <c>-backdrop</c> and <c>-row</c> (instance rows also
/// carry <c>data-instance-id</c>).
/// </summary>
[Parameter]
public string DataTestPrefix { get; set; } = "instance";
/// <summary>Placeholder shown in the field while nothing is selected.</summary>
[Parameter]
public string Placeholder { get; set; } = "Search or browse instances…";
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
private bool _open;
private string _text = string.Empty;
/// <summary>
/// The name filter applied to the forest. Empty until the search box lands; kept
/// separate from <see cref="_text"/> so a selected label never doubles as a filter.
/// </summary>
private string _filter = string.Empty;
private bool _hasFilter;
private IReadOnlyList<InstanceTreeNode> _roots = [];
private TreeView<InstanceTreeNode>? _tree;
// Last parameter values seen, so OnParametersSet can tell a real change from a
// parent that simply re-rendered with the same lists and selection.
private IReadOnlyList<Area>? _lastAreas;
private IReadOnlyList<Instance>? _lastInstances;
private int _lastSelectedId;
private bool _parametersSeen;
private string _inputId => $"ip-{_idSuffix}-input";
private string _panelId => $"ip-{_idSuffix}-panel";
/// <inheritdoc />
protected override void OnParametersSet()
{
var listsChanged = !_parametersSeen
|| !ReferenceEquals(_lastAreas, Areas)
|| !ReferenceEquals(_lastInstances, Instances);
var selectionChanged = !_parametersSeen || _lastSelectedId != SelectedInstanceId;
_parametersSeen = true;
_lastAreas = Areas;
_lastInstances = Instances;
_lastSelectedId = SelectedInstanceId;
if (!listsChanged && !selectionChanged) return;
var selected = CurrentSelection();
if (listsChanged)
{
// A site change: drop any filter, rebuild, and close if the selection went away.
_filter = string.Empty;
Rebuild();
if (selected is null) _open = false;
_text = selected is null ? string.Empty : LabelOf(selected);
}
else if (!_open)
{
_text = selected is null ? string.Empty : LabelOf(selected);
}
}
/// <summary>The instance <see cref="SelectedInstanceId"/> points at, or null when unresolved.</summary>
private Instance? CurrentSelection() =>
SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId);
/// <summary>Rebuilds the forest for the current filter.</summary>
private void Rebuild()
{
_hasFilter = !string.IsNullOrWhiteSpace(_filter);
_roots = InstanceTreeBuilder.Build(Areas, Instances, _filter);
}
private string LabelOf(Instance instance) => ItemLabel?.Invoke(instance) ?? instance.UniqueName;
private string RowLabel(InstanceTreeNode node) =>
node.Instance is { } instance ? LabelOf(instance) : node.Label;
private void Open()
{
if (Disabled || _open) return;
_open = true;
}
private void Toggle()
{
if (Disabled) return;
if (_open) Close(); else _open = true;
}
private void Close()
{
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
var selected = CurrentSelection();
_text = selected is null ? string.Empty : LabelOf(selected);
}
private Task OnRowClickAsync(InstanceTreeNode node)
{
if (node.Instance is { } instance) return SelectAsync(instance);
_tree?.ToggleExpanded(node.Key);
return Task.CompletedTask;
}
private async Task SelectAsync(Instance instance)
{
_text = LabelOf(instance);
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
await SelectedInstanceIdChanged.InvokeAsync(instance.Id);
}
}
@@ -0,0 +1,32 @@
/* Instance Picker combobox — the field is a plain input-group; only the dropdown
panel and its outside-click backdrop need styling. */
/* Transparent full-viewport catcher: a click anywhere outside the panel closes it.
Sits just under the panel and above page content (Bootstrap's dropdown z-index
band), mirroring TreeView's context-menu overlay. */
.ip-backdrop {
position: fixed;
inset: 0;
z-index: 1040;
background: transparent;
}
/* Dropdown panel anchored under the field (the wrapper is position-relative).
Scrolls once the site's tree outgrows 40% of the viewport. */
.ip-panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 1041;
max-height: 40vh;
overflow-y: auto;
min-width: 18rem;
padding: 0.25rem;
}
/* Both row kinds are clickable: an area toggles, an instance selects. */
.ip-instance,
.ip-area {
cursor: pointer;
}
@@ -601,6 +601,18 @@ else
/// <summary>Whether the node with the given key is currently expanded.</summary>
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
/// <summary>
/// Expand the node with the given key if it is collapsed, collapse it otherwise —
/// the same toggle the row chevron performs, for a consumer whose own
/// <c>NodeContent</c> handles the click (a label that toggles its branch).
/// </summary>
/// <param name="key">Key of the node to toggle, as produced by <c>KeySelector</c>.</param>
public void ToggleExpanded(object key)
{
ToggleExpand(key);
StateHasChanged();
}
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{