diff --git a/README.md b/README.md index 5c77f99..4841a96 100644 --- a/README.md +++ b/README.md @@ -19,7 +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) | +| SuperDataGrid exporter preview | **SuperDataGrid Exporter** | Optional CSV and Excel export extension for checked rows, including filtered, sorted and virtualized data | [📖 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 index 3a7034d..b74ed76 100644 --- a/SUPERDATAGRIDEXPORTER.md +++ b/SUPERDATAGRIDEXPORTER.md @@ -1,8 +1,8 @@ # 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. +The **SuperBlazorComponents.DataGridExporter** extension exports the rows that +are checked in a **SuperDataGrid**. A checked row is captured when generation +starts, so it remains exportable even if a later filter hides it. ## Installation @@ -74,11 +74,23 @@ 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**. +Only currently visible columns are exported, in their current order. If rows +are selected individually, those exact objects are exported in selection order. +If **Tout sélectionner** is used, the exporter reads all rows matching the +captured filters and sort order from **ItemsProvider**, in batches of 200 by +default, and skips rows explicitly unchecked afterwards. The batch size can be +changed with **SuperDataGridExporterOptions.BatchSize**. + +The dialog is still opened when nothing is checked, but immediately displays: +“Veuillez cocher au moins une ligne pour effectuer l’export.” Check a row and +choose **Réessayer** to continue; no file is created while the selection is +empty. Selection is frozen at the start of generation. + +For hierarchical grids, every checked row is exported, including checked child +rows. Unchecked or unexpanded children are not invented by the exporter. +Virtualized providers should implement **IDataItem.KeyValue** with a stable, +unique key. The same key is used to apply exclusions and deduplicate rows when +the provider materializes a new object instance for each batch. ## Custom columns @@ -120,5 +132,7 @@ Set **Exportable="false"** to exclude a visible column. 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. +- **ItemsProvider** is required for **Tout sélectionner** so the exporter can + retrieve every filtered row; it never treats the currently rendered + virtualized items alone as the complete dataset. Individually checked objects + are exported from the immutable selection captured at the start. diff --git a/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor index 6f65602..039adba 100644 --- a/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor +++ b/src/SuperBlazorComponents.DataGridExporter/Components/SuperDataGridExportDialog.razor @@ -65,6 +65,9 @@ private bool _initialized; private SuperDataGridExportResult? _result; + private const string SelectionRequiredMessage = + "Veuillez cocher au moins une ligne pour effectuer l’export."; + private string Extension => Format == SuperDataGridExportFormat.Csv ? ".csv" : ".xlsx"; protected override void OnParametersSet() @@ -75,6 +78,8 @@ _fileName = string.IsNullOrWhiteSpace(DefaultFileName) ? $"export-{DateTime.Now:yyyyMMdd-HHmmss}" : DefaultFileName; + if (!Grid.CaptureSelectionSnapshot().HasSelection) + _error = SelectionRequiredMessage; _initialized = true; } diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj b/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj index 0263fb7..33fb83a 100644 --- a/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj +++ b/src/SuperBlazorComponents.DataGridExporter/SuperBlazorComponents.DataGridExporter.csproj @@ -5,7 +5,7 @@ enable SuperBlazorComponents.DataGridExporter SuperBlazorComponents.DataGridExporter - 2.0.8 + 2.0.9 SuperBlazorComponents.DataGridExporter SuperBlazorComponents DataGrid Exporter Appliman diff --git a/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs index 3a8d1e4..14534b5 100644 --- a/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs +++ b/src/SuperBlazorComponents.DataGridExporter/SuperDataGridExportService.cs @@ -33,8 +33,18 @@ public Task ExportAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(grid); - if (grid.ItemsProvider is null) - throw new InvalidOperationException("The grid must define an ItemsProvider to export all rows."); + + // Capture both parts of the view before starting any asynchronous work. This + // makes an export deterministic even when the user changes the grid while it + // is being generated. + var selection = grid.CaptureSelectionSnapshot(); + if (!selection.HasSelection) + throw new InvalidOperationException( + "Veuillez cocher au moins une ligne pour effectuer l’export."); + + if (selection.AllSelected && grid.ItemsProvider is null) + throw new InvalidOperationException( + "The grid must define an ItemsProvider to export all selected rows."); var columns = ExportColumnResolver.Resolve(grid); var query = grid.CaptureQuerySnapshot(); @@ -47,15 +57,17 @@ public Task ExportAsync( format, fileName, (path, token) => format == SuperDataGridExportFormat.Csv - ? WriteCsvAsync(path, grid.ItemsProvider, query, columns, token) - : WriteExcelAsync(path, grid.ItemsProvider, query, columns, frozenColumnCount, token), + ? WriteCsvAsync(path, grid, grid.ItemsProvider, query, selection, columns, token) + : WriteExcelAsync(path, grid, grid.ItemsProvider, query, selection, columns, frozenColumnCount, token), cancellationToken); } private async Task WriteCsvAsync( string path, - GridItemsProvider provider, + SuperDataGrid grid, + GridItemsProvider? provider, SuperDataGridQuerySnapshot query, + SuperDataGridSelectionSnapshot selection, IReadOnlyList> columns, CancellationToken cancellationToken) { @@ -75,7 +87,7 @@ private async Task WriteCsvAsync( await csv.NextRecordAsync(); var rowCount = 0; - await foreach (var item in ReadAllAsync(provider, query, cancellationToken)) + await foreach (var item in ReadSelectedAsync(grid, provider, query, selection, cancellationToken)) { foreach (var column in columns) { @@ -94,8 +106,10 @@ private async Task WriteCsvAsync( private async Task WriteExcelAsync( string path, - GridItemsProvider provider, + SuperDataGrid grid, + GridItemsProvider? provider, SuperDataGridQuerySnapshot query, + SuperDataGridSelectionSnapshot selection, IReadOnlyList> columns, int frozenColumnCount, CancellationToken cancellationToken) @@ -111,7 +125,7 @@ private async Task WriteExcelAsync( } var rowCount = 0; - await foreach (var item in ReadAllAsync(provider, query, cancellationToken)) + await foreach (var item in ReadSelectedAsync(grid, provider, query, selection, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (rowCount >= ExcelMaximumDataRows) @@ -138,6 +152,61 @@ private async Task WriteExcelAsync( return rowCount; } + private async IAsyncEnumerable ReadSelectedAsync( + SuperDataGrid grid, + GridItemsProvider? provider, + SuperDataGridQuerySnapshot query, + SuperDataGridSelectionSnapshot selection, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var emittedKeys = new HashSet(); + + if (!selection.AllSelected) + { + // Keep the order in which the grid captured individual selections. The + // objects themselves are deliberately used so that a later filter or + // provider refresh cannot make a checked row disappear from the export. + foreach (var item in selection.SelectedItems) + { + cancellationToken.ThrowIfCancellationRequested(); + if (emittedKeys.Add(grid.GetItemKey(item))) + yield return item; + } + + yield break; + } + + if (provider is null) + throw new InvalidOperationException( + "The grid must define an ItemsProvider to export all selected rows."); + + // Select-all means all rows in the current filtered/sorted view, except the + // explicitly excluded keys. ItemsProvider is paged so virtualized grids are + // exported in full rather than only using the rendered viewport. + await foreach (var item in ReadAllAsync(provider, query, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + var key = grid.GetItemKey(item); + if (selection.ExcludedItemKeys.Contains(key) || !emittedKeys.Add(key)) + continue; + + yield return item; + } + + // Explicitly checked rows (notably hierarchy children) are retained even if + // they are not part of the root ItemsProvider result or became hidden by a + // later filter. Stable keys still deduplicate them against provider rows. + foreach (var item in selection.SelectedItems) + { + cancellationToken.ThrowIfCancellationRequested(); + var key = grid.GetItemKey(item); + if (selection.ExcludedItemKeys.Contains(key) || !emittedKeys.Add(key)) + continue; + + yield return item; + } + } + private async IAsyncEnumerable ReadAllAsync( GridItemsProvider provider, SuperDataGridQuerySnapshot query, diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SelectionInfo.cs b/src/SuperBlazorComponents/Components/SuperDataGrid/SelectionInfo.cs index d5fc0f4..ae5bc36 100644 --- a/src/SuperBlazorComponents/Components/SuperDataGrid/SelectionInfo.cs +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SelectionInfo.cs @@ -4,6 +4,10 @@ public sealed class SelectionInfo { public HashSet SelectedItems { get; } = []; + // HashSet is kept for fast membership checks and backwards compatibility. The + // list preserves the order in which individual rows were checked for exports. + internal List SelectionOrder { get; } = []; + internal HashSet UnselectedItemKeys { get; } = []; public int TotalCount { get; set; } @@ -14,5 +18,25 @@ public sealed class SelectionInfo public int ExcludedCount { get; set; } - public int SelectedCountTotal => SelectedCount - ExcludedCount; + public int SelectedCountTotal => Math.Max(0, SelectedCount - ExcludedCount); + + internal void AddSelected(TItem item) + { + if (SelectedItems.Add(item)) + SelectionOrder.Add(item); + } + + internal bool RemoveSelected(TItem item) + { + var removed = SelectedItems.Remove(item); + if (removed) + SelectionOrder.Remove(item); + return removed; + } + + internal void ClearSelected() + { + SelectedItems.Clear(); + SelectionOrder.Clear(); + } } diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.Selection.cs b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.Selection.cs index 156a083..0e01fa1 100644 --- a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.Selection.cs +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.Selection.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Components; +using System.Collections.Immutable; + namespace SuperBlazorComponents.Components.SuperDataGrid; public partial class SuperDataGrid @@ -60,6 +62,43 @@ internal IEnumerable SelectorMenuItemsSource public int SelectedCountTotal => _selectionInfo.SelectedCountTotal; + /// + /// Captures the current row selection into an immutable snapshot. The snapshot is + /// independent from subsequent changes made to the grid selection. + /// + public SuperDataGridSelectionSnapshot CaptureSelectionSnapshot() + { + UpdateSelectionInfo(); + + var selectedItems = _selectionInfo.SelectionOrder + .Where(_selectionInfo.SelectedItems.Contains) + .Concat(_selectionInfo.SelectedItems) + .Concat(GetSelectedHierarchyItems()) + .Distinct() + .ToImmutableArray(); + var selectedKeys = selectedItems + .Select(GetItemKey) + .ToImmutableHashSet(); + + return new SuperDataGridSelectionSnapshot( + selectedItems, + selectedKeys, + _selectionInfo.AllSelected, + _selectionInfo.UnselectedItemKeys.ToImmutableHashSet(), + _selectionInfo.SelectedCountTotal); + } + + private IEnumerable GetSelectedHierarchyItems() + { + if (!IsHierarchicalRenderingEnabled() || !_hierarchicalRootItemsLoaded) + return []; + + return _hierarchicalRootItems + .SelectMany(GetHierarchyRows) + .Select(row => row.Item) + .Where(IsRowSelected); + } + /// /// Adds one item to the row selector menu at runtime. /// @@ -113,10 +152,10 @@ public async Task SelectItemAsync(TItem item) if (SelectionMode != SuperDataGridSelectionMode.Multiple) { - _selectionInfo.SelectedItems.Clear(); + _selectionInfo.ClearSelected(); } - _selectionInfo.SelectedItems.Add(item); + _selectionInfo.AddSelected(item); if (_selectionInfo.AllSelected) { @@ -148,12 +187,12 @@ public async Task SelectRow(TItem item, bool clearOthers = true) SetItemSelected(selectedItem, false); } - _selectionInfo.SelectedItems.Clear(); + _selectionInfo.ClearSelected(); } if (!_selectionInfo.SelectedItems.Contains(item)) { - _selectionInfo.SelectedItems.Add(item); + _selectionInfo.AddSelected(item); SetItemSelected(item, true); } @@ -166,6 +205,25 @@ public async Task SelectRow(TItem item, bool clearOthers = true) StateHasChanged(); } + /// + /// Unchecks a specific row. + /// + public async Task DeselectRowAsync(TItem item) + { + if (item is null || IsRowDeleted(item)) + { + return; + } + + _selectionInfo.RemoveSelected(item); + if (_selectionInfo.AllSelected) + _selectionInfo.UnselectedItemKeys.Add(TryGetItemKey(item)); + + SetItemSelected(item, false); + await NotifySelectionChangedAsync(item); + StateHasChanged(); + } + /// /// Tries to select the first item from the currently rendered list. /// @@ -212,7 +270,7 @@ public async Task ClearSelectionAsync() } CurrentItem = default; - _selectionInfo.SelectedItems.Clear(); + _selectionInfo.ClearSelected(); _selectionInfo.UnselectedItemKeys.Clear(); _selectionInfo.AllSelected = false; @@ -245,13 +303,13 @@ public async Task SelectAllAsync() { if (IsRowDeleted(renderedItem)) { - _selectionInfo.SelectedItems.Remove(renderedItem); + _selectionInfo.RemoveSelected(renderedItem); _selectionInfo.UnselectedItemKeys.Add(TryGetItemKey(renderedItem)); SetItemSelected(renderedItem, false); continue; } - _selectionInfo.SelectedItems.Add(renderedItem); + _selectionInfo.AddSelected(renderedItem); SetItemSelected(renderedItem, true); } @@ -364,7 +422,7 @@ private async Task OnSelectionCheckboxChangeAsync(TItem item, ChangeEventArgs ar SetItemSelected(selectedItem, false); } - _selectionInfo.SelectedItems.Clear(); + _selectionInfo.ClearSelected(); _selectionInfo.AllSelected = false; _selectionInfo.UnselectedItemKeys.Clear(); } @@ -375,13 +433,13 @@ private async Task OnSelectionCheckboxChangeAsync(TItem item, ChangeEventArgs ar if (isChecked) { - _selectionInfo.SelectedItems.Add(item); + _selectionInfo.AddSelected(item); _selectionInfo.UnselectedItemKeys.Remove(itemKey); SetItemSelected(item, true); } else { - _selectionInfo.SelectedItems.Remove(item); + _selectionInfo.RemoveSelected(item); _selectionInfo.UnselectedItemKeys.Add(itemKey); SetItemSelected(item, false); } @@ -393,12 +451,12 @@ private async Task OnSelectionCheckboxChangeAsync(TItem item, ChangeEventArgs ar if (isChecked) { - _selectionInfo.SelectedItems.Add(item); + _selectionInfo.AddSelected(item); SetItemSelected(item, true); } else { - _selectionInfo.SelectedItems.Remove(item); + _selectionInfo.RemoveSelected(item); SetItemSelected(item, false); _selectionInfo.AllSelected = false; } @@ -413,7 +471,7 @@ private void SyncRenderedItemsSelectionState() { if (IsRowDeleted(renderedItem)) { - _selectionInfo.SelectedItems.Remove(renderedItem); + _selectionInfo.RemoveSelected(renderedItem); SetItemSelected(renderedItem, false); if (_selectionInfo.AllSelected) @@ -431,11 +489,11 @@ private void SyncRenderedItemsSelectionState() if (isSelected) { - _selectionInfo.SelectedItems.Add(renderedItem); + _selectionInfo.AddSelected(renderedItem); } else { - _selectionInfo.SelectedItems.Remove(renderedItem); + _selectionInfo.RemoveSelected(renderedItem); } } else @@ -449,7 +507,7 @@ private async Task NotifySelectionChangedAsync(TItem? selectedItem) { foreach (var deletedItem in _selectionInfo.SelectedItems.Where(IsRowDeleted).ToList()) { - _selectionInfo.SelectedItems.Remove(deletedItem); + _selectionInfo.RemoveSelected(deletedItem); SetItemSelected(deletedItem, false); } diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs index 691d4a8..3b16903 100644 --- a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGrid.razor.cs @@ -1271,10 +1271,25 @@ private bool IsCurrentRow(TItem item) return itemKey is not null && Equals(itemKey, _currentRowKey); } + /// + /// Gets the key used by the grid to identify a row. + /// + /// + /// When a type implements , its KeyValue is used. + /// Otherwise the item instance itself is used as a fallback. Virtualized providers + /// should therefore expose a stable IDataItem.KeyValue. + /// + public object? GetItemKey(TItem item) => TryGetItemKey(item); + private static object? TryGetItemKey(TItem item) { ArgumentNullException.ThrowIfNull(item); + if (item is IDataItem dataItem) + { + return dataItem.KeyValue; + } + var keyProperty = typeof(TItem).GetProperty(nameof(IDataItem.KeyValue)); if (keyProperty is not null) { diff --git a/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGridSelectionSnapshot.cs b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGridSelectionSnapshot.cs new file mode 100644 index 0000000..cd66391 --- /dev/null +++ b/src/SuperBlazorComponents/Components/SuperDataGrid/SuperDataGridSelectionSnapshot.cs @@ -0,0 +1,19 @@ +using System.Collections.Immutable; + +namespace SuperBlazorComponents.Components.SuperDataGrid; + +/// +/// Immutable selection state captured from a . +/// +public sealed record SuperDataGridSelectionSnapshot( + ImmutableArray SelectedItems, + ImmutableHashSet SelectedItemKeys, + bool AllSelected, + ImmutableHashSet ExcludedItemKeys, + int SelectedCountTotal) +{ + /// Gets whether at least one row belongs to the captured selection. + public bool HasSelection => + SelectedCountTotal > 0 + || SelectedItemKeys.Any(key => !ExcludedItemKeys.Contains(key)); +} diff --git a/src/SuperBlazorComponents/SuperBlazorComponents.csproj b/src/SuperBlazorComponents/SuperBlazorComponents.csproj index 7375c39..19d3388 100644 --- a/src/SuperBlazorComponents/SuperBlazorComponents.csproj +++ b/src/SuperBlazorComponents/SuperBlazorComponents.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - 2.0.8 + 2.0.9 false SuperBlazorComponents SuperBlazorComponents diff --git a/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs b/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs index e61b77e..731dff5 100644 --- a/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs +++ b/tests/SuperBlazorComponents.Tests/SuperDataGridExporterTests.cs @@ -113,6 +113,7 @@ public async Task CsvExport_ReadsEveryBatchAndProtectsFormulaStrings() }); AddColumn(grid, new TestColumn { Property = nameof(TestRow.Name), Title = "Name" }); AddColumn(grid, new TestColumn { Property = nameof(TestRow.Amount), Title = "Amount", FormatString = "{0:F2}" }); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectAllAsync()); starts.Clear(); var service = CreateService(batchSize: 2); @@ -130,6 +131,97 @@ public async Task CsvExport_ReadsEveryBatchAndProtectsFormulaStrings() @"/[a-f0-9]{64}/csv\?fileName=")); } + [TestMethod] + public async Task ExportWithoutSelection_ReturnsRequiredMessageAndCreatesNoFile() + { + var formats = new[] { SuperDataGridExportFormat.Csv, SuperDataGridExportFormat.Excel }; + foreach (var format in formats) + { + var grid = CreateGrid(_ => ValueTask.FromResult( + GridItemsProviderResult.From( + new[] { new TestRow(1, "One", 1, true) }, 1))); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + + var exception = await Assert.ThrowsExactlyAsync( + () => CreateService().ExportAsync(grid, format, "empty")); + + Assert.AreEqual( + "Veuillez cocher au moins une ligne pour effectuer l’export.", + exception.Message); + } + + Assert.IsFalse(Directory.Exists(_temporaryDirectory) + && Directory.GetFiles(_temporaryDirectory).Length > 0); + } + + [TestMethod] + public async Task CsvExport_UsesOnlyIndividuallySelectedRowsInCapturedOrder() + { + var rows = Enumerable.Range(1, 3) + .Select(index => new TestRow(index, $"Name {index}", index, true)) + .ToArray(); + var grid = CreateGrid(request => ValueTask.FromResult( + GridItemsProviderResult.From( + rows.Skip(request.StartIndex).Take(request.Count ?? rows.Length), rows.Length))); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Name), Title = "Name" }); + + await _renderedGrids[grid].InvokeAsync(() => grid.SelectRow(rows[2], clearOthers: false)); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectRow(rows[0], clearOthers: false)); + + var result = await CreateService().ExportAsync(grid, SuperDataGridExportFormat.Csv, "selected"); + var file = Directory.GetFiles(_temporaryDirectory, "*.csv").Single(); + var lines = await File.ReadAllLinesAsync(file, Encoding.UTF8); + + Assert.AreEqual(2, result.RowCount); + CollectionAssert.AreEqual(new[] { "ID,Name", "3,Name 3", "1,Name 1" }, lines); + } + + [TestMethod] + public async Task CsvExport_AllSelectedReadsBatchesAndSkipsExcludedRows() + { + var rows = Enumerable.Range(1, 5) + .Select(index => new TestRow(index, $"Name {index}", index, true)) + .ToArray(); + var starts = new List(); + var grid = CreateGrid(request => + { + starts.Add(request.StartIndex); + return ValueTask.FromResult(GridItemsProviderResult.From( + rows.Skip(request.StartIndex).Take(request.Count ?? rows.Length), rows.Length)); + }); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectAllAsync()); + starts.Clear(); + await _renderedGrids[grid].InvokeAsync(() => grid.DeselectRowAsync(rows[1])); + + var result = await CreateService(batchSize: 2) + .ExportAsync(grid, SuperDataGridExportFormat.Csv, "all-selected"); + var file = Directory.GetFiles(_temporaryDirectory, "*.csv").Single(); + var lines = await File.ReadAllLinesAsync(file, Encoding.UTF8); + + Assert.AreEqual(4, result.RowCount); + CollectionAssert.AreEqual(new[] { 0, 2, 4 }, starts); + CollectionAssert.AreEqual(new[] { "ID", "1", "3", "4", "5" }, lines); + } + + [TestMethod] + public async Task SelectionSnapshot_ExposesStableKeysAndAllSelectedState() + { + var row = new TestRow(7, "Seven", 7, true); + var grid = CreateGrid(_ => ValueTask.FromResult( + GridItemsProviderResult.From(new[] { row }, 1))); + AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectRow(row)); + + var snapshot = grid.CaptureSelectionSnapshot(); + + Assert.IsTrue(snapshot.HasSelection); + Assert.IsFalse(snapshot.AllSelected); + Assert.IsTrue(snapshot.SelectedItemKeys.Contains(7)); + Assert.AreEqual(7, grid.GetItemKey(row)); + } + [TestMethod] public async Task ExcelExport_PreservesNativeCellTypes() { @@ -140,6 +232,7 @@ public async Task ExcelExport_PreservesNativeCellTypes() 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" }); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectAllAsync()); var result = await CreateService().ExportAsync( grid, SuperDataGridExportFormat.Excel, "typed"); @@ -164,6 +257,7 @@ public async Task Export_FailsWhenProviderStopsBeforeAnnouncedTotal() new[] { new TestRow(1, "One", 1, true) }, 2) : GridItemsProviderResult.From([], 2))); AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectAllAsync()); var exception = await Assert.ThrowsExactlyAsync( () => CreateService(batchSize: 1).ExportAsync( @@ -182,6 +276,7 @@ public async Task Export_HonorsCancellationWithoutPublishingAFile() GridItemsProviderResult.From( new[] { new TestRow(1, "One", 1, true) }, 1))); AddColumn(grid, new TestColumn { Property = nameof(TestRow.Id), Title = "ID" }); + await _renderedGrids[grid].InvokeAsync(() => grid.SelectAllAsync()); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); @@ -305,6 +400,30 @@ public void ExportDialog_AfterGenerationDisplaysDownloadLink() }); } + [TestMethod] + public void ExportDialog_WithoutSelectionShowsRetryableMessageImmediately() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddSuperComponents(); + context.Services.AddSingleton(new FailingExportService()); + var grid = new SuperDataGrid + { + ItemsProvider = _ => 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")); + + StringAssert.Contains(dialog.Markup, "Veuillez cocher au moins une ligne pour effectuer l’export."); + Assert.IsFalse(dialog.Find("button.btn-primary").HasAttribute("disabled")); + dialog.Find("button.btn-primary").Click(); + dialog.WaitForAssertion(() => + StringAssert.Contains(dialog.Markup, "Veuillez cocher au moins une ligne pour effectuer l’export.")); + } + private SuperDataGridExportService CreateService(int batchSize = 200) { var options = CreateOptions(); @@ -352,7 +471,12 @@ public void Initialize() } } - private sealed record TestRow(int Id, string Name, decimal Amount, bool Enabled); + private sealed record TestRow(int Id, string Name, decimal Amount, bool Enabled) : IDataItem + { + public object KeyValue => Id; + public bool IsSelected { get; set; } + public int RowNumber { get; set; } + } private sealed class StubExportService : ISuperDataGridExportService { @@ -365,6 +489,17 @@ public Task ExportAsync( "products.csv", "/exports/token/csv", 42)); } + private sealed class FailingExportService : ISuperDataGridExportService + { + public Task ExportAsync( + SuperDataGrid grid, + SuperDataGridExportFormat format, + string fileName, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Veuillez cocher au moins une ligne pour effectuer l’export."); + } + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider { private DateTimeOffset _utcNow = utcNow;