From ba1ac38333d5d90c7a0a3e377c2ca261193c1497 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 09:29:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(central-ui):=20InstancePicker=20=E2=80=94?= =?UTF-8?q?=20combobox=20over=20the=20site's=20area=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared Bootstrap-only combobox that replaces the flat instance + + + @if (_open) + { +
+
+ + + + @RowLabel(node) + + @(_hasFilter ? "No instances match." : "No instances at this site.") + +
+ } + diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs new file mode 100644 index 00000000..55b00584 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs @@ -0,0 +1,187 @@ +using Microsoft.AspNetCore.Components; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// A combobox that picks one instance out of a site's area tree: a text field over a +/// dropdown panel hosting across the forest that +/// builds from the site's areas and instances. +/// +/// +/// It replaces the flat instance <select> 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 +/// uses for its context menu). +/// +/// +/// +/// A parent that swaps / (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 +/// (a deep link or a localStorage restore) updates the +/// field text while the panel is closed. +/// +/// +public partial class InstancePicker +{ + /// The site's areas, flat and linked by ; null is treated as empty. + [Parameter] + public IReadOnlyList? Areas { get; set; } + + /// The site's instances; null is treated as empty. + [Parameter] + public IReadOnlyList? Instances { get; set; } + + /// Id of the currently selected instance; 0 means nothing is selected. + [Parameter] + public int SelectedInstanceId { get; set; } + + /// Raised with the instance id when the operator picks a row. + [Parameter] + public EventCallback SelectedInstanceIdChanged { get; set; } + + /// Disables the field and the chevron; the panel can no longer be opened. + [Parameter] + public bool Disabled { get; set; } + + /// + /// Maps an instance to the text shown on its row and, once picked, in the field. + /// Defaults to . Note that searching always matches + /// on UniqueName (the builder's contract), whatever this renders. + /// + [Parameter] + public Func? ItemLabel { get; set; } + + /// + /// data-test root for the control: {prefix}-picker, -input, + /// -toggle, -panel, -backdrop and -row (instance rows also + /// carry data-instance-id). + /// + [Parameter] + public string DataTestPrefix { get; set; } = "instance"; + + /// Placeholder shown in the field while nothing is selected. + [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; + + /// + /// The name filter applied to the forest. Empty until the search box lands; kept + /// separate from so a selected label never doubles as a filter. + /// + private string _filter = string.Empty; + + private bool _hasFilter; + private IReadOnlyList _roots = []; + private TreeView? _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? _lastAreas; + private IReadOnlyList? _lastInstances; + private int _lastSelectedId; + private bool _parametersSeen; + + private string _inputId => $"ip-{_idSuffix}-input"; + + private string _panelId => $"ip-{_idSuffix}-panel"; + + /// + 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); + } + } + + /// The instance points at, or null when unresolved. + private Instance? CurrentSelection() => + SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId); + + /// Rebuilds the forest for the current filter. + 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); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css new file mode 100644 index 00000000..cb7575e9 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css @@ -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; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor index e5f52320..19b84920 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor @@ -601,6 +601,18 @@ else /// Whether the node with the given key is currently expanded. public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key)); + /// + /// 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 + /// NodeContent handles the click (a label that toggles its branch). + /// + /// Key of the node to toggle, as produced by KeySelector. + public void ToggleExpanded(object key) + { + ToggleExpand(key); + StateHasChanged(); + } + /// Expand every branch node in the tree. public void ExpandAll() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs new file mode 100644 index 00000000..75669dc2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs @@ -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; + +/// +/// bUnit tests for the 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 ItemLabel hook. +/// +public class InstancePickerTests : BunitContext +{ + public InstancePickerTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private static List Areas() => + [ + new("Line1") { Id = 1, SiteId = 1 }, + new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }, + ]; + + private static List Instances() => + [ + new("P1") { Id = 7, SiteId = 1, AreaId = 2 }, + new("Zulu") { Id = 8, SiteId = 1 }, + ]; + + private IRenderedComponent RenderPicker( + Action? onChanged = null, + bool disabled = false, + Func? itemLabel = null, + int selectedInstanceId = 0) => + Render(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 cut) => + cut.Find("[data-test='t-toggle']").Click(); + + private static void ClickRow(IRenderedComponent cut, string text) => + cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click(); + + private static string InputValue(IRenderedComponent 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()); + ps.Add(p => p.Instances, new List { 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)); + } +}