36e6ab590b
The import review grid rendered every parsed row and re-scanned all rows three times per render for its counts; RawCsvReviewView recomputes rows + counts once per change and the grid renders through Virtualize. The export modal embedded the whole CSV twice (textarea + base64 data: URI); it now shows a 50-line preview and links to GET /api/raw/tags.csv (ConfigEditor, like /raw), which runs the same RawTagCsvExport builder and sends it as an attachment. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
344 lines
15 KiB
Plaintext
344 lines
15 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 Microsoft.AspNetCore.Components.Web.Virtualization
|
|
@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>
|
|
@* Virtualized (R04 P6): only the rows in the scrolled view render. *@
|
|
<Virtualize Items="@_view.VisibleRows" ItemSize="34"
|
|
SpacerElement="tr" Context="row">
|
|
<tr @key="row.RowNumber" 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"
|
|
title="Remove this row from the import"
|
|
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
|
|
</td>
|
|
</tr>
|
|
</Virtualize>
|
|
</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">
|
|
<div class="btn-group" role="group">
|
|
<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>
|
|
</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;
|
|
// The grid's filtered rows + counts, recomputed per change rather than per render (R04 P6).
|
|
private readonly RawCsvReviewView _view = new();
|
|
private List<string> _commitErrors = new();
|
|
private string? _fatal;
|
|
private bool _parsing;
|
|
private bool _committing;
|
|
private bool _resolved;
|
|
|
|
// Bumped on every open and every close; an async continuation compares its captured value after each
|
|
// await so a superseded open can never write its target under a different device's displayed modal
|
|
// (same idiom as RawBrowseModal / RawManualTagEntryModal).
|
|
private int _openGeneration;
|
|
|
|
private string _effectiveDeviceId = "";
|
|
private string? _groupPrefix;
|
|
|
|
private string TargetLabel =>
|
|
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
|
|
|
|
private int OkCount => _view.OkCount;
|
|
private int ErrorCount => _view.ErrorCount;
|
|
private int RemainingCount => _view.RemainingCount;
|
|
|
|
// 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)
|
|
{
|
|
_resolved = true;
|
|
var generation = ++_openGeneration;
|
|
await ResolveTargetAsync(generation);
|
|
}
|
|
else if (!Visible)
|
|
{
|
|
_resolved = false;
|
|
_openGeneration++;
|
|
}
|
|
}
|
|
|
|
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
|
|
|
|
// Resolve the effective device id + the group-path prefix once per open.
|
|
private async Task ResolveTargetAsync(int generation)
|
|
{
|
|
_effectiveDeviceId = DeviceId;
|
|
_groupPrefix = null;
|
|
|
|
if (!string.IsNullOrEmpty(TagGroupId))
|
|
{
|
|
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
|
if (IsStale(generation)) { return; }
|
|
if (ctx is { } c)
|
|
{
|
|
_groupPrefix = c.GroupPath;
|
|
if (string.IsNullOrEmpty(_effectiveDeviceId))
|
|
{
|
|
_effectiveDeviceId = c.DeviceId;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
|
{
|
|
_fatal = null;
|
|
_commitErrors = new();
|
|
_view.Set(null);
|
|
_parsing = true;
|
|
var generation = _openGeneration;
|
|
|
|
try
|
|
{
|
|
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
|
|
using var reader = new StreamReader(stream);
|
|
var text = await reader.ReadToEndAsync();
|
|
if (IsStale(generation)) { return; }
|
|
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
|
|
_view.Set(_result);
|
|
_fatal = _result.FatalError;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_fatal = $"Could not read the file: {ex.Message}";
|
|
_result = null;
|
|
_view.Set(null);
|
|
}
|
|
finally
|
|
{
|
|
_parsing = false;
|
|
}
|
|
}
|
|
|
|
private void RemoveRow(int rowNumber) => _view.Remove(rowNumber);
|
|
|
|
private void Reset()
|
|
{
|
|
_result = null;
|
|
_fatal = null;
|
|
_commitErrors = new();
|
|
_view.Set(null);
|
|
}
|
|
|
|
private async Task Commit()
|
|
{
|
|
if (_result is null || !CanCommit)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_committing = true;
|
|
_commitErrors = new();
|
|
var generation = _openGeneration;
|
|
var targetDeviceId = _effectiveDeviceId;
|
|
try
|
|
{
|
|
var rows = _view.CommitRows();
|
|
|
|
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
|
|
// The write has landed whether or not the modal was closed/reopened meanwhile, so the parent must
|
|
// still refresh its tree; only the modal-local state writes are skipped when stale.
|
|
if (IsStale(generation))
|
|
{
|
|
if (outcome.Errors.Count == 0) { await OnSaved.InvokeAsync(); }
|
|
return;
|
|
}
|
|
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;
|
|
_openGeneration++;
|
|
await VisibleChanged.InvokeAsync(false);
|
|
}
|
|
}
|