perf(adminui): virtualize the CSV import grid; download exports from an endpoint (R04 P6)

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
This commit is contained in:
Joseph Doherty
2026-10-22 17:40:40 -04:00
parent ce9679b671
commit 36e6ab590b
7 changed files with 356 additions and 70 deletions
@@ -2,15 +2,15 @@
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.
CsvColumnMap). The download is a plain same-origin link to GET /api/raw/tags.csv (R04 P6) — the
file used to be embedded in the circuit twice (textarea + base64 data: URI); the modal now shows only
a bounded preview of the first RawTagCsvExport.PreviewLines lines.
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
@@ -43,7 +43,11 @@
<span>, group <span class="mono">@_groupFilter</span></span>
}
</p>
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
@if (_truncated)
{
<p class="small text-muted mb-1">Preview — first @RawTagCsvExport.PreviewLines lines. Download for the full file.</p>
}
<textarea class="form-control mono small" rows="10" readonly>@_preview</textarea>
}
</div>
<div class="modal-footer">
@@ -51,7 +55,7 @@
<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>
<a class="btn btn-primary" download="@_fileName" href="@_downloadUrl">Download CSV</a>
}
</div>
</div>
@@ -79,8 +83,9 @@
private bool _loading;
private bool _built;
private string? _error;
private string _csv = "";
private string _dataUri = "";
private string _preview = "";
private bool _truncated;
private string _downloadUrl = "";
private string _fileName = "tags.csv";
private int _rowCount;
private string? _groupFilter;
@@ -110,48 +115,22 @@
_error = null;
try
{
var deviceId = DeviceId;
_groupFilter = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupFilter = c.GroupPath;
if (string.IsNullOrEmpty(deviceId))
{
deviceId = c.DeviceId;
}
}
}
if (string.IsNullOrEmpty(deviceId))
// The same builder the download endpoint runs, so the preview matches the file.
var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType);
if (IsStale(generation)) { return; }
if (export is null)
{
_error = "Could not resolve the target device.";
return;
}
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
if (IsStale(generation)) { return; }
// 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}";
_groupFilter = export.GroupPath;
_rowCount = export.RowCount;
_fileName = export.FileName;
// Only the bounded preview is kept on the component; the full CSV is not held by the circuit.
(_preview, _truncated) = RawTagCsvExport.Preview(export.Csv);
_downloadUrl = RawTagCsvExport.DownloadUrl(DeviceId, TagGroupId, DriverType);
}
catch (Exception ex)
{
@@ -163,18 +142,13 @@
}
}
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;
_openGeneration++;
_csv = "";
_dataUri = "";
_preview = "";
_truncated = false;
_downloadUrl = "";
Visible = false;
await VisibleChanged.InvokeAsync(false);
}
@@ -14,6 +14,7 @@
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
@@ -82,10 +83,10 @@
</tr>
</thead>
<tbody>
@foreach (var row in _result.Rows)
{
if (_removed.Contains(row.RowNumber)) { continue; }
<tr class="@(row.Ok ? "" : "table-danger")">
@* 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>
@@ -124,7 +125,7 @@
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
</td>
</tr>
}
</Virtualize>
</tbody>
</table>
</div>
@@ -184,7 +185,8 @@
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
private readonly HashSet<int> _removed = new();
// 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;
@@ -202,9 +204,9 @@
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;
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 =>
@@ -252,7 +254,7 @@
{
_fatal = null;
_commitErrors = new();
_removed.Clear();
_view.Set(null);
_parsing = true;
var generation = _openGeneration;
@@ -263,12 +265,14 @@
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
{
@@ -276,14 +280,14 @@
}
}
private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
private void RemoveRow(int rowNumber) => _view.Remove(rowNumber);
private void Reset()
{
_result = null;
_fatal = null;
_commitErrors = new();
_removed.Clear();
_view.Set(null);
}
private async Task Commit()
@@ -299,10 +303,7 @@
var targetDeviceId = _effectiveDeviceId;
try
{
var rows = _result.Rows
.Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
.Select(r => r.ImportRow!)
.ToList();
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
@@ -43,6 +43,10 @@ public static class EndpointRouteBuilderExtensions
// ambiguous.
app.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode();
// The /raw CSV export modal's download link (R04 P6): the file goes over plain HTTP instead of
// being embedded in the circuit. ConfigEditor, like the /raw page itself.
app.MapRawTagCsvExport();
return app;
}
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// The CSV import review grid's view state (R04 P6): the parsed rows minus the ones the operator removed,
/// plus the OK / invalid / remaining counts. The modal used to re-scan every parsed row three times per
/// render for the counts and filter removals inside the row loop; this recomputes once per change and
/// hands <c>Virtualize</c> a ready list, so a 100k-row file renders only the rows in view.
/// </summary>
public sealed class RawCsvReviewView
{
private readonly HashSet<int> _removed = new();
private System.Collections.ObjectModel.ReadOnlyCollection<RawCsvReviewRow> _visible = new(new List<RawCsvReviewRow>());
/// <summary>The rows still in the import, in file order.</summary>
public System.Collections.ObjectModel.ReadOnlyCollection<RawCsvReviewRow> VisibleRows => _visible;
/// <summary>Remaining valid rows.</summary>
public int OkCount { get; private set; }
/// <summary>Remaining invalid rows.</summary>
public int ErrorCount { get; private set; }
/// <summary>Remaining rows (valid + invalid).</summary>
public int RemainingCount => _visible.Count;
/// <summary>The valid remaining rows' import rows, in file order.</summary>
/// <returns>The rows to commit.</returns>
public IReadOnlyList<RawTagImportRow> CommitRows() =>
_visible.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToList();
/// <summary>Replaces the parsed result (null clears) and forgets any removals.</summary>
/// <param name="result">The parse result.</param>
public void Set(RawCsvParseResult? result)
{
_removed.Clear();
_source = result?.Rows ?? Array.Empty<RawCsvReviewRow>();
Recompute();
}
/// <summary>Removes one row (by its file row number) from the import.</summary>
/// <param name="rowNumber">The row's 1-based data-row number.</param>
public void Remove(int rowNumber)
{
if (_removed.Add(rowNumber)) Recompute();
}
private IReadOnlyList<RawCsvReviewRow> _source = Array.Empty<RawCsvReviewRow>();
private void Recompute()
{
var visible = new List<RawCsvReviewRow>(_source.Count);
int ok = 0, bad = 0;
foreach (var row in _source)
{
if (_removed.Contains(row.RowNumber)) continue;
visible.Add(row);
if (row.Ok) ok++;
else bad++;
}
_visible = visible.AsReadOnly();
OkCount = ok;
ErrorCount = bad;
}
}
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>A built raw-tag CSV export.</summary>
/// <param name="Csv">The CSV text.</param>
/// <param name="FileName">The suggested download file name.</param>
/// <param name="RowCount">The number of exported tags.</param>
/// <param name="GroupPath">The exported group's device-relative path, or null for a whole-device export.</param>
public sealed record RawTagCsvExportResult(string Csv, string FileName, int RowCount, string? GroupPath);
/// <summary>
/// Builds raw-tag CSV exports, and serves them as a download (R04 P6). The export modal used to embed
/// the whole CSV in the circuit twice (a textarea plus a base64 <c>data:</c> URI on the anchor), so a
/// multi-MB export pushed ≈2.3× its size over SignalR into the DOM. The modal now shows a bounded
/// preview and links to <see cref="DownloadPath"/>, which streams the file over plain HTTP.
/// </summary>
public static class RawTagCsvExport
{
/// <summary>The download endpoint's route.</summary>
public const string DownloadPath = "/api/raw/tags.csv";
/// <summary>The number of CSV lines the modal previews.</summary>
public const int PreviewLines = 50;
/// <summary>Builds the export for a device, or for one tag group's subtree.</summary>
/// <param name="reader">The export reader.</param>
/// <param name="deviceId">The device; may be blank when <paramref name="tagGroupId"/> is given.</param>
/// <param name="tagGroupId">When set, only this group's subtree is exported.</param>
/// <param name="driverType">The driver type (selects the typed columns); resolved from the device when blank.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The export, or null when the target device cannot be resolved.</returns>
public static async Task<RawTagCsvExportResult?> BuildAsync(
RawTagCsvExportReader reader, string? deviceId, string? tagGroupId, string? driverType, CancellationToken ct = default)
{
string? groupPath = null;
if (!string.IsNullOrEmpty(tagGroupId))
{
var ctx = await reader.ResolveGroupContextAsync(tagGroupId, ct);
if (ctx is { } c)
{
groupPath = c.GroupPath;
if (string.IsNullOrEmpty(deviceId)) deviceId = c.DeviceId;
}
}
if (string.IsNullOrEmpty(deviceId)) return null;
if (string.IsNullOrEmpty(driverType))
{
driverType = await reader.ResolveDriverTypeAsync(deviceId, ct);
if (driverType is null) return null;
}
IEnumerable<RawCsvExportTag> tags = await reader.ReadDeviceTagsAsync(deviceId, ct);
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(groupPath))
{
var prefix = groupPath;
tags = tags.Where(t => t.TagGroupPath is not null &&
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)));
}
var list = tags.ToList();
return new RawTagCsvExportResult(RawTagCsvMapper.Export(driverType, list), BuildFileName(deviceId), list.Count, groupPath);
}
/// <summary>The first <paramref name="maxLines"/> lines of <paramref name="csv"/>, without copying the rest.</summary>
/// <param name="csv">The CSV text.</param>
/// <param name="maxLines">The maximum number of lines.</param>
/// <returns>The preview, and whether lines were cut.</returns>
public static (string Preview, bool Truncated) Preview(string csv, int maxLines = PreviewLines)
{
var idx = 0;
for (var line = 0; line < maxLines; line++)
{
var next = csv.IndexOf('\n', idx);
if (next < 0) return (csv, false);
idx = next + 1;
}
return idx >= csv.Length ? (csv, false) : (csv[..idx], true);
}
/// <summary>The download URL for an export.</summary>
/// <param name="deviceId">The device id (may be blank when a group is given).</param>
/// <param name="tagGroupId">The optional tag-group id.</param>
/// <param name="driverType">The optional driver type.</param>
/// <returns>A relative URL with escaped query values.</returns>
public static string DownloadUrl(string? deviceId, string? tagGroupId, string? driverType)
{
var query = new List<string>();
if (!string.IsNullOrEmpty(deviceId)) query.Add("deviceId=" + Uri.EscapeDataString(deviceId));
if (!string.IsNullOrEmpty(tagGroupId)) query.Add("tagGroupId=" + Uri.EscapeDataString(tagGroupId));
if (!string.IsNullOrEmpty(driverType)) query.Add("driverType=" + Uri.EscapeDataString(driverType));
return query.Count == 0 ? DownloadPath : DownloadPath + "?" + string.Join("&", query);
}
/// <summary>The download file name for a device.</summary>
/// <param name="deviceId">The device id.</param>
/// <returns>A file-system-safe name.</returns>
public static string BuildFileName(string deviceId)
{
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
return $"tags-{safe}.csv";
}
/// <summary>
/// Maps <c>GET /api/raw/tags.csv</c>. It needs the same <c>ConfigEditor</c> policy as the <c>/raw</c>
/// page whose export modal links to it — the CSV carries every tag's config, which is exactly what
/// that page shows. Browser-cookie authenticated (the anchor is a same-origin navigation).
/// </summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The route handler builder.</returns>
public static RouteHandlerBuilder MapRawTagCsvExport(this IEndpointRouteBuilder app) =>
app.MapGet(DownloadPath, async (
string? deviceId, string? tagGroupId, string? driverType,
RawTagCsvExportReader reader, CancellationToken ct) =>
{
var export = await BuildAsync(reader, deviceId, tagGroupId, driverType, ct);
// File(..., fileDownloadName) sends Content-Disposition: attachment, so the browser saves it.
return export is null
? Results.NotFound()
: Results.File(System.Text.Encoding.UTF8.GetBytes(export.Csv), "text/csv; charset=utf-8", export.FileName);
})
.RequireAuthorization(AdminUiPolicies.ConfigEditor)
.WithName("RawTagCsvExport");
}
@@ -87,8 +87,12 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
("OnSaved", EventCallback.Factory.Create(this, () => { saved++; })));
await Open(modal);
modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(
modal, RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\""));
var parsed = RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\"");
modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(modal, parsed);
// The review grid's view (R04 P6) is what Commit reads its rows and counts from; OnFileSelected
// sets both, so a test that bypasses the upload must too.
((RawCsvReviewView)modal.GetType().GetField("_view", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(modal)!).Set(parsed);
var commit = (Task)modal.GetType().GetMethod("Commit", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(modal, null)!;
@@ -122,7 +126,7 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
Field<string?>(modal, "_groupFilter").ShouldBeNull();
Field<int>(modal, "_rowCount").ShouldBe(0);
Field<string>(modal, "_dataUri").ShouldBe("");
Field<string>(modal, "_downloadUrl").ShouldBe("");
}
[Fact]
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// R04 P6: the CSV import grid renders through Virtualize from a precomputed view, and the export is
/// downloaded from an endpoint (bounded preview in the modal) instead of being embedded in the circuit.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawCsvRenderingTests
{
private static RawCsvParseResult Result(params (int Row, bool Ok)[] rows) =>
new(["Name"], rows.Select(r => new RawCsvReviewRow
{
RowNumber = r.Row, Name = $"t{r.Row}", Error = r.Ok ? null : "bad",
ImportRow = r.Ok ? new RawTagImportRow(null, new RawTagInput($"t{r.Row}", "Int32", Configuration.Enums.TagAccessLevel.Read, false, null, "{}")) : null,
}).ToList(), null);
[Fact]
public void Review_view_counts_and_filters_once_per_change()
{
var view = new RawCsvReviewView();
view.Set(Result((1, true), (2, false), (3, true)));
view.OkCount.ShouldBe(2);
view.ErrorCount.ShouldBe(1);
view.RemainingCount.ShouldBe(3);
view.Remove(2);
view.ErrorCount.ShouldBe(0);
view.VisibleRows.Select(r => r.RowNumber).ShouldBe([1, 3]);
view.CommitRows().Select(r => r.Tag.Name).ShouldBe(["t1", "t3"]);
view.Set(null);
view.RemainingCount.ShouldBe(0);
// Positive control: a fresh set after a clear shows every row again (removals are forgotten).
view.Set(Result((1, true), (2, false)));
view.RemainingCount.ShouldBe(2);
}
[Fact]
public void Preview_is_bounded_and_flags_truncation()
{
var csv = string.Concat(Enumerable.Range(0, 200).Select(i => $"row{i}\n"));
var (preview, truncated) = RawTagCsvExport.Preview(csv, maxLines: 50);
truncated.ShouldBeTrue();
preview.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length.ShouldBe(50);
var (all, cut) = RawTagCsvExport.Preview("a\nb\n", maxLines: 50);
cut.ShouldBeFalse();
all.ShouldBe("a\nb\n");
}
[Fact]
public void Download_url_escapes_its_query_values()
{
RawTagCsvExport.DownloadUrl("dev 1&x", null, "Modbus")
.ShouldBe("/api/raw/tags.csv?deviceId=dev%201%26x&driverType=Modbus");
RawTagCsvExport.DownloadUrl("", "TG/1", null).ShouldBe("/api/raw/tags.csv?tagGroupId=TG%2F1");
}
[Fact]
public void Download_endpoint_requires_the_same_ConfigEditor_policy_as_the_raw_page()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("csv-export-metadata"));
builder.Services.AddScoped<RawTagCsvExportReader>();
var app = builder.Build();
app.MapRawTagCsvExport();
var endpoint = ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>()
.Single(e => e.RoutePattern.RawText == RawTagCsvExport.DownloadPath);
endpoint.Metadata.GetMetadata<IAllowAnonymous>().ShouldBeNull();
endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>().Select(a => a.Policy)
.ShouldContain(AdminUiPolicies.ConfigEditor);
// Parity with the page that links to it.
typeof(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)
.GetCustomAttributes(typeof(AuthorizeAttribute), false).Cast<AuthorizeAttribute>()
.Single().Policy.ShouldBe(AdminUiPolicies.ConfigEditor);
}
[Fact]
public void Modals_no_longer_embed_the_csv_or_render_every_row()
{
var export = AdminUiSource.Read("Components", "Shared", "Raw", "RawCsvExportModal.razor");
export.ShouldNotContain("data:text/csv");
export.ShouldContain("RawTagCsvExport.Preview(");
var import = AdminUiSource.Read("Components", "Shared", "Raw", "RawCsvImportModal.razor");
import.ShouldContain("<Virtualize Items=\"@_view.VisibleRows\"");
import.ShouldNotContain("@foreach (var row in _result.Rows)");
AdminUiSource.Read("EndpointRouteBuilderExtensions.cs").ShouldContain("app.MapRawTagCsvExport();");
}
}