From d0b38ad726a4a80af50b1f1877de4d627677936c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 06:26:36 -0400 Subject: [PATCH] feat(transport-ui): export wizard site/instance selection (M8 E1) --- .../Pages/Design/TransportExport.razor | 116 ++++++++++++++++- .../Pages/Design/TransportExport.razor.cs | 84 ++++++++++++- .../Pages/Design/TransportExportPageTests.cs | 119 +++++++++++++++++- 3 files changed, 314 insertions(+), 5 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor index c44554b8..b9f2bd75 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor @@ -149,6 +149,17 @@ +
+ Sites & Instances + @RenderSitesList() + +
+
+ } + else + { + + } +
+ + +
+
+ + @if (expanded && instances.Count > 0) + { +
+ @foreach (var instance in instances.Where(i => MatchesFilter(i.UniqueName) + || MatchesFilter(site.Name) || MatchesFilter(site.SiteIdentifier))) + { + var instInputId = $"chk-instance-{instance.Id}"; + var siteSelected = _selectedSites.Contains(site.Id); +
+ + +
+ } +
+ } + + } + + }; + // ============================================================ // Step 2 — Review // ============================================================ @@ -214,10 +299,17 @@ var seedTemplateIds = new HashSet(SelectedTemplateIds()); var seedSharedScripts = new HashSet(_selectedSharedScripts); var seedExternalSystems = new HashSet(_selectedExternalSystems); + // M8 (E1): sites/instances the operator ticked directly are "seed"; everything + // else the resolver pulled into Sites/Instances (e.g. an instance's owning site, + // or a site's instances) is auto-included. + var seedSiteIds = new HashSet(_selectedSites); + var seedInstanceIds = new HashSet(_selectedInstances); var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id); var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id); var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id); + var autoSites = AutoIncluded(_resolved.Sites, seedSiteIds, s => s.Id); + var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);

@@ -267,11 +359,21 @@ {

  • ApiMethod: @m.Name
  • } + @foreach (var s in _resolved.Sites.Where(s => seedSiteIds.Contains(s.Id)).OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)) + { +
  • Site: @s.Name (@s.SiteIdentifier)
  • + } + @foreach (var i in _resolved.Instances.Where(i => seedInstanceIds.Contains(i.Id)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)) + { +
  • Instance: @i.UniqueName
  • + }
    Auto-included (dependencies)
    - @if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0) + @if (autoTemplates.Count + autoShared.Count + autoExternals.Count + + autoSites.Count + autoInstances.Count + _resolved.DataConnections.Count + + _resolved.TemplateFolders.Count == 0) {
    No additional dependencies.
    } @@ -294,6 +396,18 @@ {
  • ExternalSystem: @e.Name
  • } + @foreach (var s in autoSites.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)) + { +
  • Site: @s.Name (@s.SiteIdentifier)
  • + } + @foreach (var i in autoInstances.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)) + { +
  • Instance: @i.UniqueName
  • + } + @foreach (var c in _resolved.DataConnections.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) + { +
  • DataConnection: @c.Name
  • + } }
    diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor.cs index 7cf1acf7..bde0f483 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor.cs @@ -6,8 +6,10 @@ using Microsoft.JSInterop; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; @@ -51,6 +53,7 @@ public partial class TransportExport : ComponentBase [Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!; [Inject] private INotificationRepository NotificationRepo { get; set; } = default!; [Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!; + [Inject] private ISiteRepository SiteRepo { get; set; } = default!; [Inject] private DependencyResolver DepResolver { get; set; } = default!; [Inject] private IJSRuntime JS { get; set; } = default!; [Inject] private AuthenticationStateProvider Auth { get; set; } = default!; @@ -71,6 +74,11 @@ public partial class TransportExport : ComponentBase private List _smtpConfigs = new(); // Inbound API keys are not transported between environments (re-arch C4); only methods. private List _apiMethods = new(); + // M8 (E1): site/instance-scoped export. Sites are listed flat; each site's + // instances hang off it (loaded eagerly via ISiteRepository.GetInstancesBySiteIdAsync) + // so the operator can pick a whole site or drill into individual instances. + private List _sites = new(); + private readonly Dictionary> _instancesBySiteId = new(); // ---- Step 1: selection state ---- // TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key. @@ -84,6 +92,12 @@ public partial class TransportExport : ComponentBase private readonly HashSet _selectedSmtpConfigs = new(); // No _selectedApiKeys: inbound API keys are not transported (re-arch C4). private readonly HashSet _selectedApiMethods = new(); + // M8 (E1): site/instance selection backed by entity primary keys, matching the + // DependencyResolver's SiteIds/InstanceIds (NOT names — the resolver fetches by id). + private readonly HashSet _selectedSites = new(); + private readonly HashSet _selectedInstances = new(); + // Which site rows are expanded to reveal their instance checkboxes (UI-only). + private readonly HashSet _expandedSites = new(); private string _filter = string.Empty; private bool _includeDependencies = true; @@ -126,6 +140,22 @@ public partial class TransportExport : ComponentBase _smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList(); // Inbound API keys are not transported (re-arch C4) — only methods are loaded. _apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList(); + + // M8 (E1): sites + their instances for site/instance-scoped export. Each + // site's instances are loaded eagerly so the expandable picker has them + // without a per-click round-trip; sites are ordered by identifier to match + // the bundle's deterministic site ordering. + _sites = (await SiteRepo.GetAllSitesAsync()) + .OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase) + .ToList(); + _instancesBySiteId.Clear(); + foreach (var site in _sites) + { + var instances = (await SiteRepo.GetInstancesBySiteIdAsync(site.Id)) + .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase) + .ToList(); + _instancesBySiteId[site.Id] = instances; + } } catch (Exception ex) { @@ -169,7 +199,9 @@ public partial class TransportExport : ComponentBase || _selectedDbConnections.Count > 0 || _selectedNotificationLists.Count > 0 || _selectedSmtpConfigs.Count > 0 - || _selectedApiMethods.Count > 0; + || _selectedApiMethods.Count > 0 + || _selectedSites.Count > 0 + || _selectedInstances.Count > 0; private bool PassphraseValid => !string.IsNullOrEmpty(_passphrase) @@ -207,7 +239,15 @@ public partial class TransportExport : ComponentBase SmtpConfigurationIds: _selectedSmtpConfigs.ToList(), // Inbound API keys are not transported (re-arch C4) — methods only. ApiMethodIds: _selectedApiMethods.ToList(), - IncludeDependencies: _includeDependencies); + IncludeDependencies: _includeDependencies) + { + // M8 (E1): site/instance ids feed the resolver's SiteIds/InstanceIds. + // Set via init-only properties so the positional ctor stays the documented + // additive shape; the resolver dedups a selected instance against its + // already-selected owning site. + SiteIds = _selectedSites.ToList(), + InstanceIds = _selectedInstances.ToList(), + }; } private async Task GoToReviewAsync() @@ -396,6 +436,9 @@ public partial class TransportExport : ComponentBase _selectedNotificationLists.Clear(); _selectedSmtpConfigs.Clear(); _selectedApiMethods.Clear(); + _selectedSites.Clear(); + _selectedInstances.Clear(); + _expandedSites.Clear(); _filter = string.Empty; _includeDependencies = true; _resolved = null; @@ -420,6 +463,43 @@ public partial class TransportExport : ComponentBase else set.Remove(id); } + // ---- Step 1 site/instance helpers (M8 E1) ---- + + /// Instances loaded for a site, or an empty list when the site has none. + private IReadOnlyList InstancesFor(int siteId) => + _instancesBySiteId.TryGetValue(siteId, out var list) ? list : Array.Empty(); + + /// Expand/collapse a site row's nested instance list (UI-only state). + private void ToggleSiteExpanded(int siteId) + { + if (!_expandedSites.Remove(siteId)) + { + _expandedSites.Add(siteId); + } + } + + /// + /// Toggle a whole site. Selecting a site lets the resolver pull its instances, + /// so on select we clear any redundant per-instance ticks for that site; on + /// deselect we leave individual instances untouched (the operator may still + /// want a subset). Matches the resolver's dedup semantics. + /// + private void ToggleSite(int siteId, bool value) + { + if (value) + { + _selectedSites.Add(siteId); + foreach (var instance in InstancesFor(siteId)) + { + _selectedInstances.Remove(instance.Id); + } + } + else + { + _selectedSites.Remove(siteId); + } + } + // ---- Step 2 grouping helpers ---- /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs index e9de7248..ccb9bbbb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs @@ -7,8 +7,10 @@ using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; @@ -49,6 +51,7 @@ public class TransportExportPageTests : BunitContext private readonly IExternalSystemRepository _externalRepo = Substitute.For(); private readonly INotificationRepository _notificationRepo = Substitute.For(); private readonly IInboundApiRepository _inboundApiRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly IBundleExporter _exporter = Substitute.For(); public TransportExportPageTests() @@ -73,14 +76,24 @@ public class TransportExportPageTests : BunitContext .Returns(Task.FromResult>(new List())); _inboundApiRepo.GetAllApiMethodsAsync(Arg.Any()) .Returns(Task.FromResult>(new List())); + // Empty site/instance defaults — the M8 site/instance picker calls these on load + // and the DependencyResolver fans out over sites on resolve. + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List())); + _siteRepo.GetInstancesBySiteIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + _siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); Services.AddSingleton(_templateRepo); Services.AddSingleton(_externalRepo); Services.AddSingleton(_notificationRepo); Services.AddSingleton(_inboundApiRepo); + Services.AddSingleton(_siteRepo); Services.AddSingleton(_exporter); - // DependencyResolver is sealed but its only dependencies are the four - // repositories above — registering the concrete type is enough. + // DependencyResolver is sealed but its dependencies are the repositories above + // (template/external/notification/inbound + ISiteRepository for M8 site/instance + // export) — registering the concrete type is enough. Services.AddSingleton(); Services.AddSingleton>( Microsoft.Extensions.Options.Options.Create(new TransportOptions @@ -312,6 +325,108 @@ public class TransportExportPageTests : BunitContext Assert.False(result.Succeeded); } + // ───────────────────────────────────────────────────────────────────── + // Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each + // site and (when expanded) its instances. + // ───────────────────────────────────────────────────────────────────── + [Fact] + public void Renders_step1_sites_group_with_sites_and_instances() + { + var site = new Site("North Plant", "north") { Id = 5 }; + var instance = new Instance("north/pump-01") { Id = 50, SiteId = 5 }; + + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List { site })); + _siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any()) + .Returns(Task.FromResult>(new List { instance })); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("North Plant")); + + // The Sites & Instances group is present alongside the existing artifact groups. + Assert.NotNull(cut.Find("[data-testid='group-sites']")); + Assert.NotNull(cut.Find("[data-testid='sites-hint']")); + Assert.Contains("North Plant", cut.Markup); + Assert.Contains("north", cut.Markup); + + // The site row carries a site checkbox; expanding reveals the instance checkbox. + Assert.NotNull(cut.Find("#chk-site-5")); + var expandToggle = cut.Find("[data-testid='site-row'] button"); + expandToggle.Click(); + + cut.WaitForAssertion(() => + { + Assert.NotNull(cut.Find("[data-testid='site-instances']")); + Assert.NotNull(cut.Find("#chk-instance-50")); + Assert.Contains("north/pump-01", cut.Markup); + }); + } + + // ───────────────────────────────────────────────────────────────────── + // Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds; + // selecting an individual instance flows its id into InstanceIds. Verified at + // the resolver/exporter boundary (the same contract DependencyResolver reads). + // ───────────────────────────────────────────────────────────────────── + [Fact] + public async Task Step4_export_carries_selected_site_and_instance_ids() + { + var siteA = new Site("North Plant", "north") { Id = 5 }; + var siteB = new Site("South Plant", "south") { Id = 6 }; + var instanceB = new Instance("south/pump-09") { Id = 90, SiteId = 6 }; + + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List { siteA, siteB })); + _siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any()) + .Returns(Task.FromResult>(new List())); + _siteRepo.GetInstancesBySiteIdAsync(6, Arg.Any()) + .Returns(Task.FromResult>(new List { instanceB })); + // Resolver fetches selected entities by id; return them so the closure is non-empty. + _siteRepo.GetSiteByIdAsync(5, Arg.Any()) + .Returns(Task.FromResult(siteA)); + + _exporter + .ExportAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(_ => Task.FromResult(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 }))); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("North Plant")); + + // Tick site 5 (North Plant) directly. + cut.Find("#chk-site-5").Change(true); + + // Expand South Plant and tick its instance individually. + var southRow = cut.FindAll("[data-testid='site-row']") + .First(r => r.GetAttribute("data-site-id") == "6"); + southRow.QuerySelector("button")!.Click(); + cut.WaitForState(() => cut.FindAll("#chk-instance-90").Count > 0); + cut.Find("#chk-instance-90").Change(true); + + // Step 1 → 2 → 3 → export. + await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup)); + await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup)); + + cut.Find("#passphrase").Input("hunter2hunter2"); + cut.Find("#passphrase-confirm").Input("hunter2hunter2"); + await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup)); + + await _exporter.Received(1).ExportAsync( + Arg.Is(s => + s.SiteIds.Contains(5) + && s.InstanceIds.Contains(90)), + "alice", + "test-cluster", + "hunter2hunter2", + Arg.Any()); + } + // ───────────────────────────────────────────────────────────────────── // Static helpers — exercised directly so the file-naming + secret-count // contract is unit-pinned independently of the rendering surface.