feat(transport-ui): export wizard site/instance selection (M8 E1)

This commit is contained in:
Joseph Doherty
2026-06-18 06:26:36 -04:00
parent bdbf5cdab0
commit d0b38ad726
3 changed files with 314 additions and 5 deletions
@@ -149,6 +149,17 @@
</div>
</fieldset>
<fieldset class="mb-4" data-testid="group-sites">
<legend class="h6">Sites &amp; Instances</legend>
@RenderSitesList()
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="sites-hint">
Selecting a <strong>site</strong> includes its data connections and all of its
instances. Expand a site to pick individual <strong>instances</strong> instead.
Each instance pulls in its template (and that template's dependency closure)
when <em>Include all dependencies</em> is on.
</div>
</fieldset>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-primary"
disabled="@(!HasAnySelection || _resolving)"
@@ -200,6 +211,80 @@
</div>
};
// Sites &amp; Instances picker (M8 E1): a flat site checkbox list, each row
// expandable to reveal its instances. The search box matches a site by its
// Name or SiteIdentifier, and an instance by its UniqueName; a site stays
// visible when any of its instances match so the operator can drill in.
private RenderFragment RenderSitesList() => __builder =>
{
var visibleSites = _sites
.Where(site => MatchesFilter(site.Name)
|| MatchesFilter(site.SiteIdentifier)
|| InstancesFor(site.Id).Any(i => MatchesFilter(i.UniqueName)))
.ToList();
if (visibleSites.Count == 0)
{
<div class="text-muted small fst-italic">@(_sites.Count == 0 ? "No sites." : "No matches.")</div>
return;
}
<div class="d-flex flex-column gap-1">
@foreach (var site in visibleSites)
{
var siteInputId = $"chk-site-{site.Id}";
var expanded = _expandedSites.Contains(site.Id);
var instances = InstancesFor(site.Id);
<div class="border rounded p-2" data-testid="site-row" data-site-id="@site.Id">
<div class="d-flex align-items-center gap-2">
@if (instances.Count > 0)
{
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none"
aria-expanded="@expanded.ToString().ToLowerInvariant()"
aria-label="@(expanded ? "Collapse" : "Expand") instances for @site.Name"
@onclick="() => ToggleSiteExpanded(site.Id)">
<span aria-hidden="true">@(expanded ? "▾" : "▸")</span>
</button>
}
else
{
<span class="text-muted" style="width: 1rem; display: inline-block;" aria-hidden="true"></span>
}
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox"
id="@siteInputId"
checked="@_selectedSites.Contains(site.Id)"
@onchange="e => ToggleSite(site.Id, ((bool?)e.Value) == true)" />
<label class="form-check-label" for="@siteInputId">
@site.Name <span class="text-muted small">(@site.SiteIdentifier)</span>
</label>
</div>
</div>
@if (expanded && instances.Count > 0)
{
<div class="ms-4 mt-2 d-flex flex-column gap-1" data-testid="site-instances">
@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);
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@instInputId"
disabled="@siteSelected"
checked="@(siteSelected || _selectedInstances.Contains(instance.Id))"
@onchange="e => Toggle(_selectedInstances, instance.Id, ((bool?)e.Value) == true)" />
<label class="form-check-label" for="@instInputId">@instance.UniqueName</label>
</div>
}
</div>
}
</div>
}
</div>
};
// ============================================================
// Step 2 — Review
// ============================================================
@@ -214,10 +299,17 @@
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
var seedExternalSystems = new HashSet<int>(_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<int>(_selectedSites);
var seedInstanceIds = new HashSet<int>(_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);
<div>
<p class="text-body-secondary">
@@ -267,11 +359,21 @@
{
<li>ApiMethod: @m.Name</li>
}
@foreach (var s in _resolved.Sites.Where(s => seedSiteIds.Contains(s.Id)).OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
{
<li>Site: @s.Name (@s.SiteIdentifier)</li>
}
@foreach (var i in _resolved.Instances.Where(i => seedInstanceIds.Contains(i.Id)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
{
<li>Instance: @i.UniqueName</li>
}
</ul>
</div>
<div class="col-md-6" data-testid="auto-group">
<h6>Auto-included (dependencies)</h6>
@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)
{
<div class="small text-muted fst-italic">No additional dependencies.</div>
}
@@ -294,6 +396,18 @@
{
<li>ExternalSystem: @e.Name</li>
}
@foreach (var s in autoSites.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase))
{
<li>Site: @s.Name (@s.SiteIdentifier)</li>
}
@foreach (var i in autoInstances.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase))
{
<li>Instance: @i.UniqueName</li>
}
@foreach (var c in _resolved.DataConnections.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
{
<li>DataConnection: @c.Name</li>
}
</ul>
}
</div>