-
+ @* 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")));
+ }
}