diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 6d5078f13..98cfba969 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", @@ -147,6 +147,23 @@ fn data_range(s: &Session, p: &Json) -> Result { Ok(current_region(sh, d.selection.active)) } +/// The sheet and range a data command works on: the sheet `sheet` or a `Sheet!` prefix on `range` +/// names, else the active one. The selection belongs to the active sheet, so another sheet needs `range`. +fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + if let Some(t) = str_param(p, "range") + && let (Some(name), _) = split_sheet(t) + && d.wb.sheet_index(&name) != Some(sheet) + { + return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`"))); + } + if sheet != d.wb.active_sheet && p.get("range").is_none() { + return Err(bad(cmd, "missing `range`: the selection is on the active sheet")); + } + Ok((sheet, data_range(s, p)?)) +} + /// Excel's header guess: the first row is text over a column of numbers, or is bold. fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool { if let Some(t) = sh.table_at(r.start) { @@ -453,10 +470,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)?; @@ -489,8 +507,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()?; @@ -754,9 +771,9 @@ fn sort_key(v: &Value) -> (u8, i64, String) { // ---------------------------------------------------------------- tools fn remove_duplicates(s: &mut Session, p: &Json) -> Result { - let r = data_range(s, p)?; + let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; // Rows and columns past the data are blank: only the part with data is compared (whole // columns compared a million blank rows and reported them as removed duplicates). let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); @@ -766,7 +783,6 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { .and_then(Json::as_array) .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); - let sheet = d.wb.active_sheet; let start = r.start.row + header as u32; let mut removed = 0; edit(s, |cx| { @@ -1161,9 +1177,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)); @@ -1273,7 +1293,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 { @@ -1297,7 +1317,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)?; @@ -1314,25 +1333,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 77709a85f..5aac8699b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1080,6 +1080,93 @@ fn remove_duplicates_compares_shown_text() { assert_eq!(r["removed"], 1); } +#[test] +fn remove_duplicates_with_a_sheet_prefix_works_on_the_named_sheet() { + let col = |s: &Session, sheet: &str| -> Vec { + let wb = &s.doc().unwrap().wb; + let sh = wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap(); + (1..=3).map(|r| sh.value(CellRef::new(r, 0))).collect() + }; + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["a"], ["a"], ["a"]]})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Prices"})).unwrap(); + s.execute("range.setValues", json!({"range": "Prices!A1", "values": [["Item"], ["p1"], ["p1"], ["p2"]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let r = s.execute("data.removeDuplicates", json!({"range": "Prices!A1:A4", "header": true})).unwrap(); + assert_eq!(r["removed"], 1); + assert_eq!(col(&s, "Prices"), [Value::from("p1"), Value::from("p2"), Value::Empty]); + assert_eq!(col(&s, "Sheet1"), ["a", "a", "a"].map(Value::from)); + // The selection belongs to the active sheet, so another sheet needs its range. + assert!(s.execute("data.removeDuplicates", json!({"sheet": "Prices", "header": true})).is_err()); + assert!(s.execute("data.removeDuplicates", json!({"sheet": "Sheet1", "range": "Prices!A1:A4", "header": true})).is_err()); + assert!(s.execute("data.removeDuplicates", json!({"range": "Nowhere!A1:A4", "header": true})).is_err()); + 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();