Merge branch 'worktree-agent-a6cee26f4fe0abdd1' into feat/instance-picker

This commit is contained in:
Joseph Doherty
2026-09-08 09:29:45 -04:00
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> /// <summary>Whether the node with the given key is currently expanded.</summary>
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key)); 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> /// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll() public void ExpandAll()
{ {
@@ -0,0 +1,194 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for the <see cref="InstancePicker"/> combobox: the closed field, the
/// panel that opens with every area collapsed, area expansion, instance selection
/// (callback + close + field text), the disabled guard, the outside-click backdrop,
/// parent-driven selection changes and the <c>ItemLabel</c> hook.
/// </summary>
public class InstancePickerTests : BunitContext
{
public InstancePickerTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static List<Area> Areas() =>
[
new("Line1") { Id = 1, SiteId = 1 },
new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 },
];
private static List<Instance> Instances() =>
[
new("P1") { Id = 7, SiteId = 1, AreaId = 2 },
new("Zulu") { Id = 8, SiteId = 1 },
];
private IRenderedComponent<InstancePicker> RenderPicker(
Action<int>? onChanged = null,
bool disabled = false,
Func<Instance, string>? itemLabel = null,
int selectedInstanceId = 0) =>
Render<InstancePicker>(ps =>
{
ps.Add(p => p.Areas, Areas());
ps.Add(p => p.Instances, Instances());
ps.Add(p => p.DataTestPrefix, "t");
ps.Add(p => p.Disabled, disabled);
ps.Add(p => p.SelectedInstanceId, selectedInstanceId);
if (itemLabel is not null) ps.Add(p => p.ItemLabel, itemLabel);
if (onChanged is not null) ps.Add(p => p.SelectedInstanceIdChanged, onChanged);
});
private static void Open(IRenderedComponent<InstancePicker> cut) =>
cut.Find("[data-test='t-toggle']").Click();
private static void ClickRow(IRenderedComponent<InstancePicker> cut, string text) =>
cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click();
private static string InputValue(IRenderedComponent<InstancePicker> cut) =>
cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty;
[Fact]
public void Closed_ShowsNothingButTheField()
{
var cut = RenderPicker();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
var input = cut.Find("[data-test='t-input']");
Assert.Equal("combobox", input.GetAttribute("role"));
Assert.Equal("false", input.GetAttribute("aria-expanded"));
}
[Fact]
public void Toggle_OpensPanel_WithAreasCollapsed()
{
var cut = RenderPicker();
Open(cut);
Assert.Single(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("true", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
Assert.Equal(new[] { "Line1", "Zulu" }, rows);
// P1 sits under a collapsed area, so its row is not rendered at all.
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
// Area rows must not carry a data-instance-id at all (Blazor drops a null attribute).
var areaRow = cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == "Line1");
Assert.False(areaRow.HasAttribute("data-instance-id"));
}
[Fact]
public void ClickingAnAreaLabel_ExpandsIt()
{
var cut = RenderPicker();
Open(cut);
ClickRow(cut, "Line1");
Assert.Contains(cut.FindAll("[data-test='t-row']"), r => r.TextContent.Trim() == "Cell A");
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
ClickRow(cut, "Cell A");
Assert.Single(cut.FindAll("[data-instance-id='7']"));
}
[Fact]
public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal(7, picked);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Equal("P1", InputValue(cut));
}
[Fact]
public void Disabled_NeverOpens()
{
var cut = RenderPicker(disabled: true);
Assert.True(cut.Find("[data-test='t-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='t-toggle']").HasAttribute("disabled"));
Open(cut);
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void Backdrop_ClickClosesThePanel_WithoutChangingSelection()
{
int? picked = null;
var cut = RenderPicker(onChanged: id => picked = id);
Open(cut);
cut.Find("[data-test='t-backdrop']").Click();
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
Assert.Null(picked);
Assert.Equal(string.Empty, InputValue(cut));
}
[Fact]
public void ParentDrivenSelection_UpdatesTheFieldText()
{
var cut = RenderPicker();
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 8));
Assert.Equal("Zulu", InputValue(cut));
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 0));
Assert.Equal(string.Empty, InputValue(cut));
}
[Fact]
public void SiteChange_NewInstancesList_ClearsAStaleSelectionText()
{
var cut = RenderPicker(selectedInstanceId: 8);
Assert.Equal("Zulu", InputValue(cut));
Open(cut);
// A different site: neither the areas nor the previously selected instance survive.
cut.Render(ps =>
{
ps.Add(p => p.Areas, new List<Area>());
ps.Add(p => p.Instances, new List<Instance> { new("Other") { Id = 99, SiteId = 2 } });
});
Assert.Equal(string.Empty, InputValue(cut));
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
[Fact]
public void ItemLabel_IsUsedForRowsAndField()
{
var cut = RenderPicker(itemLabel: i => i.UniqueName + " (x)");
Open(cut);
var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
Assert.Equal(new[] { "Line1", "Zulu (x)" }, rows);
ClickRow(cut, "Line1");
ClickRow(cut, "Cell A");
cut.Find("[data-instance-id='7']").Click();
Assert.Equal("P1 (x)", InputValue(cut));
}
}