Files
gridcraft/crates/engine/src/cmd/pivot/layout.rs
T

293 lines
12 KiB
Rust

use super::create::source_param;
use super::fields::{canonical, in_axes, shift_values, update};
use super::params::{group_param, layout_name};
use super::{
Chart, Json, PivotDateGroup, PivotField, PivotFilter, PivotLayout, PivotSort, PivotTable, Result, Session, bad, bool_param, edit, find_pivot,
json, other, pv, str_param,
};
pub(super) fn filter(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.filter";
let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string();
let selected: Option<Vec<String>> = match p.get("selected") {
None | Some(Json::Null) => None,
Some(Json::Array(a)) => {
if a.len() > 100_000 {
return Err(bad(C, "too many items"));
}
let v: Vec<String> = a
.iter()
.map(|x| match x {
Json::String(s) => s.clone(),
Json::Null => "(blank)".into(),
other => other.to_string(),
})
.collect();
if v.is_empty() {
return Err(other("You must select at least one item."));
}
Some(v)
}
Some(Json::String(s)) => Some(vec![s.clone()]),
Some(_) => return Err(bad(C, "`selected` is a list of item labels or null")),
};
update(s, p, |pt, src, _| {
let field = canonical(C, src, &field)?;
let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
match (i, selected) {
(Some(i), None) => {
if in_axes(pt, &field) {
pt.filters.remove(i);
} else if let Some(f) = pt.filters.get_mut(i) {
f.selected = None;
}
}
(Some(i), Some(sel)) => {
if let Some(f) = pt.filters.get_mut(i) {
f.selected = Some(sel);
}
}
(None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }),
(None, None) => {}
}
Ok(json!({"field": field}))
})
}
fn axis_field<'a>(cmd: &str, pt: &'a mut PivotTable, field: &str) -> Result<&'a mut PivotField> {
pt.rows
.iter_mut()
.chain(pt.columns.iter_mut())
.find(|f| f.source_col.eq_ignore_ascii_case(field))
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the rows or columns area")))
}
pub(super) fn sort(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.sort";
let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string();
let order = match str_param(p, "order").unwrap_or("asc").to_ascii_lowercase().as_str() {
"asc" | "ascending" | "a-z" => PivotSort::Asc,
"desc" | "descending" | "z-a" => PivotSort::Desc,
"none" | "manual" | "source" => PivotSort::None,
o => return Err(bad(C, format!("unknown order `{o}` (asc|desc|none)"))),
};
update(s, p, |pt, _, _| {
axis_field(C, pt, &field)?.sort = order;
Ok(json!({}))
})
}
pub(super) fn group(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.group";
let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string();
let raw = str_param(p, "by").unwrap_or("months");
let by = group_param(Some(raw)).ok_or_else(|| bad(C, format!("unknown grouping `{raw}` (years|quarters|months|days|none)")))?;
update(s, p, |pt, src, _| {
if by != PivotDateGroup::None {
let c = src.col(&field).ok_or_else(|| bad(C, format!("no field `{field}` in the source data")))?;
if !src.numeric.get(c).copied().unwrap_or(false) {
return Err(other("Cannot group that selection."));
}
}
let f = axis_field(C, pt, &field)?;
f.date_group = by;
f.collapsed_items.clear();
Ok(json!({}))
})
}
pub(super) fn collapse(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.collapse";
let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string();
let on = bool_param(p, "collapse").unwrap_or(true);
let items: Option<Vec<String>> =
p.get("items").and_then(Json::as_array).map(|a| a.iter().take(100_000).filter_map(|x| x.as_str().map(str::to_string)).collect());
update(s, p, |pt, src, wb| {
let all = match &items {
Some(v) => v.clone(),
None => {
let f = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field));
let g = f.map(|f| f.date_group).unwrap_or_default();
match src.col(&field) {
Some(c) => pv::distinct_labels(wb, src, c, g, 100_000),
None => vec![],
}
}
};
let f = axis_field(C, pt, &field)?;
if on {
for it in all {
if !f.collapsed_items.iter().any(|x| x.eq_ignore_ascii_case(&it)) {
f.collapsed_items.push(it);
}
}
} else if items.is_none() {
f.collapsed_items.clear();
} else {
f.collapsed_items.retain(|x| !all.iter().any(|a| a.eq_ignore_ascii_case(x)));
}
Ok(json!({"collapsed": f.collapsed_items}))
})
}
pub(super) fn layout(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.layout";
let lay = match str_param(p, "layout").map(|x| x.to_ascii_lowercase()) {
Some(l) if l == "compact" => Some(PivotLayout::Compact),
Some(l) if l == "outline" => Some(PivotLayout::Outline),
Some(l) if l == "tabular" => Some(PivotLayout::Tabular),
Some(l) => return Err(bad(C, format!("unknown layout `{l}` (compact|outline|tabular)"))),
None => None,
};
let subtotals: Option<(bool, Option<bool>)> = match p.get("subtotals") {
Some(Json::Bool(b)) => Some((*b, None)),
Some(Json::String(m)) => match m.to_ascii_lowercase().as_str() {
"top" => Some((true, Some(true))),
"bottom" => Some((true, Some(false))),
"none" | "off" => Some((false, None)),
o => return Err(bad(C, format!("unknown subtotals `{o}` (top|bottom|none)"))),
},
_ => None,
};
let style = str_param(p, "style").map(str::to_string);
if style.as_ref().is_some_and(|s| s.len() > 64) {
return Err(bad(C, "style name too long"));
}
let gr = bool_param(p, "grandTotalsRows");
let gc = bool_param(p, "grandTotalsCols");
let hd = bool_param(p, "showHeaders");
update(s, p, |pt, _, _| {
if let Some(l) = lay {
pt.layout = l;
if l == PivotLayout::Tabular {
pt.subtotals_top = false;
}
}
if let Some((on, top)) = subtotals {
pt.subtotals = on;
if let Some(t) = top {
pt.subtotals_top = t;
}
}
if let Some(st) = &style {
pt.style = st.clone();
}
if let Some(v) = gr {
pt.grand_totals_rows = v;
}
if let Some(v) = gc {
pt.grand_totals_cols = v;
}
if let Some(v) = hd {
pt.show_headers = v;
}
Ok(json!({"layout": layout_name(pt.layout)}))
})
}
pub(super) fn grand_totals(s: &mut Session, p: &Json) -> Result<Json> {
let (r, c) = match str_param(p, "mode").map(|m| m.to_ascii_lowercase()) {
Some(m) if m == "off" => (Some(false), Some(false)),
Some(m) if m == "on" => (Some(true), Some(true)),
Some(m) if m == "rows" => (Some(true), Some(false)),
Some(m) if m == "columns" || m == "cols" => (Some(false), Some(true)),
Some(m) => return Err(bad("pivot.grandTotals", format!("unknown mode `{m}` (off|on|rows|columns)"))),
None => (bool_param(p, "rows"), bool_param(p, "cols")),
};
let mut q = p.as_object().cloned().unwrap_or_default();
if let Some(v) = r {
q.insert("grandTotalsRows".into(), json!(v));
}
if let Some(v) = c {
q.insert("grandTotalsCols".into(), json!(v));
}
layout(s, &Json::Object(q))
}
pub(super) fn refresh(s: &mut Session, p: &Json) -> Result<Json> {
update(s, p, |_, src, _| Ok(json!({"records": src.rows.len()})))
}
pub(super) fn refresh_all(s: &mut Session, p: &Json) -> Result<Json> {
let replace = bool_param(p, "replace").unwrap_or(false);
edit(s, |cx| {
let mut n = 0;
let mut errors = vec![];
for si in 0..cx.wb.sheets.len() {
let count = cx.wb.sheet(si).map(|sh| sh.pivots.len()).unwrap_or(0);
for pi in 0..count {
let name = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).map(|p| p.name.clone()).unwrap_or_default();
match pv::refresh(&mut cx.wb, si, pi, replace) {
Ok(_) => n += 1,
Err(e) => errors.push(json!({"pivot": name, "error": e})),
}
}
}
cx.structural = n > 0;
Ok(json!({"refreshed": n, "errors": errors}))
})
}
pub(super) fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.changeSource";
if str_param(p, "source").or_else(|| str_param(p, "range")).is_none() {
return Err(bad(C, "missing `source`"));
}
let source = source_param(s, p)?;
let (si, _) = find_pivot(s, p)?;
let src = pv::read_source(&s.doc()?.wb, &source, si).map_err(other)?;
update(s, p, move |pt, _, _| {
pt.source = source.clone();
let mut dropped: Vec<String> = vec![];
let keep = |name: &str, dropped: &mut Vec<String>| {
let ok = src.col(name).is_some();
if !ok {
dropped.push(name.to_string());
}
ok
};
// Axis fields leave one by one so Σ Values keeps its place among the rest.
for rows in [true, false] {
let mut i = 0;
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(f) = list.get(i) else { break };
if keep(&f.source_col, &mut dropped) {
i += 1;
} else {
list.remove(i);
shift_values(pt, rows, i, false);
}
}
}
pt.values.retain(|f| keep(&f.source_col, &mut dropped));
pt.filters.retain(|f| keep(&f.source_col, &mut dropped));
Ok(json!({"source": source, "dropped": dropped}))
})
}
pub(super) fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
edit(s, |cx| {
// Its PivotCharts become ordinary charts.
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
if let Some((sheet, name)) = link {
for i in 0..cx.wb.sheets.len() {
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
c.pivot = None;
}
}
}
}
pv::clear_output(&mut cx.wb, si, pi);
let sh = cx.sheet_mut(si)?;
if pi < sh.pivots.len() {
sh.pivots.remove(pi);
}
cx.structural = true;
Ok(json!({}))
})
}