Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor
T
Joseph Doherty 59ea02d971 feat(adminui): B2-WP5 CSV tag import/export + review grid
Staged CSV tag import (upload -> parse -> review grid with per-row verdicts
-> all-or-nothing commit) and export for the /raw tree Device/TagGroup.

- CsvColumnMap: per-driver typed column <-> TagConfig JSON maps (model-backed
  for the 7 typed drivers, raw-key for Galaxy/Calculation) + the common column
  dictionary; typed columns merge OVER the TagConfigJson fallback (typed wins).
- RawTagCsvMapper: pure parse (review rows + verdicts + typed/fallback
  provenance + intra-batch dup detection) and export (residual TagConfigJson)
  over the T0-2 CsvParser/CsvWriter.
- RawTagCsvExportReader: DbContext-backed read helper (no IRawTreeService
  extension) enumerating a device's tags + reconstructing group paths.
- RawCsvImportModal + RawCsvExportModal (data-URI download).
- Reflection guard test (typed column -> real model property + batch-plan
  dictionary parity) + export->import round-trip property test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:19:14 -04:00

319 lines
14 KiB
Plaintext

@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5).
Flow: upload a file (InputFile) → parse via the T0-2 CsvParser + the per-driver CsvColumnMap →
a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were
typed vs. taken from the TagConfigJson fallback → Commit, which calls IRawTreeService.ImportTagsAsync.
ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid — no partial commit.
Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub):
<RawCsvImportModal @bind-Visible="_importVisible"
DeviceId="@_importDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_importTagGroupId" (target group; its path is prepended to every row)
DriverType="@_importDriverType"
OnSaved="ReloadNodeChildren" />
For a Device node: DeviceId = node.EntityId, TagGroupId = null.
For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_fatal))
{
<div class="alert alert-danger">@_fatal</div>
}
@if (_result is null)
{
<p class="text-muted">
Upload a CSV whose header row carries the columns for a
<span class="mono">@DriverType</span> device.
Target: <span class="mono">@TargetLabel</span>.
</p>
<InputFile OnChange="OnFileSelected" accept=".csv,text/csv" class="form-control" />
@if (_parsing)
{
<p class="text-muted mt-2"><span class="spinner-border spinner-border-sm"></span> Parsing…</p>
}
}
else
{
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<span class="badge bg-secondary">@_result.Rows.Count rows</span>
<span class="badge bg-success">@OkCount valid</span>
@if (ErrorCount > 0)
{
<span class="badge bg-danger">@ErrorCount invalid</span>
}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="Reset">Choose another file</button>
</div>
@if (ErrorCount > 0)
{
<div class="alert alert-warning py-1">
Import is all-or-nothing — fix or remove the invalid rows before committing.
</div>
}
<div class="table-responsive" style="max-height:50vh;overflow:auto">
<table class="table table-sm table-hover align-middle">
<thead class="sticky-top bg-body">
<tr>
<th style="width:2.5rem"></th>
<th>Row</th>
<th>Name</th>
<th>Group</th>
<th>Verdict</th>
<th>Provenance</th>
<th style="width:2.5rem"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _result.Rows)
{
if (_removed.Contains(row.RowNumber)) { continue; }
<tr class="@(row.Ok ? "" : "table-danger")">
<td>@(row.Ok ? "✓" : "✕")</td>
<td class="mono">@row.RowNumber</td>
<td class="mono">@row.Name</td>
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
<td>
@if (row.Ok)
{
<span class="text-success">OK</span>
}
else
{
<span class="text-danger">@row.Error</span>
}
</td>
<td class="small">
@if (row.TypedColumnsUsed.Count > 0)
{
<span class="text-muted">typed: </span>
<span class="mono">@string.Join(", ", row.TypedColumnsUsed)</span>
}
@if (row.FallbackKeys.Count > 0)
{
<br /><span class="text-muted">TagConfigJson: </span>
<span class="mono">@string.Join(", ", row.FallbackKeys)</span>
}
@if (row.TypedOverrodeFallback.Count > 0)
{
<br /><span class="badge bg-info text-dark" title="A typed column overrode the same key in TagConfigJson">
typed wins: @string.Join(", ", row.TypedOverrodeFallback)
</span>
}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger py-0"
title="Remove this row from the import"
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (_commitErrors.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Commit rejected:</strong>
<ul class="mb-0">
@foreach (var e in _commitErrors)
{
<li>@e</li>
}
</ul>
</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary"
disabled="@(!CanCommit || _committing)"
@onclick="Commit">
@if (_committing)
{
<span class="spinner-border spinner-border-sm"></span>
}
Commit @RemainingCount tag@(RemainingCount == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (two-way bound).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The target device's logical id. May be blank when <see cref="TagGroupId"/> is supplied
/// (the device is then resolved from the group).</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>The target tag-group's logical id, or null to import at the device root. Its device-relative
/// path is prepended to every imported row's <c>TagGroupPath</c>.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after a successful commit so the coordinator can reload the node's children.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
private readonly HashSet<int> _removed = new();
private List<string> _commitErrors = new();
private string? _fatal;
private bool _parsing;
private bool _committing;
private bool _resolved;
private string _effectiveDeviceId = "";
private string? _groupPrefix;
private string TargetLabel =>
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0;
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
private bool CanCommit =>
_result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_resolved)
{
await ResolveTargetAsync();
_resolved = true;
}
else if (!Visible)
{
_resolved = false;
}
}
// Resolve the effective device id + the group-path prefix once per open.
private async Task ResolveTargetAsync()
{
_effectiveDeviceId = DeviceId;
_groupPrefix = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
if (string.IsNullOrEmpty(_effectiveDeviceId))
{
_effectiveDeviceId = c.DeviceId;
}
}
}
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
_fatal = null;
_commitErrors = new();
_removed.Clear();
_parsing = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
using var reader = new StreamReader(stream);
var text = await reader.ReadToEndAsync();
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
_fatal = _result.FatalError;
}
catch (Exception ex)
{
_fatal = $"Could not read the file: {ex.Message}";
_result = null;
}
finally
{
_parsing = false;
}
}
private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
private void Reset()
{
_result = null;
_fatal = null;
_commitErrors = new();
_removed.Clear();
}
private async Task Commit()
{
if (_result is null || !CanCommit)
{
return;
}
_committing = true;
_commitErrors = new();
try
{
var rows = _result.Rows
.Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
.Select(r => r.ImportRow!)
.ToList();
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
if (outcome.Errors.Count > 0)
{
_commitErrors = outcome.Errors.ToList();
return;
}
await OnSaved.InvokeAsync();
await Close();
}
catch (Exception ex)
{
_commitErrors = new() { ex.Message };
}
finally
{
_committing = false;
}
}
private async Task Close()
{
Reset();
Visible = false;
_resolved = false;
await VisibleChanged.InvokeAsync(false);
}
}