use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{
CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts, NumberSeriesStyleDefaultConfig,
};
use crate::utils::{CssKind, GradientStopSpec, canonicalize_gradient_stops};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ColumnConfigSchema {
pub fields: Vec<ControlSpec>,
}
impl ColumnConfigSchema {
pub fn active_keys(&self) -> HashSet<String> {
let mut out = HashSet::new();
for spec in &self.fields {
for k in spec.serialized_keys() {
out.insert(k.to_string());
}
}
out
}
pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
for spec in fields {
match spec {
ControlSpec::Group { fields, .. } => collect(fields, out),
leaf => out.push(leaf),
}
}
}
let mut out = vec![];
collect(&self.fields, &mut out);
out
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "kind")]
pub enum ControlSpec {
Enum {
key: String,
variants: Vec<EnumVariant>,
default: String,
},
Bool {
key: String,
default: bool,
},
Number {
key: String,
default: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
include: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
step: Option<f64>,
},
String {
key: String,
default: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
placeholder: Option<String>,
},
Color {
key: String,
default: String,
},
Palette {
key: String,
default: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<usize>,
},
GradientStops {
key: String,
default: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
discrete: bool,
},
DatetimeFormat {
#[serde(default, skip_serializing_if = "Option::is_none")]
default: Option<DatetimeFormatType>,
},
StringFormat,
NumberSeriesStyle {
default: NumberSeriesStyleDefaultConfig,
},
Symbols {
default: KeyValueOpts,
},
NumberFormat {
#[serde(default, skip_serializing_if = "Option::is_none")]
default: Option<CustomNumberFormatConfig>,
},
AggregateDepth,
Group {
key: String,
#[serde(default)]
fields: Vec<ControlSpec>,
},
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EnumVariant {
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
let stops = canonicalize_gradient_stops(stops);
match (stops.first(), stops.last()) {
(Some(first), Some(last)) if stops.len() > 2 => vec![
GradientStopSpec {
color: first.color.clone(),
offset: 0.0,
},
GradientStopSpec {
color: last.color.clone(),
offset: 1.0,
},
],
_ => stops,
}
}
impl ColumnConfigSchema {
pub fn canonicalize(self) -> Self {
self.canonicalize_defaults().group_format_controls()
}
pub fn group_format_controls(mut self) -> Self {
fn is_format(spec: &ControlSpec) -> bool {
matches!(
spec,
ControlSpec::NumberFormat { .. }
| ControlSpec::DatetimeFormat { .. }
| ControlSpec::StringFormat
)
}
fn walk(fields: &mut Vec<ControlSpec>) {
for spec in fields.iter_mut() {
if let ControlSpec::Group { key, fields } = spec
&& key != "format"
{
walk(fields);
}
}
let first = fields.iter().position(is_format);
if let Some(first) = first {
let mut formats = vec![];
let mut i = first;
while i < fields.len() {
if is_format(&fields[i]) {
formats.push(fields.remove(i));
} else {
i += 1;
}
}
fields.insert(first, ControlSpec::Group {
key: "format".to_owned(),
fields: formats,
});
}
}
walk(&mut self.fields);
self
}
pub fn canonicalize_defaults(mut self) -> Self {
fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
fields.retain_mut(|spec| {
let (kind, key, default) = match spec {
ControlSpec::Group { fields, .. } => {
canonicalize_specs(fields);
return !fields.is_empty();
},
ControlSpec::Color { key, default } => (CssKind::Color, key, default),
ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
ControlSpec::GradientStops { key, default, .. } => {
(CssKind::Gradient, key, default)
},
_ => return true,
};
match kind.canonicalize(default) {
Ok(canonical) => {
*default = canonical;
true
},
Err(error) => {
tracing::error!("Dropping `{key}` — invalid schema default: {error}");
false
},
}
});
}
canonicalize_specs(&mut self.fields);
self
}
pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
self.leaf_fields().into_iter().find_map(|spec| match spec {
ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
_ => None,
})
}
}
impl ControlSpec {
pub fn serialized_keys(&self) -> Vec<&str> {
match self {
ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
ControlSpec::StringFormat => vec!["format"],
ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
ControlSpec::Symbols { .. } => vec!["symbols"],
ControlSpec::NumberFormat { .. } => vec!["number_format"],
ControlSpec::AggregateDepth => vec!["aggregate_depth"],
ControlSpec::Enum { key, .. }
| ControlSpec::Bool { key, .. }
| ControlSpec::Number { key, .. }
| ControlSpec::String { key, .. }
| ControlSpec::Color { key, .. }
| ControlSpec::Palette { key, .. }
| ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
ControlSpec::Group { fields, .. } => {
fields.iter().flat_map(|f| f.serialized_keys()).collect()
},
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ColumnConfigFieldUpdate {
pub keys: Vec<String>,
pub value: serde_json::Map<String, Value>,
}
pub fn filter_to_schema(
config: &serde_json::Map<String, Value>,
active_keys: &HashSet<String>,
) -> serde_json::Map<String, Value> {
config
.iter()
.filter(|(k, _)| active_keys.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn color(key: &str, default: &str) -> ControlSpec {
ControlSpec::Color {
key: key.to_owned(),
default: default.to_owned(),
}
}
fn flag(key: &str) -> ControlSpec {
ControlSpec::Bool {
key: key.to_owned(),
default: false,
}
}
fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
ControlSpec::Group {
key: key.to_owned(),
fields,
}
}
#[test]
fn group_deserializes_recursively() {
let schema: ColumnConfigSchema = serde_json::from_value(json!({
"fields": [{
"kind": "Group",
"key": "legend",
"fields": [
{ "kind": "Bool", "key": "legend_on", "default": false },
{
"kind": "Group",
"key": "inner",
"fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
}
]
}]
}))
.unwrap();
let keys = schema.active_keys();
assert_eq!(
keys,
HashSet::from(["legend_on".to_owned(), "color".to_owned()])
);
let leaves = schema.leaf_fields();
assert_eq!(leaves.len(), 2);
assert!(
leaves
.iter()
.all(|s| !matches!(s, ControlSpec::Group { .. }))
);
}
#[test]
fn grouped_schema_is_equivalent_to_flat() {
let flat = ColumnConfigSchema {
fields: vec![flag("stack"), color("color", "#0366d6")],
};
let grouped = ColumnConfigSchema {
fields: vec![group("series", vec![
flag("stack"),
color("color", "#0366d6"),
])],
};
assert_eq!(flat.active_keys(), grouped.active_keys());
assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
}
#[test]
fn format_controls_group_and_merge() {
let schema = ColumnConfigSchema {
fields: vec![
ControlSpec::NumberFormat { default: None },
flag("flag"),
ControlSpec::StringFormat,
],
}
.group_format_controls();
assert_eq!(schema.fields.len(), 2);
let ControlSpec::Group { key, fields } = &schema.fields[0] else {
panic!("expected format group first");
};
assert_eq!(key, "format");
assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
assert!(matches!(fields[1], ControlSpec::StringFormat));
assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
assert_eq!(
schema.active_keys(),
HashSet::from([
"number_format".to_owned(),
"format".to_owned(),
"flag".to_owned()
])
);
}
#[test]
fn format_grouping_recurses_but_never_double_wraps() {
let schema = ColumnConfigSchema {
fields: vec![
group("format", vec![ControlSpec::NumberFormat { default: None }]),
group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
default: None,
}]),
],
}
.group_format_controls();
let ControlSpec::Group { key, fields } = &schema.fields[0] else {
panic!("expected group");
};
assert_eq!(key, "format");
assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
panic!("expected group");
};
assert!(matches!(
&fields[1],
ControlSpec::Group { key, fields }
if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
));
}
#[test]
fn format_controls_deserialize_without_default_payload() {
let schema: ColumnConfigSchema = serde_json::from_value(json!({
"fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
}))
.unwrap();
assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
default: None
}));
assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
default: None
}));
}
#[test]
fn number_format_default_payload_deserializes_flattened_families() {
let schema: ColumnConfigSchema = serde_json::from_value(json!({
"fields": [{
"kind": "NumberFormat",
"default": {
"notation": "compact",
"compactDisplay": "short",
"minimumFractionDigits": 0,
"maximumFractionDigits": 1
}
}]
}))
.unwrap();
let ControlSpec::NumberFormat {
default: Some(default),
} = &schema.fields[0]
else {
panic!("expected NumberFormat with default");
};
assert_eq!(
default._notation,
Some(crate::config::Notation::Compact(
crate::config::CompactDisplay::Short
))
);
assert_eq!(default._style, None);
assert_eq!(default.minimum_fraction_digits, Some(0.));
assert_eq!(default.maximum_fraction_digits, Some(1.));
assert_eq!(
schema.active_keys(),
HashSet::from(["number_format".to_owned()])
);
}
#[test]
fn datetime_format_default_payload_deserializes_simple_arm() {
let schema: ColumnConfigSchema = serde_json::from_value(json!({
"fields": [{
"kind": "DatetimeFormat",
"default": { "dateStyle": "medium", "timeStyle": "disabled" }
}]
}))
.unwrap();
let ControlSpec::DatetimeFormat {
default: Some(DatetimeFormatType::Simple(simple)),
} = &schema.fields[0]
else {
panic!("expected DatetimeFormat with Simple default");
};
assert_eq!(
simple.date_style,
crate::config::SimpleDatetimeFormat::Medium
);
assert_eq!(
simple.time_style,
crate::config::SimpleDatetimeFormat::Disabled
);
}
#[test]
fn canonicalize_defaults_recurses_and_drops_empty_groups() {
let schema = ColumnConfigSchema {
fields: vec![
group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
group("doomed", vec![color("bad", "not-a-color")]),
],
}
.canonicalize_defaults();
assert_eq!(schema.fields.len(), 1);
let ControlSpec::Group { key, fields } = &schema.fields[0] else {
panic!("expected group");
};
assert_eq!(key, "ok");
assert!(matches!(
&fields[0],
ControlSpec::Color { default, .. } if default == "#ff0000"
));
}
}