M7: add data tables beneath charts

Add command and UI controls for chart data tables, shared chart and PDF rendering, and native XLSX persistence. Cover live values, blanks, formatting, alignment, undo/redo, bounds and table flags with regression tests.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
campbellcody
2026-11-01 15:30:46 -05:00
co-authored by Codex
parent f5030432fc
commit b6c4500fa7
37 changed files with 560 additions and 18 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Chart Element",
["Chart Design", "Chart Layouts"],
None,
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
"{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
has_doc,
add_element
),
+1
View File
@@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
}
}
"dataLabels" => c.data_labels = on,
"dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?,
"gridlines" => c.gridlines = on,
"axisTitles" => {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
+1
View File
@@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
series,
legend: LegendPos::Bottom,
data_labels: false,
data_table: None,
gridlines: true,
style: 1,
x_title: None,
+82 -1
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -820,6 +820,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
@@ -867,7 +868,11 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
if let Some(t) = str_param(p, "type") {
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
if !c.supports_data_table() {
c.data_table = None;
}
}
update_data_table(c, p, bool_param(p, "dataTable"))?;
if let Some(method) = quartiles {
for series in &mut c.series {
series.quartiles = method;
@@ -919,6 +924,32 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Shared by Chart Properties and Add Chart Element; no separate UI state.
pub(super) fn update_data_table(c: &mut Chart, p: &Json, on: Option<bool>) -> Result<()> {
if p.get("dataTable").is_some_and(|v| !v.is_boolean()) {
return Err(bad("chart", "`dataTable` must be a boolean"));
}
if on == Some(false) {
c.data_table = None;
return Ok(());
}
let fields = ["dataTableKeys", "dataTableHorizontalBorder", "dataTableVerticalBorder", "dataTableOutline"];
if on == Some(true) || fields.iter().any(|k| p.get(k).is_some()) {
if !c.supports_data_table() {
return Err(bad("chart", "data tables need a column, bar, line, area or compatible combo chart"));
}
let table = c.data_table.get_or_insert_with(Default::default);
for (key, value) in
fields.into_iter().zip([&mut table.show_keys, &mut table.horizontal_border, &mut table.vertical_border, &mut table.outline])
{
if let Some(v) = p.get(key) {
*value = v.as_bool().ok_or_else(|| bad("chart", format!("`{key}` must be a boolean")))?;
}
}
}
Ok(())
}
fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
@@ -1748,6 +1779,56 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn chart_data_table_commands_undo_and_follow_live_values() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap();
s.execute("chart.addElement", json!({"element": "dataTable", "dataTableKeys": false})).unwrap();
let get = |s: &Session| s.active().unwrap().wb.active().unwrap().charts[0].clone();
let table = get(&s).data_table.unwrap();
assert!(!table.show_keys && table.horizontal_border && table.vertical_border && table.outline);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(get(&s).data_table, None);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(get(&s).data_table, Some(table));
s.execute("chart.set", json!({"dataTableKeys": true, "dataTableHorizontalBorder": false})).unwrap();
assert!(get(&s).data_table.as_ref().unwrap().show_keys);
assert!(!get(&s).data_table.as_ref().unwrap().horizontal_border);
s.execute("cell.set", json!({"cell": "B2", "input": "=12*3"})).unwrap();
let d = s.active().unwrap();
assert_eq!(gridcraft_chart::resolve(&d.wb, 0, &get(&s)).series[0].values[0], Some(36.0));
s.execute("chart.addElement", json!({"element": "dataTable", "on": false})).unwrap();
assert_eq!(get(&s).data_table, None);
// Old JSON documents have no table field.
let mut json = serde_json::to_value(get(&s)).unwrap();
json.as_object_mut().unwrap().remove("data_table");
assert_eq!(serde_json::from_value::<Chart>(json).unwrap().data_table, None);
}
#[test]
fn chart_data_table_refuses_unsupported_types_without_changing_history() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Q", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap();
let before = s.active().unwrap().wb.clone();
let history = s.execute("history.list", json!({})).unwrap();
for params in [json!({"dataTable": true}), json!({"dataTableKeys": true}), json!({"dataTable": "yes"})] {
assert!(s.execute("chart.set", params).is_err());
assert_eq!(s.active().unwrap().wb, before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}
assert!(s.execute("chart.addElement", json!({"element": "dataTable"})).is_err());
s.execute("chart.set", json!({"type": "line", "dataTable": true})).unwrap();
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
s.execute("chart.set", json!({"type": "scatter"})).unwrap();
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_none());
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
}
#[test]
fn manage_rules_delete_then_move() {
let mut s = Session::new();
+1
View File
@@ -260,6 +260,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
+1 -1
View File
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"freeze": sh.freeze,
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"images": sh.images.len(),
"shapes": sh.shapes.len(),
"conditionalFormats": sh.cond_formats.len(),
+1
View File
@@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
+19
View File
@@ -16,6 +16,25 @@ fn contains(hay: &[u8], needle: &str) -> bool {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn chart_data_table_prints_formatted_values_and_follows_recalculation() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 1234], ["Q2", 5678]]})).unwrap();
s.execute("home.numberFormat", json!({"range": "B2:B3", "format": "#,##0.00"})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B3", "at": "D2", "type": "column"})).unwrap();
let (off, pages) = pdf_of(&mut s, json!({"fitToPage": true}));
assert_eq!(pages, 1);
let count = |bytes: &[u8], text: &str| String::from_utf8_lossy(bytes).matches(text).count();
s.execute("chart.set", json!({"dataTable": true})).unwrap();
let (on, _) = pdf_of(&mut s, json!({"fitToPage": true}));
assert_eq!(count(&on, "(1,234.00) Tj"), count(&off, "(1,234.00) Tj") + 1);
assert_eq!(count(&on, "(5,678.00) Tj"), count(&off, "(5,678.00) Tj") + 1);
s.execute("cell.set", json!({"cell": "B2", "input": "=1000+111"})).unwrap();
let (updated, _) = pdf_of(&mut s, json!({"fitToPage": true}));
assert!(!contains(&updated, "(1,234.00) Tj"));
assert!(count(&updated, "(1,111.00) Tj") >= 2, "cell and chart data table both print the new value");
}
#[test]
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
let mut s = s();