//! PivotTable commands (Insert › PivotTable, PivotTable Analyze, PivotTable Design, Data › //! Refresh All). Every change re-renders the report as one undo step. //! //! `pivot?` parameters name a PivotTable (name or numeric id); by default it is the pivot whose //! report contains the active cell, else the last pivot on the active sheet. 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, }; 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, }; use crate::pivot as pv; use crate::selection::current_region; mod charts; mod create; mod fields; mod layout; mod params; use charts::{insert_pivot_chart, recommended}; use create::{insert_pivot, options, show_details, summarize_table}; 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}; pub fn specs() -> Vec { vec![ cmd!( "insert.pivotTable", "PivotTable", ["Insert", "Tables"], None, "{source?: \"Sheet1!$A$1:$E$200\" | table name (default: the table or current region of the selection), destination?: \"new\" (default: new sheet before the current one, report at A3) | \"Sheet1!H3\", name?, rows?: [field | {field, group?: years|quarters|months|days}], columns?: [..], values?: [field | {field, func?, name?}], filters?: [field], replace?: bool} → {id, name, sheet, anchor, fields}", has_doc, insert_pivot ), cmd!( "insert.recommendedPivotTables", "Recommended PivotTables", ["Insert", "Tables"], None, "{source?, apply?: index, destination?} → suggestions [{index, title, rows, columns, values}]; with apply, creates that one", has_doc, recommended ), cmd!( "table.summarizePivot", "Summarize with PivotTable", ["Table Design", "Tools"], None, "{table?, destination?} creates a PivotTable from the table", has_doc, summarize_table ), cmd!( "pivot.calculatedField", "Calculated Field", ["PivotTable Analyze", "Calculations"], None, "{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}", has_doc, calculated_field ), cmd!( "insert.pivotChart", "PivotChart", ["Insert", "Charts"], None, "{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}", has_doc, insert_pivot_chart ), cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields), cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields), cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list), cmd!( "pivot.addField", "Add Field", [], None, "{pivot?, field, area: rows|columns|values|filters, func?: sum|count|average|max|min|product|countNumbers|stdDev|stdDevP|var|varP, name?, position?: index, replace?}", has_doc, add_field ), cmd!( "pivot.removeField", "Remove Field", [], None, "{pivot?, field (or value caption), area?: rows|columns|values|filters, replace?}", has_doc, remove_field ), cmd!( "pivot.moveField", "Move Field", [], None, "{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}", has_doc, move_field ), cmd!( "pivot.valueSettings", "Value Field Settings", ["PivotTable Analyze", "Active Field"], None, "{pivot?, field: caption or source field | index: n, func?, name?, showAs?: normal|percentOfGrandTotal|percentOfColumnTotal|percentOfRowTotal|runningTotal|rank, numberFormat?: \"#,##0\" | null, replace?}", has_doc, value_settings ), cmd!("pivot.filter", "Filter Items", [], None, "{pivot?, field, selected: [\"East\", ..] | null (all), replace?}", has_doc, filter), cmd!("pivot.sort", "Sort Field", [], None, "{pivot?, field, order: asc|desc|none, replace?}", has_doc, sort), cmd!( "pivot.group", "Group Field", ["PivotTable Analyze", "Group"], None, "{pivot?, field, by: years|quarters|months|days|none, replace?}", has_doc, group ), cmd!( "pivot.collapse", "Expand/Collapse Field", ["PivotTable Analyze", "Active Field"], None, "{pivot?, field, items?: [labels] (default: all), collapse?: true, replace?}", has_doc, collapse ), cmd!( "pivot.layout", "PivotTable Layout", ["PivotTable Design", "Layout"], None, "{pivot?, layout?: compact|outline|tabular, grandTotalsRows?: bool, grandTotalsCols?: bool, subtotals?: bool|top|bottom|none, style?: \"PivotStyleLight16\", showHeaders?: bool, replace?}", has_doc, layout ), cmd!( "pivot.grandTotals", "Grand Totals", ["PivotTable Design", "Layout"], None, "{pivot?, mode?: off|on|rows|columns, rows?: bool, cols?: bool}", has_doc, grand_totals ), cmd!("pivot.subtotals", "Subtotals", ["PivotTable Design", "Layout"], None, "{pivot?, mode: none|top|bottom}", has_doc, |s, p| { let mode = str_param(p, "mode").unwrap_or("top"); layout(s, &with(p, "subtotals", json!(mode))) }), cmd!( "pivot.reportLayout", "Report Layout", ["PivotTable Design", "Layout"], None, "{pivot?, layout: compact|outline|tabular}", has_doc, layout ), cmd!( "pivot.style", "PivotTable Styles", ["PivotTable Design", "PivotTable Styles"], None, "{pivot?, style: \"PivotStyleMedium9\"}", has_doc, layout ), cmd!("pivot.refresh", "Refresh", ["PivotTable Analyze", "Data"], Some("Alt+F5"), "{pivot?, replace?}", has_doc, refresh), cmd!( "data.refreshAll", "Refresh All", ["Data", "Queries & Connections"], Some("Cmd+Alt+F5"), "{replace?} refreshes every PivotTable → {refreshed, errors}", has_doc, refresh_all ), cmd!( "pivot.changeSource", "Change Data Source", ["PivotTable Analyze", "Data"], None, "{pivot?, source: \"Sheet1!$A$1:$E$300\" | table name, replace?} (fields missing from the new source are removed)", has_doc, change_source ), cmd!( "pivot.showDetails", "Show Details", [], None, "{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).", has_doc, show_details ), cmd!( "pivot.options", "PivotTable Options", ["PivotTable Analyze", "PivotTable"], None, "{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}", has_doc, options ), cmd!( "pivot.delete", "Delete PivotTable", ["PivotTable Analyze", "Actions"], None, "{pivot?} clears the report and removes the PivotTable", has_doc, delete ), ] } fn with(p: &Json, k: &str, v: Json) -> Json { let mut m = p.as_object().cloned().unwrap_or_default(); m.insert(k.into(), v); Json::Object(m) } fn other(msg: impl Into) -> EngineError { EngineError::Other(msg.into()) } // ---------------------------------------------------------------- lookup /// (sheet, index) of the pivot a call targets. fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; let active = d.wb.active_sheet(); if let Some(v) = p.get("pivot").filter(|v| !v.is_null()) { let mut order: Vec = vec![active]; order.extend((0..d.wb.sheets.len()).filter(|i| *i != active)); for si in order { let Some(sh) = d.wb.sheet(si) else { continue }; let hit = sh.pivots.iter().position(|pt| match v { Json::String(n) => pt.name.eq_ignore_ascii_case(n), Json::Number(n) => n.as_u64() == Some(pt.id as u64), _ => false, }); if let Some(i) = hit { return Ok((si, i)); } } return Err(bad("pivot", format!("no PivotTable `{}`", v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())))); } let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let at = d.selection.active; if let Some(i) = sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(at))) { return Ok((active, i)); } if !sh.pivots.is_empty() { return Ok((active, sh.pivots.len() - 1)); } Err(EngineError::Disabled("pivot".into(), "select a cell in a PivotTable (or pass `pivot`)".into())) } #[cfg(test)] mod tests;