59ea02d971
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
319 lines
14 KiB
Plaintext
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);
|
|
}
|
|
}
|