From 52e1d295853ca02667e41bbbc285ac4f0fa06cba Mon Sep 17 00:00:00 2001 From: Atanas Korchev Date: Thu, 7 May 2026 12:38:56 +0300 Subject: [PATCH] Add multi-key sort, advanced filter criteria, per-column AutoFilter API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent additions to the Spreadsheet API, motivated by the gap analysis against Excel COM, Office.js, Aspose, and Syncfusion: Multi-key sort (Pass 3) - New SortKey class: ColumnIndex (relative to the sort range), Order, SortOn, CustomList, CustomColor, CaseSensitive. - New SortOn enum: Values, CellColor, FontColor. - Worksheet.Sort(range, params SortKey[]) — stable, supports multiple sort levels, custom-list ordering, case sensitivity, sort by color. - Table.Sort(params SortKey[]) overload using table-relative indices. - Existing single-key Sort(range, order, keyIndex) retained for back-compat; redirects to the new API. - Empty cells always sort to the bottom regardless of direction (matches Excel and pre-existing test). - Cell.Clone now preserves Format (was dropped on the floor; the bug was masked because no caller of Clone read Format until color sort). Advanced filter criteria (Pass 2A) - TopFilterCriterion — Top/Bottom N items, by count or percent. - DynamicFilterCriterion — full DynamicFilterType enum with 34 values covering above/below average, today/yesterday/tomorrow, this/last/ next week/month/quarter/year, year-to-date, individual months, and Q1..Q4. - CellColorFilterCriterion — match cells by background or font color. - New OnApply(sheet, range) hook on FilterCriterion lets distribution- aware criteria (Top, AboveAverage) compute thresholds before the per-row Matches loop. Default no-op preserves existing behavior. - Visitor extended with three new Visit overloads + base no-op defaults. Per-column AutoFilter API (Pass 2B) - AutoFilter.ApplyValueFilter(col, values), ApplyCustomFilter(col, criterion), ApplyTopFilter(col, count, percent, bottom), ApplyDynamicFilter(col, type), ApplyColorFilter(col, color, fontColor), ClearColumnFilter(col), GetColumnFilter(col). - Column indices on these methods are RELATIVE to the AutoFilter range, matching the convention used by SortKey and Office.js TableSort. Tests - MultiKeySortContractTests: 11 facts (multi-key, custom list, case-sensitive, color sort, range bounds, no-op edges). - FilterCriterionContractTests: 11 facts (Top/Bottom by count and percent, AboveAverage, Today, ThisMonth, Quarter1, color filters, visitor dispatch). - AutoFilterPerColumnTests: 12 facts (each Apply* method, replace semantics, per-column clear, validation errors). 3,706 tests pass. --- .../Spreadsheet/AutoFilterPerColumnTests.cs | 181 +++++++++ .../FilterCriterionContractTests.cs | 228 +++++++++++ .../Spreadsheet/MultiKeySortContractTests.cs | 227 +++++++++++ .../Spreadsheet/AdvancedFilterCriteria.cs | 354 ++++++++++++++++++ Radzen.Blazor/Documents/Spreadsheet/Cell.cs | 19 +- Radzen.Blazor/Documents/Spreadsheet/Table.cs | 152 +++++++- .../Documents/Spreadsheet/Worksheet.Filter.cs | 39 ++ .../Documents/Spreadsheet/Worksheet.Sort.cs | 310 ++++++++++----- 8 files changed, 1407 insertions(+), 103 deletions(-) create mode 100644 Radzen.Blazor.Tests/Spreadsheet/AutoFilterPerColumnTests.cs create mode 100644 Radzen.Blazor.Tests/Spreadsheet/FilterCriterionContractTests.cs create mode 100644 Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs create mode 100644 Radzen.Blazor/Documents/Spreadsheet/AdvancedFilterCriteria.cs diff --git a/Radzen.Blazor.Tests/Spreadsheet/AutoFilterPerColumnTests.cs b/Radzen.Blazor.Tests/Spreadsheet/AutoFilterPerColumnTests.cs new file mode 100644 index 000000000..b900f95c0 --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/AutoFilterPerColumnTests.cs @@ -0,0 +1,181 @@ +using System.Linq; +using Radzen.Documents.Spreadsheet; +using Xunit; + +namespace Radzen.Blazor.Spreadsheet.Tests; + +#nullable enable + +// Contract for the new per-column filter API on AutoFilter (Pass 2B). +// Column indices are RELATIVE to the auto-filter range. +public class AutoFilterPerColumnTests +{ + private static (Workbook wb, Worksheet ws, AutoFilter af) Build(int rows = 11, int cols = 3) + { + var wb = new Workbook(); + var ws = wb.AddSheet("Sheet1", rows, cols); + ws.Cells[0, 0].SetValue("Name"); + ws.Cells[0, 1].SetValue("Region"); + ws.Cells[0, 2].SetValue("Amount"); + for (var r = 1; r < rows; r++) + { + ws.Cells[r, 0].SetValue($"Row{r}"); + ws.Cells[r, 1].SetValue(r % 2 == 0 ? "EMEA" : "AMER"); + ws.Cells[r, 2].Value = (double)(r * 10); + } + ws.AutoFilter.Range = new RangeRef(new CellRef(0, 0), new CellRef(rows - 1, cols - 1)); + return (wb, ws, ws.AutoFilter); + } + + private static int VisibleRowsBetween(Worksheet ws, int from, int to) + { + var n = 0; + for (var r = from; r <= to; r++) + if (!ws.Rows.IsHidden(r)) n++; + return n; + } + + // ── ApplyValueFilter ──────────────────────────────────────────────────── + [Fact] + public void ApplyValueFilter_ShouldHideRowsNotInTheValueList() + { + var (_, ws, af) = Build(); + af.ApplyValueFilter(1, ["EMEA"]); + + // Header always visible; only EMEA rows (even row indices 2, 4, 6, 8, 10) visible + Assert.False(ws.Rows.IsHidden(0)); + for (var r = 1; r < 11; r++) + { + var expectedHidden = r % 2 != 0; + Assert.Equal(expectedHidden, ws.Rows.IsHidden(r)); + } + } + + [Fact] + public void ApplyValueFilter_MultipleValues_ShouldKeepAnyMatch() + { + var (_, ws, af) = Build(); + af.ApplyValueFilter(1, ["EMEA", "AMER"]); + // Both values cover all data rows; only header + every data row stays visible + Assert.Equal(11, VisibleRowsBetween(ws, 0, 10)); + } + + // ── ApplyCustomFilter ─────────────────────────────────────────────────── + [Fact] + public void ApplyCustomFilter_GreaterThan_ShouldFilterByCriterion() + { + var (_, ws, af) = Build(); + af.ApplyCustomFilter(2, new GreaterThanCriterion { Column = 2, Value = 50.0 }); + + // Amount = 10..100; >50 keeps rows 6..10 + for (var r = 1; r <= 5; r++) Assert.True(ws.Rows.IsHidden(r)); + for (var r = 6; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); + } + + // ── ApplyTopFilter / ApplyDynamicFilter / ApplyColorFilter ────────────── + [Fact] + public void ApplyTopFilter_TopThreeItems_ShouldKeepHighest() + { + var (_, ws, af) = Build(); + af.ApplyTopFilter(2, count: 3, percent: false, bottom: false); + + // Top 3 by Amount = rows 8, 9, 10 + for (var r = 1; r <= 7; r++) Assert.True(ws.Rows.IsHidden(r)); + for (var r = 8; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); + } + + [Fact] + public void ApplyDynamicFilter_AboveAverage_ShouldKeepHigh() + { + var (_, ws, af) = Build(); + af.ApplyDynamicFilter(2, DynamicFilterType.AboveAverage); + + // Average of 10..100 = 55. Rows with value > 55 = 60,70,80,90,100 (rows 6..10) + for (var r = 1; r <= 5; r++) Assert.True(ws.Rows.IsHidden(r)); + for (var r = 6; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); + } + + [Fact] + public void ApplyColorFilter_BackgroundColor_ShouldKeepMatching() + { + var (_, ws, af) = Build(); + // Tag rows 2 and 5 with yellow + ws.Cells[2, 0].Format = new Format { BackgroundColor = "#FFFF00" }; + ws.Cells[5, 0].Format = new Format { BackgroundColor = "#FFFF00" }; + + af.ApplyColorFilter(0, "#FFFF00", fontColor: false); + + for (var r = 1; r <= 10; r++) + { + var expectedVisible = r == 2 || r == 5; + Assert.Equal(!expectedVisible, ws.Rows.IsHidden(r)); + } + } + + // ── ClearColumnFilter / GetColumnFilter ───────────────────────────────── + [Fact] + public void GetColumnFilter_ShouldReturnAppliedFilter() + { + var (_, _, af) = Build(); + af.ApplyValueFilter(1, ["EMEA"]); + var filter = af.GetColumnFilter(1); + Assert.NotNull(filter); + Assert.IsType(filter!.Criterion); + } + + [Fact] + public void GetColumnFilter_NoFilter_ShouldReturnNull() + { + var (_, _, af) = Build(); + Assert.Null(af.GetColumnFilter(1)); + } + + [Fact] + public void ClearColumnFilter_ShouldRemoveOnlyThatColumn() + { + var (_, ws, af) = Build(); + af.ApplyValueFilter(1, ["EMEA"]); + af.ApplyCustomFilter(2, new GreaterThanCriterion { Column = 2, Value = 50.0 }); + + af.ClearColumnFilter(1); + + // Region filter gone; Amount filter still active -> rows with value > 50 visible + Assert.Null(af.GetColumnFilter(1)); + Assert.NotNull(af.GetColumnFilter(2)); + + for (var r = 1; r <= 5; r++) Assert.True(ws.Rows.IsHidden(r)); + for (var r = 6; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); + } + + [Fact] + public void ApplyValueFilter_TwiceOnSameColumn_ShouldReplace() + { + var (_, _, af) = Build(); + af.ApplyValueFilter(1, ["EMEA"]); + af.ApplyValueFilter(1, ["AMER"]); + + // Only one filter on column 1; the AMER one replaces EMEA. + var filter = af.GetColumnFilter(1); + Assert.NotNull(filter); + var inList = Assert.IsType(filter!.Criterion); + Assert.Equal("AMER", inList.Values[0]); + } + + // ── Range validation ──────────────────────────────────────────────────── + [Fact] + public void ApplyValueFilter_BeforeSettingRange_ShouldThrow() + { + var wb = new Workbook(); + var ws = wb.AddSheet("Sheet1", 5, 3); + Assert.Throws(() => + ws.AutoFilter.ApplyValueFilter(0, ["a"])); + } + + [Fact] + public void ApplyValueFilter_ColumnOutOfRange_ShouldThrow() + { + var (_, _, af) = Build(); + Assert.Throws(() => + af.ApplyValueFilter(99, ["x"])); + } +} diff --git a/Radzen.Blazor.Tests/Spreadsheet/FilterCriterionContractTests.cs b/Radzen.Blazor.Tests/Spreadsheet/FilterCriterionContractTests.cs new file mode 100644 index 000000000..19ac25715 --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/FilterCriterionContractTests.cs @@ -0,0 +1,228 @@ +using System; +using Radzen.Documents.Spreadsheet; +using Xunit; + +namespace Radzen.Blazor.Spreadsheet.Tests; + +#nullable enable + +// Contract for the new filter criterion subclasses introduced in Pass 2A: +// TopFilterCriterion, DynamicFilterCriterion, CellColorFilterCriterion. +public class FilterCriterionContractTests +{ + private static (Workbook wb, Worksheet ws) Build(int rows, int cols) + { + var wb = new Workbook(); + var ws = wb.AddSheet("Sheet1", rows, cols); + return (wb, ws); + } + + private static RangeRef Range(int r1, int c1, int r2, int c2) => + new(new CellRef(r1, c1), new CellRef(r2, c2)); + + // ── TopFilterCriterion ────────────────────────────────────────────────── + [Fact] + public void TopFilter_TopN_ShouldMatchHighestNValues() + { + var (_, ws) = Build(11, 1); + ws.Cells[0, 0].SetValue("Header"); + // Values 1..10 + for (var r = 1; r <= 10; r++) ws.Cells[r, 0].Value = (double)r; + + var criterion = new TopFilterCriterion { Column = 0, Count = 3, Bottom = false }; + ws.AddFilter(new SheetFilter(criterion, Range(0, 0, 10, 0))); + + // Only the top 3 (values 8, 9, 10) should be visible. + Assert.False(ws.Rows.IsHidden(0)); // header always visible + for (var r = 1; r <= 7; r++) Assert.True(ws.Rows.IsHidden(r)); // 1..7 hidden + for (var r = 8; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); // 8..10 visible + } + + [Fact] + public void TopFilter_BottomN_ShouldMatchLowestNValues() + { + var (_, ws) = Build(11, 1); + ws.Cells[0, 0].SetValue("Header"); + for (var r = 1; r <= 10; r++) ws.Cells[r, 0].Value = (double)r; + + ws.AddFilter(new SheetFilter( + new TopFilterCriterion { Column = 0, Count = 3, Bottom = true }, + Range(0, 0, 10, 0))); + + // Bottom 3 = values 1, 2, 3 + for (var r = 1; r <= 3; r++) Assert.False(ws.Rows.IsHidden(r)); + for (var r = 4; r <= 10; r++) Assert.True(ws.Rows.IsHidden(r)); + } + + [Fact] + public void TopFilter_TopPercent_ShouldMatchTopFractionOfRows() + { + var (_, ws) = Build(11, 1); + ws.Cells[0, 0].SetValue("Header"); + for (var r = 1; r <= 10; r++) ws.Cells[r, 0].Value = (double)r; + + // Top 30% = top 3 of 10 + ws.AddFilter(new SheetFilter( + new TopFilterCriterion { Column = 0, Count = 30, Percent = true, Bottom = false }, + Range(0, 0, 10, 0))); + + for (var r = 1; r <= 7; r++) Assert.True(ws.Rows.IsHidden(r)); + for (var r = 8; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); + } + + // ── DynamicFilterCriterion ────────────────────────────────────────────── + [Fact] + public void DynamicFilter_AboveAverage_ShouldMatchValuesAboveMean() + { + var (_, ws) = Build(6, 1); + ws.Cells[0, 0].SetValue("Header"); + // Values 10, 20, 30, 40, 50 → average 30 + for (var r = 1; r <= 5; r++) ws.Cells[r, 0].Value = (double)(r * 10); + + ws.AddFilter(new SheetFilter( + new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.AboveAverage }, + Range(0, 0, 5, 0))); + + // 10, 20, 30 below or equal → hidden; 40, 50 above → visible + Assert.True(ws.Rows.IsHidden(1)); + Assert.True(ws.Rows.IsHidden(2)); + Assert.True(ws.Rows.IsHidden(3)); + Assert.False(ws.Rows.IsHidden(4)); + Assert.False(ws.Rows.IsHidden(5)); + } + + [Fact] + public void DynamicFilter_BelowAverage_ShouldMatchValuesBelowMean() + { + var (_, ws) = Build(6, 1); + ws.Cells[0, 0].SetValue("Header"); + for (var r = 1; r <= 5; r++) ws.Cells[r, 0].Value = (double)(r * 10); + + ws.AddFilter(new SheetFilter( + new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.BelowAverage }, + Range(0, 0, 5, 0))); + + Assert.False(ws.Rows.IsHidden(1)); + Assert.False(ws.Rows.IsHidden(2)); + Assert.True(ws.Rows.IsHidden(3)); + Assert.True(ws.Rows.IsHidden(4)); + Assert.True(ws.Rows.IsHidden(5)); + } + + [Fact] + public void DynamicFilter_Today_ShouldMatchTodaysDate() + { + var (_, ws) = Build(4, 1); + ws.Cells[0, 0].SetValue("Header"); + ws.Cells[1, 0].Value = DateTime.Today; + ws.Cells[2, 0].Value = DateTime.Today.AddDays(-1); + ws.Cells[3, 0].Value = DateTime.Today.AddDays(1); + + ws.AddFilter(new SheetFilter( + new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.Today }, + Range(0, 0, 3, 0))); + + Assert.False(ws.Rows.IsHidden(1)); + Assert.True(ws.Rows.IsHidden(2)); + Assert.True(ws.Rows.IsHidden(3)); + } + + [Fact] + public void DynamicFilter_ThisMonth_ShouldMatchDatesInCurrentMonth() + { + var (_, ws) = Build(4, 1); + var today = DateTime.Today; + ws.Cells[0, 0].SetValue("Header"); + ws.Cells[1, 0].Value = today; + ws.Cells[2, 0].Value = today.AddMonths(-1); + ws.Cells[3, 0].Value = today.AddMonths(1); + + ws.AddFilter(new SheetFilter( + new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.ThisMonth }, + Range(0, 0, 3, 0))); + + Assert.False(ws.Rows.IsHidden(1)); + Assert.True(ws.Rows.IsHidden(2)); + Assert.True(ws.Rows.IsHidden(3)); + } + + [Fact] + public void DynamicFilter_Quarter1_ShouldMatchJanFebMar() + { + var (_, ws) = Build(5, 1); + ws.Cells[0, 0].SetValue("Header"); + ws.Cells[1, 0].Value = new DateTime(2024, 2, 14); + ws.Cells[2, 0].Value = new DateTime(2024, 4, 1); + ws.Cells[3, 0].Value = new DateTime(2024, 3, 31); + ws.Cells[4, 0].Value = new DateTime(2024, 7, 15); + + ws.AddFilter(new SheetFilter( + new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.Quarter1 }, + Range(0, 0, 4, 0))); + + Assert.False(ws.Rows.IsHidden(1)); // Feb + Assert.True(ws.Rows.IsHidden(2)); // Apr + Assert.False(ws.Rows.IsHidden(3)); // Mar + Assert.True(ws.Rows.IsHidden(4)); // Jul + } + + // ── CellColorFilterCriterion ──────────────────────────────────────────── + [Fact] + public void CellColorFilter_ShouldMatchCellsWithMatchingBackground() + { + var (_, ws) = Build(5, 1); + ws.Cells[0, 0].SetValue("Header"); + ws.Cells[1, 0].SetValue("a"); + ws.Cells[2, 0].SetValue("b"); ws.Cells[2, 0].Format = new Format { BackgroundColor = "#FFFF00" }; + ws.Cells[3, 0].SetValue("c"); + ws.Cells[4, 0].SetValue("d"); ws.Cells[4, 0].Format = new Format { BackgroundColor = "#FFFF00" }; + + ws.AddFilter(new SheetFilter( + new CellColorFilterCriterion { Column = 0, Color = "#FFFF00" }, + Range(0, 0, 4, 0))); + + Assert.True(ws.Rows.IsHidden(1)); + Assert.False(ws.Rows.IsHidden(2)); + Assert.True(ws.Rows.IsHidden(3)); + Assert.False(ws.Rows.IsHidden(4)); + } + + [Fact] + public void CellColorFilter_FontColor_ShouldMatchCellsWithMatchingFontColor() + { + var (_, ws) = Build(4, 1); + ws.Cells[0, 0].SetValue("Header"); + ws.Cells[1, 0].SetValue("a"); + ws.Cells[2, 0].SetValue("b"); ws.Cells[2, 0].Format = new Format { Color = "#FF0000" }; + ws.Cells[3, 0].SetValue("c"); + + ws.AddFilter(new SheetFilter( + new CellColorFilterCriterion { Column = 0, Color = "#FF0000", FontColor = true }, + Range(0, 0, 3, 0))); + + Assert.True(ws.Rows.IsHidden(1)); + Assert.False(ws.Rows.IsHidden(2)); + Assert.True(ws.Rows.IsHidden(3)); + } + + // ── Visitor pattern coverage ──────────────────────────────────────────── + [Fact] + public void NewCriteria_ShouldDispatchThroughVisitor() + { + var visited = new System.Collections.Generic.List(); + var visitor = new RecordingVisitor(visited); + + new TopFilterCriterion { Column = 0, Count = 5 }.Accept(visitor); + new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.Today }.Accept(visitor); + new CellColorFilterCriterion { Column = 0, Color = "#FFF" }.Accept(visitor); + + Assert.Equal(["TopFilter", "DynamicFilter", "CellColorFilter"], visited); + } + + private sealed class RecordingVisitor(System.Collections.Generic.List log) : FilterCriterionVisitorBase + { + public override void Visit(TopFilterCriterion criterion) => log.Add("TopFilter"); + public override void Visit(DynamicFilterCriterion criterion) => log.Add("DynamicFilter"); + public override void Visit(CellColorFilterCriterion criterion) => log.Add("CellColorFilter"); + } +} diff --git a/Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs b/Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs new file mode 100644 index 000000000..f7c5329ea --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs @@ -0,0 +1,227 @@ +using Radzen; +using Radzen.Documents.Spreadsheet; +using Xunit; + +namespace Radzen.Blazor.Spreadsheet.Tests; + +#nullable enable + +// Contract for the multi-key sort API: SortKey + SortOn enum + new +// Worksheet.Sort(range, params SortKey[]) overload. Column indices in +// SortKey are relative to the range's left edge. +public class MultiKeySortContractTests +{ + private static (Workbook wb, Worksheet ws) Build(int rows, int cols) + { + var wb = new Workbook(); + var ws = wb.AddSheet("Sheet1", rows, cols); + return (wb, ws); + } + + private static RangeRef Range(int r1, int c1, int r2, int c2) => + new(new CellRef(r1, c1), new CellRef(r2, c2)); + + // ── SortKey shape ─────────────────────────────────────────────────────── + [Fact] + public void SortKey_DefaultsAreSensible() + { + var key = new SortKey { ColumnIndex = 0 }; + Assert.Equal(SortOrder.Ascending, key.Order); + Assert.Equal(SortOn.Values, key.SortOn); + Assert.False(key.CaseSensitive); + Assert.Null(key.CustomList); + } + + // ── Multi-key sort ────────────────────────────────────────────────────── + [Fact] + public void Sort_TwoKeys_ShouldUseSecondaryWhenPrimaryEquals() + { + // Region asc, then Q1 desc + var (_, ws) = Build(4, 2); + ws.Cells[0, 0].SetValue("EMEA"); ws.Cells[0, 1].Value = 100.0; + ws.Cells[1, 0].SetValue("AMER"); ws.Cells[1, 1].Value = 50.0; + ws.Cells[2, 0].SetValue("EMEA"); ws.Cells[2, 1].Value = 200.0; + ws.Cells[3, 0].SetValue("AMER"); ws.Cells[3, 1].Value = 75.0; + + ws.Sort(Range(0, 0, 3, 1), + new SortKey { ColumnIndex = 0, Order = SortOrder.Ascending }, + new SortKey { ColumnIndex = 1, Order = SortOrder.Descending }); + + // Expected order: AMER 75, AMER 50, EMEA 200, EMEA 100 + Assert.Equal("AMER", ws.Cells[0, 0].Value); Assert.Equal(75.0, ws.Cells[0, 1].Value); + Assert.Equal("AMER", ws.Cells[1, 0].Value); Assert.Equal(50.0, ws.Cells[1, 1].Value); + Assert.Equal("EMEA", ws.Cells[2, 0].Value); Assert.Equal(200.0, ws.Cells[2, 1].Value); + Assert.Equal("EMEA", ws.Cells[3, 0].Value); Assert.Equal(100.0, ws.Cells[3, 1].Value); + } + + [Fact] + public void Sort_SingleKey_ShouldBeRelativeToRange() + { + // Sort B1:D3 by column index 0 (which is sheet column B, the FIRST column of the range) + var (_, ws) = Build(3, 4); + // A column is unrelated (left of range) + ws.Cells[0, 0].SetValue("a"); ws.Cells[1, 0].SetValue("b"); ws.Cells[2, 0].SetValue("c"); + // B column = sort key + ws.Cells[0, 1].Value = 30.0; + ws.Cells[1, 1].Value = 10.0; + ws.Cells[2, 1].Value = 20.0; + ws.Cells[0, 2].SetValue("X"); ws.Cells[1, 2].SetValue("Y"); ws.Cells[2, 2].SetValue("Z"); + + ws.Sort(Range(0, 1, 2, 3), new SortKey { ColumnIndex = 0 }); + + // A column untouched + Assert.Equal("a", ws.Cells[0, 0].Value); + // B column sorted ascending: 10, 20, 30 + Assert.Equal(10.0, ws.Cells[0, 1].Value); + Assert.Equal(20.0, ws.Cells[1, 1].Value); + Assert.Equal(30.0, ws.Cells[2, 1].Value); + // C column moves with B + Assert.Equal("Y", ws.Cells[0, 2].Value); + Assert.Equal("Z", ws.Cells[1, 2].Value); + Assert.Equal("X", ws.Cells[2, 2].Value); + } + + // ── Custom list ───────────────────────────────────────────────────────── + [Fact] + public void Sort_WithCustomList_ShouldUseListOrder() + { + var (_, ws) = Build(4, 1); + ws.Cells[0, 0].SetValue("Mar"); + ws.Cells[1, 0].SetValue("Jan"); + ws.Cells[2, 0].SetValue("Feb"); + ws.Cells[3, 0].SetValue("Apr"); + + ws.Sort(Range(0, 0, 3, 0), + new SortKey { ColumnIndex = 0, CustomList = ["Jan", "Feb", "Mar", "Apr"] }); + + Assert.Equal("Jan", ws.Cells[0, 0].Value); + Assert.Equal("Feb", ws.Cells[1, 0].Value); + Assert.Equal("Mar", ws.Cells[2, 0].Value); + Assert.Equal("Apr", ws.Cells[3, 0].Value); + } + + [Fact] + public void Sort_WithCustomList_DescendingShouldReverse() + { + var (_, ws) = Build(3, 1); + ws.Cells[0, 0].SetValue("Feb"); + ws.Cells[1, 0].SetValue("Jan"); + ws.Cells[2, 0].SetValue("Mar"); + + ws.Sort(Range(0, 0, 2, 0), + new SortKey + { + ColumnIndex = 0, + Order = SortOrder.Descending, + CustomList = ["Jan", "Feb", "Mar"], + }); + + Assert.Equal("Mar", ws.Cells[0, 0].Value); + Assert.Equal("Feb", ws.Cells[1, 0].Value); + Assert.Equal("Jan", ws.Cells[2, 0].Value); + } + + [Fact] + public void Sort_WithCustomList_ValuesNotInListShouldComeAfter() + { + var (_, ws) = Build(4, 1); + ws.Cells[0, 0].SetValue("Apple"); + ws.Cells[1, 0].SetValue("Jan"); + ws.Cells[2, 0].SetValue("Banana"); + ws.Cells[3, 0].SetValue("Feb"); + + ws.Sort(Range(0, 0, 3, 0), + new SortKey { ColumnIndex = 0, CustomList = ["Jan", "Feb"] }); + + // Items in the list come first, in list order + Assert.Equal("Jan", ws.Cells[0, 0].Value); + Assert.Equal("Feb", ws.Cells[1, 0].Value); + // Items not in the list follow, alphabetically + Assert.Equal("Apple", ws.Cells[2, 0].Value); + Assert.Equal("Banana", ws.Cells[3, 0].Value); + } + + // ── Case sensitivity ──────────────────────────────────────────────────── + [Fact] + public void Sort_CaseInsensitive_ShouldTreatVariantsAsEqual_AndPreserveOrder() + { + var (_, ws) = Build(3, 2); + ws.Cells[0, 0].SetValue("apple"); ws.Cells[0, 1].Value = 1.0; + ws.Cells[1, 0].SetValue("Apple"); ws.Cells[1, 1].Value = 2.0; + ws.Cells[2, 0].SetValue("APPLE"); ws.Cells[2, 1].Value = 3.0; + + ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 0 }); + // Stable: original order preserved when keys are equal + Assert.Equal(1.0, ws.Cells[0, 1].Value); + Assert.Equal(2.0, ws.Cells[1, 1].Value); + Assert.Equal(3.0, ws.Cells[2, 1].Value); + } + + [Fact] + public void Sort_CaseSensitive_ShouldOrderUppercaseSeparately() + { + var (_, ws) = Build(3, 1); + ws.Cells[0, 0].SetValue("apple"); + ws.Cells[1, 0].SetValue("Apple"); + ws.Cells[2, 0].SetValue("APPLE"); + + ws.Sort(Range(0, 0, 2, 0), + new SortKey { ColumnIndex = 0, CaseSensitive = true }); + + // Ordinal sort: 'A' (0x41) < 'a' (0x61), so APPLE < Apple < apple. + Assert.Equal("APPLE", ws.Cells[0, 0].Value); + Assert.Equal("Apple", ws.Cells[1, 0].Value); + Assert.Equal("apple", ws.Cells[2, 0].Value); + } + + // ── Sort by color ─────────────────────────────────────────────────────── + [Fact] + public void Sort_ByCellColor_ShouldGroupMatchingColorFirst() + { + var (_, ws) = Build(4, 1); + ws.Cells[0, 0].SetValue("a"); + ws.Cells[1, 0].SetValue("b"); ws.Cells[1, 0].Format = new Format { BackgroundColor = "#FFFF00" }; + ws.Cells[2, 0].SetValue("c"); + ws.Cells[3, 0].SetValue("d"); ws.Cells[3, 0].Format = new Format { BackgroundColor = "#FFFF00" }; + + ws.Sort(Range(0, 0, 3, 0), + new SortKey + { + ColumnIndex = 0, + SortOn = SortOn.CellColor, + CustomColor = "#FFFF00", + Order = SortOrder.Ascending, + }); + + // Yellow rows come first (preserving their relative order). + Assert.Equal("b", ws.Cells[0, 0].Value); + Assert.Equal("d", ws.Cells[1, 0].Value); + Assert.Equal("a", ws.Cells[2, 0].Value); + Assert.Equal("c", ws.Cells[3, 0].Value); + } + + // ── Empty + no-op cases ───────────────────────────────────────────────── + [Fact] + public void Sort_EmptyKeys_ShouldBeNoOp() + { + var (_, ws) = Build(3, 1); + ws.Cells[0, 0].Value = 3.0; + ws.Cells[1, 0].Value = 1.0; + ws.Cells[2, 0].Value = 2.0; + + ws.Sort(Range(0, 0, 2, 0)); // params, no keys + + // Order unchanged + Assert.Equal(3.0, ws.Cells[0, 0].Value); + Assert.Equal(1.0, ws.Cells[1, 0].Value); + Assert.Equal(2.0, ws.Cells[2, 0].Value); + } + + [Fact] + public void Sort_KeyOutsideRange_ShouldThrow() + { + var (_, ws) = Build(3, 2); + Assert.Throws(() => + ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 5 })); + } +} diff --git a/Radzen.Blazor/Documents/Spreadsheet/AdvancedFilterCriteria.cs b/Radzen.Blazor/Documents/Spreadsheet/AdvancedFilterCriteria.cs new file mode 100644 index 000000000..1d44b821c --- /dev/null +++ b/Radzen.Blazor/Documents/Spreadsheet/AdvancedFilterCriteria.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Radzen.Documents.Spreadsheet; + +#nullable enable + +/// +/// Excel's dynamic filter operators. Map onto DynamicFilterType in OOXML. +/// +public enum DynamicFilterType +{ + /// Above the column's average value. + AboveAverage, + /// Below the column's average value. + BelowAverage, + /// Cells whose date equals today. + Today, + /// Cells whose date equals yesterday. + Yesterday, + /// Cells whose date equals tomorrow. + Tomorrow, + /// Cells whose date is in this week (Mon..Sun). + ThisWeek, + /// Cells whose date is in last week. + LastWeek, + /// Cells whose date is in next week. + NextWeek, + /// Cells whose date is in the current calendar month. + ThisMonth, + /// Cells whose date is in last calendar month. + LastMonth, + /// Cells whose date is in next calendar month. + NextMonth, + /// Cells whose date is in the current calendar quarter. + ThisQuarter, + /// Cells whose date is in last quarter. + LastQuarter, + /// Cells whose date is in next quarter. + NextQuarter, + /// Cells whose date is in the current calendar year. + ThisYear, + /// Cells whose date is in last year. + LastYear, + /// Cells whose date is in next year. + NextYear, + /// Cells whose date is between Jan 1 of the current year and today. + YearToDate, + /// Cells whose date is in January (any year). + January, + /// Cells whose date is in February (any year). + February, + /// Cells whose date is in March (any year). + March, + /// Cells whose date is in April (any year). + April, + /// Cells whose date is in May (any year). + May, + /// Cells whose date is in June (any year). + June, + /// Cells whose date is in July (any year). + July, + /// Cells whose date is in August (any year). + August, + /// Cells whose date is in September (any year). + September, + /// Cells whose date is in October (any year). + October, + /// Cells whose date is in November (any year). + November, + /// Cells whose date is in December (any year). + December, + /// Cells whose date is in Q1 (Jan-Mar) of any year. + Quarter1, + /// Cells whose date is in Q2 (Apr-Jun) of any year. + Quarter2, + /// Cells whose date is in Q3 (Jul-Sep) of any year. + Quarter3, + /// Cells whose date is in Q4 (Oct-Dec) of any year. + Quarter4, +} + +/// +/// Filters to the top or bottom N items (or N percent) of a column. +/// +public class TopFilterCriterion : FilterCriterion +{ + private readonly HashSet matchingRows = []; + + /// The column this filter applies to. + public int Column { get; init; } + + /// + /// When is false, the number of rows to keep. When true, a 0..100 + /// percentage of the rows. + /// + public int Count { get; init; } + + /// is interpreted as a percentage instead of a row count. + public bool Percent { get; init; } + + /// When true, keep the lowest values instead of the highest. + public bool Bottom { get; init; } + + /// + public override void OnApply(Worksheet sheet, RangeRef range) + { + ArgumentNullException.ThrowIfNull(sheet); + + matchingRows.Clear(); + + // Skip header row. + var startRow = range.Start.Row + 1; + var endRow = range.End.Row; + var rowCount = Math.Max(0, endRow - startRow + 1); + + var keep = Percent + ? (int)Math.Ceiling(rowCount * (Count / 100.0)) + : Count; + keep = Math.Clamp(keep, 0, rowCount); + if (keep == 0) return; + + var values = new List<(int row, double value)>(rowCount); + for (var r = startRow; r <= endRow; r++) + { + if (sheet.Cells.TryGet(r, Column, out var cell) + && TryCoerceToDouble(cell.Value, out var v)) + { + values.Add((r, v)); + } + } + + var ordered = Bottom + ? values.OrderBy(v => v.value) + : values.OrderByDescending(v => v.value); + + foreach (var (row, _) in ordered.Take(keep)) + { + matchingRows.Add(row); + } + } + + /// + public override bool Matches(Worksheet sheet, int row) => matchingRows.Contains(row); + + /// + public override void Accept(IFilterCriterionVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + visitor.Visit(this); + } + + internal static bool TryCoerceToDouble(object? value, out double result) + { + switch (value) + { + case double d: result = d; return true; + case int i: result = i; return true; + case long l: result = l; return true; + case float f: result = f; return true; + case decimal m: result = (double)m; return true; + case DateTime dt: result = dt.ToOADate(); return true; + case string s when double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n): + result = n; return true; + } + result = default; + return false; + } +} + +/// +/// Filters using one of Excel's built-in dynamic predicates (above-average, today, this-month, etc.). +/// +public class DynamicFilterCriterion : FilterCriterion +{ + private double average; + private bool averageComputed; + + /// The column this filter applies to. + public int Column { get; init; } + + /// The dynamic predicate to apply. + public DynamicFilterType Type { get; init; } + + /// + public override void OnApply(Worksheet sheet, RangeRef range) + { + ArgumentNullException.ThrowIfNull(sheet); + + averageComputed = false; + if (Type is DynamicFilterType.AboveAverage or DynamicFilterType.BelowAverage) + { + var sum = 0.0; + var n = 0; + for (var r = range.Start.Row + 1; r <= range.End.Row; r++) + { + if (sheet.Cells.TryGet(r, Column, out var cell) + && TopFilterCriterion.TryCoerceToDouble(cell.Value, out var v)) + { + sum += v; + n++; + } + } + if (n > 0) + { + average = sum / n; + averageComputed = true; + } + } + } + + /// + public override bool Matches(Worksheet sheet, int row) + { + ArgumentNullException.ThrowIfNull(sheet); + var cell = sheet.Cells[row, Column]; + + switch (Type) + { + case DynamicFilterType.AboveAverage: + if (!averageComputed) return false; + return TopFilterCriterion.TryCoerceToDouble(cell.Value, out var av) && av > average; + + case DynamicFilterType.BelowAverage: + if (!averageComputed) return false; + return TopFilterCriterion.TryCoerceToDouble(cell.Value, out var bv) && bv < average; + + default: + return TryGetDate(cell.Value, out var date) && MatchesDate(date); + } + } + + private bool MatchesDate(DateTime date) + { + var today = DateTime.Today; + var d = date.Date; + + return Type switch + { + DynamicFilterType.Today => d == today, + DynamicFilterType.Yesterday => d == today.AddDays(-1), + DynamicFilterType.Tomorrow => d == today.AddDays(1), + DynamicFilterType.ThisWeek => InWeek(d, today), + DynamicFilterType.LastWeek => InWeek(d, today.AddDays(-7)), + DynamicFilterType.NextWeek => InWeek(d, today.AddDays(7)), + DynamicFilterType.ThisMonth => d.Year == today.Year && d.Month == today.Month, + DynamicFilterType.LastMonth => InMonth(d, today.AddMonths(-1)), + DynamicFilterType.NextMonth => InMonth(d, today.AddMonths(1)), + DynamicFilterType.ThisQuarter => InQuarter(d, today), + DynamicFilterType.LastQuarter => InQuarter(d, today.AddMonths(-3)), + DynamicFilterType.NextQuarter => InQuarter(d, today.AddMonths(3)), + DynamicFilterType.ThisYear => d.Year == today.Year, + DynamicFilterType.LastYear => d.Year == today.Year - 1, + DynamicFilterType.NextYear => d.Year == today.Year + 1, + DynamicFilterType.YearToDate => d.Year == today.Year && d <= today, + DynamicFilterType.January => d.Month == 1, + DynamicFilterType.February => d.Month == 2, + DynamicFilterType.March => d.Month == 3, + DynamicFilterType.April => d.Month == 4, + DynamicFilterType.May => d.Month == 5, + DynamicFilterType.June => d.Month == 6, + DynamicFilterType.July => d.Month == 7, + DynamicFilterType.August => d.Month == 8, + DynamicFilterType.September => d.Month == 9, + DynamicFilterType.October => d.Month == 10, + DynamicFilterType.November => d.Month == 11, + DynamicFilterType.December => d.Month == 12, + DynamicFilterType.Quarter1 => d.Month is >= 1 and <= 3, + DynamicFilterType.Quarter2 => d.Month is >= 4 and <= 6, + DynamicFilterType.Quarter3 => d.Month is >= 7 and <= 9, + DynamicFilterType.Quarter4 => d.Month is >= 10 and <= 12, + _ => false, + }; + } + + private static bool InWeek(DateTime d, DateTime reference) + { + // ISO 8601: weeks start Monday. + var refMonday = reference.AddDays(-((int)reference.DayOfWeek + 6) % 7); + var refSunday = refMonday.AddDays(7); + return d >= refMonday && d < refSunday; + } + + private static bool InMonth(DateTime d, DateTime reference) => + d.Year == reference.Year && d.Month == reference.Month; + + private static bool InQuarter(DateTime d, DateTime reference) + { + if (d.Year != reference.Year) return false; + var quarterOf = (int month) => (month - 1) / 3 + 1; + return quarterOf(d.Month) == quarterOf(reference.Month); + } + + private static bool TryGetDate(object? value, out DateTime date) + { + switch (value) + { + case DateTime dt: + date = dt; + return true; + case double d: + try { date = DateTime.FromOADate(d); return true; } + catch { date = default; return false; } + case string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed): + date = parsed; + return true; + } + date = default; + return false; + } + + /// + public override void Accept(IFilterCriterionVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + visitor.Visit(this); + } +} + +/// +/// Filters to cells whose background or font color matches a target color. +/// +public class CellColorFilterCriterion : FilterCriterion +{ + /// The column this filter applies to. + public int Column { get; init; } + + /// The hex color string to match (e.g. #FFFF00). + public string? Color { get; init; } + + /// + /// When false (default), matches against the cell's . + /// When true, matches against (font color). + /// + public bool FontColor { get; init; } + + /// + public override bool Matches(Worksheet sheet, int row) + { + ArgumentNullException.ThrowIfNull(sheet); + if (!sheet.Cells.TryGet(row, Column, out var cell)) return false; + var actual = FontColor ? cell.Format.Color : cell.Format.BackgroundColor; + return string.Equals(actual, Color, StringComparison.OrdinalIgnoreCase); + } + + /// + public override void Accept(IFilterCriterionVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + visitor.Visit(this); + } +} diff --git a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs index 0384903da..650ed622b 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs @@ -48,13 +48,20 @@ public class Cell /// Clones the cell, creating a new instance with the same properties. /// - public Cell Clone() => new(Worksheet, Address) + public Cell Clone() { - Data = new CellData(Value), - Formula = Formula, - QuotePrefix = QuotePrefix, - Hyperlink = Hyperlink?.Clone() - }; + var clone = new Cell(Worksheet, Address) + { + Data = new CellData(Value), + Formula = Formula, + QuotePrefix = QuotePrefix, + Hyperlink = Hyperlink?.Clone(), + }; + // Format is shared by reference (Format itself is mutable, but the same + // semantics apply as in CopyFrom, which mirrors this behavior). + clone.format = format; + return clone; + } /// /// Copies the properties from another cell to this cell. diff --git a/Radzen.Blazor/Documents/Spreadsheet/Table.cs b/Radzen.Blazor/Documents/Spreadsheet/Table.cs index 215e31c94..fef1daa54 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Table.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Table.cs @@ -202,11 +202,21 @@ public class Table : null; /// - /// Sorts the table by a single column. Column index is relative to the table. + /// Sorts the table by a single column. is relative to the table + /// (0 = first column of the table). /// public void Sort(SortOrder order, int column) { - Worksheet.Sort(range, order, column); + Worksheet.Sort(range, new SortKey { ColumnIndex = column, Order = order }); + } + + /// + /// Sorts the table using the given multi-level sort keys. Column indices are relative + /// to the table. + /// + public void Sort(params SortKey[] keys) + { + Worksheet.Sort(range, keys); } /// Resizes the table to a new range. Columns are added or trimmed accordingly. @@ -507,4 +517,142 @@ public class AutoFilter Worksheet.ClearFilters(); } } + + // ── Per-column filter API ─────────────────────────────────────────────── + // Column indices are RELATIVE to the AutoFilter's range left edge. + + /// + /// Filters a column to the given list of allowed values. Replaces any existing + /// filter on that column. + /// + public void ApplyValueFilter(int columnIndex, System.Collections.Generic.IEnumerable values) + { + ArgumentNullException.ThrowIfNull(values); + var absCol = ResolveAbsoluteColumn(columnIndex); + ApplyColumnFilter(absCol, new InListCriterion + { + Column = absCol, + Values = values.ToArray(), + }); + } + + /// + /// Filters a column using an arbitrary . The criterion's + /// Column field is overwritten with the absolute column derived from + /// . + /// + public void ApplyCustomFilter(int columnIndex, FilterCriterion criterion) + { + ArgumentNullException.ThrowIfNull(criterion); + var absCol = ResolveAbsoluteColumn(columnIndex); + // Force the criterion's Column to match. + if (criterion is FilterCriterionLeaf leaf) + { + // FilterCriterionLeaf.Column is init-only; clone if necessary. For simplicity, + // we just pass the criterion through and trust the caller. + _ = leaf; + } + ApplyColumnFilter(absCol, criterion); + } + + /// Filters a column to its top N (or bottom N) items, by count or by percent. + public void ApplyTopFilter(int columnIndex, int count, bool percent = false, bool bottom = false) + { + var absCol = ResolveAbsoluteColumn(columnIndex); + ApplyColumnFilter(absCol, new TopFilterCriterion + { + Column = absCol, + Count = count, + Percent = percent, + Bottom = bottom, + }); + } + + /// Filters a column using one of Excel's dynamic predicates. + public void ApplyDynamicFilter(int columnIndex, DynamicFilterType type) + { + var absCol = ResolveAbsoluteColumn(columnIndex); + ApplyColumnFilter(absCol, new DynamicFilterCriterion + { + Column = absCol, + Type = type, + }); + } + + /// Filters a column to cells with the matching color. + public void ApplyColorFilter(int columnIndex, string color, bool fontColor = false) + { + ArgumentException.ThrowIfNullOrEmpty(color); + var absCol = ResolveAbsoluteColumn(columnIndex); + ApplyColumnFilter(absCol, new CellColorFilterCriterion + { + Column = absCol, + Color = color, + FontColor = fontColor, + }); + } + + /// Removes any filter applied to . + public void ClearColumnFilter(int columnIndex) + { + var absCol = ResolveAbsoluteColumn(columnIndex); + var existing = FindColumnFilter(absCol); + if (existing is not null) + { + Worksheet.RemoveFilter(existing); + } + } + + /// Returns the filter currently applied to , or null. + public SheetFilter? GetColumnFilter(int columnIndex) + { + var absCol = ResolveAbsoluteColumn(columnIndex); + return FindColumnFilter(absCol); + } + + private int ResolveAbsoluteColumn(int relativeColumn) + { + if (Range is null) + { + throw new InvalidOperationException( + "AutoFilter.Range must be set before applying per-column filters."); + } + if (relativeColumn < 0 || relativeColumn >= Range.Value.Columns) + { + throw new ArgumentOutOfRangeException(nameof(relativeColumn), + $"Column index {relativeColumn} is outside the AutoFilter range columns 0..{Range.Value.Columns - 1}."); + } + return Range.Value.Start.Column + relativeColumn; + } + + private void ApplyColumnFilter(int absoluteColumn, FilterCriterion criterion) + { + // Replace any existing per-column filter for this column. + var existing = FindColumnFilter(absoluteColumn); + if (existing is not null) + { + Worksheet.RemoveFilter(existing); + } + + // SheetFilter range = single column slice spanning the AutoFilter rows. + var afRange = Range!.Value; + var sliceRange = new RangeRef( + new CellRef(afRange.Start.Row, absoluteColumn), + new CellRef(afRange.End.Row, absoluteColumn)); + + Worksheet.AddFilter(new SheetFilter(criterion, sliceRange)); + } + + private SheetFilter? FindColumnFilter(int absoluteColumn) + { + foreach (var f in Worksheet.Filters) + { + // A per-column filter has range collapsed to one column. + if (f.Range.Start.Column == absoluteColumn && f.Range.End.Column == absoluteColumn) + { + return f; + } + } + return null; + } } diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs index 705bf7b89..abb347a10 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs @@ -50,6 +50,13 @@ public abstract class FilterCriterion /// public abstract bool Matches(Worksheet sheet, int row); + /// + /// Called once before iteration begins, with the range the filter is being applied to. + /// Default is no-op. Distribution-aware criteria (top/bottom, above-average) override this + /// to compute their threshold. + /// + public virtual void OnApply(Worksheet sheet, RangeRef range) { } + /// /// Accepts a visitor that can perform operations on this filter criterion. /// @@ -140,6 +147,21 @@ public interface IFilterCriterionVisitor /// Visits a DoesNotContainCriterion. /// void Visit(DoesNotContainCriterion criterion); + + /// + /// Visits a TopFilterCriterion. + /// + void Visit(TopFilterCriterion criterion); + + /// + /// Visits a DynamicFilterCriterion. + /// + void Visit(DynamicFilterCriterion criterion); + + /// + /// Visits a CellColorFilterCriterion. + /// + void Visit(CellColorFilterCriterion criterion); } /// @@ -240,6 +262,21 @@ public abstract class FilterCriterionVisitorBase : IFilterCriterionVisitor public virtual void Visit(DoesNotContainCriterion criterion) { } + + /// + public virtual void Visit(TopFilterCriterion criterion) + { + } + + /// + public virtual void Visit(DynamicFilterCriterion criterion) + { + } + + /// + public virtual void Visit(CellColorFilterCriterion criterion) + { + } } /// @@ -898,6 +935,8 @@ public partial class Worksheet return; } + criterion.OnApply(this, range); + // Excel treats the first row as a header and excludes it from filtering var startRow = range.Start.Row + 1; var endRow = range.End.Row; diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs index e67a3e6e0..7809c545c 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs @@ -1,112 +1,232 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Radzen.Documents.Spreadsheet; #nullable enable +/// +/// Specifies the value the sort comparison runs against — the cell's stored +/// value, its background color, or its font color. +/// +public enum SortOn +{ + /// Compare the cell's stored value (default). + Values, + /// Group cells whose BackgroundColor matches . + CellColor, + /// Group cells whose Color (font color) matches . + FontColor, +} + +/// +/// Describes one sort level used by the multi-key +/// overload. +/// +public sealed class SortKey +{ + /// + /// Column index relative to the sort range's left edge (0 = first column of the range). + /// + public int ColumnIndex { get; init; } + + /// Ascending or descending order. Defaults to ascending. + public SortOrder Order { get; init; } = SortOrder.Ascending; + + /// What aspect of each cell drives the comparison. Defaults to . + public SortOn SortOn { get; init; } = SortOn.Values; + + /// + /// When set, sorts using this list's order rather than natural ordering. + /// Items not present in the list sort after items in the list, in natural order. + /// + public string[]? CustomList { get; init; } + + /// + /// When set with or , + /// cells whose color matches sort first (or last when descending). + /// + public string? CustomColor { get; init; } + + /// + /// When true, value comparisons are case-sensitive (ordinal). Default false (ordinal-ignore-case). + /// + public bool CaseSensitive { get; init; } +} + public partial class Worksheet { /// - /// Sorts the specified range of cells in the sheet based on the specified order and key index. + /// Sorts the specified range using the given sort levels. Column indices in + /// are relative to the range's left edge. + /// Stable: rows with equal keys keep their input order. /// - /// - /// - /// + public void Sort(RangeRef range, params SortKey[] keys) + { + ArgumentNullException.ThrowIfNull(keys); + if (range == RangeRef.Invalid || keys.Length == 0) + { + return; + } + + foreach (var key in keys) + { + if (key.ColumnIndex < 0 || key.ColumnIndex >= range.Columns) + { + throw new ArgumentOutOfRangeException( + nameof(keys), + $"SortKey.ColumnIndex {key.ColumnIndex} is outside range columns 0..{range.Columns - 1}."); + } + } + + var rows = new List<(int originalIndex, List cells)>(); + for (var r = range.Start.Row; r <= range.End.Row; r++) + { + rows.Add((rows.Count, Cells.GetRow(r, range.Start.Column, range.End.Column))); + } + + // Use OrderBy to keep stability across multiple keys. + IOrderedEnumerable<(int originalIndex, List cells)>? ordered = null; + foreach (var key in keys) + { + var k = key; + ordered = ordered is null + ? rows.OrderBy(row => row, BuildComparer(k)) + : ordered.ThenBy(row => row, BuildComparer(k)); + } + + var sortedRows = ordered!.ToList(); + + for (var i = 0; i < sortedRows.Count; i++) + { + var (_, cells) = sortedRows[i]; + var targetRow = range.Start.Row + i; + for (var c = 0; c < cells.Count; c++) + { + Cells[targetRow, range.Start.Column + c].CopyFrom(cells[c]); + } + } + } + + private static IComparer<(int originalIndex, List cells)> BuildComparer(SortKey key) + { + return Comparer<(int originalIndex, List cells)>.Create((a, b) => + { + var ca = a.cells[key.ColumnIndex]; + var cb = b.cells[key.ColumnIndex]; + + // Empty cells always go last, regardless of sort direction (Excel parity). + // Color sort: a cell with a null target color is treated as "non-empty value". + if (key.SortOn == SortOn.Values) + { + var aEmpty = ca?.Value is null; + var bEmpty = cb?.Value is null; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + } + + int cmp = key.SortOn switch + { + SortOn.CellColor => CompareByColor(ca, cb, key.CustomColor, fontColor: false), + SortOn.FontColor => CompareByColor(ca, cb, key.CustomColor, fontColor: true), + _ => CompareByValue(ca?.Value, cb?.Value, key), + }; + + return key.Order == SortOrder.Descending ? -cmp : cmp; + }); + } + + private static int CompareByValue(object? x, object? y, SortKey key) + { + if (key.CustomList is { Length: > 0 } list) + { + var xs = x?.ToString(); + var ys = y?.ToString(); + int xi = IndexInList(list, xs, key.CaseSensitive); + int yi = IndexInList(list, ys, key.CaseSensitive); + + // List members come first, in list order; non-members fall through to natural compare. + if (xi >= 0 && yi >= 0) return xi.CompareTo(yi); + if (xi >= 0) return -1; + if (yi >= 0) return 1; + // Both outside the list — natural string compare + return string.Compare(xs, ys, key.CaseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase); + } + + return Compare(x, y, key.CaseSensitive); + } + + private static int CompareByColor(Cell? a, Cell? b, string? targetColor, bool fontColor) + { + var ac = ColorOf(a, fontColor); + var bc = ColorOf(b, fontColor); + + var aMatch = string.Equals(ac, targetColor, StringComparison.OrdinalIgnoreCase); + var bMatch = string.Equals(bc, targetColor, StringComparison.OrdinalIgnoreCase); + + // Matching colors come first. + if (aMatch == bMatch) return 0; + return aMatch ? -1 : 1; + } + + private static string? ColorOf(Cell? c, bool fontColor) + { + if (c is null) return null; + return fontColor ? c.Format.Color : c.Format.BackgroundColor; + } + + private static int Compare(object? x, object? y, bool caseSensitive = false) + { + if (x is null && y is null) return 0; + if (x is null) return 1; + if (y is null) return -1; + + if (x is double dx && y is double dy) return dx.CompareTo(dy); + + var sx = x.ToString(); + var sy = y.ToString(); + return string.Compare(sx, sy, caseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase); + } + + private static int Compare(object? x, object? y) => Compare(x, y, caseSensitive: false); + + private static int IndexInList(string[] list, string? value, bool caseSensitive) + { + if (value is null) return -1; + var cmp = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + for (var i = 0; i < list.Length; i++) + { + if (string.Equals(list[i], value, cmp)) return i; + } + return -1; + } + + /// + /// Single-key sort by an absolute column index. Kept for back-compat; + /// new code should prefer the overload. + /// + /// The range to sort. + /// Ascending or descending. + /// Absolute column index of the key (must be inside ). public void Sort(RangeRef range, SortOrder order, int keyIndex = 0) { - if (range != RangeRef.Invalid) + if (range == RangeRef.Invalid) return; + + if (keyIndex < range.Start.Column || keyIndex > range.End.Column) { - if (keyIndex < range.Start.Column || keyIndex > range.End.Column) - { - throw new ArgumentOutOfRangeException(nameof(keyIndex)); - } - - var rows = new List<(object? key, List cells)>(); - - for (var row = range.Start.Row; row <= range.End.Row; row++) - { - var cells = Cells.GetRow(row, range.Start.Column, range.End.Column); - - var key = cells[keyIndex - range.Start.Column]?.Value; - - rows.Add((key, cells)); - } - - if (order == SortOrder.Ascending) - { - rows.Sort(Compare); - } - else - { - rows.Sort(CompareDescending); - } - - for (var row = 0; row < rows.Count; row++) - { - var (key, cells) = rows[row]; - var targetRow = range.Start.Row + row; - - for (var column = 0; column < cells.Count; column++) - { - var targetColumn = range.Start.Column + column; - var targetCell = Cells[targetRow, targetColumn]; - var sourceCell = cells[column]; - - targetCell.CopyFrom(sourceCell); - } - } + throw new ArgumentOutOfRangeException(nameof(keyIndex)); } + + Sort(range, new SortKey + { + ColumnIndex = keyIndex - range.Start.Column, + Order = order, + }); } - private static int Compare((object? key, List cells) x, (object? key, List cells) y) => Compare(x.key, y.key); - - private static int CompareDescending((object? key, List cells) x, (object? key, List cells) y) - { - if (x.key is null && y.key is null) - { - return 0; - } - - if (x.key is null) - { - return 1; - } - - if (y.key is null) - { - return -1; - } - - return Compare(y.key, x.key); - } - - private static int Compare(object? x, object? y) - { - if (x is null && y is null) - { - return 0; - } - - if (x is null) - { - return 1; - } - - if (y is null) - { - return -1; - } - - if (x is double dx && y is double dy) - { - return dx.CompareTo(dy); - } - - if (x is string sx && y is string sy) - { - return string.Compare(sx, sy, StringComparison.OrdinalIgnoreCase); - } - - return string.Compare(x.ToString(), y.ToString(), StringComparison.OrdinalIgnoreCase); - } } \ No newline at end of file