@* 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): 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) { } @code { /// Whether the modal is shown (two-way bound). [Parameter] public bool Visible { get; set; } /// Raised when changes (supports @bind-Visible). [Parameter] public EventCallback VisibleChanged { get; set; } /// The target device's logical id. May be blank when is supplied /// (the device is then resolved from the group). [Parameter] public string DeviceId { get; set; } = ""; /// 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 TagGroupPath. [Parameter] public string? TagGroupId { get; set; } /// The device's driver-type string — selects the per-driver typed column set. [Parameter] public string DriverType { get; set; } = ""; /// Raised after a successful commit so the coordinator can reload the node's children. [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 _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); } }