feat(transport-ui): import Map step + per-line diff view (M8 E2)

This commit is contained in:
Joseph Doherty
2026-06-18 07:21:23 -04:00
parent e67587ec93
commit c8211f6363
5 changed files with 733 additions and 4 deletions
@@ -1,9 +1,12 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -68,6 +71,10 @@ public partial class TransportImport : ComponentBase, IDisposable
[Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
// M8 E2: the Map step needs the destination environment's sites + each
// site's connections to populate the "map to existing target" dropdowns.
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
// ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
@@ -98,6 +105,27 @@ public partial class TransportImport : ComponentBase, IDisposable
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
// ---- Step 3 (Map sub-section, M8 E2): name mapping ----
// The sentinel dropdown value for "Create new" — empty string can't collide
// with a real SiteIdentifier / connection Name (both are non-empty).
private const string CreateNewValue = "";
// Destination environment's sites, loaded once when the preview carries any
// required mappings.
private IReadOnlyList<Site> _targetSites = Array.Empty<Site>();
// Per-target-site connections, loaded lazily and cached by SiteIdentifier.
private readonly Dictionary<string, IReadOnlyList<DataConnection>> _targetConnections = new();
// Operator's site choices, keyed by SourceSiteIdentifier. Value is the chosen
// target SiteIdentifier, or CreateNewValue ("") for "Create new". Seeded from
// each RequiredSiteMapping.AutoMatchTargetIdentifier.
private readonly Dictionary<string, string> _siteChoices = new();
// Operator's connection choices, keyed by (SourceSiteIdentifier, SourceConnectionName).
// Value is the chosen target connection Name, or CreateNewValue for "Create new".
private readonly Dictionary<(string SourceSite, string SourceConn), string> _connectionChoices = new();
// ---- Step 4: confirm ----
private string _confirmEnvironmentText = string.Empty;
@@ -392,6 +420,7 @@ public partial class TransportImport : ComponentBase, IDisposable
{
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
_resolutions = BuildDefaultResolutions(_preview);
await InitNameMappingStateAsync(_preview);
_step = ImportWizardStep.Diff;
}
catch (Exception ex)
@@ -400,6 +429,193 @@ public partial class TransportImport : ComponentBase, IDisposable
}
}
/// <summary>
/// M8 E2: prepares the Map sub-section state for a freshly loaded preview.
/// When the preview carries no required site/connection mappings this is a
/// no-op (the Map section is hidden) and we skip the site/connection reads
/// entirely — central-config-only bundles never touch the destination's
/// site catalogue.
///
/// <para>Default choices follow the reference precedence: each site/connection
/// is seeded to its <c>AutoMatch*</c> value when the importer found a match,
/// otherwise to "Create new". The operator's explicit dropdown choice then
/// wins over the auto-match default.</para>
/// </summary>
private async Task InitNameMappingStateAsync(ImportPreview preview)
{
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
if (preview.RequiredSiteMappings.Count == 0 && preview.RequiredConnectionMappings.Count == 0)
{
return;
}
_targetSites = await SiteRepo.GetAllSitesAsync(CancellationToken.None);
foreach (var rsm in preview.RequiredSiteMappings)
{
// Seed to the auto-matched target identifier when present AND that
// target still exists in the destination; otherwise "Create new".
var seed = rsm.AutoMatchTargetIdentifier is not null
&& _targetSites.Any(s => s.SiteIdentifier == rsm.AutoMatchTargetIdentifier)
? rsm.AutoMatchTargetIdentifier
: CreateNewValue;
_siteChoices[rsm.SourceSiteIdentifier] = seed;
// Eagerly load the seeded target's connections so the connection-row
// dropdowns beneath this site can render their option sets without an
// async hop on first render.
if (!string.IsNullOrEmpty(seed) && !_targetConnections.ContainsKey(seed))
{
var target = _targetSites.First(s => s.SiteIdentifier == seed);
_targetConnections[seed] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
foreach (var rcm in preview.RequiredConnectionMappings)
{
// Default the connection to its auto-match. We don't pre-validate it
// against the chosen target's connection list here — the dropdown's
// option set (driven by the chosen site) governs what's selectable,
// and the seed simply renders as the initial selection.
_connectionChoices[(rcm.SourceSiteIdentifier, rcm.SourceConnectionName)] =
rcm.AutoMatchTargetName ?? CreateNewValue;
}
}
/// <summary>
/// Returns the connections of the destination site the operator chose for
/// <paramref name="sourceSiteIdentifier"/> (used to populate a connection
/// row's dropdown). Reads the <c>_targetConnections</c> cache, which is
/// populated for every chosen target site by <see cref="InitNameMappingStateAsync"/>
/// (seeded auto-matches) and <see cref="OnSiteChoiceChangedAsync"/> (operator
/// changes). Returns an empty list when the source site maps to "Create new"
/// (no existing target connections to bind to).
/// </summary>
private IReadOnlyList<DataConnection> ConnectionsForChosenTarget(string sourceSiteIdentifier)
{
if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier)
|| string.IsNullOrEmpty(targetSiteIdentifier))
{
return Array.Empty<DataConnection>();
}
return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached)
? cached
: Array.Empty<DataConnection>();
}
/// <summary>
/// Handles a site-mapping dropdown change. Records the choice and, when a
/// concrete target site is chosen, eagerly loads + caches that site's
/// connections so the per-connection dropdowns underneath it can render
/// their option sets synchronously.
/// </summary>
private async Task OnSiteChoiceChangedAsync(string sourceSiteIdentifier, string? value)
{
var chosen = value ?? CreateNewValue;
_siteChoices[sourceSiteIdentifier] = chosen;
if (!string.IsNullOrEmpty(chosen) && !_targetConnections.ContainsKey(chosen))
{
var target = _targetSites.FirstOrDefault(s => s.SiteIdentifier == chosen);
if (target is not null)
{
_targetConnections[chosen] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
}
private void OnConnectionChoiceChanged(string sourceSiteIdentifier, string sourceConnectionName, string? value)
{
_connectionChoices[(sourceSiteIdentifier, sourceConnectionName)] = value ?? CreateNewValue;
}
/// <summary>
/// M8 E2: folds the operator's Map-step choices into a <see cref="BundleNameMap"/>.
/// A concrete chosen target → <see cref="MappingAction.MapToExisting"/> with that
/// target identifier/name; the "Create new" sentinel → <see cref="MappingAction.CreateNew"/>
/// with a null target. Iterates the preview's required mappings so the map's shape
/// always matches what the bundle references (a missing choice falls back to its
/// auto-match default, mirroring the seed). Returns <see cref="BundleNameMap.Empty"/>
/// when the preview carries no required mappings.
/// </summary>
private BundleNameMap BuildNameMap()
{
if (_preview is null
|| (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0))
{
return BundleNameMap.Empty;
}
var sites = new List<SiteMapping>(_preview.RequiredSiteMappings.Count);
foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c)
? c
: (rsm.AutoMatchTargetIdentifier ?? CreateNewValue);
sites.Add(string.IsNullOrEmpty(chosen)
? new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.CreateNew, null)
: new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.MapToExisting, chosen));
}
var connections = new List<ConnectionMapping>(_preview.RequiredConnectionMappings.Count);
foreach (var rcm in _preview.RequiredConnectionMappings)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosen = _connectionChoices.TryGetValue(key, out var c)
? c
: (rcm.AutoMatchTargetName ?? CreateNewValue);
connections.Add(string.IsNullOrEmpty(chosen)
? new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.CreateNew, null)
: new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.MapToExisting, chosen));
}
return new BundleNameMap(sites, connections);
}
/// <summary>
/// M8 E2: parses a Modified item's <c>FieldDiffJson</c> and returns the
/// <c>lineDiff</c> object for the first code field that carries one, or null
/// when the diff has no line-level payload (ordinary fields render as a coarse
/// summary instead). Tolerant of malformed/absent JSON — a parse failure
/// simply yields null so the row degrades to the coarse field-diff view.
/// </summary>
internal static JsonElement? TryExtractLineDiff(string? fieldDiffJson)
{
if (string.IsNullOrWhiteSpace(fieldDiffJson))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(fieldDiffJson);
if (!doc.RootElement.TryGetProperty("changes", out var changes)
|| changes.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var change in changes.EnumerateArray())
{
if (change.ValueKind == JsonValueKind.Object
&& change.TryGetProperty("lineDiff", out var lineDiff)
&& lineDiff.ValueKind == JsonValueKind.Object)
{
// Clone so the element outlives the JsonDocument we dispose.
return lineDiff.Clone();
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Builds the default resolution per preview item:
/// <list type="bullet">
@@ -527,11 +743,16 @@ public partial class TransportImport : ComponentBase, IDisposable
try
{
var user = await Auth.GetCurrentUsernameAsync();
// M8 E2: fold the operator's Map-step choices into the name map. For
// central-config-only bundles (no required mappings) this is
// BundleNameMap.Empty, which the importer normalises away.
var nameMap = BuildNameMap();
_result = await BundleImporter.ApplyAsync(
_session.SessionId,
_resolutions.Values.ToList(),
user,
CancellationToken.None);
CancellationToken.None,
nameMap: nameMap);
_step = ImportWizardStep.Result;
}
catch (SemanticValidationException ex)
@@ -564,6 +785,11 @@ public partial class TransportImport : ComponentBase, IDisposable
_confirmEnvironmentText = string.Empty;
_result = null;
_validationErrors = null;
// M8 E2: clear the Map sub-section state too.
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
}
/// <summary>