diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 2e7b379d0..78604cd35 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -80,13 +80,13 @@ pub fn specs() -> Vec { "Group", ["Data", "Outline"], Some("Alt+Shift+Right"), - "{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}", + "{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}", has_doc, |s, p| group(s, p, true) ), - cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group( - s, p, false - )), + cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| { + group(s, p, false) + }), cmd!( "data.hideDetail", "Hide Detail", @@ -454,10 +454,11 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b // ---------------------------------------------------------------- filter fn toggle_filter(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let (sheet, r) = data_target(s, p, "data.filter")?; + // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. + let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start }; let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); - let in_table = - s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone())); + let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); if let Some(name) = in_table { return edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; @@ -490,8 +491,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { /// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. fn install_filter(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; - let mut r = data_range(s, p)?; + let (sheet, mut r) = data_target(s, p, "data.filter")?; // A selected header row filters the data below it (#201). if p.get("range").is_none() && r.height() == 1 { let d = s.doc()?; @@ -1159,9 +1159,13 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { } fn group(s: &mut Session, p: &Json, on: bool) -> Result { + let si = target_sheet(s, p)?; + // The selection belongs to the active sheet, so another sheet needs its lines. + if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() { + return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); + } let (rows, a, b) = outline_lines(s, p)?; edit(s, |cx| { - let si = cx.sheet_index(); let sh = cx.sheet_mut(si)?; let map = if rows { &mut sh.rows } else { &mut sh.cols }; let b = b.min(a.saturating_add(1_048_575)); @@ -1261,7 +1265,7 @@ fn show_levels(s: &mut Session, p: &Json) -> Result { } fn subtotal(s: &mut Session, p: &Json) -> Result { - let r = data_range(s, p)?; + let (sheet, r) = data_target(s, p, "data.subtotal")?; let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); let func = str_param(p, "function").unwrap_or("sum"); let code = match func { @@ -1285,7 +1289,6 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { "product" => "Product", _ => "Total", }; - let sheet = s.doc()?.wb.active_sheet; // Group boundaries (header in the first row). let d = s.doc()?; let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; @@ -1302,25 +1305,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { // Insert a total row after each group, bottom-up so indices stay valid. let mut inserted = 0; for (g0, g1, name) in groups.iter().rev() { - s.execute("home.insertRows", json!({"rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; let row = g1 + 1; let lbl = CellRef::new(row, group_col).a1(); - s.execute("cell.set", json!({"cell": lbl, "input": format!("{name} {label}")}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; for c in &cols { let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); - s.execute("cell.set", json!({"cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; } - s.execute("home.bold", json!({"range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}))?; - s.execute("data.group", json!({"rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; + s.execute( + "home.bold", + json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), + )?; + s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; inserted += 1; } let last = r.end.row + inserted + 1; let lbl = CellRef::new(last, group_col).a1(); - s.execute("home.insertRows", json!({"rows": format!("{}:{}", last + 1, last + 1)}))?; - s.execute("cell.set", json!({"cell": lbl, "input": format!("Grand {label}")}))?; + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; for c in &cols { let range = RangeRef::new(CellRef::new(r.start.row + 1, *c), CellRef::new(last - 1, *c)).a1(); - s.execute("cell.set", json!({"cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; } Ok(json!({"groups": inserted})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19a512d73..0d5dd5f8d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1062,6 +1062,70 @@ fn remove_duplicates_with_a_sheet_prefix_works_on_the_named_sheet() { assert_eq!(col(&s, "Sheet1"), ["a", "a", "a"].map(Value::from)); } +#[test] +fn filter_with_a_sheet_prefix_toggles_the_named_sheet() { + let sheet = |s: &Session, name: &str| -> gridcraft_model::Sheet { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index(name).unwrap()).unwrap().clone() + }; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 3], ["b", 1]]})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Prices"})).unwrap(); + s.execute("range.setValues", json!({"range": "Prices!A1", "values": [["Item", "Price"], ["p1", 10], ["p2", 30]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let r = s.execute("data.filter", json!({"range": "Prices!A1:B3"})).unwrap(); + assert_eq!(r["range"], "A1:B3"); + assert_eq!(sheet(&s, "Prices").autofilter.map(|a| a.range.a1()), Some("A1:B3".to_string())); + assert!(sheet(&s, "Sheet1").autofilter.is_none()); + s.execute("data.filter", json!({"range": "Prices!A1:B3"})).unwrap(); + assert!(sheet(&s, "Prices").autofilter.is_none()); + assert!(sheet(&s, "Sheet1").autofilter.is_none()); + assert!(s.execute("data.filter", json!({"sheet": "Prices"})).is_err()); +} + +#[test] +fn filter_with_a_sheet_prefix_toggles_the_table_at_the_range() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Prices"})).unwrap(); + s.execute("range.setValues", json!({"range": "Prices!A1", "values": [["Item", "Price"], ["p1", 10], ["p2", 30]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + // The active cell is outside the table's cells, so only `range` can find the table. + s.execute("selection.set", json!({"range": "E10"})).unwrap(); + let prices = |s: &Session| { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index("Prices").unwrap()).unwrap().clone() + }; + assert!(prices(&s).tables[0].filter_button); + s.execute("data.filter", json!({"range": "Prices!A2:B3"})).unwrap(); + assert!(!prices(&s).tables[0].filter_button); + assert!(prices(&s).autofilter.is_none()); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); +} + +#[test] +fn subtotal_with_a_sheet_prefix_works_on_the_named_sheet() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["x", 1], ["x", 2], ["y", 3]]})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Prices"})).unwrap(); + s.execute("range.setValues", json!({"range": "Prices!A1", "values": [["Item", "Qty"], ["a", 1], ["a", 2], ["b", 4]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let r = s.execute("data.subtotal", json!({"range": "Prices!A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap(); + assert_eq!(r["groups"], 2); + let wb = &s.doc().unwrap().wb; + let prices = wb.sheet(wb.sheet_index("Prices").unwrap()).unwrap(); + let col = |sh: &gridcraft_model::Sheet, c: u32| -> Vec { (0..7).map(|r| sh.value(CellRef::new(r, c))).collect() }; + assert_eq!(col(prices, 0), ["Item", "a", "a", "a Total", "b", "b Total", "Grand Total"].map(Value::from)); + assert_eq!(col(prices, 1)[1..], [1.0, 2.0, 3.0, 4.0, 4.0, 7.0].map(Value::from)); + assert!(wb.styles.get(prices.style_id(CellRef::new(3, 0))).font.bold); + assert_eq!(prices.rows.get(&1).map(|l| l.outline), Some(1)); + let sheet1 = wb.sheet(wb.sheet_index("Sheet1").unwrap()).unwrap(); + assert_eq!(col(sheet1, 0)[..4], ["Name", "x", "x", "y"].map(Value::from)); + assert!(sheet1.value(CellRef::new(4, 0)).is_empty()); + assert!(sheet1.rows.values().all(|l| l.outline == 0)); + assert!(s.execute("data.group", json!({"sheet": "Prices"})).is_err()); +} + #[test] fn formatting_and_styles() { let mut s = s();