diff --git a/.github/workflows/ci-publish.yml b/.github/workflows/ci-publish.yml index 95ee282..6813778 100644 --- a/.github/workflows/ci-publish.yml +++ b/.github/workflows/ci-publish.yml @@ -12,7 +12,8 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true SOLUTION_FILE: SuperBlazorComponents.slnx - PACKAGE_PROJECT: src/SuperBlazorComponents/SuperBlazorComponents.csproj + MAIN_PACKAGE_PROJECT: src/SuperBlazorComponents/SuperBlazorComponents.csproj + EXPORTER_PACKAGE_PROJECT: src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj permissions: contents: read @@ -64,7 +65,14 @@ jobs: - name: Pack SuperBlazorComponents run: > - dotnet pack ${{ env.PACKAGE_PROJECT }} + dotnet pack ${{ env.MAIN_PACKAGE_PROJECT }} + --no-build + --configuration Release + --output ./nupkgs + + - name: Pack SuperBlazorComponents.DataGridExporter + run: > + dotnet pack ${{ env.EXPORTER_PACKAGE_PROJECT }} --no-build --configuration Release --output ./nupkgs diff --git a/README.md b/README.md index d5ac910..5c77f99 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | Preview | Component | Description | Docs | |---|---|---|---| | SuperDataGrid preview | **SuperDataGrid** | Virtualized data grid — frozen columns/rows, hierarchical lazy-loading rows, reordering, resizing, filtering, sorting, inline editing, row selection, settings persistence | [📖 SUPERDATAGRID.md](SUPERDATAGRID.md) | +| SuperDataGrid exporter preview | **SuperDataGrid Exporter** | Optional CSV and Excel export extension for complete filtered, sorted and virtualized datasets | [📖 SUPERDATAGRIDEXPORTER.md](SUPERDATAGRIDEXPORTER.md) | | SuperLayout preview | **SuperLayout** | Responsive app layout — header, sidebar, body, footer, chat panel with collapsible sidebar | [📖 SUPERLAYOUT.md](SUPERLAYOUT.md) | | SuperContext preview | **SuperContext** | Context-aware tabs and contextual panels — discover components by runtime type and zone, render one or many contexts, and isolate host state per instance | [📖 SUPERCONTEXT.md](SUPERCONTEXT.md) | | SuperTabs preview | **SuperTabs** | Dynamic tabbed interface — badges, closable tabs, lazy loading, persistence (URL + localStorage), keyboard navigation, service-driven management | [📖 SUPERTABS.md](SUPERTABS.md) | diff --git a/SUPERDATAGRIDEXPORTER.md b/SUPERDATAGRIDEXPORTER.md new file mode 100644 index 0000000..3a7034d --- /dev/null +++ b/SUPERDATAGRIDEXPORTER.md @@ -0,0 +1,124 @@ +# SuperDataGrid CSV and Excel exporter + +The **SuperBlazorComponents.DataGridExporter** extension exports the complete +filtered and sorted view of a **SuperDataGrid**, including rows that are not +currently rendered by virtualization. + +## Installation + +Reference both packages/projects: + +~~~bash +dotnet add package SuperBlazorComponents +dotnet add package SuperBlazorComponents.DataGridExporter +~~~ + +Register the exporter and map its download endpoint in **Program.cs**: + +~~~csharp +using SuperBlazorComponents.DataGridExporter; + +builder.Services.AddSuperComponents(); +builder.Services.AddSuperDataGridExporter(options => +{ + options.TemporaryDirectory = Path.Combine( + builder.Environment.ContentRootPath, "_temp", "grid-exports"); + options.FileLifetime = TimeSpan.FromHours(24); + options.CleanupInterval = TimeSpan.FromDays(1); +}); + +var app = builder.Build(); +app.MapSuperDataGridExporter(); +~~~ + +The endpoint uses an unguessable 256-bit token and is anonymous by design. +Files expire after **FileLifetime**; cleanup runs at startup and then at +**CleanupInterval**. + +## Usage + +Keep a component reference to the grid and pass it to either export button: + +~~~razor +@using SuperBlazorComponents.DataGridExporter.Components + + + +
+
+ + +
+
+
+ + +
+ +@code { + private SuperDataGrid? _grid; +} +~~~ + +Set **IconOnly="true"** to hide the text while preserving it as the tooltip +and accessible label. The Bootstrap **ms-auto** wrapper aligns the export +actions to the right of the grid header. The grid keeps the custom header area +separated from its built-in actions. Omit **IconOnly** to display the icon and +text together. + +Only currently visible columns are exported, in their current order. The +exporter captures the grid filters and sort order once, then reads the complete +result from **ItemsProvider** in batches. Hierarchical grids export root items +only. The default batch size is 200 rows and can be changed with +**SuperDataGridExporterOptions.BatchSize**. + +## Custom columns + +Headers are resolved in this order: + +1. **ExportHeader** +2. plain text found directly in **HeaderTemplate** +3. **Title** +4. **Property** + +Values use **ExportValue** first, then **Property** or **For**. Configure both +overrides when a column is entirely template-driven: + +~~~razor + + + + +@code { + private static object GetExportedStatus(Product item) + => item.IsActive ? "Active" : "Inactive"; +} +~~~ + +Set **Exportable="false"** to exclude a visible column. + +## Formats and limits + +- CSV defaults to UTF-8 without BOM, comma delimiter and invariant culture. + These defaults are configurable through **SuperDataGridExporterOptions**. +- CSV strings beginning with formula control characters are prefixed safely by + default. +- Excel preserves native number, date and boolean cell types, freezes the + header, reproduces the grid's left frozen columns, enables filtering and + sizes columns to their content. Excel cannot freeze columns from the right. +- One worksheet supports at most 1,048,575 exported data rows because the + header occupies the first Excel row. +- The grid must use **ItemsProvider**; the currently rendered virtualized items + alone are intentionally never treated as the complete dataset. diff --git a/SuperBlazorComponents.slnx b/SuperBlazorComponents.slnx index 6de2220..5e7c836 100644 --- a/SuperBlazorComponents.slnx +++ b/SuperBlazorComponents.slnx @@ -7,6 +7,7 @@ + diff --git a/src/DemoWebSite/Components/Pages/SuperGridDemo.razor b/src/DemoWebSite/Components/Pages/SuperGridDemo.razor index 20ade86..f0699b1 100644 --- a/src/DemoWebSite/Components/Pages/SuperGridDemo.razor +++ b/src/DemoWebSite/Components/Pages/SuperGridDemo.razor @@ -4,6 +4,7 @@ @using SuperBlazorComponents.Components.SuperDataGrid @using SuperBlazorComponents.Components.SuperDataGrid.Filters @using SuperBlazorComponents.Components.SuperDataGrid.Tools +@using SuperBlazorComponents.DataGridExporter.Components SuperGrid Demo - 5000 Items @@ -78,10 +79,24 @@ -
+
Header +
+ + +
@@ -194,9 +209,23 @@ FreezeLeftColumns="1" GridId="supergrid-demo"> - +
+ +
+ + +
+
diff --git a/src/DemoWebSite/DemoWebSite.csproj b/src/DemoWebSite/DemoWebSite.csproj index 65644bb..2183339 100644 --- a/src/DemoWebSite/DemoWebSite.csproj +++ b/src/DemoWebSite/DemoWebSite.csproj @@ -32,6 +32,9 @@ Always + + Always + Always @@ -71,12 +74,13 @@ - - + + + diff --git a/src/DemoWebSite/Program.cs b/src/DemoWebSite/Program.cs index 5cf3a58..1f55102 100644 --- a/src/DemoWebSite/Program.cs +++ b/src/DemoWebSite/Program.cs @@ -8,6 +8,7 @@ using SuperBlazorComponents; using SuperBlazorComponents.Components.SuperDataGrid; +using SuperBlazorComponents.DataGridExporter; using System.Text.RegularExpressions; @@ -76,6 +77,15 @@ DisplayDefaultFooterTemplate = true }); }); +builder.Services.AddSuperDataGridExporter(options => +{ + options.TemporaryDirectory = Path.Combine( + builder.Environment.ContentRootPath, + "_temp", + "data-grid-exports"); + options.FileLifetime = TimeSpan.FromHours(24); + options.CleanupInterval = TimeSpan.FromDays(1); +}); System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = new System.Globalization.CultureInfo("en-US"); System.Globalization.CultureInfo.DefaultThreadCurrentCulture = new System.Globalization.CultureInfo("en-US"); @@ -113,6 +123,7 @@ }) })); app.MapMcp("/mcp"); +app.MapSuperDataGridExporter(); app.MapGet("/demo-source", (string route, IWebHostEnvironment environment) => { diff --git a/src/DemoWebSite/_temp/data-grid-exports/e4afaf7eca362f7d1afc0ae8a250468641e73f93cf3f118548ca7a15fe169100.xlsx b/src/DemoWebSite/_temp/data-grid-exports/e4afaf7eca362f7d1afc0ae8a250468641e73f93cf3f118548ca7a15fe169100.xlsx new file mode 100644 index 0000000..d2c1d87 Binary files /dev/null and b/src/DemoWebSite/_temp/data-grid-exports/e4afaf7eca362f7d1afc0ae8a250468641e73f93cf3f118548ca7a15fe169100.xlsx differ diff --git a/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridCsvExportButton.razor b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridCsvExportButton.razor new file mode 100644 index 0000000..0bd34d9 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridCsvExportButton.razor @@ -0,0 +1,17 @@ +@typeparam TItem +@inherits SuperDataGridExportButtonBase + + + +@code { + protected override SuperDataGridExportFormat Format => SuperDataGridExportFormat.Csv; + protected override string DefaultText => "Exporter CSV"; + protected override string DialogTitle => "Export CSV"; +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExcelExportButton.razor b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExcelExportButton.razor new file mode 100644 index 0000000..c832664 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExcelExportButton.razor @@ -0,0 +1,17 @@ +@typeparam TItem +@inherits SuperDataGridExportButtonBase + + + +@code { + protected override SuperDataGridExportFormat Format => SuperDataGridExportFormat.Excel; + protected override string DefaultText => "Exporter Excel"; + protected override string DialogTitle => "Export Excel"; +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportButtonBase.cs b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportButtonBase.cs new file mode 100644 index 0000000..4e6db00 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportButtonBase.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +using SuperBlazorComponents.Components.Buttons; +using SuperBlazorComponents.Components.Dialogs; +using SuperBlazorComponents.Components.SuperDataGrid; +using SuperBlazorComponents.Services; + +namespace SuperBlazorComponents.DataGridExporter.Components; + +public abstract class SuperDataGridExportButtonBase : ComponentBase +{ + [Inject] + private SuperDialogService DialogService { get; set; } = default!; + + [Parameter, EditorRequired] + public SuperDataGrid? Grid { get; set; } + + [CascadingParameter] + private SuperDataGrid? CascadedGrid { get; set; } + + [Parameter] + public string? DefaultFileName { get; set; } + + [Parameter] + public string? Text { get; set; } + + [Parameter] + public bool Disabled { get; set; } + + [Parameter] + public SuperButtonStyle Style { get; set; } = SuperButtonStyle.Primary; + + [Parameter] + public SuperButtonSize Size { get; set; } = SuperButtonSize.Default; + + [Parameter] + public bool Outline { get; set; } + + /// + /// Displays only the format icon. The resolved text remains available as + /// the button tooltip and accessible label. + /// + [Parameter] + public bool IconOnly { get; set; } + + protected abstract SuperDataGridExportFormat Format { get; } + protected abstract string DefaultText { get; } + protected abstract string DialogTitle { get; } + + protected string ResolvedText => string.IsNullOrWhiteSpace(Text) ? DefaultText : Text; + private SuperDataGrid? EffectiveGrid => Grid ?? CascadedGrid; + protected bool IsDisabled => Disabled || EffectiveGrid is null; + + protected async Task OpenDialogAsync(MouseEventArgs _) + { + var grid = EffectiveGrid; + if (grid is null) + return; + + var parameters = new Dictionary + { + [nameof(SuperDataGridExportDialog.Grid)] = grid, + [nameof(SuperDataGridExportDialog.Format)] = Format, + [nameof(SuperDataGridExportDialog.DefaultFileName)] = DefaultFileName ?? string.Empty + }; + + await DialogService.OpenAsync>( + DialogTitle, + parameters, + new DialogOptions { Width = "560px" }); + } +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor new file mode 100644 index 0000000..6f65602 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor @@ -0,0 +1,114 @@ +@typeparam TItem +@implements IDisposable +@inject ISuperDataGridExportService ExportService +@inject SuperDialogService DialogService + +
+ @if (_result is null) + { +
+ + +
L’extension @Extension sera ajoutée automatiquement.
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { + + } + +
+ + +
+ } + else + { +
+ Le fichier @_result.FileName est prêt (@_result.RowCount ligne(s)). +
+ + } +
+ +@code { + [Parameter, EditorRequired] + public SuperDataGrid Grid { get; set; } = default!; + + [Parameter] + public SuperDataGridExportFormat Format { get; set; } + + [Parameter] + public string DefaultFileName { get; set; } = string.Empty; + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private string _fileName = string.Empty; + private string? _error; + private bool _isGenerating; + private bool _initialized; + private SuperDataGridExportResult? _result; + + private string Extension => Format == SuperDataGridExportFormat.Csv ? ".csv" : ".xlsx"; + + protected override void OnParametersSet() + { + if (_initialized) + return; + + _fileName = string.IsNullOrWhiteSpace(DefaultFileName) + ? $"export-{DateTime.Now:yyyyMMdd-HHmmss}" + : DefaultFileName; + _initialized = true; + } + + private async Task GenerateAsync(MouseEventArgs _) + { + if (_isGenerating || string.IsNullOrWhiteSpace(_fileName)) + return; + + _isGenerating = true; + _error = null; + try + { + _result = await ExportService.ExportAsync( + Grid, Format, _fileName, _cancellationTokenSource.Token); + } + catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested) + { + } + catch (Exception exception) + { + _error = exception.Message; + } + finally + { + _isGenerating = false; + } + } + + private Task CancelAsync(MouseEventArgs _) => DialogService.Close(null); + private Task CloseAsync(MouseEventArgs _) => DialogService.Close(_result); + + public void Dispose() + { + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + } +} diff --git a/src/SuperBlazorComponents.DataGridExporter/ISuperDataGridExportService.cs b/src/SuperBlazorComponents.DataGridExporter/ISuperDataGridExportService.cs new file mode 100644 index 0000000..34ff372 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/ISuperDataGridExportService.cs @@ -0,0 +1,12 @@ +using SuperBlazorComponents.Components.SuperDataGrid; + +namespace SuperBlazorComponents.DataGridExporter; + +public interface ISuperDataGridExportService +{ + Task ExportAsync( + SuperDataGrid grid, + SuperDataGridExportFormat format, + string fileName, + CancellationToken cancellationToken = default); +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Internal/ExportColumn.cs b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportColumn.cs new file mode 100644 index 0000000..791c1f5 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportColumn.cs @@ -0,0 +1,6 @@ +namespace SuperBlazorComponents.DataGridExporter.Internal; + +internal sealed record ExportColumn( + string Header, + string? FormatString, + Func ValueAccessor); diff --git a/src/SuperBlazorComponents.DataGridExporter/Internal/ExportColumnResolver.cs b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportColumnResolver.cs new file mode 100644 index 0000000..ddcf3c9 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportColumnResolver.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Reflection; +using System.Text.RegularExpressions; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; + +using SuperBlazorComponents.Components.SuperDataGrid; + +namespace SuperBlazorComponents.DataGridExporter.Internal; + +internal static partial class ExportColumnResolver +{ + private static readonly ConcurrentDictionary<(Type Type, string Path), Func> Accessors = new(); + + public static IReadOnlyList> Resolve(SuperDataGrid grid) + { + var result = new List>(); + + foreach (var column in grid.ColumnsCollection.Where(c => c.Exportable && c.IsCurrentlyVisible)) + { + var header = FirstNotEmpty( + column.ExportHeader, + ExtractHeaderText(column.HeaderTemplate), + column.Title, + column.Property); + + if (string.IsNullOrWhiteSpace(header)) + { + throw new InvalidOperationException( + "An exportable grid column has no resolvable header. Set ExportHeader on the column."); + } + + Func accessor; + if (column.ExportValue is not null) + { + accessor = column.ExportValue; + } + else if (!string.IsNullOrWhiteSpace(column.Property)) + { + var untypedAccessor = Accessors.GetOrAdd( + (typeof(TItem), column.Property), + static key => CreateAccessor(key.Type, key.Path)); + accessor = item => item is null ? null : untypedAccessor(item); + } + else + { + throw new InvalidOperationException( + $"Column '{header}' has no resolvable value. Set Property, For, or ExportValue on the column."); + } + + result.Add(new ExportColumn(header, column.FormatString, accessor)); + } + + if (result.Count == 0) + throw new InvalidOperationException("The grid has no visible exportable columns."); + + return result; + } + + private static Func CreateAccessor(Type itemType, string propertyPath) + { + var members = new List(); + var currentType = itemType; + + foreach (var segment in propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var member = (MemberInfo?)currentType.GetProperty(segment, BindingFlags.Instance | BindingFlags.Public) + ?? currentType.GetField(segment, BindingFlags.Instance | BindingFlags.Public); + + if (member is null) + throw new InvalidOperationException($"Property path '{propertyPath}' was not found on '{itemType.Name}'."); + + members.Add(member); + currentType = member switch + { + PropertyInfo property => property.PropertyType, + FieldInfo field => field.FieldType, + _ => throw new UnreachableException() + }; + } + + return instance => + { + object? value = instance; + foreach (var member in members) + { + if (value is null) + return null; + value = member switch + { + PropertyInfo property => property.GetValue(value), + FieldInfo field => field.GetValue(value), + _ => null + }; + } + return value; + }; + } + +#pragma warning disable BL0006 // Deliberately inspect simple text frames; component output falls back to Title/Property. + private static string? ExtractHeaderText(RenderFragment? template) + { + if (template is null) + return null; + + var builder = new RenderTreeBuilder(); + template(builder); + var frames = builder.GetFrames(); + var parts = new List(); + + for (var i = 0; i < frames.Count; i++) + { + var frame = frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Text) + parts.Add(frame.TextContent); + else if (frame.FrameType == RenderTreeFrameType.Markup) + parts.Add(HtmlTagRegex().Replace(frame.MarkupContent, " ")); + } + + var text = WhitespaceRegex().Replace(WebUtility.HtmlDecode(string.Join(" ", parts)), " ").Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } +#pragma warning restore BL0006 + + private static string? FirstNotEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); + + [GeneratedRegex("<[^>]+>")] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex("\\s+")] + private static partial Regex WhitespaceRegex(); +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Internal/ExportFileCleanupService.cs b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportFileCleanupService.cs new file mode 100644 index 0000000..f66da55 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportFileCleanupService.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SuperBlazorComponents.DataGridExporter.Internal; + +internal sealed class ExportFileCleanupService : BackgroundService +{ + private readonly ExportFileStore _store; + private readonly SuperDataGridExporterOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public ExportFileCleanupService( + ExportFileStore store, + SuperDataGridExporterOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _store = store; + _options = options; + _timeProvider = timeProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await CleanupAsync(stoppingToken); + using var timer = new PeriodicTimer(_options.CleanupInterval, _timeProvider); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + await CleanupAsync(stoppingToken); + } + + private async Task CleanupAsync(CancellationToken cancellationToken) + { + try + { + await _store.CleanupExpiredAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + _logger.LogError(exception, "Data-grid export cleanup failed."); + } + } +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Internal/ExportFileStore.cs b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportFileStore.cs new file mode 100644 index 0000000..7b7879b --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Internal/ExportFileStore.cs @@ -0,0 +1,162 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +using Microsoft.Extensions.Logging; + +namespace SuperBlazorComponents.DataGridExporter.Internal; + +internal sealed partial class ExportFileStore +{ + private readonly SuperDataGridExporterOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public ExportFileStore( + SuperDataGridExporterOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _options = options; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task CreateAsync( + SuperDataGridExportFormat format, + string requestedFileName, + Func> writer, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(_options.TemporaryDirectory); + + var extension = GetExtension(format); + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var finalPath = Path.Combine(_options.TemporaryDirectory, $"{token}.{extension}"); + var partialPath = Path.Combine(_options.TemporaryDirectory, $"{token}.partial.{extension}"); + var downloadName = SanitizeFileName(requestedFileName, extension); + + try + { + var rowCount = await writer(partialPath, cancellationToken); + File.Move(partialPath, finalPath); + File.SetLastWriteTimeUtc(finalPath, _timeProvider.GetUtcNow().UtcDateTime); + + var url = $"{_options.NormalizedDownloadRoute}/{token}/{extension}?fileName={Uri.EscapeDataString(downloadName)}"; + return new SuperDataGridExportResult(downloadName, url, rowCount); + } + catch + { + TryDelete(partialPath); + TryDelete(finalPath); + throw; + } + } + + public StoredExportFile? TryResolve(string token, string format, string? requestedFileName) + { + if (!TokenRegex().IsMatch(token) || !TryParseFormat(format, out var exportFormat)) + return null; + + var extension = GetExtension(exportFormat); + var path = Path.Combine(_options.TemporaryDirectory, $"{token}.{extension}"); + if (!File.Exists(path)) + return null; + + var expiresAt = File.GetLastWriteTimeUtc(path) + _options.FileLifetime; + if (expiresAt <= _timeProvider.GetUtcNow().UtcDateTime) + { + TryDelete(path); + return null; + } + + return new StoredExportFile( + path, + exportFormat == SuperDataGridExportFormat.Csv + ? "text/csv; charset=utf-8" + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + SanitizeFileName(requestedFileName, extension)); + } + + public Task CleanupExpiredAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(_options.TemporaryDirectory)) + return Task.CompletedTask; + + var threshold = _timeProvider.GetUtcNow().UtcDateTime - _options.FileLifetime; + foreach (var path in Directory.EnumerateFiles(_options.TemporaryDirectory, "*", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ManagedFileRegex().IsMatch(Path.GetFileName(path))) + continue; + + try + { + if (File.GetLastWriteTimeUtc(path) <= threshold) + File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(exception, "Unable to delete expired data-grid export file {FilePath}", path); + } + } + + return Task.CompletedTask; + } + + internal static string SanitizeFileName(string? requestedFileName, string extension) + { + var name = Path.GetFileName(requestedFileName ?? string.Empty).Trim(); + if (name.EndsWith($".{extension}", StringComparison.OrdinalIgnoreCase)) + name = name[..^(extension.Length + 1)]; + else if (Path.HasExtension(name)) + name = Path.GetFileNameWithoutExtension(name); + + name = InvalidFileNameRegex().Replace(name, "_").Trim(' ', '.'); + if (string.IsNullOrWhiteSpace(name)) + name = "export"; + if (name.Length > 120) + name = name[..120]; + + return $"{name}.{extension}"; + } + + internal static string GetExtension(SuperDataGridExportFormat format) + => format == SuperDataGridExportFormat.Csv ? "csv" : "xlsx"; + + private static bool TryParseFormat(string value, out SuperDataGridExportFormat format) + { + if (string.Equals(value, "csv", StringComparison.OrdinalIgnoreCase)) + { + format = SuperDataGridExportFormat.Csv; + return true; + } + if (string.Equals(value, "xlsx", StringComparison.OrdinalIgnoreCase)) + { + format = SuperDataGridExportFormat.Excel; + return true; + } + + format = default; + return false; + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + [GeneratedRegex("^[a-f0-9]{64}$", RegexOptions.CultureInvariant)] + private static partial Regex TokenRegex(); + + [GeneratedRegex("^[a-f0-9]{64}(?:\\.partial)?\\.(?:csv|xlsx)$", RegexOptions.CultureInvariant)] + private static partial Regex ManagedFileRegex(); + + [GeneratedRegex("[<>:\"/\\\\|?*\\x00-\\x1F]", RegexOptions.CultureInvariant)] + private static partial Regex InvalidFileNameRegex(); +} diff --git a/src/SuperBlazorComponents.DataGridExporter/Internal/StoredExportFile.cs b/src/SuperBlazorComponents.DataGridExporter/Internal/StoredExportFile.cs new file mode 100644 index 0000000..22cdb62 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Internal/StoredExportFile.cs @@ -0,0 +1,3 @@ +namespace SuperBlazorComponents.DataGridExporter.Internal; + +internal sealed record StoredExportFile(string Path, string ContentType, string DownloadName); diff --git a/src/SuperBlazorComponents.DataGridExporter/Properties/AssemblyInfo.cs b/src/SuperBlazorComponents.DataGridExporter/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..356b2a7 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SuperBlazorComponents.Tests")] diff --git a/src/SuperBlazorComponents.DataGridExporter/StartupExtensions.cs b/src/SuperBlazorComponents.DataGridExporter/StartupExtensions.cs new file mode 100644 index 0000000..76a9cf2 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/StartupExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using SuperBlazorComponents.DataGridExporter.Internal; + +namespace SuperBlazorComponents.DataGridExporter; + +public static class StartupExtensions +{ + public static IServiceCollection AddSuperDataGridExporter( + this IServiceCollection services, + Action? configure = null) + { + var options = new SuperDataGridExporterOptions(); + configure?.Invoke(options); + options.Validate(); + + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(options); + services.AddSingleton(); + services.AddScoped(); + services.AddHostedService(); + return services; + } + + public static IEndpointConventionBuilder MapSuperDataGridExporter(this IEndpointRouteBuilder endpoints) + { + var options = endpoints.ServiceProvider.GetRequiredService(); + var pattern = $"{options.NormalizedDownloadRoute}/{{token}}/{{format}}"; + + return endpoints.MapGet(pattern, ( + string token, + string format, + string? fileName, + ExportFileStore store) => + { + var file = store.TryResolve(token, format, fileName); + return file is null + ? Results.NotFound() + : Results.File(file.Path, file.ContentType, file.DownloadName, enableRangeProcessing: true); + }).AllowAnonymous(); + } +} diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj b/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj new file mode 100644 index 0000000..d21743f --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + SuperBlazorComponents.DataGridExporter + SuperBlazorComponents.DataGridExporter + 2.0.7 + SuperBlazorComponents.DataGridExporter + SuperBlazorComponents DataGrid Exporter + Appliman + Appliman + CSV and Excel export components for SuperDataGrid, including virtualized data. + blazor;datagrid;csv;excel;export;dotnet + MIT + SUPERDATAGRIDEXPORTER.md + + + + + + + + + + + + + + + + diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportFormat.cs b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportFormat.cs new file mode 100644 index 0000000..49a279f --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportFormat.cs @@ -0,0 +1,7 @@ +namespace SuperBlazorComponents.DataGridExporter; + +public enum SuperDataGridExportFormat +{ + Csv, + Excel +} diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportResult.cs b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportResult.cs new file mode 100644 index 0000000..00e5018 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportResult.cs @@ -0,0 +1,3 @@ +namespace SuperBlazorComponents.DataGridExporter; + +public sealed record SuperDataGridExportResult(string FileName, string DownloadUrl, int RowCount); diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs new file mode 100644 index 0000000..3a8d1e4 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs @@ -0,0 +1,233 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +using ClosedXML.Excel; + +using CsvHelper; +using CsvHelper.Configuration; + +using SuperBlazorComponents.Components.SuperDataGrid; +using SuperBlazorComponents.DataGridExporter.Internal; + +namespace SuperBlazorComponents.DataGridExporter; + +internal sealed class SuperDataGridExportService : ISuperDataGridExportService +{ + private const int ExcelMaximumDataRows = 1_048_575; + + private readonly SuperDataGridExporterOptions _options; + private readonly ExportFileStore _fileStore; + + public SuperDataGridExportService( + SuperDataGridExporterOptions options, + ExportFileStore fileStore) + { + _options = options; + _fileStore = fileStore; + } + + public Task ExportAsync( + SuperDataGrid grid, + SuperDataGridExportFormat format, + string fileName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(grid); + if (grid.ItemsProvider is null) + throw new InvalidOperationException("The grid must define an ItemsProvider to export all rows."); + + var columns = ExportColumnResolver.Resolve(grid); + var query = grid.CaptureQuerySnapshot(); + var frozenColumnCount = grid.ColumnsCollection + .Where(column => column.IsCurrentlyVisible) + .Take(Math.Max(0, grid.FreezeLeftColumns)) + .Count(column => column.Exportable); + + return _fileStore.CreateAsync( + format, + fileName, + (path, token) => format == SuperDataGridExportFormat.Csv + ? WriteCsvAsync(path, grid.ItemsProvider, query, columns, token) + : WriteExcelAsync(path, grid.ItemsProvider, query, columns, frozenColumnCount, token), + cancellationToken); + } + + private async Task WriteCsvAsync( + string path, + GridItemsProvider provider, + SuperDataGridQuerySnapshot query, + IReadOnlyList> columns, + CancellationToken cancellationToken) + { + var configuration = new CsvConfiguration(_options.CsvCulture) + { + Delimiter = _options.CsvDelimiter, + HasHeaderRecord = true + }; + + await using var stream = new FileStream( + path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true); + await using var textWriter = new StreamWriter(stream, _options.CsvEncoding); + using var csv = new CsvWriter(textWriter, configuration); + + foreach (var column in columns) + csv.WriteField(column.Header); + await csv.NextRecordAsync(); + + var rowCount = 0; + await foreach (var item in ReadAllAsync(provider, query, cancellationToken)) + { + foreach (var column in columns) + { + var value = column.ValueAccessor(item); + var formatted = FormatCsvValue(value, column.FormatString); + csv.WriteField(ProtectCsvValue(value, formatted)); + } + + await csv.NextRecordAsync(); + rowCount++; + } + + await textWriter.FlushAsync(cancellationToken); + return rowCount; + } + + private async Task WriteExcelAsync( + string path, + GridItemsProvider provider, + SuperDataGridQuerySnapshot query, + IReadOnlyList> columns, + int frozenColumnCount, + CancellationToken cancellationToken) + { + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Export"); + + for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++) + { + var cell = worksheet.Cell(1, columnIndex + 1); + cell.SetValue(columns[columnIndex].Header); + cell.Style.Font.Bold = true; + } + + var rowCount = 0; + await foreach (var item in ReadAllAsync(provider, query, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (rowCount >= ExcelMaximumDataRows) + { + throw new InvalidOperationException( + $"Excel supports at most {ExcelMaximumDataRows:N0} data rows per worksheet."); + } + + var rowNumber = rowCount + 2; + for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++) + SetExcelValue(worksheet.Cell(rowNumber, columnIndex + 1), columns[columnIndex].ValueAccessor(item)); + + rowCount++; + } + + worksheet.SheetView.FreezeRows(1); + if (frozenColumnCount > 0) + worksheet.SheetView.FreezeColumns(Math.Min(frozenColumnCount, columns.Count)); + worksheet.Range(1, 1, rowCount + 1, columns.Count).SetAutoFilter(); + worksheet.Columns(1, columns.Count).AdjustToContents( + 1, + Math.Min(rowCount + 1, 10_000)); + workbook.SaveAs(path); + return rowCount; + } + + private async IAsyncEnumerable ReadAllAsync( + GridItemsProvider provider, + SuperDataGridQuerySnapshot query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var startIndex = 0; + int? expectedTotal = null; + var filters = query.Filters.Select(filter => filter.ToFilterInfo()).ToArray(); + + while (expectedTotal is null || startIndex < expectedTotal.Value) + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new GridItemsProviderRequest( + startIndex, + _options.BatchSize, + query.SortColumn, + query.SortDirection, + filters, + cancellationToken); + var result = await provider(request); + expectedTotal ??= Math.Max(0, result.TotalItemCount); + var items = result.Items.Take(_options.BatchSize).ToArray(); + + if (items.Length == 0) + { + if (startIndex < expectedTotal.Value) + { + throw new InvalidOperationException( + $"The grid ItemsProvider returned no rows at index {startIndex} before the announced total of {expectedTotal.Value} rows."); + } + yield break; + } + + foreach (var item in items) + yield return item; + + startIndex += items.Length; + } + } + + private string FormatCsvValue(object? value, string? formatString) + { + if (value is null) + return string.Empty; + + if (!string.IsNullOrWhiteSpace(formatString)) + return string.Format(_options.CsvCulture, formatString, value); + + return value switch + { + DateTime dateTime => dateTime.ToString("O", _options.CsvCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", _options.CsvCulture), + IFormattable formattable => formattable.ToString(null, _options.CsvCulture), + _ => value.ToString() ?? string.Empty + }; + } + + private string ProtectCsvValue(object? source, string formatted) + { + if (!_options.ProtectCsvFormulas || source is not string || string.IsNullOrEmpty(formatted)) + return formatted; + + var firstMeaningful = formatted.FirstOrDefault(character => !char.IsWhiteSpace(character)); + return firstMeaningful is '=' or '+' or '-' or '@' ? "'" + formatted : formatted; + } + + private static void SetExcelValue(IXLCell cell, object? value) + { + switch (value) + { + case null: cell.Clear(XLClearOptions.Contents); break; + case string text: cell.SetValue(text); break; + case bool boolean: cell.SetValue(boolean); break; + case byte number: cell.SetValue(number); break; + case sbyte number: cell.SetValue(number); break; + case short number: cell.SetValue(number); break; + case ushort number: cell.SetValue(number); break; + case int number: cell.SetValue(number); break; + case uint number: cell.SetValue(number); break; + case long number: cell.SetValue(number); break; + case ulong number when number <= long.MaxValue: cell.SetValue((long)number); break; + case float number: cell.SetValue(number); break; + case double number: cell.SetValue(number); break; + case decimal number: cell.SetValue(number); break; + case DateTime dateTime: cell.SetValue(dateTime); break; + case DateTimeOffset dateTimeOffset: cell.SetValue(dateTimeOffset.DateTime); break; + case DateOnly date: cell.SetValue(date.ToDateTime(TimeOnly.MinValue)); break; + case TimeSpan timeSpan: cell.SetValue(timeSpan); break; + case Enum enumValue: cell.SetValue(enumValue.ToString()); break; + default: cell.SetValue(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); break; + } + } +} diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExporterOptions.cs b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExporterOptions.cs new file mode 100644 index 0000000..e4f2509 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExporterOptions.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using System.Text; + +namespace SuperBlazorComponents.DataGridExporter; + +public sealed class SuperDataGridExporterOptions +{ + public string TemporaryDirectory { get; set; } = Path.Combine( + Path.GetTempPath(), "SuperBlazorComponents.DataGridExporter"); + + public TimeSpan FileLifetime { get; set; } = TimeSpan.FromHours(24); + public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromDays(1); + public string DownloadRoute { get; set; } = "/_super-datagrid-export"; + public int BatchSize { get; set; } = 200; + public string CsvDelimiter { get; set; } = ","; + public Encoding CsvEncoding { get; set; } = new UTF8Encoding(false); + public CultureInfo CsvCulture { get; set; } = CultureInfo.InvariantCulture; + public bool ProtectCsvFormulas { get; set; } = true; + + internal void Validate() + { + if (string.IsNullOrWhiteSpace(TemporaryDirectory)) + throw new InvalidOperationException("TemporaryDirectory must be configured."); + if (FileLifetime <= TimeSpan.Zero) + throw new InvalidOperationException("FileLifetime must be greater than zero."); + if (CleanupInterval <= TimeSpan.Zero) + throw new InvalidOperationException("CleanupInterval must be greater than zero."); + if (BatchSize <= 0) + throw new InvalidOperationException("BatchSize must be greater than zero."); + if (string.IsNullOrEmpty(CsvDelimiter)) + throw new InvalidOperationException("CsvDelimiter must not be empty."); + if (string.IsNullOrWhiteSpace(DownloadRoute)) + throw new InvalidOperationException("DownloadRoute must be configured."); + } + + internal string NormalizedDownloadRoute => "/" + DownloadRoute.Trim('/'); +} diff --git a/src/SuperBlazorComponents.DataGridExporter/_Imports.razor b/src/SuperBlazorComponents.DataGridExporter/_Imports.razor new file mode 100644 index 0000000..ab43a78 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/_Imports.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +@using SuperBlazorComponents.Components +@using SuperBlazorComponents.Components.Buttons +@using SuperBlazorComponents.Components.SuperDataGrid +@using SuperBlazorComponents.DataGridExporter +@using SuperBlazorComponents.Services diff --git a/src/SuperBlazorComponents.DataGridExporter/wwwroot/icons/microsoft-excel.svg b/src/SuperBlazorComponents.DataGridExporter/wwwroot/icons/microsoft-excel.svg new file mode 100644 index 0000000..5e76853 --- /dev/null +++ b/src/SuperBlazorComponents.DataGridExporter/wwwroot/icons/microsoft-excel.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/SuperBlazorComponents/Components/Buttons/SuperButton.razor.cs b/src/SuperBlazorComponents/Components/Buttons/SuperButton.razor.cs index 0b7c596..1692b3b 100644 --- a/src/SuperBlazorComponents/Components/Buttons/SuperButton.razor.cs +++ b/src/SuperBlazorComponents/Components/Buttons/SuperButton.razor.cs @@ -31,6 +31,13 @@ public partial class SuperButton : IAsyncDisposable [Parameter] public string? Icon { get; set; } + /// + /// Displays only the leading icon or image while retaining + /// as the accessible label and tooltip. + /// + [Parameter] + public bool IconOnly { get; set; } + /// /// Url of the image displayed in place of the icon when provided. /// @@ -77,7 +84,7 @@ public partial class SuperButton : IAsyncDisposable private bool HasLeadingVisual => !string.IsNullOrWhiteSpace(Image) || !string.IsNullOrWhiteSpace(Icon); - private bool UseIconOnly => IsCollapsedOrHidden && HasLeadingVisual; + private bool UseIconOnly => HasLeadingVisual && (IconOnly || IsCollapsedOrHidden); private string ButtonTypeAttributeValue => ButtonType switch { diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/DataGridColumn.razor b/src/SuperBlazorComponents/Components/SuperDataGrid/DataGridColumn.razor index 5ec14f8..14fcaed 100644 --- a/src/SuperBlazorComponents/Components/SuperDataGrid/DataGridColumn.razor +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/DataGridColumn.razor @@ -131,6 +131,25 @@ [Parameter] public RenderFragment? HeaderTemplate { get; set; } + /// + /// Optional plain-text header used by data exporters. When omitted, exporters can + /// fall back to , and . + /// + [Parameter] + public string? ExportHeader { get; set; } + + /// + /// Optional value selector used by data exporters for template-only or computed columns. + /// + [Parameter] + public Func? ExportValue { get; set; } + + /// + /// Gets or sets whether this column is included by data exporters. + /// + [Parameter] + public bool Exportable { get; set; } = true; + /// /// Custom template for rendering the footer. /// @@ -166,6 +185,11 @@ /// internal bool CurrentVisible => _currentVisible; + /// + /// Gets the effective visibility after runtime grid settings have been applied. + /// + public bool IsCurrentlyVisible => CurrentVisible; + protected override void OnInitialized() { _defaultWidth = Width; diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor index 99f2f4b..ad625c2 100644 --- a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor @@ -25,7 +25,7 @@
} -
+
@if (HeaderTemplate is not null) { @HeaderTemplate diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs index b8f5026..691d4a8 100644 --- a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; +using System.Collections.Immutable; using System.Runtime.CompilerServices; namespace SuperBlazorComponents.Components.SuperDataGrid; @@ -358,6 +359,32 @@ public partial class SuperDataGrid : IAsyncDisposable /// public IReadOnlyList> ColumnsCollection => _columns; + /// + /// Captures the current sorting and filtering state so a long-running operation can + /// consistently query the same grid view. + /// + public SuperDataGridQuerySnapshot CaptureQuerySnapshot() + { + var filters = _filterInfoList.Select(CreateFilterSnapshot).ToImmutableArray(); + return new SuperDataGridQuerySnapshot(_sortColumn, _sortDirection, filters); + } + + private static SuperDataGridFilterSnapshot CreateFilterSnapshot(SuperDataGridFilterInfo source) + { + return new SuperDataGridFilterSnapshot( + source.PropertyName, + source.PropertyValue, + source.SelectedValues.ToImmutableArray(), + source.StartDate, + source.EndDate, + source.FromNumericValue, + source.ToNumericValue, + source.PeriodName, + source.PeriodPreset, + source.PropertyType, + source.Operator); + } + public string FooterText { get diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGridQuerySnapshot.cs b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGridQuerySnapshot.cs new file mode 100644 index 0000000..55883e0 --- /dev/null +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGridQuerySnapshot.cs @@ -0,0 +1,45 @@ +using System.Collections.Immutable; + +using SuperBlazorComponents.Components.SuperDateRange; + +namespace SuperBlazorComponents.Components.SuperDataGrid; + +/// +/// Immutable snapshot of the query currently displayed by a . +/// +public sealed record SuperDataGridQuerySnapshot( + string? SortColumn, + SortDirection SortDirection, + ImmutableArray Filters); + +/// +/// Immutable representation of one active grid filter. +/// +public sealed record SuperDataGridFilterSnapshot( + string PropertyName, + string? PropertyValue, + ImmutableArray SelectedValues, + DateTimeOffset? StartDate, + DateTimeOffset? EndDate, + long? FromNumericValue, + long? ToNumericValue, + string? PeriodName, + SuperDateRangePreset? PeriodPreset, + Type PropertyType, + SuperDataGridFilterOperator Operator) +{ + public SuperDataGridFilterInfo ToFilterInfo() => new() + { + PropertyName = PropertyName, + PropertyValue = PropertyValue, + SelectedValues = SelectedValues, + StartDate = StartDate, + EndDate = EndDate, + FromNumericValue = FromNumericValue, + ToNumericValue = ToNumericValue, + PeriodName = PeriodName, + PeriodPreset = PeriodPreset, + PropertyType = PropertyType, + Operator = Operator + }; +} diff --git a/src/SuperBlazorComponents/SuperBlazorComponents.csproj b/src/SuperBlazorComponents/SuperBlazorComponents.csproj index 58c3d59..316c162 100644 --- a/src/SuperBlazorComponents/SuperBlazorComponents.csproj +++ b/src/SuperBlazorComponents/SuperBlazorComponents.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - 2.0.6 + 2.0.7 false SuperBlazorComponents SuperBlazorComponents diff --git a/tests/SuperBlazorComponents.Tests/SuperBlazorComponents.Tests.csproj b/tests/SuperBlazorComponents.Tests/SuperBlazorComponents.Tests.csproj index a873e09..2ee4ab1 100644 --- a/tests/SuperBlazorComponents.Tests/SuperBlazorComponents.Tests.csproj +++ b/tests/SuperBlazorComponents.Tests/SuperBlazorComponents.Tests.csproj @@ -8,15 +8,16 @@ - - - - - + + + + + + diff --git a/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs b/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs new file mode 100644 index 0000000..a367be3 --- /dev/null +++ b/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs @@ -0,0 +1,359 @@ +using System.Globalization; +using System.Text; + +using Bunit; + +using ClosedXML.Excel; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using SuperBlazorComponents.Components.SuperDataGrid; +using SuperBlazorComponents.DataGridExporter; +using SuperBlazorComponents.DataGridExporter.Components; +using SuperBlazorComponents.DataGridExporter.Internal; + +namespace SuperBlazorComponents.Tests; + +#pragma warning disable BL0005 // Tests intentionally construct component instances to exercise the exporter directly. + +[TestClass] +public sealed class SuperDataGridExporterTests +{ + private string _temporaryDirectory = null!; + private readonly List _contexts = []; + private readonly Dictionary, IRenderedComponent>> _renderedGrids = []; + + [TestInitialize] + public void Setup() + { + _temporaryDirectory = Path.Combine( + Path.GetTempPath(), + "SuperBlazorComponents.Tests", + Guid.NewGuid().ToString("N")); + } + + [TestCleanup] + public void Cleanup() + { + foreach (var context in _contexts) + context.Dispose(); + _contexts.Clear(); + _renderedGrids.Clear(); + + if (Directory.Exists(_temporaryDirectory)) + Directory.Delete(_temporaryDirectory, recursive: true); + } + + [TestMethod] + public void ColumnResolver_UsesConfiguredPriorityAndExportValue() + { + var grid = CreateGrid((request) => + ValueTask.FromResult(GridItemsProviderResult.Empty())); + var column = AddColumn(grid, new TestColumn + { + Property = nameof(TestRow.Name), + Title = "Title", + ExportHeader = "Explicit", + HeaderTemplate = builder => builder.AddContent(0, "Template"), + ExportValue = row => row.Name.ToUpperInvariant() + }); + AddColumn(grid, new TestColumn + { + Property = nameof(TestRow.Id), + Title = "Hidden", + Visible = false + }); + + var resolved = ExportColumnResolver.Resolve(grid); + + Assert.HasCount(1, resolved); + Assert.AreEqual("Explicit", resolved[0].Header); + Assert.AreEqual("ALPHA", resolved[0].ValueAccessor(new TestRow(1, "Alpha", 12.5m, true))); + Assert.IsTrue(column.IsCurrentlyVisible); + } + + [TestMethod] + public void ColumnResolver_ExtractsSimpleHeaderTemplateAndRejectsMissingValue() + { + var grid = CreateGrid((request) => + ValueTask.FromResult(GridItemsProviderResult.Empty())); + AddColumn(grid, new TestColumn + { + HeaderTemplate = builder => + { + builder.OpenElement(0, "strong"); + builder.AddContent(1, "Custom header"); + builder.CloseElement(); + } + }); + + var exception = Assert.ThrowsExactly( + () => ExportColumnResolver.Resolve(grid)); + + StringAssert.Contains(exception.Message, "Custom header"); + StringAssert.Contains(exception.Message, "ExportValue"); + } + + [TestMethod] + public async Task CsvExport_ReadsEveryBatchAndProtectsFormulaStrings() + { + var rows = Enumerable.Range(1, 5) + .Select(index => new TestRow(index, index == 2 ? "=SUM(A1:A2)" : $"Name {index}", index + .25m, index % 2 == 0)) + .ToArray(); + var starts = new List(); + var grid = CreateGrid(request => + { + starts.Add(request.StartIndex); + var page = rows.Skip(request.StartIndex).Take(request.Count ?? rows.Length).ToArray(); + return ValueTask.FromResult(GridItemsProviderResult.From(page, rows.Length)); + }); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Name), Title = "Name" }); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Amount), Title = "Amount", FormatString = "{0:F2}" }); + starts.Clear(); + + var service = CreateService(batchSize: 2); + var result = await service.ExportAsync(grid, SuperDataGridExportFormat.Csv, "../unsafe:report.csv"); + var file = Directory.GetFiles(_temporaryDirectory, "*.csv").Single(); + var content = await File.ReadAllTextAsync(file, Encoding.UTF8); + + Assert.AreEqual(5, result.RowCount); + Assert.AreEqual("unsafe_report.csv", result.FileName); + CollectionAssert.AreEqual(new[] { 0, 2, 4 }, starts); + StringAssert.StartsWith(content, "Name,Amount"); + StringAssert.Contains(content, "'=SUM(A1:A2)"); + StringAssert.Contains(content, "1.25"); + StringAssert.Matches(result.DownloadUrl, new System.Text.RegularExpressions.Regex( + @"/[a-f0-9]{64}/csv\?fileName=")); + } + + [TestMethod] + public async Task ExcelExport_PreservesNativeCellTypes() + { + var rows = new[] { new TestRow(1, "Alpha", 12.5m, true) }; + var grid = CreateGrid(request => ValueTask.FromResult( + GridItemsProviderResult.From(rows.Skip(request.StartIndex), rows.Length))); + grid.FreezeLeftColumns = 2; + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Amount), Title = "Amount" }); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Enabled), Title = "Enabled" }); + + var result = await CreateService().ExportAsync( + grid, SuperDataGridExportFormat.Excel, "typed"); + var file = Directory.GetFiles(_temporaryDirectory, "*.xlsx").Single(); + using var workbook = new XLWorkbook(file); + var worksheet = workbook.Worksheet("Export"); + + Assert.AreEqual(1, result.RowCount); + Assert.AreEqual(XLDataType.Number, worksheet.Cell(2, 1).DataType); + Assert.AreEqual(XLDataType.Number, worksheet.Cell(2, 2).DataType); + Assert.AreEqual(XLDataType.Boolean, worksheet.Cell(2, 3).DataType); + Assert.IsTrue(worksheet.SheetView.SplitRow > 0); + Assert.AreEqual(2, worksheet.SheetView.SplitColumn); + } + + [TestMethod] + public async Task Export_FailsWhenProviderStopsBeforeAnnouncedTotal() + { + var grid = CreateGrid(request => ValueTask.FromResult( + request.StartIndex == 0 + ? GridItemsProviderResult.From( + new[] { new TestRow(1, "One", 1, true) }, 2) + : GridItemsProviderResult.From([], 2))); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + + var exception = await Assert.ThrowsExactlyAsync( + () => CreateService(batchSize: 1).ExportAsync( + grid, SuperDataGridExportFormat.Csv, "broken")); + + StringAssert.Contains(exception.Message, "announced total"); + Assert.HasCount(0, Directory.Exists(_temporaryDirectory) + ? Directory.GetFiles(_temporaryDirectory) + : []); + } + + [TestMethod] + public async Task Export_HonorsCancellationWithoutPublishingAFile() + { + var grid = CreateGrid(request => ValueTask.FromResult( + GridItemsProviderResult.From( + new[] { new TestRow(1, "One", 1, true) }, 1))); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsExactlyAsync( + () => CreateService().ExportAsync( + grid, + SuperDataGridExportFormat.Csv, + "cancelled", + cancellation.Token)); + + Assert.HasCount(0, Directory.Exists(_temporaryDirectory) + ? Directory.GetFiles(_temporaryDirectory) + : []); + } + + [TestMethod] + public async Task FileStore_RejectsExpiredTokenAndCleansOldPartialFiles() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2026, 8, 29, 10, 0, 0, TimeSpan.Zero)); + var options = CreateOptions(); + options.FileLifetime = TimeSpan.FromHours(1); + var store = new ExportFileStore(options, clock, NullLogger.Instance); + var result = await store.CreateAsync( + SuperDataGridExportFormat.Csv, + "test", + async (path, cancellationToken) => + { + await File.WriteAllTextAsync(path, "value", cancellationToken); + return 1; + }, + CancellationToken.None); + var token = result.DownloadUrl.Split('/', StringSplitOptions.RemoveEmptyEntries)[1]; + var partial = Path.Combine(_temporaryDirectory, $"{new string('a', 64)}.partial.csv"); + await File.WriteAllTextAsync(partial, "partial"); + File.SetLastWriteTimeUtc(partial, clock.GetUtcNow().UtcDateTime - TimeSpan.FromHours(2)); + + clock.Advance(TimeSpan.FromHours(2)); + Assert.IsNull(store.TryResolve(token, "csv", "test.csv")); + await store.CleanupExpiredAsync(CancellationToken.None); + + Assert.IsFalse(File.Exists(partial)); + Assert.HasCount(0, Directory.GetFiles(_temporaryDirectory)); + } + + [TestMethod] + public void ExportButtons_RenderSuperButtonIconsAndDisableWithoutGrid() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddSuperComponents(); + + var csv = context.Render>(); + var excel = context.Render>(); + + Assert.HasCount(1, csv.FindAll(".fa-file-csv")); + var excelLogo = excel.Find("img.super-button-image"); + Assert.AreEqual( + "_content/SuperBlazorComponents.DataGridExporter/icons/microsoft-excel.svg", + excelLogo.GetAttribute("src")); + Assert.IsTrue(csv.Find("button").HasAttribute("disabled")); + Assert.IsTrue(excel.Find("button").HasAttribute("disabled")); + } + + [TestMethod] + public void ExportButtons_IconOnlyHidesTextAndKeepsAccessibleLabel() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddSuperComponents(); + + var csv = context.Render>(parameters => parameters + .Add(component => component.IconOnly, true)); + var button = csv.Find("button"); + + Assert.AreEqual("Exporter CSV", button.GetAttribute("title")); + Assert.AreEqual("Exporter CSV", button.GetAttribute("aria-label")); + Assert.IsFalse(button.TextContent.Contains("Exporter CSV", StringComparison.Ordinal)); + Assert.HasCount(1, button.QuerySelectorAll(".fa-file-csv")); + } + + [TestMethod] + public void ExportDialog_AfterGenerationDisplaysDownloadLink() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddSuperComponents(); + context.Services.AddSingleton(new StubExportService()); + var grid = new SuperDataGrid + { + ItemsProvider = request => ValueTask.FromResult(GridItemsProviderResult.Empty()) + }; + + var dialog = context.Render>(parameters => parameters + .Add(component => component.Grid, grid) + .Add(component => component.Format, SuperDataGridExportFormat.Csv) + .Add(component => component.DefaultFileName, "products")); + + dialog.Find("button.btn-primary").Click(); + + dialog.WaitForAssertion(() => + { + var link = dialog.Find("a.btn-primary"); + Assert.AreEqual("/exports/token/csv", link.GetAttribute("href")); + StringAssert.Contains(dialog.Markup, "products.csv"); + }); + } + + private SuperDataGridExportService CreateService(int batchSize = 200) + { + var options = CreateOptions(); + options.BatchSize = batchSize; + var store = new ExportFileStore(options, TimeProvider.System, NullLogger.Instance); + return new SuperDataGridExportService(options, store); + } + + private SuperDataGridExporterOptions CreateOptions() => new() + { + TemporaryDirectory = _temporaryDirectory, + FileLifetime = TimeSpan.FromHours(24), + CleanupInterval = TimeSpan.FromDays(1), + CsvCulture = CultureInfo.InvariantCulture + }; + + private SuperDataGrid CreateGrid(GridItemsProvider provider) + { + var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddLogging(); + context.Services.AddSuperComponents(); + _contexts.Add(context); + + var renderedGrid = context.Render>(parameters => parameters + .Add(component => component.ItemsProvider, provider)); + _renderedGrids.Add(renderedGrid.Instance, renderedGrid); + return renderedGrid.Instance; + } + + private TestColumn AddColumn(SuperDataGrid grid, TestColumn column) + { + column.Initialize(); + _renderedGrids[grid].InvokeAsync( + () => grid.AddColumn(grid.ColumnsCollection.Count, column)).GetAwaiter().GetResult(); + return column; + } + + private sealed class TestColumn : DataGridColumn + { + public void Initialize() + { + base.OnInitialized(); + base.OnParametersSet(); + } + } + + private sealed record TestRow(int Id, string Name, decimal Amount, bool Enabled); + + private sealed class StubExportService : ISuperDataGridExportService + { + public Task ExportAsync( + SuperDataGrid grid, + SuperDataGridExportFormat format, + string fileName, + CancellationToken cancellationToken = default) + => Task.FromResult(new SuperDataGridExportResult( + "products.csv", "/exports/token/csv", 42)); + } + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => _utcNow; + public void Advance(TimeSpan duration) => _utcNow += duration; + } +} +#pragma warning restore BL0005