diff --git a/Radzen.Blazor.Tests/Spreadsheet/FormulaDisplayTests.cs b/Radzen.Blazor.Tests/Spreadsheet/FormulaDisplayTests.cs new file mode 100644 index 000000000..087acb811 --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/FormulaDisplayTests.cs @@ -0,0 +1,52 @@ +using Bunit; +using Xunit; + +using Radzen.Documents.Spreadsheet; + +namespace Radzen.Blazor.Spreadsheet.Tests; + +public class FormulaDisplayTests +{ + [Fact] + public void CrossSheetFormula_ModelRetainsSheetPrefix() + { + var wb = new Workbook(); + var q1 = wb.AddSheet("Q1 Sales", 20, 10); + var summary = wb.AddSheet("Summary", 20, 10); + + q1.Cells["B2"].Value = 10d; + q1.Cells["C2"].Value = 20d; + q1.Cells["D2"].Value = 30d; + + summary.Cells["B2"].Formula = "=SUM('Q1 Sales'!B2:D2)"; + + Assert.Equal(60d, summary.Cells["B2"].Value); + Assert.Equal("=SUM('Q1 Sales'!B2:D2)", summary.Cells["B2"].Formula); + Assert.Equal("=SUM('Q1 Sales'!B2:D2)", summary.Cells["B2"].GetValue()); + } + + [Fact] + public void Highlight_CrossSheetReference_KeepsSheetPrefix() + { + using var ctx = new TestContext(); + + var cut = ctx.RenderComponent( + parameters => parameters.Add(p => p.Value, "=SUM('Q1 Sales'!B2:D2)")); + + // The syntax-highlight overlay must keep the worksheet prefix (it previously rendered + // the bare cell address, dropping 'Q1 Sales'!). + Assert.Contains("'Q1 Sales'!B2", cut.Markup); + } + + [Fact] + public void Highlight_BareCellReference_StillRenders() + { + using var ctx = new TestContext(); + + var cut = ctx.RenderComponent( + parameters => parameters.Add(p => p.Value, "=B2+C2")); + + Assert.Contains("B2", cut.Markup); + Assert.Contains("C2", cut.Markup); + } +} diff --git a/Radzen.Blazor.Tests/Spreadsheet/SpreadsheetRegressionTests.cs b/Radzen.Blazor.Tests/Spreadsheet/SpreadsheetRegressionTests.cs new file mode 100644 index 000000000..1ebadc0d0 --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/SpreadsheetRegressionTests.cs @@ -0,0 +1,144 @@ +using Xunit; + +using Radzen.Documents.Spreadsheet; + +namespace Radzen.Blazor.Spreadsheet.Tests; + +// Regressions found during end-user testing of RadzenSpreadsheet. +public class SpreadsheetRegressionTests +{ + // #1 - A batched value edit must still notify the edited cell (regression from wrapping + // every command in Worksheet.Batch). Dependents were handled by EndUpdate; the changed + // value cell was not. + [Fact] + public void BatchedValueEdit_NotifiesEditedCell() + { + var sheet = new Worksheet(10, 10); + var changed = 0; + sheet.Cells[2, 2].Changed += _ => changed++; + + sheet.Batch(() => sheet.Cells[2, 2].Value = 5d); + + Assert.Equal(5d, sheet.Cells[2, 2].Value); + Assert.Equal(1, changed); + } + + [Fact] + public void ClearContentsViaStack_NotifiesClearedCell() + { + var sheet = new Worksheet(10, 10); + sheet.Cells[0, 0].Value = 7d; + var stack = new UndoRedoStack(sheet); + + var changed = 0; + sheet.Cells[0, 0].Changed += _ => changed++; + + stack.Execute(new ClearContentsCommand(sheet, new RangeRef(new CellRef(0, 0), new CellRef(0, 0)))); + + Assert.Null(sheet.Cells[0, 0].Value); + Assert.True(changed >= 1, "cleared cell should raise Changed"); + } + + // #2 - Sorting a table must never move data into the header (or totals) row. + [Fact] + public void TableSort_KeepsHeaderRow() + { + var wb = new Workbook(); + var ws = wb.AddSheet("S", 10, 2); + ws.Cells[0, 0].SetValue("Name"); + ws.Cells[0, 1].SetValue("Dept"); + ws.Cells[1, 0].SetValue("Alice"); + ws.Cells[2, 0].SetValue("Carol"); + ws.Cells[3, 0].SetValue("Tina"); + + var table = ws.AddTable("T", RangeRef.Parse("A1:B4")); + table.Sort(SortOrder.Descending, 0); + + Assert.Equal("Name", ws.Cells[0, 0].Value); // header intact + Assert.Equal("Tina", ws.Cells[1, 0].Value); // first data row (descending) + Assert.Equal("Carol", ws.Cells[2, 0].Value); + Assert.Equal("Alice", ws.Cells[3, 0].Value); + } + + // #6 - A sort must re-apply active filters by value, so filtered-out rows stay hidden. + private static Worksheet BuildFilteredSheet() + { + var wb = new Workbook(); + var ws = wb.AddSheet("S", 20, 2); + ws.Cells[0, 0].SetValue("Name"); + ws.Cells[0, 1].SetValue("Dept"); + var data = new[] + { + ("Alice", "Eng"), ("Bob", "Mkt"), ("Carol", "Eng"), + ("Dave", "Sales"), ("Tina", "Mkt"), ("Sam", "Eng"), + }; + for (var i = 0; i < data.Length; i++) + { + ws.Cells[i + 1, 0].SetValue(data[i].Item1); + ws.Cells[i + 1, 1].SetValue(data[i].Item2); + } + + ws.AutoFilter.Range = new RangeRef(new CellRef(0, 0), new CellRef(6, 1)); + ws.AutoFilter.ApplyValueFilter(1, ["Eng"]); + return ws; + } + + [Fact] + public void SortPreservesActiveFilter() + { + var ws = BuildFilteredSheet(); + + ws.Sort(new RangeRef(new CellRef(1, 0), new CellRef(6, 1)), + new SortKey { ColumnIndex = 0, Order = SortOrder.Descending }); + + var visible = 0; + for (var r = 1; r <= 6; r++) + { + if (!ws.Rows.IsHidden(r)) + { + visible++; + Assert.Equal("Eng", ws.Cells[r, 1].Value); + } + } + + Assert.Equal(3, visible); // Alice, Carol, Sam + } + + [Fact] + public void UndoSortRestoresFilter() + { + var ws = BuildFilteredSheet(); + var stack = new UndoRedoStack(ws); + + stack.Execute(new SortCommand(ws, new RangeRef(new CellRef(1, 0), new CellRef(6, 1)), + SortOrder.Descending, 0)); + stack.Undo(); + + // Original order restored; filter re-applied by value -> only the Eng rows visible. + Assert.Equal("Alice", ws.Cells[1, 0].Value); + Assert.False(ws.Rows.IsHidden(1)); // Alice / Eng + Assert.True(ws.Rows.IsHidden(2)); // Bob / Mkt + Assert.False(ws.Rows.IsHidden(3)); // Carol / Eng + Assert.True(ws.Rows.IsHidden(4)); // Dave / Sales + Assert.True(ws.Rows.IsHidden(5)); // Tina / Mkt + Assert.False(ws.Rows.IsHidden(6)); // Sam / Eng + } + + // #3 - the now-enabled Merge button's action must merge the selected range (the command + // the toolbar dispatches: MergeCellsCommand over Selection.Range). + [Fact] + public void MergeCommand_MergesSelectionRange() + { + var sheet = new Worksheet(10, 10); + var stack = new UndoRedoStack(sheet); + var range = new RangeRef(new CellRef(4, 5), new CellRef(5, 6)); // F5:G6 + sheet.Selection.Select(range); + + stack.Execute(new MergeCellsCommand(sheet, sheet.Selection.Range, center: true)); + + Assert.Equal(range, sheet.MergedCells.GetMergedRange(new CellRef(4, 5))); + + stack.Undo(); + Assert.Equal(RangeRef.Invalid, sheet.MergedCells.GetMergedRange(new CellRef(4, 5))); + } +} diff --git a/Radzen.Blazor/Documents/Spreadsheet/Table.cs b/Radzen.Blazor/Documents/Spreadsheet/Table.cs index 290b44f88..cbe1ab407 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Table.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Table.cs @@ -339,7 +339,8 @@ public class Table /// public void Sort(SortOrder order, int column) { - Worksheet.Sort(range, new SortKey { ColumnIndex = column, Order = order }); + // Sort only the data body - never the header or totals row. + Worksheet.Sort(DataBodyRange, new SortKey { ColumnIndex = column, Order = order }); } /// @@ -348,7 +349,8 @@ public class Table /// public void Sort(params SortKey[] keys) { - Worksheet.Sort(range, keys); + // Sort only the data body - never the header or totals row. + Worksheet.Sort(DataBodyRange, keys); } /// Resizes the table to a new range. Columns are added or trimmed accordingly. diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs index 4fb7fc21a..6d33f2fd2 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Filter.cs @@ -840,6 +840,19 @@ public partial class Worksheet Rows.EndUpdate(); } + /// + /// Re-evaluates all active filters against current cell values, so rows are hidden by value + /// at their current positions. Call after a sort (Excel re-applies filters by value). No-op + /// when no filters are active, so it never disturbs manually hidden rows. + /// + internal void ReapplyFilters() + { + if (filters.Count > 0) + { + ApplyFilters(); + } + } + private void Filter(RangeRef range, FilterCriterion criterion) { if (range == RangeRef.Invalid) diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs index f77d23a6b..478eb69b8 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.Sort.cs @@ -98,6 +98,9 @@ public partial class Worksheet // CopyFrom re-triggers formula evaluation, which on a sheet with aggregate // formulas (e.g. SUM over the sorted range) is O(N^2) per sort. Batch(() => WriteSortedRows(range, sortedRows)); + + // Excel re-applies active filters by value after a sort, so filtered-out rows stay hidden. + ReapplyFilters(); } private void WriteSortedRows(RangeRef range, List<(int originalIndex, List cells)> sortedRows) diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs index bb58578c4..4b53ef2c2 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs @@ -383,7 +383,14 @@ public partial class Worksheet internal void OnCellValueChanged(Cell cell) { - if (!IsUpdating && !isEvaluating) + if (isEvaluating) + { + return; + } + + // During a batch, EndUpdate recalculates dependent formulas once. The changed cell + // itself is not a formula node, so it would never be notified - fire its event here. + if (!IsUpdating) { var dependents = graph.GetTopologicallySortedDependencies(cell); @@ -391,9 +398,9 @@ public partial class Worksheet { EvaluateFormula(dependentCell); } - - cell.OnChanged(); } + + cell.OnChanged(); } internal void OnCellFormulaChanged(Cell cell) diff --git a/Radzen.Blazor/RadzenSpreadsheet.razor.cs b/Radzen.Blazor/RadzenSpreadsheet.razor.cs index 8100e66d0..6e361a186 100644 --- a/Radzen.Blazor/RadzenSpreadsheet.razor.cs +++ b/Radzen.Blazor/RadzenSpreadsheet.razor.cs @@ -671,23 +671,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr { if (Worksheet != null) { - // Check if we're in a data table - foreach (var table in Worksheet.Tables) - { - if (table.Range.Contains(cellMenuRow, cellMenuColumn)) - { - var command = new SortCommand(Worksheet, table.Range, order, cellMenuColumn); - await ExecuteAsync(command); - break; - } - } - - // Check if we're in an auto filter - if (Worksheet.AutoFilter.Range is not null && Worksheet.AutoFilter.Range.Value.Contains(cellMenuRow, cellMenuColumn)) - { - var command = new SortCommand(Worksheet, Worksheet.AutoFilter.Range.Value, order, cellMenuColumn, skipHeaderRow: true); - await ExecuteAsync(command); - } + await ExecuteAsync(BuildSortCommand(cellMenuRow, cellMenuColumn, order)); } if (cellMenuPopup != null) @@ -696,6 +680,26 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr } } + // Builds a sort command scoped to the data the clicked cell belongs to, never including a + // table/auto-filter header (or table totals) row in the sorted range. + private SortCommand BuildSortCommand(int row, int column, SortOrder order) + { + foreach (var table in Worksheet!.Tables) + { + if (table.Range.Contains(row, column)) + { + return new SortCommand(Worksheet, table.DataBodyRange, order, column); + } + } + + if (Worksheet.AutoFilter.Range is { } afRange && afRange.Contains(row, column)) + { + return new SortCommand(Worksheet, afRange, order, column, skipHeaderRow: true); + } + + return new SortCommand(Worksheet, new RangeRef(new CellRef(0, 0), new CellRef(Worksheet.RowCount - 1, Worksheet.ColumnCount - 1)), order, column); + } + private async Task OnCellMenuClearAsync() { if (Worksheet != null) @@ -1324,10 +1328,10 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr await ExecuteAsync(new ClearContentsCommand(Worksheet, Worksheet.Selection.Range)); break; case "sort-ascending": - await ExecuteAsync(new SortCommand(Worksheet, new RangeRef(new CellRef(0, 0), new CellRef(Worksheet.RowCount - 1, Worksheet.ColumnCount - 1)), SortOrder.Ascending, column)); + await ExecuteAsync(BuildSortCommand(row, column, SortOrder.Ascending)); break; case "sort-descending": - await ExecuteAsync(new SortCommand(Worksheet, new RangeRef(new CellRef(0, 0), new CellRef(Worksheet.RowCount - 1, Worksheet.ColumnCount - 1)), SortOrder.Descending, column)); + await ExecuteAsync(BuildSortCommand(row, column, SortOrder.Descending)); break; case "convert-table-to-range": case "delete-table": diff --git a/Radzen.Blazor/Spreadsheet/MultiKeySortCommand.cs b/Radzen.Blazor/Spreadsheet/MultiKeySortCommand.cs index 4171468e2..f92165d8f 100644 --- a/Radzen.Blazor/Spreadsheet/MultiKeySortCommand.cs +++ b/Radzen.Blazor/Spreadsheet/MultiKeySortCommand.cs @@ -65,5 +65,8 @@ public class MultiKeySortCommand : RangeSnapshotCommandBase } RestoreSnapshot(); + + // RestoreSnapshot only restores cell content, not row visibility - re-apply filters by value. + sheet.ReapplyFilters(); } } diff --git a/Radzen.Blazor/Spreadsheet/SheetEditorHighlight.razor.cs b/Radzen.Blazor/Spreadsheet/SheetEditorHighlight.razor.cs index 2b01385de..75ea4dc72 100644 --- a/Radzen.Blazor/Spreadsheet/SheetEditorHighlight.razor.cs +++ b/Radzen.Blazor/Spreadsheet/SheetEditorHighlight.razor.cs @@ -38,7 +38,9 @@ public partial class SheetEditorHighlight : ComponentBase { var highlightToken = new HighlightToken { - Text = token.Type == FormulaTokenType.CellIdentifier ? token.Address.ToString() : token.Value, + // Use the raw lexed text so the highlight overlay aligns with the input and + // keeps a cross-sheet reference's worksheet prefix (Address.ToString() drops it). + Text = token.Value, Class = GetTokenClassName(token.Type), Style = GetTokenStyle(token.Type, refCount) }; diff --git a/Radzen.Blazor/Spreadsheet/SortCommand.cs b/Radzen.Blazor/Spreadsheet/SortCommand.cs index 2e16812f1..a73eb8370 100644 --- a/Radzen.Blazor/Spreadsheet/SortCommand.cs +++ b/Radzen.Blazor/Spreadsheet/SortCommand.cs @@ -75,5 +75,8 @@ public class SortCommand : RangeSnapshotCommandBase } RestoreSnapshot(); + + // RestoreSnapshot only restores cell content, not row visibility - re-apply filters by value. + sheet.ReapplyFilters(); } } diff --git a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetCellBorders.razor b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetCellBorders.razor index d271dc094..350b47753 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetCellBorders.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetCellBorders.razor @@ -2,6 +2,7 @@ @using Radzen.Blazor @using Radzen.Blazor.Spreadsheet @using Radzen.Documents.Spreadsheet +@implements IDisposable @@ -28,6 +29,30 @@ => Worksheet?.Selection.Cell == CellRef.Invalid || Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.CellFormatting) == false; + private readonly EventBinding selectionBinding; + + public RadzenSpreadsheetCellBorders() + { + selectionBinding = new EventBinding( + s => s.Changed += OnSelectionChanged, + s => s.Changed -= OnSelectionChanged); + } + + protected override void OnParametersSet() + { + selectionBinding.Bind(Worksheet?.Selection); + } + + private void OnSelectionChanged() + { + StateHasChanged(); + } + + public void Dispose() + { + selectionBinding.Dispose(); + } + private async Task OnClick(RadzenSplitButtonItem? item) { if (Worksheet is null || Worksheet.Selection.Cell == CellRef.Invalid || Spreadsheet is null) diff --git a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor index 294c95a3b..4f016ba34 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor @@ -4,6 +4,7 @@ @using Radzen.Blazor.Spreadsheet.Tools @using Radzen.Documents.Spreadsheet @inject DialogService DialogService +@implements IDisposable @@ -28,6 +29,30 @@ => Worksheet?.Selection.Cell == CellRef.Invalid || Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.ConditionalFormatting) == false; + private readonly EventBinding selectionBinding; + + public RadzenSpreadsheetConditionalFormat() + { + selectionBinding = new EventBinding( + s => s.Changed += OnSelectionChanged, + s => s.Changed -= OnSelectionChanged); + } + + protected override void OnParametersSet() + { + selectionBinding.Bind(Worksheet?.Selection); + } + + private void OnSelectionChanged() + { + StateHasChanged(); + } + + public void Dispose() + { + selectionBinding.Dispose(); + } + private async Task OnClickAsync(RadzenSplitButtonItem? item) { if (Worksheet is null || Worksheet.Selection.Cell == CellRef.Invalid) diff --git a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor index 17c5b998b..e6a6e5214 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor @@ -4,6 +4,7 @@ @using Radzen.Blazor.Spreadsheet.Tools @using Radzen.Documents.Spreadsheet @inject DialogService DialogService +@implements IDisposable @@ -25,6 +26,30 @@ => Worksheet?.Selection.Cell == CellRef.Invalid || Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.DataValidation) == false; + private readonly EventBinding selectionBinding; + + public RadzenSpreadsheetDataValidation() + { + selectionBinding = new EventBinding( + s => s.Changed += OnSelectionChanged, + s => s.Changed -= OnSelectionChanged); + } + + protected override void OnParametersSet() + { + selectionBinding.Bind(Worksheet?.Selection); + } + + private void OnSelectionChanged() + { + StateHasChanged(); + } + + public void Dispose() + { + selectionBinding.Dispose(); + } + private async Task OnClickAsync(RadzenSplitButtonItem? item) { if (Worksheet is null || Worksheet.Selection.Cell == CellRef.Invalid || Spreadsheet is null) diff --git a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetMergeCells.razor b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetMergeCells.razor index 9b5461317..7a35abb7e 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetMergeCells.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetMergeCells.razor @@ -2,6 +2,7 @@ @using Radzen.Blazor @using Radzen.Blazor.Spreadsheet @using Radzen.Documents.Spreadsheet +@implements IDisposable @@ -24,6 +25,30 @@ => Worksheet?.Selection.Cell == CellRef.Invalid || Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.Merging) == false; + private readonly EventBinding selectionBinding; + + public RadzenSpreadsheetMergeCells() + { + selectionBinding = new EventBinding( + s => s.Changed += OnSelectionChanged, + s => s.Changed -= OnSelectionChanged); + } + + protected override void OnParametersSet() + { + selectionBinding.Bind(Worksheet?.Selection); + } + + private void OnSelectionChanged() + { + StateHasChanged(); + } + + public void Dispose() + { + selectionBinding.Dispose(); + } + private async Task OnClick(RadzenSplitButtonItem? item) { if (Worksheet is null || Worksheet.Selection.Cell == CellRef.Invalid || Spreadsheet is null)