From 0f97317933536ba8f4c0b76129048a0f26d79816 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 09:45:07 -0400 Subject: [PATCH] feat(central-ui): Flow Debugger picks the instance through InstancePicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script entry's flat instance - - @foreach (var i in _instances) - { - - } - + @* A site can hold hundreds of instances, so this is the shared area-tree + combobox, not a flat list (design 2026-09-08). _areas/_instances are + loaded once per site — the picker treats a new list as a site change. *@ +
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index b61cf3d0..a2296c36 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -111,7 +111,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable private List _methods = []; private List _sites = []; + + /// + /// The selected site's instances, feeding the InstancePicker alongside + /// . The picker compares both by REFERENCE — a new list object is read + /// as "the site changed" and closes its panel — so they are assigned exactly once per site + /// load () and never rebuilt on a render path. + /// private List _instances = []; + + /// The selected site's areas; the picker nests them into its tree. See . + private List _areas = []; private List _scripts = []; private int _selectedMethodId; @@ -411,9 +421,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable private async Task LoadInstancesAsync() { - _instances = _selectedSiteId > 0 - ? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList() - : []; + if (_selectedSiteId <= 0) + { + _instances = []; + _areas = []; + return; + } + + _instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) + .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList(); + // The picker nests these itself (InstanceTreeBuilder), so order does not matter here. + _areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); + } + + /// + /// The picker raises the id rather than binding a <select>, so the page applies it + /// before running the same change handler the old dropdown used. + /// + private async Task OnInstancePickedAsync(int id) + { + _selectedInstanceId = id; + await OnInstanceChangedAsync(); } /// The instance's scripts, from its flattened configuration (canonical names, effective triggers). diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index ccc22fea..0a368c08 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -603,10 +603,27 @@ public class FlowDebuggerPageTests : BunitContext """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null); + /// + /// Picks an instance through the InstancePicker combobox that replaced the flat + /// instance <select> (design 2026-09-08): open the panel, click the row. The + /// fixture instance has no area, so its row sits at the root and needs no expansion. + /// + private static void PickInstance(IRenderedComponent cut, int id) + { + cut.Find("[data-test='flow-instance-toggle']").Click(); + cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click(); + } + + /// Clicks the picker row (an area label toggles it, an instance label selects it). + private static void ClickPickerRow(IRenderedComponent cut, string label) => + cut.FindAll("[data-test='flow-instance-row']").First(r => r.TextContent.Trim() == label).Click(); + private void WireInstance() { _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); + _templates.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>([])); _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }); _flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick])); _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction()); @@ -620,7 +637,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); @@ -634,7 +651,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("OnFlag"); Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent); @@ -653,7 +670,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("Manual"); Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']")); @@ -672,7 +689,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("Tick"); Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); @@ -714,7 +731,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("Manual"); cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); @@ -751,4 +768,51 @@ public class FlowDebuggerPageTests : BunitContext Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + + [Fact] + public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded() + { + RegisterServices(); + WireInstance(); + // Same instance, now filed under Line1 / Cell A so the picker has a tree to drill into. + _templates.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>( + [new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }])); + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>( + [new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }])); + + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("[data-test='flow-instance-toggle']").Click(); + + // The panel opens collapsed: only the root area, and the instance is not rendered at all. + Assert.Equal(["Line1"], cut.FindAll("[data-test='flow-instance-row']").Select(r => r.TextContent.Trim()).ToList()); + Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']")); + + ClickPickerRow(cut, "Line1"); + Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']")); + + ClickPickerRow(cut, "Cell A"); + cut.Find("[data-test='flow-instance-row'][data-instance-id='7']").Click(); + + // Picking the row ran OnInstanceChangedAsync, so the script list is loaded. + var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); + Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); + } + + [Fact] + public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker() + { + RegisterServices(); + WireInstance(); + var nav = Services.GetRequiredService(); + nav.NavigateTo("/deployment/flow-debugger?instanceId=7"); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value"))); + } }