PivotTables: Show Details, double-click a value cell to list its source records on a new sheet (#2364)

This commit is contained in:
Marco Arnulfo
2026-10-23 20:21:27 +02:00
parent 33a45130a1
commit 0c14f59555
6 changed files with 317 additions and 12 deletions
+197 -10
View File
@@ -179,6 +179,15 @@ pub fn specs() -> Vec<CommandSpec> {
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}",
has_doc,
show_details
),
cmd!(
"pivot.delete",
"Delete PivotTable",
@@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let (si, anchor) = match target {
Some(t) => t,
None => {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
let at = insert_sheet_before_active(cx);
new_sheet_at = Some(at);
(at, CellRef::new(2, 0))
}
@@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
Ok(res)
}
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
at
}
/// Show Details (double-click a value cell): the source records behind it, as values with their
/// column's number format, under the source headers on a new sheet.
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
.iter()
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".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(), "select a value cell in a PivotTable".into()))?;
if d.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let count = records.len();
let mut new_sheet_at: Option<usize> = None;
let res = edit(s, |cx| {
let si = insert_sheet_before_active(cx);
new_sheet_at = Some(si);
let base = cx.wb.styles.get(StyleId::default()).clone();
let fmts: Vec<StyleId> = src
.formats
.iter()
.map(|f| {
let mut st = base.clone();
st.num_fmt = NumFmt::new(f);
cx.wb.styles.intern(st)
})
.collect();
// Columns wide enough for their text, as the PivotTable report's are (dates would show
// as ####); the first rows are enough to judge a column.
let font = base.font.clone();
let widths: Vec<f32> = src
.headers
.iter()
.enumerate()
.map(|(c, h)| {
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
let widest = texts
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
(widest + 14.0).min(320.0)
})
.collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths.into_iter().enumerate() {
if w > sh.col_width(c as u32) {
sh.cols.entry(c as u32).or_default().size = Some(w);
}
}
for (c, h) in src.headers.iter().enumerate() {
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
for (c, v) in rec.iter().enumerate() {
let style = fmts.get(c).copied().unwrap_or_default();
if v.is_empty() && style == StyleId::default() {
continue;
}
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
}
}
let name = sh.name.clone();
cx.wb.active_sheet = si;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
})?;
if let Some(at) = new_sheet_at
&& let Ok(d) = s.doc_mut()
{
d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(res)
}
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "table") {
@@ -2236,6 +2334,7 @@ mod tests {
"data.refreshAll",
"pivot.changeSource",
"pivot.delete",
"pivot.showDetails",
];
let hostile = [
json!({}),
@@ -2363,4 +2462,92 @@ mod tests {
assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
/// The active sheet's first five columns as shown, down to the first blank row.
fn sheet_rows(s: &mut Session) -> Vec<Vec<String>> {
let mut out = Vec::new();
for r in 0..30u32 {
let row: Vec<String> = (0..5)
.map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string())
.collect();
if row.iter().all(String::is_empty) {
break;
}
out.push(row);
}
out
}
#[test]
fn show_details_lists_a_value_cells_records_on_a_new_sheet() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 is East × Apples (100 + 60); the active cell is the default.
s.execute("selection.set", json!({"range": "B5"})).unwrap();
let r = s.execute("pivot.showDetails", json!({})).unwrap();
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
vec![
vec!["Region", "Product", "Date", "Units", "Sales"],
vec!["East", "Apples", "1/15/2026", "10", "100"],
vec!["East", "Apples", "9/9/2026", "6", "60"],
]
);
// The dates fit their column instead of showing ####.
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5));
// One undo step takes the sheet away.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(val(&s, "B5"), Value::Number(160.0));
}
#[test]
fn show_details_on_a_total_takes_every_record_it_adds_up() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
let records = |s: &mut Session, cell: &str| {
let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap();
s.execute("edit.undo", json!({})).unwrap();
n
};
assert_eq!(records(&mut s, "E5"), 5, "East's row total");
assert_eq!(records(&mut s, "C8"), 4, "Pears' column total");
assert_eq!(records(&mut s, "E8"), 12, "the grand total");
// A subtotal at the top of its group, in a two-level row axis.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(records(&mut s, "B4"), 5);
assert_eq!(text(&s, "A6"), "Pears");
assert_eq!(records(&mut s, "B6"), 2);
}
#[test]
fn show_details_leaves_out_the_records_the_report_filters_out() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap();
assert_eq!(r["records"], 2);
let rows = sheet_rows(&mut s);
assert_eq!(rows.len(), 3);
assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}");
}
#[test]
fn show_details_needs_a_value_cell() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] {
assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
}