feat(transport-ui): import Map step + per-line diff view (M8 E2)
This commit is contained in:
@@ -206,6 +206,8 @@
|
||||
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
|
||||
|
||||
<div>
|
||||
@RenderMapSection();
|
||||
|
||||
<p class="text-body-secondary">
|
||||
Review each artifact in the bundle and choose how it should be applied
|
||||
to this environment. Identical items are skipped automatically; new
|
||||
@@ -245,11 +247,22 @@
|
||||
</tr>
|
||||
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
|
||||
{
|
||||
var lineDiff = TryExtractLineDiff(item.FieldDiffJson);
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<details>
|
||||
<summary class="small">Field diff</summary>
|
||||
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
|
||||
@if (lineDiff is not null)
|
||||
{
|
||||
<div class="mt-2" data-testid="code-line-diff">
|
||||
<div class="small text-body-secondary mb-1">Code changes</div>
|
||||
<LineDiffView LineDiff="lineDiff" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
|
||||
}
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -283,6 +296,123 @@
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 3 — Map sub-section (M8 E2)
|
||||
// ============================================================
|
||||
// Shown only when the preview references source-environment sites/connections
|
||||
// the operator must resolve before import. For central-config-only bundles the
|
||||
// preview carries no required mappings and this renders nothing.
|
||||
private RenderFragment RenderMapSection() => __builder =>
|
||||
{
|
||||
if (_preview is null) return;
|
||||
var hasSiteMappings = _preview.RequiredSiteMappings.Count > 0;
|
||||
var hasConnMappings = _preview.RequiredConnectionMappings.Count > 0;
|
||||
if (!hasSiteMappings && !hasConnMappings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="card mb-4" data-testid="map-section">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<strong>Resolve site & connection references</strong>
|
||||
<span class="small text-body-secondary ms-2">
|
||||
This bundle references sites/connections from its source environment.
|
||||
Map each to an existing target, or create a new one.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (hasSiteMappings)
|
||||
{
|
||||
<h6 class="small text-uppercase text-body-secondary">Sites</h6>
|
||||
<table class="table table-sm align-middle mb-4" data-testid="map-sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source identifier</th>
|
||||
<th>Source name</th>
|
||||
<th>Map to target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rsm in _preview.RequiredSiteMappings)
|
||||
{
|
||||
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
|
||||
<tr data-testid="map-site-row">
|
||||
<td><code>@rsm.SourceSiteIdentifier</code></td>
|
||||
<td>@rsm.SourceSiteName</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
style="max-width: 22rem;"
|
||||
data-testid="@($"map-site-select-{rsm.SourceSiteIdentifier}")"
|
||||
value="@chosen"
|
||||
@onchange="e => OnSiteChoiceChangedAsync(rsm.SourceSiteIdentifier, e.Value?.ToString())">
|
||||
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosen))">Create new</option>
|
||||
@foreach (var site in _targetSites)
|
||||
{
|
||||
<option value="@site.SiteIdentifier" selected="@(chosen == site.SiteIdentifier)">
|
||||
@site.Name (@site.SiteIdentifier)
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (hasConnMappings)
|
||||
{
|
||||
<h6 class="small text-uppercase text-body-secondary">Connections</h6>
|
||||
@foreach (var grp in _preview.RequiredConnectionMappings.GroupBy(m => m.SourceSiteIdentifier))
|
||||
{
|
||||
var siteTarget = _siteChoices.TryGetValue(grp.Key, out var st) ? st : CreateNewValue;
|
||||
var targetConns = ConnectionsForChosenTarget(grp.Key);
|
||||
<div class="mb-3" data-testid="map-conn-group">
|
||||
<div class="small text-body-secondary mb-1">
|
||||
Site <code>@grp.Key</code>
|
||||
@if (string.IsNullOrEmpty(siteTarget))
|
||||
{
|
||||
<span class="badge bg-success ms-1">new site</span>
|
||||
}
|
||||
</div>
|
||||
<table class="table table-sm align-middle mb-0" data-testid="map-conns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source connection</th>
|
||||
<th>Map to target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rcm in grp)
|
||||
{
|
||||
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
|
||||
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
|
||||
<tr data-testid="map-conn-row">
|
||||
<td>@rcm.SourceConnectionName</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
style="max-width: 22rem;"
|
||||
data-testid="@($"map-conn-select-{rcm.SourceSiteIdentifier}-{rcm.SourceConnectionName}")"
|
||||
value="@chosenConn"
|
||||
@onchange="e => OnConnectionChoiceChanged(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, e.Value?.ToString())">
|
||||
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosenConn))">Create new</option>
|
||||
@foreach (var conn in targetConns)
|
||||
{
|
||||
<option value="@conn.Name" selected="@(chosenConn == conn.Name)">@conn.Name</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
|
||||
{
|
||||
var (cls, label) = item.Kind switch
|
||||
|
||||
+227
-1
@@ -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>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
@using System.Text.Json
|
||||
|
||||
@*
|
||||
LineDiffView (Component #24, Task M8 E2).
|
||||
|
||||
Renders a single Code field's "lineDiff" payload — the line-level diff that the
|
||||
importer attaches to a Modified ImportPreviewItem's FieldDiffJson for code fields —
|
||||
as a compact, GitHub-style +/- list. Pure presentation: it takes an already-parsed
|
||||
JsonElement (the value of the "lineDiff" key) and walks its "hunks" array.
|
||||
|
||||
hunk op ∈ "context" | "add" | "remove" (lowercase):
|
||||
context → muted line, both old/new line numbers
|
||||
add → green-ish line, new line number only (no oldLineNo)
|
||||
remove → red-ish line, old line number only (no newLineNo)
|
||||
|
||||
When the payload's "truncated" flag is true a trailing marker summarises the
|
||||
addedCount / removedCount the diff could not show in full.
|
||||
|
||||
No third-party diff/charting library — Bootstrap utility classes + a small
|
||||
monospace block only.
|
||||
*@
|
||||
|
||||
@if (_hunks.Count == 0 && !_truncated)
|
||||
{
|
||||
<div class="text-muted small fst-italic" data-testid="line-diff-empty">No line-level changes.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded bg-body-tertiary font-monospace small overflow-auto"
|
||||
style="max-height: 22rem;" data-testid="line-diff">
|
||||
@foreach (var hunk in _hunks)
|
||||
{
|
||||
var (rowCls, gutter, sign) = hunk.Op switch
|
||||
{
|
||||
"add" => ("bg-success-subtle text-success-emphasis", FormatGutter(null, hunk.NewLineNo), "+"),
|
||||
"remove" => ("bg-danger-subtle text-danger-emphasis", FormatGutter(hunk.OldLineNo, null), "-"),
|
||||
_ => ("text-body-secondary", FormatGutter(hunk.OldLineNo, hunk.NewLineNo), " "),
|
||||
};
|
||||
<div class="d-flex @rowCls" data-testid="@($"line-diff-{hunk.Op}")">
|
||||
<span class="px-2 text-body-tertiary text-nowrap user-select-none"
|
||||
style="min-width: 6.5rem;">@gutter</span>
|
||||
<span class="px-1 text-nowrap user-select-none">@sign</span>
|
||||
<span class="px-2 text-break flex-grow-1" style="white-space: pre-wrap;">@hunk.Text</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_truncated)
|
||||
{
|
||||
<div class="text-muted small fst-italic mt-1" data-testid="line-diff-truncated">
|
||||
… diff truncated (+@_addedCount / -@_removedCount more)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The parsed value of a Modified item's <c>FieldDiffJson</c> code field's
|
||||
/// <c>lineDiff</c> key. When null the component renders nothing meaningful —
|
||||
/// callers should only render it for code fields that actually carry a
|
||||
/// <c>lineDiff</c> object.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public JsonElement? LineDiff { get; set; }
|
||||
|
||||
private readonly List<Hunk> _hunks = new();
|
||||
private bool _truncated;
|
||||
private int _addedCount;
|
||||
private int _removedCount;
|
||||
|
||||
private sealed record Hunk(string Op, string Text, int? OldLineNo, int? NewLineNo);
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_hunks.Clear();
|
||||
_truncated = false;
|
||||
_addedCount = 0;
|
||||
_removedCount = 0;
|
||||
|
||||
if (LineDiff is not { ValueKind: JsonValueKind.Object } payload)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.TryGetProperty("truncated", out var truncatedEl)
|
||||
&& truncatedEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
_truncated = truncatedEl.GetBoolean();
|
||||
}
|
||||
if (payload.TryGetProperty("addedCount", out var addedEl)
|
||||
&& addedEl.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
_addedCount = addedEl.GetInt32();
|
||||
}
|
||||
if (payload.TryGetProperty("removedCount", out var removedEl)
|
||||
&& removedEl.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
_removedCount = removedEl.GetInt32();
|
||||
}
|
||||
|
||||
if (payload.TryGetProperty("hunks", out var hunksEl)
|
||||
&& hunksEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var h in hunksEl.EnumerateArray())
|
||||
{
|
||||
if (h.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var op = h.TryGetProperty("op", out var opEl) && opEl.ValueKind == JsonValueKind.String
|
||||
? opEl.GetString() ?? "context"
|
||||
: "context";
|
||||
var text = h.TryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String
|
||||
? textEl.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
int? oldLineNo = h.TryGetProperty("oldLineNo", out var oldEl) && oldEl.ValueKind == JsonValueKind.Number
|
||||
? oldEl.GetInt32()
|
||||
: null;
|
||||
int? newLineNo = h.TryGetProperty("newLineNo", out var newEl) && newEl.ValueKind == JsonValueKind.Number
|
||||
? newEl.GetInt32()
|
||||
: null;
|
||||
_hunks.Add(new Hunk(op, text, oldLineNo, newLineNo));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatGutter(int? oldLineNo, int? newLineNo)
|
||||
{
|
||||
var left = oldLineNo?.ToString() ?? string.Empty;
|
||||
var right = newLineNo?.ToString() ?? string.Empty;
|
||||
return $"{left,3} {right,3}";
|
||||
}
|
||||
}
|
||||
+240
-2
@@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
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;
|
||||
@@ -37,6 +39,10 @@ public class TransportImportPageTests : BunitContext
|
||||
{
|
||||
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
|
||||
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
|
||||
// M8 E2: the Map step injects ISiteRepository to populate target-site +
|
||||
// target-connection dropdowns. Register a substitute so every test (not just
|
||||
// the Map tests) can render the wizard without a missing-service failure.
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
|
||||
public TransportImportPageTests()
|
||||
{
|
||||
@@ -44,6 +50,12 @@ public class TransportImportPageTests : BunitContext
|
||||
|
||||
Services.AddSingleton(_importer);
|
||||
Services.AddSingleton(_auditService);
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Site>());
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<DataConnection>());
|
||||
Services.AddSingleton(_siteRepo);
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
@@ -248,7 +260,8 @@ public class TransportImportPageTests : BunitContext
|
||||
session.SessionId,
|
||||
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>())
|
||||
Arg.Any<CancellationToken>(),
|
||||
Arg.Any<BundleNameMap>())
|
||||
.Returns(expectedResult);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
@@ -275,7 +288,8 @@ public class TransportImportPageTests : BunitContext
|
||||
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
|
||||
&& r.Action == ResolutionAction.Overwrite)),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>());
|
||||
Arg.Any<CancellationToken>(),
|
||||
Arg.Any<BundleNameMap>());
|
||||
|
||||
Assert.Equal(
|
||||
TransportImportPage.ImportWizardStep.Result,
|
||||
@@ -329,6 +343,230 @@ public class TransportImportPageTests : BunitContext
|
||||
Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 8 (M8 E2): the Map sub-section renders one row per required site /
|
||||
// connection mapping with the auto-match defaults pre-selected, and the
|
||||
// section is hidden entirely when the preview carries no required mappings.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Map_section_renders_required_rows_with_automatch_defaults()
|
||||
{
|
||||
// Target environment has one site (site-b) with one connection (opc-main),
|
||||
// which the bundle's source site "site-a" / connection "opc-a" auto-match to.
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
|
||||
|
||||
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
||||
var preview = new ImportPreview(
|
||||
session.SessionId,
|
||||
new List<ImportPreviewItem>(),
|
||||
new List<RequiredSiteMapping> { new("site-a", "Site A", "site-b") },
|
||||
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
|
||||
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||
await cut.InvokeAsync(async () =>
|
||||
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
||||
cut.Render();
|
||||
|
||||
// Section present with one site row and one connection row.
|
||||
Assert.NotNull(cut.Find("[data-testid='map-section']"));
|
||||
Assert.Single(cut.FindAll("[data-testid='map-site-row']"));
|
||||
Assert.Single(cut.FindAll("[data-testid='map-conn-row']"));
|
||||
|
||||
// Auto-match defaults: site-a → site-b, opc-a → opc-main.
|
||||
var siteSelect = cut.Find("[data-testid='map-site-select-site-a']");
|
||||
var selectedSiteOpt = siteSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
|
||||
Assert.Equal("site-b", selectedSiteOpt.GetAttribute("value"));
|
||||
|
||||
var connSelect = cut.Find("[data-testid='map-conn-select-site-a-opc-a']");
|
||||
var selectedConnOpt = connSelect.QuerySelectorAll("option").Single(o => o.HasAttribute("selected"));
|
||||
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Map_section_hidden_when_no_required_mappings()
|
||||
{
|
||||
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
||||
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
|
||||
});
|
||||
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||
await cut.InvokeAsync(async () =>
|
||||
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
||||
cut.Render();
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-testid='map-section']"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 9 (M8 E2): the operator's Map choices fold into the BundleNameMap
|
||||
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
|
||||
// CreateNew. Captured via the substituted IBundleImporter.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
|
||||
{
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Site> { new("Site B", "site-b") { Id = 7 } });
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection> { new("opc-main", "OpcUa", 7) { Id = 11 } });
|
||||
|
||||
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
||||
var preview = new ImportPreview(
|
||||
session.SessionId,
|
||||
new List<ImportPreviewItem>(),
|
||||
// site-a auto-matches site-b; site-c has no auto-match (→ Create new default).
|
||||
new List<RequiredSiteMapping>
|
||||
{
|
||||
new("site-a", "Site A", "site-b"),
|
||||
new("site-c", "Site C", null),
|
||||
},
|
||||
// opc-a (under site-a) auto-matches opc-main.
|
||||
new List<RequiredConnectionMapping> { new("site-a", "opc-a", "opc-main") });
|
||||
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
||||
|
||||
var expectedResult = new ImportResult(
|
||||
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), Guid.NewGuid().ToString());
|
||||
_importer.ApplyAsync(
|
||||
session.SessionId,
|
||||
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>(),
|
||||
Arg.Any<BundleNameMap>())
|
||||
.Returns(expectedResult);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||
await cut.InvokeAsync(async () =>
|
||||
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
|
||||
await cut.InvokeAsync(async () => await InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
|
||||
|
||||
await _importer.Received(1).ApplyAsync(
|
||||
session.SessionId,
|
||||
Arg.Any<IReadOnlyList<ImportResolution>>(),
|
||||
"alice",
|
||||
Arg.Any<CancellationToken>(),
|
||||
Arg.Is<BundleNameMap>(m =>
|
||||
m.Sites.Count == 2
|
||||
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-a"
|
||||
&& s.Action == MappingAction.MapToExisting && s.TargetSiteIdentifier == "site-b")
|
||||
&& m.Sites.Any(s => s.SourceSiteIdentifier == "site-c"
|
||||
&& s.Action == MappingAction.CreateNew && s.TargetSiteIdentifier == null)
|
||||
&& m.Connections.Count == 1
|
||||
&& m.Connections.Any(c => c.SourceSiteIdentifier == "site-a"
|
||||
&& c.SourceConnectionName == "opc-a"
|
||||
&& c.Action == MappingAction.MapToExisting && c.TargetConnectionName == "opc-main")));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 10 (M8 E2): a Modified row whose FieldDiffJson carries a code
|
||||
// lineDiff renders +/- lines via LineDiffView.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
|
||||
{
|
||||
const string fieldDiffJson = """
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"field": "Code",
|
||||
"oldValue": "a\nb\nc",
|
||||
"newValue": "a\nB\nc",
|
||||
"lineDiff": {
|
||||
"hunks": [
|
||||
{ "op": "context", "text": "a", "oldLineNo": 1, "newLineNo": 1 },
|
||||
{ "op": "remove", "text": "b", "oldLineNo": 2 },
|
||||
{ "op": "add", "text": "B", "newLineNo": 2 },
|
||||
{ "op": "context", "text": "c", "oldLineNo": 3, "newLineNo": 3 }
|
||||
],
|
||||
"truncated": false,
|
||||
"addedCount": 1,
|
||||
"removedCount": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
||||
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
|
||||
});
|
||||
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||
await cut.InvokeAsync(async () =>
|
||||
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
||||
cut.Render();
|
||||
|
||||
// The code line-diff block is present and shows one add + one remove line.
|
||||
Assert.NotNull(cut.Find("[data-testid='code-line-diff']"));
|
||||
Assert.NotNull(cut.Find("[data-testid='line-diff']"));
|
||||
Assert.Single(cut.FindAll("[data-testid='line-diff-add']"));
|
||||
Assert.Single(cut.FindAll("[data-testid='line-diff-remove']"));
|
||||
// The raw JSON <pre> fallback is NOT used for code-field diffs.
|
||||
Assert.DoesNotContain("\"lineDiff\"", cut.Find("[data-testid='code-line-diff']").InnerHtml);
|
||||
// No truncation marker for a complete diff.
|
||||
Assert.Empty(cut.FindAll("[data-testid='line-diff-truncated']"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Test 11 (M8 E2): truncation marker shows when the lineDiff is truncated.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
|
||||
{
|
||||
const string fieldDiffJson = """
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"field": "Code",
|
||||
"oldValue": "x",
|
||||
"newValue": "y",
|
||||
"lineDiff": {
|
||||
"hunks": [
|
||||
{ "op": "remove", "text": "x", "oldLineNo": 1 },
|
||||
{ "op": "add", "text": "y", "newLineNo": 1 }
|
||||
],
|
||||
"truncated": true,
|
||||
"addedCount": 12,
|
||||
"removedCount": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
|
||||
var preview = new ImportPreview(session.SessionId, new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "Pump", 1, 2, ConflictKind.Modified, fieldDiffJson, null),
|
||||
});
|
||||
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>()).Returns(preview);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||
await cut.InvokeAsync(async () =>
|
||||
await InvokeAsyncMethod(cut.Instance, "LoadPreviewAndAdvanceAsync"));
|
||||
cut.Render();
|
||||
|
||||
var marker = cut.Find("[data-testid='line-diff-truncated']");
|
||||
Assert.Contains("truncated", marker.TextContent);
|
||||
Assert.Contains("+12", marker.TextContent);
|
||||
Assert.Contains("-8", marker.TextContent);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reflection helpers — the wizard's per-instance state is private (the
|
||||
// razor partial pattern). We poke at it via reflection rather than
|
||||
|
||||
@@ -222,6 +222,8 @@ public sealed class QueryStringDrillInTests
|
||||
var importer = Substitute.For<IBundleImporter>();
|
||||
Services.AddSingleton(importer);
|
||||
Services.AddSingleton(Substitute.For<IAuditService>());
|
||||
// M8 E2: TransportImport's Map step injects ISiteRepository.
|
||||
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user