Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.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

171 lines
6.3 KiB
Plaintext

@* CSV tag export for a Raw-tree Device / TagGroup (B2-WP5).
Reads the device's tags (via the WP5-owned RawTagCsvExportReader — no IRawTreeService extension) and
renders them in the same column shape RawCsvImportModal consumes (T0-2 CsvWriter, per-driver
CsvColumnMap). The CSV is offered as a self-contained data-URI download anchor (no JS interop, no
external host — matches the app's CSP posture) plus a preview textarea.
Modal contract (the RawTree coordinator wires this in place of the OnExportCsv stub):
<RawCsvExportModal @bind-Visible="_exportVisible"
DeviceId="@_exportDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_exportTagGroupId" (when set, only that group's subtree is exported)
DriverType="@_exportDriverType" /> *@
@using System.Text
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@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-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Export tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm"></span> Reading tags…</p>
}
else if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
else
{
<p class="text-muted">
<span class="badge bg-secondary">@_rowCount tags</span>
for <span class="mono">@DriverType</span> device
@if (!string.IsNullOrEmpty(_groupFilter))
{
<span>, group <span class="mono">@_groupFilter</span></span>
}
</p>
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Close</button>
@if (!_loading && _error is null)
{
<a class="btn btn-primary" download="@_fileName" href="@_dataUri">Download CSV</a>
}
</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 device to export. May be blank when <see cref="TagGroupId"/> is supplied.</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>When set, only tags under this group's subtree are exported; null exports the whole device.</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; } = "";
private bool _loading;
private bool _built;
private string? _error;
private string _csv = "";
private string _dataUri = "";
private string _fileName = "tags.csv";
private int _rowCount;
private string? _groupFilter;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_built)
{
_built = true;
await BuildAsync();
}
else if (!Visible)
{
_built = false;
}
}
private async Task BuildAsync()
{
_loading = true;
_error = null;
try
{
var deviceId = DeviceId;
_groupFilter = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (ctx is { } c)
{
_groupFilter = c.GroupPath;
if (string.IsNullOrEmpty(deviceId))
{
deviceId = c.DeviceId;
}
}
}
if (string.IsNullOrEmpty(deviceId))
{
_error = "Could not resolve the target device.";
return;
}
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(_groupFilter))
{
var prefix = _groupFilter;
tags = tags
.Where(t => t.TagGroupPath is not null &&
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)))
.ToList();
}
_rowCount = tags.Count;
_csv = RawTagCsvMapper.Export(DriverType, tags);
_fileName = BuildFileName(deviceId);
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv));
_dataUri = $"data:text/csv;charset=utf-8;base64,{base64}";
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_loading = false;
}
}
private static string BuildFileName(string deviceId)
{
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
return $"tags-{safe}.csv";
}
private async Task Close()
{
_built = false;
_csv = "";
_dataUri = "";
Visible = false;
await VisibleChanged.InvokeAsync(false);
}
}