mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Add multi-key sort, advanced filter criteria, per-column AutoFilter API
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.
This commit is contained in:
committed by
Vladimir Enchev
parent
bb20899bed
commit
52e1d29585
181
Radzen.Blazor.Tests/Spreadsheet/AutoFilterPerColumnTests.cs
Normal file
181
Radzen.Blazor.Tests/Spreadsheet/AutoFilterPerColumnTests.cs
Normal file
@@ -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<InListCriterion>(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<InListCriterion>(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<System.InvalidOperationException>(() =>
|
||||
ws.AutoFilter.ApplyValueFilter(0, ["a"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyValueFilter_ColumnOutOfRange_ShouldThrow()
|
||||
{
|
||||
var (_, _, af) = Build();
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
|
||||
af.ApplyValueFilter(99, ["x"]));
|
||||
}
|
||||
}
|
||||
228
Radzen.Blazor.Tests/Spreadsheet/FilterCriterionContractTests.cs
Normal file
228
Radzen.Blazor.Tests/Spreadsheet/FilterCriterionContractTests.cs
Normal file
@@ -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<string>();
|
||||
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<string> 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");
|
||||
}
|
||||
}
|
||||
227
Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs
Normal file
227
Radzen.Blazor.Tests/Spreadsheet/MultiKeySortContractTests.cs
Normal file
@@ -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<System.ArgumentOutOfRangeException>(() =>
|
||||
ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 5 }));
|
||||
}
|
||||
}
|
||||
354
Radzen.Blazor/Documents/Spreadsheet/AdvancedFilterCriteria.cs
Normal file
354
Radzen.Blazor/Documents/Spreadsheet/AdvancedFilterCriteria.cs
Normal file
@@ -0,0 +1,354 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Radzen.Documents.Spreadsheet;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Excel's dynamic filter operators. Map onto <c>DynamicFilterType</c> in OOXML.
|
||||
/// </summary>
|
||||
public enum DynamicFilterType
|
||||
{
|
||||
/// <summary>Above the column's average value.</summary>
|
||||
AboveAverage,
|
||||
/// <summary>Below the column's average value.</summary>
|
||||
BelowAverage,
|
||||
/// <summary>Cells whose date equals today.</summary>
|
||||
Today,
|
||||
/// <summary>Cells whose date equals yesterday.</summary>
|
||||
Yesterday,
|
||||
/// <summary>Cells whose date equals tomorrow.</summary>
|
||||
Tomorrow,
|
||||
/// <summary>Cells whose date is in this week (Mon..Sun).</summary>
|
||||
ThisWeek,
|
||||
/// <summary>Cells whose date is in last week.</summary>
|
||||
LastWeek,
|
||||
/// <summary>Cells whose date is in next week.</summary>
|
||||
NextWeek,
|
||||
/// <summary>Cells whose date is in the current calendar month.</summary>
|
||||
ThisMonth,
|
||||
/// <summary>Cells whose date is in last calendar month.</summary>
|
||||
LastMonth,
|
||||
/// <summary>Cells whose date is in next calendar month.</summary>
|
||||
NextMonth,
|
||||
/// <summary>Cells whose date is in the current calendar quarter.</summary>
|
||||
ThisQuarter,
|
||||
/// <summary>Cells whose date is in last quarter.</summary>
|
||||
LastQuarter,
|
||||
/// <summary>Cells whose date is in next quarter.</summary>
|
||||
NextQuarter,
|
||||
/// <summary>Cells whose date is in the current calendar year.</summary>
|
||||
ThisYear,
|
||||
/// <summary>Cells whose date is in last year.</summary>
|
||||
LastYear,
|
||||
/// <summary>Cells whose date is in next year.</summary>
|
||||
NextYear,
|
||||
/// <summary>Cells whose date is between Jan 1 of the current year and today.</summary>
|
||||
YearToDate,
|
||||
/// <summary>Cells whose date is in January (any year).</summary>
|
||||
January,
|
||||
/// <summary>Cells whose date is in February (any year).</summary>
|
||||
February,
|
||||
/// <summary>Cells whose date is in March (any year).</summary>
|
||||
March,
|
||||
/// <summary>Cells whose date is in April (any year).</summary>
|
||||
April,
|
||||
/// <summary>Cells whose date is in May (any year).</summary>
|
||||
May,
|
||||
/// <summary>Cells whose date is in June (any year).</summary>
|
||||
June,
|
||||
/// <summary>Cells whose date is in July (any year).</summary>
|
||||
July,
|
||||
/// <summary>Cells whose date is in August (any year).</summary>
|
||||
August,
|
||||
/// <summary>Cells whose date is in September (any year).</summary>
|
||||
September,
|
||||
/// <summary>Cells whose date is in October (any year).</summary>
|
||||
October,
|
||||
/// <summary>Cells whose date is in November (any year).</summary>
|
||||
November,
|
||||
/// <summary>Cells whose date is in December (any year).</summary>
|
||||
December,
|
||||
/// <summary>Cells whose date is in Q1 (Jan-Mar) of any year.</summary>
|
||||
Quarter1,
|
||||
/// <summary>Cells whose date is in Q2 (Apr-Jun) of any year.</summary>
|
||||
Quarter2,
|
||||
/// <summary>Cells whose date is in Q3 (Jul-Sep) of any year.</summary>
|
||||
Quarter3,
|
||||
/// <summary>Cells whose date is in Q4 (Oct-Dec) of any year.</summary>
|
||||
Quarter4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters to the top or bottom N items (or N percent) of a column.
|
||||
/// </summary>
|
||||
public class TopFilterCriterion : FilterCriterion
|
||||
{
|
||||
private readonly HashSet<int> matchingRows = [];
|
||||
|
||||
/// <summary>The column this filter applies to.</summary>
|
||||
public int Column { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see cref="Percent"/> is false, the number of rows to keep. When true, a 0..100
|
||||
/// percentage of the rows.
|
||||
/// </summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary><see cref="Count"/> is interpreted as a percentage instead of a row count.</summary>
|
||||
public bool Percent { get; init; }
|
||||
|
||||
/// <summary>When true, keep the lowest values instead of the highest.</summary>
|
||||
public bool Bottom { get; init; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Matches(Worksheet sheet, int row) => matchingRows.Contains(row);
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters using one of Excel's built-in dynamic predicates (above-average, today, this-month, etc.).
|
||||
/// </summary>
|
||||
public class DynamicFilterCriterion : FilterCriterion
|
||||
{
|
||||
private double average;
|
||||
private bool averageComputed;
|
||||
|
||||
/// <summary>The column this filter applies to.</summary>
|
||||
public int Column { get; init; }
|
||||
|
||||
/// <summary>The dynamic predicate to apply.</summary>
|
||||
public DynamicFilterType Type { get; init; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Accept(IFilterCriterionVisitor visitor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(visitor);
|
||||
visitor.Visit(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters to cells whose background or font color matches a target color.
|
||||
/// </summary>
|
||||
public class CellColorFilterCriterion : FilterCriterion
|
||||
{
|
||||
/// <summary>The column this filter applies to.</summary>
|
||||
public int Column { get; init; }
|
||||
|
||||
/// <summary>The hex color string to match (e.g. <c>#FFFF00</c>).</summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When false (default), matches against the cell's <see cref="Format.BackgroundColor"/>.
|
||||
/// When true, matches against <see cref="Format.Color"/> (font color).
|
||||
/// </summary>
|
||||
public bool FontColor { get; init; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Accept(IFilterCriterionVisitor visitor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(visitor);
|
||||
visitor.Visit(this);
|
||||
}
|
||||
}
|
||||
@@ -48,13 +48,20 @@ public class Cell
|
||||
/// Clones the cell, creating a new instance with the same properties.
|
||||
/// </summary>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the properties from another cell to this cell.
|
||||
|
||||
@@ -202,11 +202,21 @@ public class Table
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the table by a single column. Column index is relative to the table.
|
||||
/// Sorts the table by a single column. <paramref name="column"/> is relative to the table
|
||||
/// (0 = first column of the table).
|
||||
/// </summary>
|
||||
public void Sort(SortOrder order, int column)
|
||||
{
|
||||
Worksheet.Sort(range, order, column);
|
||||
Worksheet.Sort(range, new SortKey { ColumnIndex = column, Order = order });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the table using the given multi-level sort keys. Column indices are relative
|
||||
/// to the table.
|
||||
/// </summary>
|
||||
public void Sort(params SortKey[] keys)
|
||||
{
|
||||
Worksheet.Sort(range, keys);
|
||||
}
|
||||
|
||||
/// <summary>Resizes the table to a new range. Columns are added or trimmed accordingly.</summary>
|
||||
@@ -507,4 +517,142 @@ public class AutoFilter
|
||||
Worksheet.ClearFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-column filter API ───────────────────────────────────────────────
|
||||
// Column indices are RELATIVE to the AutoFilter's range left edge.
|
||||
|
||||
/// <summary>
|
||||
/// Filters a column to the given list of allowed values. Replaces any existing
|
||||
/// filter on that column.
|
||||
/// </summary>
|
||||
public void ApplyValueFilter(int columnIndex, System.Collections.Generic.IEnumerable<object?> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
var absCol = ResolveAbsoluteColumn(columnIndex);
|
||||
ApplyColumnFilter(absCol, new InListCriterion
|
||||
{
|
||||
Column = absCol,
|
||||
Values = values.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters a column using an arbitrary <see cref="FilterCriterion"/>. The criterion's
|
||||
/// <c>Column</c> field is overwritten with the absolute column derived from
|
||||
/// <paramref name="columnIndex"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Filters a column to its top N (or bottom N) items, by count or by percent.</summary>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Filters a column using one of Excel's dynamic predicates.</summary>
|
||||
public void ApplyDynamicFilter(int columnIndex, DynamicFilterType type)
|
||||
{
|
||||
var absCol = ResolveAbsoluteColumn(columnIndex);
|
||||
ApplyColumnFilter(absCol, new DynamicFilterCriterion
|
||||
{
|
||||
Column = absCol,
|
||||
Type = type,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Filters a column to cells with the matching color.</summary>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Removes any filter applied to <paramref name="columnIndex"/>.</summary>
|
||||
public void ClearColumnFilter(int columnIndex)
|
||||
{
|
||||
var absCol = ResolveAbsoluteColumn(columnIndex);
|
||||
var existing = FindColumnFilter(absCol);
|
||||
if (existing is not null)
|
||||
{
|
||||
Worksheet.RemoveFilter(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the filter currently applied to <paramref name="columnIndex"/>, or null.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ public abstract class FilterCriterion
|
||||
/// <returns></returns>
|
||||
public abstract bool Matches(Worksheet sheet, int row);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public virtual void OnApply(Worksheet sheet, RangeRef range) { }
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a visitor that can perform operations on this filter criterion.
|
||||
/// </summary>
|
||||
@@ -140,6 +147,21 @@ public interface IFilterCriterionVisitor
|
||||
/// Visits a DoesNotContainCriterion.
|
||||
/// </summary>
|
||||
void Visit(DoesNotContainCriterion criterion);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a TopFilterCriterion.
|
||||
/// </summary>
|
||||
void Visit(TopFilterCriterion criterion);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a DynamicFilterCriterion.
|
||||
/// </summary>
|
||||
void Visit(DynamicFilterCriterion criterion);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a CellColorFilterCriterion.
|
||||
/// </summary>
|
||||
void Visit(CellColorFilterCriterion criterion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -240,6 +262,21 @@ public abstract class FilterCriterionVisitorBase : IFilterCriterionVisitor
|
||||
public virtual void Visit(DoesNotContainCriterion criterion)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual void Visit(TopFilterCriterion criterion)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual void Visit(DynamicFilterCriterion criterion)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual void Visit(CellColorFilterCriterion criterion)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,112 +1,232 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Radzen.Documents.Spreadsheet;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the value the sort comparison runs against — the cell's stored
|
||||
/// value, its background color, or its font color.
|
||||
/// </summary>
|
||||
public enum SortOn
|
||||
{
|
||||
/// <summary>Compare the cell's stored value (default).</summary>
|
||||
Values,
|
||||
/// <summary>Group cells whose <c>BackgroundColor</c> matches <see cref="SortKey.CustomColor"/>.</summary>
|
||||
CellColor,
|
||||
/// <summary>Group cells whose <c>Color</c> (font color) matches <see cref="SortKey.CustomColor"/>.</summary>
|
||||
FontColor,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes one sort level used by the multi-key
|
||||
/// <see cref="Worksheet.Sort(RangeRef, SortKey[])"/> overload.
|
||||
/// </summary>
|
||||
public sealed class SortKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Column index relative to the sort range's left edge (0 = first column of the range).
|
||||
/// </summary>
|
||||
public int ColumnIndex { get; init; }
|
||||
|
||||
/// <summary>Ascending or descending order. Defaults to ascending.</summary>
|
||||
public SortOrder Order { get; init; } = SortOrder.Ascending;
|
||||
|
||||
/// <summary>What aspect of each cell drives the comparison. Defaults to <see cref="SortOn.Values"/>.</summary>
|
||||
public SortOn SortOn { get; init; } = SortOn.Values;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string[]? CustomList { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When set with <see cref="SortOn.CellColor"/> or <see cref="SortOn.FontColor"/>,
|
||||
/// cells whose color matches sort first (or last when descending).
|
||||
/// </summary>
|
||||
public string? CustomColor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, value comparisons are case-sensitive (ordinal). Default false (ordinal-ignore-case).
|
||||
/// </summary>
|
||||
public bool CaseSensitive { get; init; }
|
||||
}
|
||||
|
||||
public partial class Worksheet
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <paramref name="keys"/> are <em>relative</em> to the range's left edge.
|
||||
/// Stable: rows with equal keys keep their input order.
|
||||
/// </summary>
|
||||
/// <param name="range"></param>
|
||||
/// <param name="order"></param>
|
||||
/// <param name="keyIndex"></param>
|
||||
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<Cell> 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<Cell> 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<Cell> cells)> BuildComparer(SortKey key)
|
||||
{
|
||||
return Comparer<(int originalIndex, List<Cell> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-key sort by an absolute column index. Kept for back-compat;
|
||||
/// new code should prefer the <see cref="Sort(RangeRef, SortKey[])"/> overload.
|
||||
/// </summary>
|
||||
/// <param name="range">The range to sort.</param>
|
||||
/// <param name="order">Ascending or descending.</param>
|
||||
/// <param name="keyIndex">Absolute column index of the key (must be inside <paramref name="range"/>).</param>
|
||||
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<Cell> 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<Cell> cells) x, (object? key, List<Cell> cells) y) => Compare(x.key, y.key);
|
||||
|
||||
private static int CompareDescending((object? key, List<Cell> cells) x, (object? key, List<Cell> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user