mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
PivotTables: Show Details, double-click a value cell to list its source records on a new sheet (#2364)
This commit is contained in:
+197
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,6 +803,30 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -
|
||||
/// Computes a PivotTable report from the workbook.
|
||||
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
compute_from(wb, pt, &src, None)
|
||||
}
|
||||
|
||||
/// Show Details: the source list and the indices of its records behind the value cell at
|
||||
/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when
|
||||
/// that cell holds no value (a label, a header, a blank).
|
||||
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
|
||||
compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
}
|
||||
|
||||
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
|
||||
/// value cell at `at`, and the paths of every record the filters let through.
|
||||
struct Probe {
|
||||
at: (usize, usize),
|
||||
hit: Option<(Vec<u32>, Vec<u32>)>,
|
||||
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
|
||||
}
|
||||
|
||||
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
|
||||
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
|
||||
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
@@ -914,6 +938,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
if !col_leaves.contains(&cp[..shown]) {
|
||||
col_leaves.insert(cp[..shown].to_vec());
|
||||
}
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
pr.paths.push((ri, rp.clone(), cp.clone()));
|
||||
}
|
||||
row_set.insert(rp);
|
||||
col_set.insert(cp);
|
||||
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
|
||||
@@ -1278,6 +1305,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
_ => raw,
|
||||
},
|
||||
};
|
||||
if let Some(pr) = probe.as_deref_mut()
|
||||
&& pr.at == (r, l + j)
|
||||
{
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
}
|
||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2386,6 +2386,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
&& !mods.shift
|
||||
{
|
||||
app.grid.last_click = None;
|
||||
// A PivotTable's value cell shows its source records on a new sheet (Excel's Show
|
||||
// Details); its labels and headers still edit.
|
||||
if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c)))
|
||||
&& app.run("pivot.showDetails", json!({"cell": c.a1()})).is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Following a hyperlink requires a single click in Excel; double-click edits.
|
||||
app.begin_edit(None, false);
|
||||
return;
|
||||
|
||||
@@ -59,6 +59,7 @@ mod page_layout_view;
|
||||
mod page_setup_margins;
|
||||
mod paste_special_dialog;
|
||||
mod pivot_pane;
|
||||
mod pivot_show_details;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod right_to_left;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 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.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap();
|
||||
// Report at D3: D4 East, E4 its Sum of Sales.
|
||||
session
|
||||
.execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]}))
|
||||
.unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center()
|
||||
}
|
||||
|
||||
fn double_click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) {
|
||||
for _ in 0..2 {
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_value_cell_shows_its_records_on_a_new_sheet() {
|
||||
let mut h = harness();
|
||||
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");
|
||||
let wb = &app.session.active().unwrap().wb;
|
||||
assert_eq!(wb.sheets.len(), 2);
|
||||
let sheet = wb.active().unwrap();
|
||||
assert_eq!(sheet.name, "Sheet2");
|
||||
let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display();
|
||||
assert_eq!(
|
||||
[text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")],
|
||||
["Region", "Sales", "East", "100", "East", "30", ""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_row_label_still_edits() {
|
||||
let mut h = harness();
|
||||
let pos = center(&h, "D4");
|
||||
double_click(&mut h, pos);
|
||||
|
||||
let app = h.state();
|
||||
assert!(app.editor.is_some());
|
||||
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
|
||||
}
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: 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
|
||||
@@ -84,7 +84,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F)
|
||||
- **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).
|
||||
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.
|
||||
- **Impact:** pivots are the analyst's main tool.
|
||||
- **Estimate:** 20–30 h for beta (25–40 h full).
|
||||
|
||||
@@ -178,6 +179,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |
|
||||
|
||||
Reference in New Issue
Block a user