@* 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 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; private readonly HashSet _removed = new(); private List _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); } }