Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ed2bfdf
PivotTables: slicers from XLSX files are shown, filter their PivotTab…
neilcawse Oct 12, 2026
ba35b62
PivotTables: slicers on grouped dates show the groups, keep one item …
neilcawse Oct 12, 2026
5cbee8e
PivotTables: a slicer's date grouping holds outside the axes and at t…
neilcawse Oct 12, 2026
c3ec216
PivotTables: moving a field between Rows, Columns and Filters keeps i…
neilcawse Oct 12, 2026
1058f3e
PivotTables: slicer caches name the right sheet, and skipped slicers …
Oct 14, 2026
0a10411
Slicer test checks the coarser-level warning text
Oct 14, 2026
9ad5f1c
Merge main (f1894c6) into the branch
neilcawse Oct 17, 2026
34cdbf5
Merge main (23829d8) into the branch
neilcawse Oct 20, 2026
3d5969c
Cache pivot slicer tiles by workbook revision
neilcawse Oct 20, 2026
da9604e
Key the slicer tile cache on the workbook version, so a manual recalc…
neilcawse Oct 20, 2026
95b234f
Slicer tile cache: hold a Weak to the workbook, not an Arc
neilcawse Oct 20, 2026
ccf527b
docs: clarify unsupported slicer parity
neilcawse Oct 20, 2026
0453820
docs: describe PivotTable slicer support
neilcawse Oct 20, 2026
7c17a59
fix pivot slicer filter edge cases
neilcawse Oct 20, 2026
673df7b
fmt
neilcawse Oct 20, 2026
a7a6946
preserve slicer filters when removing pivot axes
neilcawse Oct 20, 2026
7ccf0a4
retain axis filters only for connected slicers
neilcawse Oct 20, 2026
402706a
Merge main (06920de) into the branch
neilcawse Oct 22, 2026
0c95ade
Fix merge follow-up formatting and imports
neilcawse Oct 22, 2026
d6bef5a
Fix parity documentation merge ordering
neilcawse Oct 22, 2026
2a50662
Merge main (ad10881) into the branch
neilcawse Oct 25, 2026
5fcc45c
Merge main (d8c5f58) into the branch
neilcawse Oct 26, 2026
554a5df
Merge main (45f5ab1) into the branch
neilcawse Oct 27, 2026
7e78bad
Merge main (68a4b1a) into the branch
neilcawse Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions crates/engine/src/cmd/pivot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{
Anchor, Cell, Chart, ChartKind, LegendPos, NumFmt, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Sheet, StyleId, Workbook,
PivotSort, PivotTable, PivotValue, Sheet, SlicerSource, StyleId, Workbook,
};
use serde_json::{Value as Json, json};

use super::{
CommandSpec, Ctx, EngineError, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, str_param,
u32_param,
CommandSpec, Ctx, EngineError, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, split_sheet,
str_param, u32_param,
};
use crate::pivot as pv;
use crate::selection::current_region;
Expand All @@ -28,6 +28,8 @@ mod params;

use charts::{insert_pivot_chart, recommended};
use create::{insert_pivot, options, show_details, summarize_table};
#[cfg(test)]
use fields::has_connected_slicer;
use fields::{add_field, calculated_field, fields, list, move_field, remove_field, value_settings};
use layout::{change_source, collapse, delete, filter, grand_totals, group, layout, refresh, refresh_all, sort};

Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/cmd/pivot/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
None
} else {
let d = s.doc()?;
let (sh, body) = crate::cmd::split_sheet(&dest);
let (sh, body) = super::split_sheet(&dest);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("insert.pivotTable", format!("no sheet named `{n}`")))?,
None => active,
Expand Down Expand Up @@ -334,7 +334,7 @@ pub(super) fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::<Vec<_>>(),
"columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::<Vec<_>>(),
"values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::<Vec<_>>(),
"filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::<Vec<_>>(),
"filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !f.hidden && !in_axes(pt, &f.source_col)})).collect::<Vec<_>>(),
"layout": layout_name(pt.layout),
"grandTotalsRows": pt.grand_totals_rows,
"grandTotalsCols": pt.grand_totals_cols,
Expand Down
98 changes: 66 additions & 32 deletions crates/engine/src/cmd/pivot/fields.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use super::create::pivot_json;
use super::params::{area_param, func_param, position_param, show_as_param};
use super::{
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, Workbook, bad,
bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, SlicerSource,
Workbook, bad, bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
};

// ---------------------------------------------------------------- layout editing (pure)
Expand Down Expand Up @@ -128,11 +128,16 @@ pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: Area
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS)));
}
let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
// A report filter that selects nothing in particular goes away; a selection stays
// as the field's hidden items.
let held = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).map(|f| pt.filter_group(f)).unwrap_or_default();
for f in pt.filters.iter_mut().filter(|f| f.source_col.eq_ignore_ascii_case(&field)) {
f.date_group = held;
}
let existing =
take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), date_group: held, ..Default::default() });
// A report filter that selects nothing in particular goes away; a selection stays as the field's hidden items.
pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none()));
insert_axis_field(pt, area == "rows", existing, position);
pt.normalize_filter_groups();
}
"values" => {
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
Expand All @@ -155,11 +160,16 @@ pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: Area
pt.values.insert(at, v);
}
_ => {
take_from_axes(pt, &field);
// The field keeps its date grouping (a cache field's) in the Filters area.
let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
let group = match existing.and_then(|i| pt.filters.get(i)) {
Some(f) => pt.filter_group(f),
None => pt.field_group(&field),
};
take_from_axes(pt, &field);
let entry = match existing {
Some(i) => pt.filters.remove(i),
None => PivotFilter { source_col: field, selected: None },
Some(i) => PivotFilter { hidden: false, date_group: group, ..pt.filters.remove(i) },
None => PivotFilter { source_col: field, selected: None, hidden: false, date_group: group },
};
let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len());
pt.filters.insert(at, entry);
Expand All @@ -168,20 +178,41 @@ pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: Area
Ok(())
}

/// Removes `field` from an area; returns the source field it referred to.
fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result<String> {
/// Whether a slicer on any sheet connects to this PivotTable and field.
pub(super) fn has_connected_slicer(wb: &Workbook, sheet: usize, pt: &PivotTable, field: &str) -> bool {
let Some(sheet_name) = wb.sheet(sheet).map(|sh| sh.name.as_str()) else { return false };
wb.sheets.iter().any(|sh| {
sh.slicers.iter().any(|sl| match &sl.source {
SlicerSource::Pivot { pivots, field: slicer_field, .. } if slicer_field.eq_ignore_ascii_case(field) => {
pivots.iter().any(|p| p.sheet.eq_ignore_ascii_case(sheet_name) && p.name.eq_ignore_ascii_case(&pt.name))
}
_ => false,
})
})
}

fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str, keep_slicer: bool) -> Result<String> {
match area {
"rows" | "columns" => {
let rows = area == "rows";
let held = keep_slicer
.then(|| pt.filters.iter().find(|x| x.source_col.eq_ignore_ascii_case(field)).map(|x| (x.selected.clone(), pt.filter_group(x))))
.flatten();
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let i = list
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(field))
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?;
let f = list.remove(i);
shift_values(pt, rows, i, false);
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col));
Ok(f.source_col)
let source = f.source_col;
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&source));
if let Some((selected, date_group)) = held
&& (selected.is_some() || date_group != PivotDateGroup::None)
{
pt.filters.push(PivotFilter { source_col: source.clone(), selected, hidden: true, date_group });
}
Ok(source)
}
"values" => {
let i = pt
Expand All @@ -196,7 +227,7 @@ fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) ->
let i = pt
.filters
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col))
.position(|f| f.source_col.eq_ignore_ascii_case(field) && !f.hidden && !in_axes(pt, &f.source_col))
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?;
Ok(pt.filters.remove(i).source_col)
}
Expand Down Expand Up @@ -251,7 +282,7 @@ pub(super) fn fields(s: &mut Session, p: &Json) -> Result<Json> {
if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) {
areas.push("values");
}
if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) {
if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h) && !f.hidden) && !in_axes(pt, h) {
areas.push("filters");
}
json!({
Expand Down Expand Up @@ -385,15 +416,20 @@ pub(super) fn remove_field(s: &mut Session, p: &Json) -> Result<Json> {
Some(a) => Some(area_param(C, Some(a))?),
None => None,
};
let (si, pi) = find_pivot(s, p)?;
let keep_slicer = {
let d = s.doc()?;
d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).is_some_and(|pt| has_connected_slicer(&d.wb, si, pt, &field))
};
update(s, p, |pt, _, _| {
match area {
Some(a) => {
remove_from_area(C, pt, &field, a)?;
remove_from_area(C, pt, &field, a, keep_slicer)?;
}
None => {
let mut any = false;
for a in ["rows", "columns", "values", "filters"] {
any |= remove_from_area(C, pt, &field, a).is_ok();
any |= remove_from_area(C, pt, &field, a, keep_slicer).is_ok();
}
if !any {
return Err(bad(C, format!("`{field}` isn't in the PivotTable")));
Expand All @@ -410,6 +446,11 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let from = area_param(C, str_param(p, "from"))?;
let to = area_param(C, str_param(p, "to"))?;
let pos = position_param(p);
let keep_slicer = {
let d = s.doc()?;
let (si, pi) = find_pivot(s, p)?;
d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).is_some_and(|pt| has_connected_slicer(&d.wb, si, pt, &field))
};
update(s, p, |pt, src, _| {
if is_values_field(src, &field) {
return move_values_field(C, pt, from, to, pos);
Expand Down Expand Up @@ -443,7 +484,7 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let i = pt
.filters
.iter()
.position(|f| f.source_col.eq_ignore_ascii_case(&field))
.position(|f| f.source_col.eq_ignore_ascii_case(&field) && !f.hidden && !in_axes(pt, &f.source_col))
.ok_or_else(|| bad(C, format!("`{field}` isn't in the filters area")))?;
let f = pt.filters.remove(i);
let at = pos.unwrap_or(pt.filters.len()).min(pt.filters.len());
Expand All @@ -452,22 +493,15 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
}
return Ok(json!({}));
}
// Keep axis settings (sort, grouping) when moving between rows and columns.
let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let src_field = remove_from_area(C, pt, &field, from)?;
// Values in or out: the field leaves its area. Between Rows, Columns and Filters it moves
// the way addField moves it, keeping its axis settings, date grouping and filter
// selection (`from` is only checked).
let src_field = if from == "values" || to == "values" {
remove_from_area(C, pt, &field, from, keep_slicer)?
} else {
remove_from_area(C, &mut pt.clone(), &field, from, keep_slicer)?
};
add_to_area(pt, src, AreaPlacement { cmd: C, field: &src_field, area: to, func: None, name: None, position: pos })?;
if matches!(to, "rows" | "columns") {
let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns };
if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) {
*slot = k;
}
if let Some(h) = hidden.filter(|h| h.selected.is_some())
&& !pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(&src_field))
{
pt.filters.push(h);
}
}
Ok(json!({"field": src_field, "to": to}))
})
}
Expand Down
26 changes: 11 additions & 15 deletions crates/engine/src/cmd/pivot/layout.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::create::source_param;
use super::fields::{canonical, in_axes, shift_values, update};
use super::fields::{canonical, shift_values, update};
use super::params::{group_param, layout_name};
use super::{
Chart, Json, PivotDateGroup, PivotField, PivotFilter, PivotLayout, PivotSort, PivotTable, Result, Session, bad, bool_param, edit, find_pivot,
Expand Down Expand Up @@ -33,22 +33,17 @@ pub(super) fn filter(s: &mut Session, p: &Json) -> Result<Json> {
};
update(s, p, |pt, src, _| {
let field = canonical(C, src, &field)?;
let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
match (i, selected) {
(Some(i), None) => {
if in_axes(pt, &field) {
pt.filters.remove(i);
} else if let Some(f) = pt.filters.get_mut(i) {
f.selected = None;
}
match pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)) {
Some(f) => {
let group = pt.filter_group(f);
pt.set_filter(&field, selected, group);
}
(Some(i), Some(sel)) => {
if let Some(f) = pt.filters.get_mut(i) {
f.selected = Some(sel);
// A field in no area becomes a report filter.
None => {
if let Some(sel) = selected {
pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel), ..PivotFilter::default() });
}
}
(None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }),
(None, None) => {}
}
Ok(json!({"field": field}))
})
Expand Down Expand Up @@ -269,7 +264,7 @@ pub(super) fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
pub(super) fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
edit(s, |cx| {
// Its PivotCharts become ordinary charts.
// Its PivotCharts become ordinary charts, and its slicers lose it.
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
if let Some((sheet, name)) = link {
for i in 0..cx.wb.sheets.len() {
Expand All @@ -280,6 +275,7 @@ pub(super) fn delete(s: &mut Session, p: &Json) -> Result<Json> {
}
}
}
crate::cmd::slicer::drop_pivot(&mut cx.wb, &sheet, &name);
}
pv::clear_output(&mut cx.wb, si, pi);
let sh = cx.sheet_mut(si)?;
Expand Down
Loading
Loading