diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 1451e2dab3..6ea64cd692 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -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; @@ -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}; diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs index 5a65609c3b..6372baaebc 100644 --- a/crates/engine/src/cmd/pivot/create.rs +++ b/crates/engine/src/cmd/pivot/create.rs @@ -90,7 +90,7 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { 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, @@ -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::>(), "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::>(), "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::>(), - "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), + "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !f.hidden && !in_axes(pt, &f.source_col)})).collect::>(), "layout": layout_name(pt.layout), "grandTotalsRows": pt.grand_totals_rows, "grandTotalsCols": pt.grand_totals_cols, diff --git a/crates/engine/src/cmd/pivot/fields.rs b/crates/engine/src/cmd/pivot/fields.rs index 48c25657ce..241a06b15b 100644 --- a/crates/engine/src/cmd/pivot/fields.rs +++ b/crates/engine/src/cmd/pivot/fields.rs @@ -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) @@ -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 { @@ -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); @@ -168,11 +178,26 @@ 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 { +/// 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 { 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() @@ -180,8 +205,14 @@ fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> .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 @@ -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) } @@ -251,7 +282,7 @@ pub(super) fn fields(s: &mut Session, p: &Json) -> Result { 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!({ @@ -385,15 +416,20 @@ pub(super) fn remove_field(s: &mut Session, p: &Json) -> Result { 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"))); @@ -410,6 +446,11 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result { 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); @@ -443,7 +484,7 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result { 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()); @@ -452,22 +493,15 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result { } 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})) }) } diff --git a/crates/engine/src/cmd/pivot/layout.rs b/crates/engine/src/cmd/pivot/layout.rs index 9b6a9527e8..e728b29033 100644 --- a/crates/engine/src/cmd/pivot/layout.rs +++ b/crates/engine/src/cmd/pivot/layout.rs @@ -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, @@ -33,22 +33,17 @@ pub(super) fn filter(s: &mut Session, p: &Json) -> Result { }; 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})) }) @@ -269,7 +264,7 @@ pub(super) fn change_source(s: &mut Session, p: &Json) -> Result { pub(super) fn delete(s: &mut Session, p: &Json) -> Result { 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() { @@ -280,6 +275,7 @@ pub(super) fn delete(s: &mut Session, p: &Json) -> Result { } } } + crate::cmd::slicer::drop_pivot(&mut cx.wb, &sheet, &name); } pv::clear_output(&mut cx.wb, si, pi); let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 4e4f6870c5..bb618abbe9 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -143,6 +143,34 @@ fn date_grouping_by_quarter_and_month() { assert!(s.execute("pivot.group", json!({"field": "Region", "by": "years"})).is_err()); } +#[test] +fn move_field_keeps_the_date_grouping() { + use gridcraft_model::PivotDateGroup; + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Date"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); + // Columns → Filters: the report filter keeps the months. + s.execute("pivot.moveField", json!({"field": "Date", "from": "columns", "to": "filters"})).unwrap(); + let p = pt(&s); + assert!(p.columns.is_empty()); + let f = p.filters.iter().find(|f| f.source_col == "Date").unwrap(); + assert_eq!(p.filter_group(f), PivotDateGroup::Months); + // Filters → Rows: Date on rows grouped by the filter's grouping. + s.execute("pivot.moveField", json!({"field": "Date", "from": "filters", "to": "rows"})).unwrap(); + let p = pt(&s); + assert_eq!(p.rows.iter().find(|f| f.source_col == "Date").map(|f| f.date_group), Some(PivotDateGroup::Months)); + assert!(p.filters.is_empty()); + assert!(find_row(&s, "Jan").is_some()); + // Undo brings back the report filter with its months. + s.execute("edit.undo", json!({})).unwrap(); + let p = pt(&s); + assert!(p.rows.iter().all(|f| f.source_col != "Date")); + assert_eq!(p.filters.first().map(|f| p.filter_group(f)), Some(PivotDateGroup::Months)); + // Rows → Values takes the field off Rows. + s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "values"})).unwrap(); + assert!(pt(&s).rows.is_empty()); +} + #[test] fn filters_and_report_filter_rows() { let mut s = sales(); @@ -780,6 +808,293 @@ fn collapsed_column_items_count_as_one_column_group() { s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); } +/// A slicer on `field` of the PivotTables `pivots` (names on the active sheet). +fn add_pivot_slicer(s: &mut Session, id: u32, field: &str, pivots: &[&str]) { + use gridcraft_model::{Anchor, PivotSort, Slicer, SlicerCrossFilter, SlicerPivot, SlicerSource}; + let d = s.doc_mut().unwrap(); + let si = d.wb.active_sheet(); + let sheet = d.wb.sheets[si].name.clone(); + let sh = std::sync::Arc::make_mut(&mut d.wb).sheet_mut(si).unwrap(); + sh.slicers.push(Slicer { + id, + source: SlicerSource::Pivot { + pivots: pivots.iter().map(|n| SlicerPivot { sheet: sheet.clone(), name: n.to_string() }).collect(), + field: field.into(), + sort: PivotSort::Asc, + cross_filter: SlicerCrossFilter::default(), + show_missing: true, + }, + caption: field.into(), + anchor: Anchor { width: 150.0, height: 200.0, ..Anchor::default() }, + columns: 1, + name: String::new(), + style: String::new(), + row_height: None, + }); +} + +/// The slicer's tiles as (label, selected). +fn slicer_tiles(s: &Session, id: u32) -> Vec<(String, bool)> { + let d = s.doc().unwrap(); + let sl = d.wb.active().unwrap().slicers.iter().find(|x| x.id == id).unwrap(); + crate::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet(), sl).into_iter().map(|t| (t.label, t.selected)).collect() +} + +#[test] +fn pivot_slicer_selects_items_of_its_pivot_tables() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + add_pivot_slicer(&mut s, 1, "Region", &["PivotTable1"]); + add_pivot_slicer(&mut s, 2, "Product", &["PivotTable1"]); + // A slicer whose PivotTable is gone shows no tiles and ignores clicks. + add_pivot_slicer(&mut s, 3, "Region", &["Missing"]); + let tiles = slicer_tiles; + assert_eq!(tiles(&s, 1), vec![("East".into(), true), ("North".into(), true), ("West".into(), true)]); + assert!(tiles(&s, 3).is_empty()); + assert!(s.execute("slicer.toggle", json!({"slicer": 3, "value": "East"})).is_err()); + + // A field on Rows: clicking a tile selects that item of the field and refreshes the table. + s.execute("slicer.toggle", json!({"slicer": 1, "value": "East"})).unwrap(); + assert_eq!(tiles(&s, 1), vec![("East".into(), true), ("North".into(), false), ("West".into(), false)]); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Region").unwrap(); + assert_eq!((f.selected, f.hidden), (Some(vec!["East".to_string()]), false)); + assert!(find_row(&s, "East").is_some()); + assert!(find_row(&s, "North").is_none() && find_row(&s, "West").is_none()); + s.execute("slicer.clear", json!({"slicer": 1})).unwrap(); + assert!(pt(&s).filters.is_empty()); + assert!(find_row(&s, "North").is_some()); + + // A field the table doesn't show: a hidden filter, with no report filter cell. + let anchor = pt(&s).anchor; + s.execute("slicer.toggle", json!({"slicer": 2, "value": "Apples"})).unwrap(); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Product").unwrap(); + assert_eq!((f.selected, f.hidden), (Some(vec!["Apples".to_string()]), true)); + assert_eq!(pt(&s).anchor, anchor, "a hidden filter adds no filter rows"); + let total = find_row(&s, "Grand Total").map(|r| val(&s, &CellRef::new(r, anchor.col + 1).a1()).as_f64().unwrap()).unwrap(); + assert!(total > 0.0 && total < TOTAL, "{total}"); + s.execute("slicer.toggle", json!({"slicer": 2, "value": "Pears", "multi": true})).unwrap(); + assert_eq!(tiles(&s, 2), vec![("Apples".into(), true), ("Pears".into(), true), ("Plums".into(), false)]); + s.execute("slicer.selectAll", json!({"slicer": 2})).unwrap(); + assert!(pt(&s).filters.is_empty()); +} + +#[test] +fn move_field_rejects_hidden_slicer_filter_as_filters_area() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + add_pivot_slicer(&mut s, 1, "Product", &["PivotTable1"]); + s.execute("slicer.toggle", json!({"slicer": 1, "value": "Apples"})).unwrap(); + assert!(s.execute("pivot.moveField", json!({"field": "Product", "from": "filters", "to": "filters"})).is_err()); + let p = pt(&s); + let f = p.filters.iter().find(|f| f.source_col == "Product").unwrap(); + assert!(f.hidden); +} + +#[test] +fn move_axis_to_values_keeps_slicer_selection_and_grouping() { + use gridcraft_model::PivotDateGroup; + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Date"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); + add_pivot_slicer(&mut s, 1, "Date", &["PivotTable1"]); + s.execute("slicer.toggle", json!({"slicer": 1, "value": "Jan"})).unwrap(); + let d = s.doc().unwrap(); + assert!(super::has_connected_slicer(&d.wb, d.wb.active_sheet(), &pt(&s), "Date")); + let before = pt(&s); + let bf = before.filters.iter().find(|f| f.source_col == "Date").unwrap(); + assert_eq!(before.filter_group(bf), PivotDateGroup::Months); + s.execute("pivot.moveField", json!({"field": "Date", "from": "columns", "to": "values"})).unwrap(); + let p = pt(&s); + let f = p.filters.iter().find(|f| f.source_col == "Date").unwrap(); + assert_eq!(f.selected, Some(vec!["Jan".to_string()])); + assert!(f.hidden); + assert_eq!(p.filter_group(f), PivotDateGroup::Months); + assert_eq!(slicer_tiles(&s, 1), months_on(&["Jan"])); + let mut removed = sales(); + removed.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Date"], "values": ["Sales"]})).unwrap(); + removed.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); + add_pivot_slicer(&mut removed, 1, "Date", &["PivotTable1"]); + removed.execute("slicer.toggle", json!({"slicer": 1, "value": "Jan"})).unwrap(); + removed.execute("pivot.removeField", json!({"field": "Date", "area": "columns"})).unwrap(); + let rp = pt(&removed); + let rf = rp.filters.iter().find(|f| f.source_col == "Date").unwrap(); + assert_eq!(rf.selected, Some(vec!["Jan".to_string()])); + assert!(rf.hidden); + assert_eq!(rp.filter_group(rf), PivotDateGroup::Months); +} + +#[test] +fn move_axis_to_values_drops_manual_axis_filter() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "values"})).unwrap(); + assert!(pt(&s).filters.iter().all(|f| f.source_col != "Region")); +} +#[test] +fn pivot_slicer_on_grouped_dates_shows_the_groups() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Date", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + s.execute("pivot.group", json!({"field": "Date", "by": "quarters"})).unwrap(); + add_pivot_slicer(&mut s, 1, "Date", &["PivotTable1"]); + let quarters = |on: [bool; 4]| ["Qtr1", "Qtr2", "Qtr3", "Qtr4"].into_iter().zip(on).map(|(q, o)| (q.to_string(), o)).collect::>(); + assert_eq!(slicer_tiles(&s, 1), quarters([true; 4])); + // The PivotTable's filter selects groups, and the tiles show it. + s.execute("pivot.filter", json!({"field": "Date", "selected": ["Qtr1"]})).unwrap(); + assert_eq!(slicer_tiles(&s, 1), quarters([true, false, false, false])); + // Clicking a tile filters the PivotTable to that group. + s.execute("slicer.toggle", json!({"slicer": 1, "value": "Qtr3"})).unwrap(); + assert_eq!(slicer_tiles(&s, 1), quarters([false, false, true, false])); + assert_eq!(text(&s, "A4"), "Qtr3"); + assert_eq!(val(&s, "B4"), Value::Number(195.0)); + assert_eq!(text(&s, "A5"), "Grand Total"); +} + +#[test] +fn pivot_slicer_keeps_one_item_selected() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + add_pivot_slicer(&mut s, 1, "Region", &["PivotTable1"]); + for r in ["East", "North", "West"] { + s.execute("slicer.toggle", json!({"slicer": 1, "value": r, "multi": true})).unwrap(); + } + // Ctrl-clicking the last selected item leaves it selected, as in Excel. + assert_eq!(slicer_tiles(&s, 1), vec![("East".into(), false), ("North".into(), false), ("West".into(), true)]); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Region").unwrap(); + assert_eq!(f.selected, Some(vec!["West".to_string()])); + assert!(find_row(&s, "West").is_some()); +} + +/// The package `bytes` with part `name` replaced by `edit` of it. +fn edit_part(bytes: &[u8], name: &str, edit: impl Fn(&str) -> String) -> Vec { + use std::io::{Read, Write}; + let mut zin = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut zout = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..zin.len() { + let mut f = zin.by_index(i).unwrap(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + let part = f.name().to_string(); + if part == name { + data = edit(&String::from_utf8(data).unwrap()).into_bytes(); + } + zout.start_file(part, zip::write::SimpleFileOptions::default()).unwrap(); + zout.write_all(&data).unwrap(); + } + zout.finish().unwrap().into_inner() +} +fn save(s: &Session) -> Vec { + gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap() +} +/// Opens `bytes` on the PivotTable's sheet and refreshes. +fn reopen(s: &mut Session, bytes: &[u8]) { + s.execute("file.open", json!({"name": "x.xlsx", "base64": crate::io::base64_encode(bytes)})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet2"})).unwrap(); + s.execute("data.refreshAll", json!({})).unwrap(); +} +/// The active sheet's first slicer. +fn first_slicer(s: &Session) -> u32 { + s.doc().unwrap().wb.active().unwrap().slicers[0].id +} +/// The value next to row label `label` in the PivotTable. +fn sum_of(s: &Session, label: &str) -> Option { + let col = pt(s).last_range.unwrap().start.col + 1; + find_row(s, label).map(|r| val(s, &CellRef::new(r, col).a1())) +} +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +fn months_on(on: &[&str]) -> Vec<(String, bool)> { + MONTHS.iter().map(|m| (m.to_string(), on.contains(m))).collect() +} + +/// Excel's usual dashboard: a slicer on Date grouped by months, and a PivotTable by Region +/// with Date in no area. The grouping belongs to the cache field, so the PivotTable filters +/// by month, the tiles are months, and both survive a save. +#[test] +fn pivot_slicer_on_grouped_date_in_no_area_keeps_the_grouping() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Date", "area": "columns"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); + add_pivot_slicer(&mut s, 1, "Date", &["PivotTable1"]); + s.execute("slicer.toggle", json!({"slicer": 1, "value": "Jan"})).unwrap(); + s.execute("slicer.toggle", json!({"slicer": 1, "value": "Feb", "multi": true})).unwrap(); + // Take Date out of the PivotTable's areas in the file, keeping the cache's grouping. + let bytes = edit_part(&save(&s), "xl/pivotTables/pivotTable1.xml", |x| { + let (a, b) = (x.find("").unwrap() + "".len()); + format!("{}{}", &x[..a], &x[b..]).replace(" axis=\"axisCol\"", "") + }); + reopen(&mut s, &bytes); + assert!(pt(&s).columns.is_empty()); + assert_eq!(slicer_tiles(&s, first_slicer(&s)), months_on(&["Jan", "Feb"])); + assert_eq!(sum_of(&s, "East"), Some(Value::Number(100.0))); + assert_eq!(sum_of(&s, "West"), Some(Value::Number(50.0))); + assert_eq!(sum_of(&s, "North"), None); + assert_eq!(sum_of(&s, "Grand Total"), Some(Value::Number(150.0))); + + // A click filters by month, and the selection survives a save. + s.execute("slicer.toggle", json!({"slicer": first_slicer(&s), "value": "Mar", "multi": true})).unwrap(); + assert_eq!(sum_of(&s, "East"), Some(Value::Number(220.0))); + let bytes = save(&s); + reopen(&mut s, &bytes); + assert_eq!(slicer_tiles(&s, first_slicer(&s)), months_on(&["Jan", "Feb", "Mar"])); + assert_eq!(sum_of(&s, "Grand Total"), Some(Value::Number(270.0))); + // Selecting everything keeps the months. + s.execute("slicer.selectAll", json!({"slicer": first_slicer(&s)})).unwrap(); + assert_eq!(slicer_tiles(&s, first_slicer(&s)), months_on(&MONTHS)); + assert_eq!(sum_of(&s, "Grand Total"), Some(Value::Number(TOTAL))); + let bytes = save(&s); + reopen(&mut s, &bytes); + assert_eq!(slicer_tiles(&s, first_slicer(&s)), months_on(&MONTHS)); +} + +/// With Date on Rows by quarters then months, the slicer lists the base cache field's months +/// (as Excel's does), a click filters by month, and it's saved the same way in the slicer +/// cache and the PivotTable. +#[test] +fn pivot_slicer_on_multi_level_dates_lists_the_base_level() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Date", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + s.execute("pivot.group", json!({"field": "Date", "by": "quarters"})).unwrap(); + { + let d = s.doc_mut().unwrap(); + let si = d.wb.active_sheet(); + let p = std::sync::Arc::make_mut(&mut d.wb).sheet_mut(si).unwrap().pivots.last_mut().unwrap(); + p.rows.push(gridcraft_model::PivotField { + source_col: "Date".into(), + date_group: gridcraft_model::PivotDateGroup::Months, + ..Default::default() + }); + } + s.execute("pivot.refresh", json!({})).unwrap(); + add_pivot_slicer(&mut s, 1, "Date", &["PivotTable1"]); + assert_eq!(slicer_tiles(&s, 1), months_on(&MONTHS)); + s.execute("slicer.toggle", json!({"slicer": 1, "value": "Mar"})).unwrap(); + assert_eq!(slicer_tiles(&s, 1), months_on(&["Mar"])); + assert_eq!(sum_of(&s, "Qtr1"), Some(Value::Number(120.0))); + assert_eq!(sum_of(&s, "Mar"), Some(Value::Number(120.0))); + assert_eq!(sum_of(&s, "Qtr2"), None); + assert_eq!(sum_of(&s, "Grand Total"), Some(Value::Number(120.0))); + + // The slicer cache selects the base field's Mar item (after `<1/1/2026` and Jan, Feb). + let bytes = save(&s); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut cache = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("xl/slicerCaches/slicerCache1.xml").unwrap(), &mut cache).unwrap(); + assert!(cache.contains("") && cache.matches("s=\"1\"").count() == 1, "{cache}"); + reopen(&mut s, &bytes); + assert_eq!(slicer_tiles(&s, first_slicer(&s)), months_on(&["Mar"])); + assert_eq!(sum_of(&s, "Grand Total"), Some(Value::Number(120.0))); +} #[test] fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { let mut s = sales(); diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 13956e35ce..388f5faa7c 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -134,6 +134,14 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result { ch.pivot = Some(crate::pivot::chart_link(&name, &pivot)); } } + // So does a slicer. + for sl in sh.slicers.iter_mut() { + if let gridcraft_model::SlicerSource::Pivot { pivots, .. } = &mut sl.source { + for p in pivots.iter_mut().filter(|p| p.sheet.eq_ignore_ascii_case(&old)) { + p.sheet = name.clone(); + } + } + } } for n in cx.wb.names.iter_mut() { if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index bd961c9d53..47ac6fbde6 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -1,12 +1,16 @@ -//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles. +//! Slicers: on-sheet widgets that filter a table column or a PivotTable field by clicking value +//! tiles. //! -//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a -//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and -//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` / -//! `table.insertSlicer`) selects everything, i.e. applies no filter. +//! A slicer is a thin front-end over the existing filter machinery. A table slicer's selection is +//! written as a [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's +//! range) and [`super::data::apply_filter`] hides the rows. A PivotTable slicer's selection is each +//! connected PivotTable's [`PivotFilter`] on the field, and the PivotTables are refreshed. Creating +//! a slicer (`insert.slicer` / `table.insertSlicer`) selects everything, i.e. applies no filter. use gridcraft_core::CellRef; -use gridcraft_model::{Anchor, AnchorMode, AutoFilter, FilterCriterion, Slicer, Workbook}; +use gridcraft_model::{Anchor, AnchorMode, AutoFilter, FilterCriterion, Slicer, SlicerPivot, SlicerSource, Workbook}; + +use crate::pivot::{self as pv, SlicerTile}; use serde_json::{Value as Json, json}; use super::{ @@ -62,28 +66,82 @@ pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> out } -/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the -/// slicer has no filter (`selected == None`). -pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> { - column_values(wb, sheet, &sl.table, &sl.column) - .into_iter() - .map(|v| { - let on = match &sl.selected { - None => true, - Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), - }; - (v, on) +/// A slicer's tiles, for the UI. Every tile is selected when the slicer has no filter. +pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec { + match &sl.source { + SlicerSource::Table { table, column, selected } => column_values(wb, sheet, table, column) + .into_iter() + .map(|v| { + let on = match selected { + None => true, + Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), + }; + SlicerTile { key: v.clone(), label: v, selected: on, has_data: true } + }) + .collect(), + SlicerSource::Pivot { pivots, field, sort, cross_filter, .. } => { + let Some((si, pi)) = slicer_pivots(wb, pivots).into_iter().next() else { return vec![] }; + let Some(pt) = wb.sheet(si).and_then(|sh| sh.pivots.get(pi)) else { return vec![] }; + pv::slicer_tiles(wb, pt, si, field, *sort, *cross_filter) + } + } +} + +/// The connected PivotTables that exist, as (sheet, index). +pub fn slicer_pivots(wb: &Workbook, pivots: &[SlicerPivot]) -> Vec<(usize, usize)> { + pivots + .iter() + .filter_map(|p| { + let si = wb.sheet_index(&p.sheet)?; + let pi = wb.sheet(si)?.pivots.iter().position(|t| t.name.eq_ignore_ascii_case(&p.name))?; + Some((si, pi)) }) .collect() } +/// Sets `field`'s item selection (`None`: everything) on each PivotTable and refreshes them. A field +/// outside Rows and Columns gets a hidden filter (no report filter cell), as an Excel slicer does. +/// The labels are in the grouping of the field's base cache field, as the tiles are. +fn set_pivot_selection(wb: &mut Workbook, pivots: &[(usize, usize)], field: &str, selected: Option>) -> Result<()> { + for &(si, pi) in pivots { + let Some(pt) = wb.sheet_mut(si).and_then(|sh| sh.pivots.get_mut(pi)) else { continue }; + let group = pt.field_group(field); + pt.set_filter(field, selected.clone(), group); + pv::refresh(wb, si, pi, false).map_err(EngineError::Other)?; + } + Ok(()) +} + +/// The selection after clicking `value` among `all`: a single click shows only that value (or, +/// on the sole selected value, clears the filter); `multi` toggles it, except that the last +/// selected value stays selected, as in Excel. Everything selected is the same as no filter. +fn next_selection(current: Option>, all: &[String], value: &str, multi: bool) -> Option> { + if multi { + let mut set: Vec = current.unwrap_or_else(|| all.to_vec()); + match set.iter().position(|x| x.eq_ignore_ascii_case(value)) { + Some(pos) if set.len() > 1 => { + set.remove(pos); + } + Some(_) => {} + None => set.push(value.to_string()), + } + if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } + } else { + match ¤t { + Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(value)) => None, + _ => Some(vec![value.to_string()]), + } + } +} + /// Writes the slicer's current selection into the sheet filter and re-applies it. fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { let info = wb.sheet(sheet).and_then(|sh| { let sl = sh.slicers.iter().find(|s| s.id == id)?; - let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; - let ci = t.column_index(&sl.column)?; - Some((t.range, t.range.start.col + ci as u32, sl.selected.clone())) + let SlicerSource::Table { table, column, selected } = &sl.source else { return None }; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table))?; + let ci = t.column_index(column)?; + Some((t.range, t.range.start.col + ci as u32, selected.clone())) }); let Some((table_range, abs_col, selected)) = info else { return }; { @@ -139,12 +197,13 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result { let id = d.wb.next_object_id(); let slicer = Slicer { id, - table, - column: column.clone(), + source: SlicerSource::Table { table, column: column.clone(), selected: None }, caption: column, anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly }, - selected: None, columns, + name: String::new(), + style: String::new(), + row_height: None, }; edit(s, |cx| { cx.sheet_mut(sheet)?.slicers.push(slicer); @@ -168,37 +227,32 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); let multi = bool_param(p, "multi").unwrap_or(false); let sheet = s.doc()?.wb.active_sheet(); - let (table, column, current) = { + let (new_selected, pivot) = { let d = s.doc()?; let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; - (sl.table.clone(), sl.column.clone(), sl.selected.clone()) - }; - let all = column_values(&s.doc()?.wb, sheet, &table, &column); - if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) { - return Err(bad("slicer.toggle", "value is not in the column")); - } - let new_selected: Option> = if multi { - let mut set: Vec = current.unwrap_or_else(|| all.clone()); - match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) { - Some(pos) => { - set.remove(pos); - } - None => set.push(value.clone()), - } - // Everything selected is the same as no filter. - if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } - } else { - // Single click: show only this value; clicking the sole selected value clears the filter. - match ¤t { - Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None, - _ => Some(vec![value.clone()]), - } + let tiles = slicer_items(&d.wb, sheet, sl); + let Some(tile) = tiles.iter().find(|t| t.label.eq_ignore_ascii_case(&value) || t.key.eq_ignore_ascii_case(&value)) else { + return Err(bad("slicer.toggle", "value is not one of the slicer's items")); + }; + let all: Vec = tiles.iter().map(|t| t.key.clone()).collect(); + let current = (!tiles.iter().all(|t| t.selected)).then(|| tiles.iter().filter(|t| t.selected).map(|t| t.key.clone()).collect()); + let new_selected = next_selection(current, &all, &tile.key, multi); + let pivot = match &sl.source { + SlicerSource::Pivot { pivots, field, .. } => Some((slicer_pivots(&d.wb, pivots), field.clone())), + SlicerSource::Table { .. } => None, + }; + (new_selected, pivot) }; edit(s, |cx| { - if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { - sl.selected = new_selected; + match &pivot { + Some((targets, field)) => set_pivot_selection(&mut cx.wb, targets, field, new_selected)?, + None => { + if let Some(SlicerSource::Table { selected, .. }) = cx.sheet_mut(sheet)?.slicers.get_mut(idx).map(|sl| &mut sl.source) { + *selected = new_selected; + } + reapply_slicer(&mut cx.wb, sheet, id); + } } - reapply_slicer(&mut cx.wb, sheet, id); cx.structural = true; Ok(Json::Null) }) @@ -207,36 +261,69 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { fn slicer_select_all(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let sheet = s.doc()?.wb.active_sheet(); + let d = s.doc()?; + let pivot = d.wb.active().and_then(|sh| sh.slicers.get(idx)).and_then(|sl| match &sl.source { + SlicerSource::Pivot { pivots, field, .. } => Some((slicer_pivots(&d.wb, pivots), field.clone())), + SlicerSource::Table { .. } => None, + }); edit(s, |cx| { - if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { - sl.selected = None; + match &pivot { + Some((targets, field)) => set_pivot_selection(&mut cx.wb, targets, field, None)?, + None => { + if let Some(SlicerSource::Table { selected, .. }) = cx.sheet_mut(sheet)?.slicers.get_mut(idx).map(|sl| &mut sl.source) { + *selected = None; + } + reapply_slicer(&mut cx.wb, sheet, id); + } } - reapply_slicer(&mut cx.wb, sheet, id); cx.structural = true; Ok(Json::Null) }) } +/// Deletes a slicer. A table slicer's filter is cleared; a PivotTable slicer leaves its +/// PivotTables filtered, as Excel does. fn slicer_delete(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let sheet = s.doc()?.wb.active_sheet(); - // Absolute column of the slicer, so its filter criterion can be removed too. + // Absolute column of a table slicer, so its filter criterion can be removed too. let abs_col = s.doc().ok().and_then(|d| { let sh = d.wb.active()?; - let sl = sh.slicers.get(idx)?; - let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; - let ci = t.column_index(&sl.column)?; + let SlicerSource::Table { table, column, .. } = &sh.slicers.get(idx)?.source else { return None }; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table))?; + let ci = t.column_index(column)?; Some(t.range.start.col + ci as u32) }); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; sh.slicers.retain(|x| x.id != id); - if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) { - let off = abs.saturating_sub(af.range.start.col); - af.criteria.retain(|(o, _)| *o != off); + if let Some(abs) = abs_col { + if let Some(af) = sh.autofilter.as_mut() { + let off = abs.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + } + super::data::apply_filter(&mut cx.wb, sheet); } - super::data::apply_filter(&mut cx.wb, sheet); cx.structural = true; Ok(Json::Null) }) } + +/// Disconnects the PivotTable `name` on sheet `sheet` from its slicers; slicers left with no +/// PivotTable are deleted (used when the PivotTable is deleted). +pub(crate) fn drop_pivot(wb: &mut Workbook, sheet: &str, name: &str) { + let linked = |p: &SlicerPivot| p.sheet.eq_ignore_ascii_case(sheet) && p.name.eq_ignore_ascii_case(name); + let links = |sl: &Slicer| matches!(&sl.source, SlicerSource::Pivot { pivots, .. } if pivots.iter().any(linked)); + for i in 0..wb.sheets.len() { + if !wb.sheet(i).is_some_and(|sh| sh.slicers.iter().any(links)) { + continue; + } + let Some(sh) = wb.sheet_mut(i) else { continue }; + for sl in sh.slicers.iter_mut() { + if let SlicerSource::Pivot { pivots, .. } = &mut sl.source { + pivots.retain(|p| !linked(p)); + } + } + sh.slicers.retain(|sl| !matches!(&sl.source, SlicerSource::Pivot { pivots, .. } if pivots.is_empty())); + } +} diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index fb72492d5a..bd970bcf85 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -13,7 +13,7 @@ pub use gridcraft_core::quote_sheet; use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet}; use gridcraft_model::{ BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, - PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook, + PivotSort, PivotTable, PivotValue, Series, SlicerCrossFilter, Style, StyleId, Workbook, }; use crate::pivot_calc::CalcSet; @@ -27,12 +27,11 @@ use charts::rebind_charts; pub use charts::{chart_link, chart_series, clear_output, links_to, parse_chart_link}; use compute::compute_from; pub use compute::{NOT_A_VALUE_CELL, detail_cells, detail_records}; -pub use items::{distinct_labels, func_caption, item_label}; +pub use items::{SlicerTile, distinct_labels, func_caption, item_label, slicer_tiles}; #[cfg(test)] use output::data_cols_exceed_sheet; pub use output::{CellKind, OutCell, Output, RowKind}; use output::{cell_style, look}; - /// Largest source block read (cells). pub const MAX_SOURCE_CELLS: u64 = 10_000_000; /// Largest report written (cells). diff --git a/crates/engine/src/pivot/compute.rs b/crates/engine/src/pivot/compute.rs index 9ebf447376..96b49b4d9e 100644 --- a/crates/engine/src/pivot/compute.rs +++ b/crates/engine/src/pivot/compute.rs @@ -1,4 +1,4 @@ -use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label}; +use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label, selects}; use super::output::{AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, data_cols_exceed_sheet, flatten_cols}; use super::{ CalcSet, CellError, CellRef, HashMap, HashSet, MAX_AXIS_FIELDS, MAX_COLS, MAX_VALUE_FIELDS, PivotDateGroup, PivotField, PivotFilter, PivotFunc, @@ -101,8 +101,8 @@ fn aggregate_record( ) -> Result<(), String> { for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) { if let Some(selected) = &field.selected { - let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt); - if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) { + let value = rec.get(*c).unwrap_or(&Value::Empty); + if !selects(selected, value, &item_label(input.wb, value, *group, fmt), *group) { return Ok(()); } } @@ -380,15 +380,14 @@ fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, con type ReportFilter<'a> = (usize, &'a PivotFilter, PivotDateGroup, bool); fn report_filters<'a>(pt: &'a PivotTable, src: &Source) -> (Vec>, Vec>) { - let axis_group = - |name: &str| pt.rows.iter().chain(pt.columns.iter()).find(|field| field.source_col.eq_ignore_ascii_case(name)).map(|field| field.date_group); + let on_axis = |name: &str| pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(name)); let filters: Vec<_> = pt .filters .iter() .filter_map(|filter| { let column = src.col(&filter.source_col)?; - let group = axis_group(&filter.source_col); - Some((column, filter, group.unwrap_or_default(), group.is_none())) + let group = pt.filter_group(filter); + Some((column, filter, group, !on_axis(&filter.source_col) && !filter.hidden)) }) .collect(); let shown = filters.iter().copied().filter(|filter| filter.3).collect(); diff --git a/crates/engine/src/pivot/items.rs b/crates/engine/src/pivot/items.rs index 1a39c59d97..621a96ad5b 100644 --- a/crates/engine/src/pivot/items.rs +++ b/crates/engine/src/pivot/items.rs @@ -1,4 +1,7 @@ -use super::{CellError, HashMap, MONTHS, Ordering, PivotDateGroup, PivotField, PivotFunc, PivotSort, Source, Value, Workbook, datetime_from_serial}; +use super::{ + CellError, HashMap, MONTHS, Ordering, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotSort, PivotTable, SlicerCrossFilter, Source, + Value, Workbook, datetime_from_serial, read_source, +}; // ---------------------------------------------------------------- items @@ -107,6 +110,90 @@ pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDate items.into_iter().take(limit).map(|i| i.label).collect() } +/// Whether a filter's selected labels take in a source value: by its label, or (outside a date +/// grouping) a number by its plain number text, which is how files name number and date items. +pub(super) fn selects(sel: &[String], v: &Value, label: &str, group: PivotDateGroup) -> bool { + if sel.iter().any(|s| s.eq_ignore_ascii_case(label)) { + return true; + } + match v.scalar() { + Value::Number(n) if group == PivotDateGroup::None => sel.contains(&gridcraft_core::number_to_text(n)), + _ => false, + } +} + +/// A slicer tile: one item of a PivotTable field. +#[derive(Clone, Debug, PartialEq)] +pub struct SlicerTile { + /// The item as shown (numbers and dates in the source format). + pub label: String, + /// The label a [`PivotFilter`] selects the item by: the plain number text for numbers (as + /// files store them), else the label. + pub key: String, + pub selected: bool, + /// Some source row with this item passes the PivotTable's other filters. + pub has_data: bool, +} + +/// The items of `field` in `pt`'s source as slicer tiles: selection from the PivotTable's filter +/// on the field, in `sort` order (`None`: source order), and with `cross` the items without data +/// last or marked as Excel does. A date-grouped field's tiles are the groups of its base cache +/// field (`Jan`…, the finest level), which is what Excel's slicer lists. A filter on another +/// level of the field filters like another field's. +pub fn slicer_tiles(wb: &Workbook, pt: &PivotTable, sheet: usize, field: &str, sort: PivotSort, cross: SlicerCrossFilter) -> Vec { + let Ok(src) = read_source(wb, &pt.source, sheet) else { return vec![] }; + let Some(col) = src.col(field) else { return vec![] }; + let fmt_of = |c: usize| src.formats.get(c).map(String::as_str).unwrap_or("General"); + let group = pt.field_group(field); + let own_level = |f: &PivotFilter| f.source_col.eq_ignore_ascii_case(field) && pt.filter_group(f) == group; + let own = pt.filters.iter().find(|f| own_level(f)).and_then(|f| f.selected.as_deref()); + let others: Vec<(usize, &[String], PivotDateGroup)> = pt + .filters + .iter() + .filter(|f| !own_level(f)) + .filter_map(|f| Some((src.col(&f.source_col)?, f.selected.as_deref()?, pt.filter_group(f)))) + .collect(); + let mut fi = FieldItems::default(); + let mut data: Vec = Vec::new(); + for (i, r) in src.rows.iter().enumerate() { + let k = fi.intern(wb, r.get(col).unwrap_or(&Value::Empty), group, fmt_of(col), i) as usize; + if data.len() <= k { + data.resize(k + 1, false); + } + if !data[k] + && others.iter().all(|(c, sel, g)| { + let v = r.get(*c).unwrap_or(&Value::Empty); + selects(sel, v, &item_label(wb, v, *g, fmt_of(*c)), *g) + }) + { + data[k] = true; + } + if fi.items.len() > 100_000 { + break; + } + } + let mut items: Vec<(Item, bool)> = fi.items.into_iter().zip(data).collect(); + match sort { + PivotSort::Asc => items.sort_by(|a, b| cmp_items(&a.0, &b.0)), + PivotSort::Desc => items.sort_by(|a, b| cmp_items(&b.0, &a.0)), + PivotSort::None => {} + } + if cross == SlicerCrossFilter::ShowItemsWithDataAtTop { + items.sort_by_key(|(_, d)| !*d); + } + items + .into_iter() + .map(|(it, has_data)| { + let key = match &it.value { + Value::Number(n) => gridcraft_core::number_to_text(*n), + _ => it.label.clone(), + }; + let selected = own.is_none_or(|sel| selects(sel, &it.value, &it.label, group)); + SlicerTile { label: it.label, key, selected, has_data: has_data || cross == SlicerCrossFilter::None } + }) + .collect() +} + // ---------------------------------------------------------------- aggregation #[derive(Clone, Debug, Default)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df3..1376345f7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1902,7 +1902,7 @@ fn slicer_filters_table() { let d = s.doc().unwrap(); let sh = d.wb.active().unwrap(); assert_eq!(sh.slicers.len(), 1); - assert_eq!(sh.slicers[0].column, "Region"); + assert_eq!(sh.slicers[0].field(), "Region"); } // Single-click "East": only the two East rows remain (West row 2 and North row 4 hidden). @@ -1922,7 +1922,7 @@ fn slicer_filters_table() { // Clear restores everything. s.execute("slicer.clear", json!({"slicer": id})).unwrap(); assert_eq!(hidden(&s), 0); - assert!(s.doc().unwrap().wb.active().unwrap().slicers[0].selected.is_none()); + assert!(matches!(&s.doc().unwrap().wb.active().unwrap().slicers[0].source, gridcraft_model::SlicerSource::Table { selected: None, .. })); // Deleting the slicer drops it and clears its filter. s.execute("slicer.toggle", json!({"slicer": id, "value": "East"})).unwrap(); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 2394319c33..8505863ebb 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -702,24 +702,99 @@ pub struct AutoFilter { pub criteria: Vec<(u32, FilterCriterion)>, } -/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles -/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it, -/// so a slicer is just a visual front-end over the existing filter machinery. +/// A slicer: an on-sheet filter widget whose value tiles drive an existing filter, so a slicer is +/// just a visual front-end over the filter machinery (see [`SlicerSource`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Slicer { pub id: u32, - /// Name of the table this slicer filters. - pub table: String, - /// Header name of the filtered column within the table. - pub column: String, - /// Caption shown in the slicer header (defaults to the column name). + /// What the slicer filters, and where its selection lives. + #[serde(flatten)] + pub source: SlicerSource, + /// Caption shown in the slicer header (defaults to the column or field name). pub caption: String, pub anchor: Anchor, - /// Selected display values. `None` means every value (no filter from this slicer); an empty - /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons". - pub selected: Option>, /// Number of button columns in the layout (Excel default 1). pub columns: u32, + /// The slicer's name (unique in a workbook; Excel's Slicer Settings "Name"). Empty: derived + /// from the caption when saved. + #[serde(default)] + pub name: String, + /// Slicer style name (`SlicerStyleLight1`…). Empty: the default style. + #[serde(default)] + pub style: String, + /// Button height in pixels; `None` uses the default. + #[serde(default)] + pub row_height: Option, +} + +impl Slicer { + /// The column or field the slicer filters. + pub fn field(&self) -> &str { + match &self.source { + SlicerSource::Table { column, .. } => column, + SlicerSource::Pivot { field, .. } => field, + } + } +} + +/// What a slicer filters. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SlicerSource { + /// One column of a table: the selection is written as that column's + /// `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applied. + Table { + /// Name of the table. + table: String, + /// Header name of the filtered column within the table. + column: String, + /// Selected display values. `None` means every value (no filter from this slicer); an + /// empty list means nothing is selected (all rows hidden), matching Excel's "clear all + /// buttons". + selected: Option>, + }, + /// One field of one or more PivotTables built from the same source. The selection is not + /// stored here: it is each PivotTable's [`PivotFilter`] on the field (its hidden items), so the + /// slicer and the PivotTables always agree. + Pivot { + /// The PivotTables it filters. + pivots: Vec, + /// Source field (column header) name. + field: String, + /// Tile order: ascending, descending, or data source order (`PivotSort::None`). + #[serde(default)] + sort: PivotSort, + #[serde(default)] + cross_filter: SlicerCrossFilter, + /// Whether items no longer in the source are shown (Excel's "Show items deleted from the + /// data source"; kept for the file). + #[serde(default = "default_true")] + show_missing: bool, + }, +} + +fn default_true() -> bool { + true +} + +/// A PivotTable a slicer filters: its sheet and name. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SlicerPivot { + pub sheet: String, + pub name: String, +} + +/// How a slicer shows items that have no data under its PivotTables' other filters (Excel's +/// "Visually indicate items with no data" options). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SlicerCrossFilter { + /// Items with no data aren't marked. + None, + /// Items with data first (the default). + #[default] + ShowItemsWithDataAtTop, + /// Items with no data stay in their place. + ShowItemsWithNoData, } #[cfg(test)] diff --git a/crates/model/src/features/pivot.rs b/crates/model/src/features/pivot.rs index b34015f4cd..dabaf63072 100644 --- a/crates/model/src/features/pivot.rs +++ b/crates/model/src/features/pivot.rs @@ -35,6 +35,19 @@ pub enum PivotDateGroup { Days, } +impl PivotDateGroup { + /// Finest first: days, months, quarters, years, then ungrouped. + pub fn coarseness(self) -> u8 { + match self { + PivotDateGroup::Days => 0, + PivotDateGroup::Months => 1, + PivotDateGroup::Quarters => 2, + PivotDateGroup::Years => 3, + PivotDateGroup::None => 4, + } + } +} + /// A field placed in the Rows or Columns area. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -97,6 +110,15 @@ pub struct PivotFilter { pub source_col: String, /// Item labels shown; `None` = all. pub selected: Option>, + /// For a field outside Rows and Columns: it filters the PivotTable without being a report + /// filter (no filter cell above the table). Set by a slicer on a field the PivotTable doesn't + /// show, and read from files as hidden items of such a field. + pub hidden: bool, + /// The date grouping `selected` is in, when it isn't the first Rows/Columns field's on the + /// column (`None`): a field in no area keeps its cache field's grouping here (ECMA-376 puts + /// `fieldGroup` on the cache field), and on a field grouped at several levels it names the + /// level filtered. See [`PivotTable::filter_group`]. + pub date_group: PivotDateGroup, } /// A PivotTable: its definition plus where its output was last written. The output itself is @@ -149,6 +171,74 @@ pub struct PivotCalcField { pub formula: String, } +impl PivotTable { + fn axis(&self) -> impl Iterator { + self.rows.iter().chain(self.columns.iter()) + } + + /// The grouping a filter on `col` gets when it names none: the first Rows/Columns field's. + pub fn implied_group(&self, col: &str) -> PivotDateGroup { + self.axis().find(|f| f.source_col.eq_ignore_ascii_case(col)).map(|f| f.date_group).unwrap_or_default() + } + + /// The date grouping a filter's labels are in. + pub fn filter_group(&self, f: &PivotFilter) -> PivotDateGroup { + if f.date_group == PivotDateGroup::None { self.implied_group(&f.source_col) } else { f.date_group } + } + + /// Clears each filter's `date_group` where the first Rows/Columns field implies it, so one + /// filter has one representation. + pub fn normalize_filter_groups(&mut self) { + let implied: Vec = self.filters.iter().map(|f| self.implied_group(&f.source_col)).collect(); + for (f, g) in self.filters.iter_mut().zip(implied) { + if f.date_group == g { + f.date_group = PivotDateGroup::None; + } + } + } + + /// The grouping of `col`'s base cache field: the finest of its Rows/Columns fields' and its + /// filter's, as a cache groups the base field by the finest level. A slicer on `col` lists + /// these items. + pub fn field_group(&self, col: &str) -> PivotDateGroup { + let filter = self.filters.iter().filter(|f| f.source_col.eq_ignore_ascii_case(col)).map(|f| self.filter_group(f)); + let axis = self.axis().filter(|f| f.source_col.eq_ignore_ascii_case(col)).map(|f| f.date_group); + axis.chain(filter).min_by_key(|g| g.coarseness()).unwrap_or_default() + } + + /// Sets the filter on `col` to `selected` (`None`: everything) with its labels in `group`, + /// adding a hidden filter for a field in no area. Clearing a filter removes it, except a + /// report filter and a filter that holds the field's grouping (none of its Rows/Columns + /// fields has it), which keep `selected: None`. + pub fn set_filter(&mut self, col: &str, selected: Option>, group: PivotDateGroup) { + let on_axis = self.axis().any(|f| f.source_col.eq_ignore_ascii_case(col)); + let date_group = if group == self.implied_group(col) { PivotDateGroup::None } else { group }; + let i = self.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(col)); + match (i, selected) { + (Some(i), None) => { + let Some(f) = self.filters.get(i) else { return }; + let grouping = f.date_group != PivotDateGroup::None + && !self.axis().any(|a| a.source_col.eq_ignore_ascii_case(col) && a.date_group == f.date_group); + if grouping || !(on_axis || f.hidden) { + if let Some(f) = self.filters.get_mut(i) { + f.selected = None; + } + } else { + self.filters.remove(i); + } + } + (Some(i), Some(sel)) => { + if let Some(f) = self.filters.get_mut(i) { + f.selected = Some(sel); + f.date_group = date_group; + } + } + (None, Some(sel)) => self.filters.push(PivotFilter { source_col: col.to_string(), selected: Some(sel), hidden: !on_axis, date_group }), + (None, None) => {} + } + } +} + impl Default for PivotTable { fn default() -> Self { PivotTable { diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 8adab4918c..05dcce9cbf 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -4,12 +4,29 @@ use egui::epaint::{PathShape, PathStroke}; use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2}; use gridcraft_chart::{HAlign, Prim, VAlign}; -use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook}; +use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Slicer, Workbook}; +use std::collections::HashMap; +use std::sync::{Arc, Weak}; use crate::SheetApp; use crate::grid::Geo; use crate::theme::{self, Tokens}; +/// The slicer's tiles, computed once per workbook version. Every change to a document, undoable or +/// not (recalculation, refresh), installs a new `Arc`; the cache holds a `Weak` to the one +/// it was built from, so a new version is always told apart (the allocation, and so its address, +/// stays reserved while the `Weak` lives) without a strong reference that would make the next edit's +/// `Arc::make_mut` copy the whole workbook. +fn cached_slicer_items<'a>(app: &'a mut SheetApp, wb: &Workbook, si: usize, sl: &Slicer) -> &'a [gridcraft_engine::pivot::SlicerTile] { + let cur = app.session.active().map(|d| Arc::downgrade(&d.wb)); + let fresh = matches!((&app.grid.slicer_tiles, &cur), (Some((Some(built), _)), Some(cur)) if Weak::ptr_eq(built, cur)); + if !fresh { + app.grid.slicer_tiles = None; + } + let (_, tiles) = app.grid.slicer_tiles.get_or_insert_with(|| (cur, HashMap::new())); + tiles.entry((si, sl.id)).or_insert_with(|| gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl)) +} + fn rgba(c: [u8; 4]) -> Color32 { Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) } @@ -259,7 +276,7 @@ pub(crate) fn objects_hidden(wb: &Workbook) -> bool { wb.show_objects == gridcraft_engine::model::ShowObjects::None } -pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec { +pub(crate) fn paint_objects(app: &mut SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec { let mut painted = Vec::new(); if objects_hidden(wb) { return painted; @@ -397,11 +414,12 @@ pub struct SlicerLayout { pub tiles: Vec, } -pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout { +/// `row_height`: the slicer's button height in pixels (`None`: the default). +pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32, row_height: Option) -> SlicerLayout { let header_h = 26.0 * z; let pad = 6.0 * z; let gap = 3.0 * z; - let tile_h = 22.0 * z; + let tile_h = row_height.filter(|h| h.is_finite()).unwrap_or(22.0).clamp(8.0, 400.0) * z; let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); let cb = 16.0 * z; let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb)); @@ -421,15 +439,15 @@ pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout SlicerLayout { header, clear, tiles } } -fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { +fn paint_slicers(app: &mut SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { let tk = theme::Tokens::get(p.ctx()); for sl in &sh.slicers { let r = anchor_rect(geo, sh, &sl.anchor); if !r.intersects(p.clip_rect()) { continue; } - let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); - let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let items = cached_slicer_items(app, wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns, sl.row_height); let radius = 4.0 * geo.z; // Card + soft shadow. p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18)); @@ -438,22 +456,28 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz // Header. p.rect_filled(lay.header, radius, tk.accent_soft); p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft); - let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() }; + let cap = if sl.caption.is_empty() { sl.field().to_string() } else { sl.caption.clone() }; p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark); // Clear-filter button (tinted when a filter is active). - let filtered = sl.selected.is_some(); + let filtered = items.iter().any(|t| !t.selected); if let Some(icon) = crate::icons::from_name("filter") { crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim }); } // Value tiles. - for ((val, on), tr) in items.iter().zip(&lay.tiles) { + for (tile, tr) in items.iter().zip(&lay.tiles) { if !r.contains(tr.left_top()) { continue; // overflowed the card; skip (no scroll yet) } - let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) }; + // Items with no data under the PivotTables' other filters are dimmed. + let (fill, fg) = match (tile.selected, tile.has_data) { + (true, true) => (tk.accent, tk.on_accent), + (true, false) => (tk.accent_soft, tk.text_dim), + (false, true) => (tk.hover, tk.text_dim), + (false, false) => (tk.window, tk.text_dim), + }; p.rect_filled(*tr, 3.0 * geo.z, fill); let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); - cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); + cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, &tile.label, theme::ui_font(12.0 * geo.z), fg); } if app.selected_chart == Some(sl.id) { selection_frame(p, r); @@ -469,7 +493,7 @@ pub enum SlicerAction { /// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns /// `None` so the caller can treat it as a select/drag on the object. -pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> { +pub fn slicer_click(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> { if !geo.cells.contains(p) || objects_hidden(wb) { return None; } @@ -478,14 +502,14 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> if !r.contains(p) { continue; } - let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); - let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let items = cached_slicer_items(app, wb, si, sl); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns, sl.row_height); if lay.clear.contains(p) { return Some((sl.id, SlicerAction::Clear)); } - for ((val, _on), tr) in items.iter().zip(&lay.tiles) { + for (tile, tr) in items.iter().zip(&lay.tiles) { if r.contains(tr.left_top()) && tr.contains(p) { - return Some((sl.id, SlicerAction::Toggle(val.clone()))); + return Some((sl.id, SlicerAction::Toggle(tile.key.clone()))); } } return None; // inside this (topmost) slicer, but on its header/background → select/drag @@ -534,3 +558,74 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static s } None } + +#[cfg(test)] +mod tests { + use super::*; + use gridcraft_engine::model::{Anchor, PivotSort, Slicer, SlicerCrossFilter, SlicerPivot, SlicerSource}; + use serde_json::json; + + #[test] + fn slicer_tile_cache_refreshes_after_source_edit() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session + .execute("range.setValues", json!({"range": "A1:B4", "values": [["Region", "Sales"], ["East", 1], ["West", 2], ["North", 3]]})) + .unwrap(); + session.execute("insert.pivotTable", json!({"source": "A1:B4", "destination": "D1", "rows": ["Region"], "values": ["Sales"]})).unwrap(); + let mut app = SheetApp::new(session, Default::default()); + { + let d = app.session.doc_mut().unwrap(); + let sheet = d.wb.active_sheet(); + let name = d.wb.sheet(sheet).unwrap().name.clone(); + let pivot = d.wb.sheet(sheet).unwrap().pivots[0].name.clone(); + let slicer = Slicer { + id: 100, + source: SlicerSource::Pivot { + pivots: vec![SlicerPivot { sheet: name, name: pivot }], + field: "Region".into(), + sort: PivotSort::Asc, + cross_filter: SlicerCrossFilter::default(), + show_missing: true, + }, + caption: "Region".into(), + anchor: Anchor::default(), + columns: 1, + name: String::new(), + style: String::new(), + row_height: None, + }; + std::sync::Arc::make_mut(&mut d.wb).sheet_mut(sheet).unwrap().slicers.push(slicer); + } + let wb = app.session.active().unwrap().wb.clone(); + let slicer = wb.active().unwrap().slicers[0].clone(); + let strong = Arc::strong_count(&wb); + let before: Vec<_> = cached_slicer_items(&mut app, &wb, wb.active_sheet(), &slicer).iter().map(|x| x.label.clone()).collect(); + assert_eq!(before, ["East", "North", "West"]); + // The cache holds no strong reference, so the next edit doesn't copy the workbook. + assert_eq!(Arc::strong_count(&wb), strong); + + app.session.execute("cell.set", json!({"cell": "A2", "input": "South"})).unwrap(); + let wb = app.session.active().unwrap().wb.clone(); + let slicer = wb.active().unwrap().slicers[0].clone(); + let after: Vec<_> = cached_slicer_items(&mut app, &wb, wb.active_sheet(), &slicer).iter().map(|x| x.label.clone()).collect(); + assert_eq!(after, ["North", "South", "West"]); + + // A source value that changes only on a manual recalculation (not undoable, so the + // document revision stays) still refreshes the tiles. + app.session.execute("cell.set", json!({"cell": "F1", "input": "West"})).unwrap(); + app.session.execute("cell.set", json!({"cell": "A3", "input": "=F1"})).unwrap(); + app.session.execute("formulas.calculationOptions", json!({"mode": "manual"})).unwrap(); + app.session.execute("cell.set", json!({"cell": "F1", "input": "Central"})).unwrap(); + let tiles = |app: &mut SheetApp| { + let wb = app.session.active().unwrap().wb.clone(); + let slicer = wb.active().unwrap().slicers[0].clone(); + cached_slicer_items(app, &wb, wb.active_sheet(), &slicer).iter().map(|x| x.label.clone()).collect::>() + }; + assert_eq!(tiles(&mut app), ["North", "South", "West"]); + let revision = app.session.active().unwrap().revision; + app.session.execute("formulas.calculateNow", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!(tiles(&mut app), ["Central", "North", "South"]); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c5e1dd4730..1c0da648ef 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3,12 +3,14 @@ //! formats, tables, comments, filters, sparklines, floating charts/pictures/shapes and the //! in-cell editor. -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Weak}; use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use gridcraft_engine::cf::{CfCache, CfLook}; use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters}; use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook}; +use gridcraft_engine::pivot::SlicerTile; use serde_json::json; use crate::SheetApp; @@ -21,6 +23,8 @@ pub(crate) type PaneQuad = ((u32, u32), (u32, u32), Rect); type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>); /// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot). type DetailsCache = ((u64, u64, usize, usize), Option>); +/// Cached slicer tiles by (sheet, slicer id), with the workbook version they were computed from. +type SlicerTilesCache = (Option>, HashMap<(usize, u32), Vec>); mod cell_paint; mod cursor; @@ -216,6 +220,9 @@ pub struct GridState { pub pane: Option, /// Ink stroke being drawn (sheet points). pub ink: Vec<[f32; 2]>, + /// Slicer tiles by (sheet, slicer id), with the workbook version they were computed from + /// (`None` without a document). Any other version, undoable or not, replaces them. + pub slicer_tiles: Option, /// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet). pub pages: Option, /// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot): diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 4ffeaeb83d..aae315cc05 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -105,7 +105,7 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo if primary_clicked && !resp.drag_started() && let Some(p) = pos - && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) + && let Some((id, act)) = crate::chartview::slicer_click(app, geo, sh, wb, wb.active_sheet(), p) { app.selected_chart = Some(id); match act { diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 0668ca9d86..2c878d3988 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -181,6 +181,8 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( } else { cx.warn(format!("chart {target} is missing from the package and was skipped ({part}, object {n})")); } + } else if crate::slicer::read_frame(cx, &sheet.name, gd, anchor) { + // A slicer: joined with its slicer part and cache once the sheets are read. } else if uri.contains("chartex") || uri.contains("chartEx") { cx.warn(format!("a histogram, waterfall, treemap… chart without its chart part was skipped ({part}, object {n})")); } else { @@ -318,6 +320,8 @@ pub enum Obj<'a> { Image(&'a Image, String), Chart(&'a gridcraft_model::Chart, String), Shape(&'a Shape), + /// A PivotTable slicer and its (unique) name. + Slicer(&'a gridcraft_model::Slicer, String), } /// The drawing part for a sheet's objects (relationship ids already assigned). @@ -366,6 +370,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { xfrm(&ch.anchor, "xdr:xfrm") ); } + Obj::Slicer(sl, name) => { + let (open, close) = anchor_open(sheet, &sl.anchor); + close_tag = close; + s.push_str(&open); + s.push_str(&crate::slicer::frame_xml(id, name, &xfrm(&sl.anchor, "xdr:xfrm"), &xfrm(&sl.anchor, "a:xfrm"))); + } Obj::Shape(sh) => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b228203..be33cc51ab 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -28,6 +28,7 @@ mod read; mod richdata; mod sheet_read; mod sheet_write; +mod slicer; mod styles; mod tables; #[cfg(any(test, feature = "test-support"))] diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 45a6188933..20ea0ff4dd 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -1,18 +1,17 @@ //! PivotTables: pivot cache definitions and records (ECMA-376 Part 1 §18.10.1) and PivotTable //! definitions (§18.10.1.73), written from the model's [`PivotTable`]s and read back into them. //! -//! Writing: one cache per PivotTable, with a records snapshot of the source list (omitted, with -//! `saveData="0"`, for very large sources) and `refreshOnLoad="1"` so a reader recomputes the -//! report from the source on open. Fields used on an axis get shared items (or, for date -//! grouping, `fieldGroup` group items) so the table's `` can refer to them. The report -//! cells the engine wrote stay on the sheet as plain cells. +//! Writing: one cache per PivotTable (the PivotTables a slicer connects share one), with a +//! records snapshot of the source list (omitted, with `saveData="0"`, for very large sources) and +//! `refreshOnLoad="1"` so a reader recomputes the report from the source on open. Fields used on +//! an axis get shared items (or, for date grouping, `fieldGroup` group items) so the table's +//! `` can refer to them. The report cells the engine wrote stay on the sheet as plain cells. //! //! Reading: the cache's source and field names plus the table's areas, functions, show-values-as, //! layout flags, grand totals and style become a model [`PivotTable`]. Anything we don't model //! (OLAP sources, calculated fields, numeric grouping…) becomes a warning. use std::collections::HashMap; -use std::fmt::Write as _; use gridcraft_core::date::datetime_from_serial; use gridcraft_core::{CellRef, DateSystem, RangeRef, Value, number_to_text}; @@ -29,9 +28,9 @@ use crate::xml::{El, esc_attr}; mod read; mod write; -pub use read::{CachesIn, read_pivots}; -pub use write::PivotWriter; - +pub use read::{CachesIn, NonSourceField, read_pivots}; +pub use write::{PivotSlicerInfo, PivotWriter}; +pub(crate) use write::{can_share, slicer_field_ok}; pub const CT_PIVOT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"; pub const CT_PIVOT_CACHE_DEF: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"; pub const CT_PIVOT_CACHE_REC: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"; @@ -46,6 +45,8 @@ const MAX_ITEMS: usize = 1_048_576; const MAX_FIELDS: usize = 16_384; /// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank). const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}"; +/// Extension carrying a cache's id for slicer caches. +const EXT_X14_CACHE_ID: &str = "{725AE2AE-9491-48be-B2B4-4EB974FC3084}"; const EXT_X14_PIVOT_TABLE: &str = "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}"; const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; diff --git a/crates/xlsx/src/pivot/read.rs b/crates/xlsx/src/pivot/read.rs index 134a8ab227..b3e4bf31cc 100644 --- a/crates/xlsx/src/pivot/read.rs +++ b/crates/xlsx/src/pivot/read.rs @@ -31,6 +31,8 @@ impl FieldIn { struct CacheIn { source: String, fields: Vec, + /// `x14:pivotCacheDefinition pivotCacheId`: how slicer caches name the cache. + slicer_id: Option, } /// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. @@ -55,6 +57,53 @@ impl CachesIn { } CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } } + + /// Whether a slicer cache id resolves to a parsed PivotTable cache. + pub fn has_slicer_cache(&self, cache_id: u32) -> bool { + self.parsed.values().flatten().any(|c| c.slicer_id == Some(cache_id)) + } + + /// Item captions of field `field` in the cache a slicer cache names by `pivotCacheId`, in + /// shared item order (what the slicer cache's `` indices refer to), and the field's date + /// grouping. `None` for a grouping GridCraft can't compute. + pub fn slicer_labels(&self, cache_id: u32, field: &str) -> Option<(&[String], PivotDateGroup)> { + let cache = self.parsed.values().flatten().find(|c| c.slicer_id == Some(cache_id))?; + let f = cache.fields.iter().find(|f| f.database && same(&f.name, field))?; + let group = match &f.group { + None => PivotDateGroup::None, + Some((_, by, _)) => (*by)?, + }; + Some((f.labels(), group)) + } + + /// What `field` names in the cache a slicer cache names by `pivotCacheId`, when it is not a + /// source column. `None` when it is one, or the cache isn't known. + pub fn slicer_non_source(&self, cache_id: u32, field: &str) -> Option> { + let cache = self.parsed.values().flatten().find(|c| c.slicer_id == Some(cache_id))?; + if cache.fields.iter().any(|f| f.database && same(&f.name, field)) { + return None; + } + let base = cache + .fields + .iter() + .find(|f| same(&f.name, field)) + .and_then(|f| f.group.as_ref()) + .and_then(|(base, by, _)| by.and((*base).and_then(|b| cache.fields.get(b)))) + .filter(|b| b.database); + Some(match base { + Some(b) => NonSourceField::DateLevel { base: &b.name }, + None => NonSourceField::Other, + }) + } +} + +/// A slicer cache's source field that is not a source column of its pivot cache. +pub enum NonSourceField<'a> { + /// A coarser date level, which Excel keeps in a field of its own when a date is grouped at + /// several levels ("Years" over "Date" grouped by years and months), grouping column `base`. + DateLevel { base: &'a str }, + /// Another field (calculated, grouped in a way GridCraft can't compute) or none of that name. + Other, } fn item_label_el(e: &El, sys: DateSystem) -> String { @@ -130,7 +179,9 @@ fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option cx.warn("a PivotTable's cache has no fields; kept as plain cells"); return None; } - Some(CacheIn { source, fields }) + let slicer_id = + x.child("extLst").and_then(|l| l.kids("ext").find_map(|e| e.child("pivotCacheDefinition"))).and_then(|d| d.attr_u32("pivotCacheId")); + Some(CacheIn { source, fields, slicer_id }) } /// Reads the PivotTables related to a worksheet part. @@ -280,7 +331,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); if !filters.iter().any(|ff| same(&ff.source_col, &name)) { - filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); + filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown), hidden: false, date_group: group }); } } } @@ -291,7 +342,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option= 0).map(|i| i as usize) else { continue }; - let Some((name, _)) = field_ref(cx, cache, i) else { continue }; + let Some((name, group)) = field_ref(cx, cache, i) else { continue }; let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); let selected = if let Some(k) = p.attr_u32("item") { labels.get(k as usize).map(|l| vec![l.clone()]) @@ -307,7 +358,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option, x: &El, cache: &CacheIn, id: u32) -> Option