diff --git a/ROADMAP.md b/ROADMAP.md index 3223104af..17b8d76cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,6 +139,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | | 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years | +| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 229ff22b6..05acb4170 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -188,6 +188,15 @@ pub fn specs() -> Vec { 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", @@ -755,8 +764,13 @@ fn show_details(s: &mut Session, p: &Json) -> Result { .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; - let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + // With the option off, a report that is out of date or a source that cannot be read is + // reported as the option; only a cell that is not a value cell keeps its own message. + if !pt.enable_show_details && !matches!(found, Ok(None)) { + return Err(other(SHOW_DETAILS_OFF)); + } + let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; if d.wb.protected_structure { return Err(other("The workbook is protected and cannot be changed.")); } @@ -824,6 +838,32 @@ fn show_details(s: &mut Session, p: &Json) -> Result { Ok(res) } +/// The message Show Details gives while Enable show details is off. +const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable. Turn it on with pivot.options and enableShowDetails set to true."; + +/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not +/// refreshed. Without params it returns the current setting. +fn options(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let enable = bool_param(p, "enableShowDetails"); + if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { + return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); + } + let current = |s: &Session| -> Result { + let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + Ok(json!({"enableShowDetails": pt.enable_show_details})) + }; + let Some(on) = enable else { return current(s) }; + if current(s)?["enableShowDetails"] == json!(on) { + return current(s); + } + edit(s, |cx| { + let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; + pt.enable_show_details = on; + Ok(json!({"enableShowDetails": on})) + }) +} + fn summarize_table(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let name = match str_param(p, "table") { @@ -861,6 +901,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), "valuesOnRows": pt.values_on_rows, "valuesPosition": pt.values_position, + "enableShowDetails": pt.enable_show_details, }) } @@ -2580,4 +2621,41 @@ mod tests { } assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); } + + #[test] + fn enable_show_details_off_stops_show_details() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert!(!pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("turned off") && e.contains("pivot.options") && e.contains("enableShowDetails"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // A label still reports that it is not a value cell. + let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); + assert!(!e.contains("turned off"), "{e}"); + // Setting the same value again adds no undo step. + let after = s.doc().unwrap().revision; + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert_eq!(s.doc().unwrap().revision, after); + assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); + // One undo turns the option back on. + s.execute("edit.undo", json!({})).unwrap(); + assert!(pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); + } + + #[test] + fn enable_show_details_off_is_reported_before_an_out_of_date_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + // The source changes after the report was written, so the report is out of date. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index aa250b9b0..e7114b802 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -1044,6 +1044,9 @@ pub struct PivotTable { pub values: Vec, pub filters: Vec, pub layout: PivotLayout, + /// PivotTable Options › Data › Enable show details: Show Details lists a value cell's + /// source records only while this is on. + pub enable_show_details: bool, /// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows". pub grand_totals_rows: bool, /// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns". @@ -1087,6 +1090,7 @@ impl Default for PivotTable { values: vec![], filters: vec![], layout: PivotLayout::Compact, + enable_show_details: true, grand_totals_rows: true, grand_totals_cols: true, subtotals: true, diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index 9f11b29eb..959eea0f6 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -1,5 +1,6 @@ //! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's -//! Show Details); a label of the report still opens the cell for editing. +//! Show Details) unless its Enable show details option is off; a label of the report still opens +//! the cell for editing. use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use gridcraft_engine::Session; @@ -104,3 +105,17 @@ fn double_click_in_an_out_of_date_report_asks_for_a_refresh() { assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } + +#[test] +fn double_click_with_enable_show_details_off_says_why_and_does_not_edit() { + let mut h = harness(); + h.state_mut().session.execute("pivot.options", json!({"pivot": "PivotTable1", "enableShowDetails": false})).unwrap(); + h.run_steps(2); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("enableShowDetails")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index f75523432..08f89335b 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -818,6 +818,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn if !pt.show_headers { s.push_str(" showHeaders=\"0\""); } + if !pt.enable_show_details { + s.push_str(" enableDrill=\"0\""); + } // `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"), // `colGrandTotals` the grand total column at its right ("On for Rows"). if !pt.grand_totals_cols { @@ -1282,6 +1285,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option Workbook { wb.names.push(DefinedName { name: "SalesData".into(), scope: None, formula: "Data!$A$1:$F$9".into(), comment: String::new(), hidden: false }); let mut report = Sheet::new("Report"); - // Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item. + // Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item, Enable + // show details off. let p1 = PivotTable { id: 1, name: "Sales by Region".into(), @@ -106,6 +107,7 @@ fn workbook() -> Workbook { ], filters: vec![PivotFilter { source_col: "Channel".into(), selected: Some(vec!["Online".into()]) }], layout: PivotLayout::Tabular, + enable_show_details: false, grand_totals_rows: false, grand_totals_cols: true, subtotals: false, @@ -165,6 +167,7 @@ fn workbook() -> Workbook { PivotFilter { source_col: "Product".into(), selected: Some(vec!["A".into(), "B".into()]) }, ], layout: PivotLayout::Outline, + enable_show_details: true, grand_totals_rows: true, grand_totals_cols: false, subtotals: true, @@ -663,6 +666,24 @@ fn grand_totals_read_in_excel_direction() { } } +// `enableDrill="0"` is PivotTable Options › Data › Enable show details turned off; without the +// attribute the option is on. +#[test] +fn enable_show_details_reads_and_writes_enable_drill() { + let table = |attr: &str| { + format!( + r#""# + ) + }; + for (attr, on) in [("enableDrill=\"0\"", false), ("enableDrill=\"1\"", true), ("", true)] { + let (wb, _) = read_xlsx(&with_pivot(&table(attr), Some(GOOD_CACHE))).unwrap(); + assert_eq!(wb.sheet(0).unwrap().pivots[0].enable_show_details, on, "{attr}"); + let parts = unzip(&write_xlsx(&wb).unwrap()); + let written = String::from_utf8(parts.get("xl/pivotTables/pivotTable1.xml").unwrap().clone()).unwrap(); + assert_eq!(written.contains(" enableDrill=\"0\""), !on, "{attr}: {written}"); + } +} + #[test] fn data_on_rows_with_one_value_field_reads_without_warning() { let table = r#""#; diff --git a/docs/gaps.md b/docs/gaps.md index b07727991..761a0e10d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -85,7 +85,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists - its source records on a new sheet; the "Enable show details" option isn't modelled yet. + its source records on a new sheet. The "Enable show details" option (`pivot.options` + `enableShowDetails`, saved as `enableDrill`) turns it off; it has no PivotTable Options dialog + yet (command only), and the right-click Show Details menu item is missing. - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -179,6 +181,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite |