Fix RadzenSpreadsheet edit refresh, table sort, filter and tool issues

- Edited value cells now repaint: fire the changed cell's notification even
  inside a command batch (only dependent recalc is deferred to EndUpdate).
- Table sort excludes the header (and totals) row by sorting DataBodyRange;
  the cell-menu and context-menu sorts are now table/auto-filter aware.
- Re-apply active filters after a sort (and on sort undo) so filtered-out
  rows stay hidden by value.
- Enable Merge, Borders, Conditional and Data Validation tools when a range
  is selected by subscribing to Selection.Changed.
- Keep the worksheet prefix on cross-sheet references in the formula-bar
  syntax highlighter (use the raw token text).

Adds regression tests for all of the above.
This commit is contained in:
Atanas Korchev
2026-06-17 18:08:12 +03:00
parent 172fc588b8
commit cae0e7d3a1
14 changed files with 358 additions and 25 deletions

View File

@@ -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<SheetEditorHighlight>(
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<SheetEditorHighlight>(
parameters => parameters.Add(p => p.Value, "=B2+C2"));
Assert.Contains("B2", cut.Markup);
Assert.Contains("C2", cut.Markup);
}
}

View File

@@ -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)));
}
}

View File

@@ -339,7 +339,8 @@ public class Table
/// </summary>
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 });
}
/// <summary>
@@ -348,7 +349,8 @@ public class Table
/// </summary>
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);
}
/// <summary>Resizes the table to a new range. Columns are added or trimmed accordingly.</summary>

View File

@@ -840,6 +840,19 @@ public partial class Worksheet
Rows.EndUpdate();
}
/// <summary>
/// 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.
/// </summary>
internal void ReapplyFilters()
{
if (filters.Count > 0)
{
ApplyFilters();
}
}
private void Filter(RangeRef range, FilterCriterion criterion)
{
if (range == RangeRef.Invalid)

View File

@@ -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<Cell> cells)> sortedRows)

View File

@@ -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)

View File

@@ -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":

View File

@@ -65,5 +65,8 @@ public class MultiKeySortCommand : RangeSnapshotCommandBase
}
RestoreSnapshot();
// RestoreSnapshot only restores cell content, not row visibility - re-apply filters by value.
sheet.ReapplyFilters();
}
}

View File

@@ -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)
};

View File

@@ -75,5 +75,8 @@ public class SortCommand : RangeSnapshotCommandBase
}
RestoreSnapshot();
// RestoreSnapshot only restores cell content, not row visibility - re-apply filters by value.
sheet.ReapplyFilters();
}
}

View File

@@ -2,6 +2,7 @@
@using Radzen.Blazor
@using Radzen.Blazor.Spreadsheet
@using Radzen.Documents.Spreadsheet
@implements IDisposable
<RadzenSplitButton Click=@OnClick Icon="border_all" Disabled=@IsDisabled Variant="Variant.Outlined" ButtonStyle="ButtonStyle.Base" Size="ButtonSize.Small">
<ChildContent>
@@ -28,6 +29,30 @@
=> Worksheet?.Selection.Cell == CellRef.Invalid
|| Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.CellFormatting) == false;
private readonly EventBinding<Selection> selectionBinding;
public RadzenSpreadsheetCellBorders()
{
selectionBinding = new EventBinding<Selection>(
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)

View File

@@ -4,6 +4,7 @@
@using Radzen.Blazor.Spreadsheet.Tools
@using Radzen.Documents.Spreadsheet
@inject DialogService DialogService
@implements IDisposable
<RadzenSplitButton Click=@OnClickAsync Icon="format_color_fill" Text=@(Spreadsheet?.Localize(nameof(RadzenStrings.Spreadsheet_Conditional)) ?? "Conditional") Disabled=@IsDisabled Variant="Variant.Outlined" ButtonStyle="ButtonStyle.Base" Size="ButtonSize.Small">
<ChildContent>
@@ -28,6 +29,30 @@
=> Worksheet?.Selection.Cell == CellRef.Invalid
|| Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.ConditionalFormatting) == false;
private readonly EventBinding<Selection> selectionBinding;
public RadzenSpreadsheetConditionalFormat()
{
selectionBinding = new EventBinding<Selection>(
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)

View File

@@ -4,6 +4,7 @@
@using Radzen.Blazor.Spreadsheet.Tools
@using Radzen.Documents.Spreadsheet
@inject DialogService DialogService
@implements IDisposable
<RadzenSplitButton Click=@OnClickAsync Icon="rule" Text=@(Spreadsheet?.Localize(nameof(RadzenStrings.Spreadsheet_Validation)) ?? "Validation") Disabled=@IsDisabled Variant="Variant.Outlined" ButtonStyle="ButtonStyle.Base" Size="ButtonSize.Small">
<ChildContent>
@@ -25,6 +26,30 @@
=> Worksheet?.Selection.Cell == CellRef.Invalid
|| Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.DataValidation) == false;
private readonly EventBinding<Selection> selectionBinding;
public RadzenSpreadsheetDataValidation()
{
selectionBinding = new EventBinding<Selection>(
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)

View File

@@ -2,6 +2,7 @@
@using Radzen.Blazor
@using Radzen.Blazor.Spreadsheet
@using Radzen.Documents.Spreadsheet
@implements IDisposable
<RadzenSplitButton Click=@OnClick Icon="merge_type" Disabled=@IsDisabled Variant="Variant.Outlined" ButtonStyle="ButtonStyle.Base" Size="ButtonSize.Small">
<ChildContent>
@@ -24,6 +25,30 @@
=> Worksheet?.Selection.Cell == CellRef.Invalid
|| Spreadsheet?.IsFeatureAllowed(SpreadsheetFeature.Merging) == false;
private readonly EventBinding<Selection> selectionBinding;
public RadzenSpreadsheetMergeCells()
{
selectionBinding = new EventBinding<Selection>(
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)