use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PanelKind {
BarChart,
LineChart,
PieChart,
Scatter,
Table,
Metric,
Gauge,
Markdown,
List,
AreaChart,
Heatmap,
Timeline,
Funnel,
Game,
Doc,
Dashboard,
Form,
TextInput,
TextArea,
Dropdown,
Select,
Checkbox,
Radio,
Button,
NumberInput,
DateInput,
FileUpload,
RichText,
Unknown(String),
}
impl std::str::FromStr for PanelKind {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(s))
}
}
impl PanelKind {
pub fn parse(s: &str) -> Self {
match s {
"bar_chart" => Self::BarChart,
"line_chart" => Self::LineChart,
"pie_chart" => Self::PieChart,
"scatter" => Self::Scatter,
"table" => Self::Table,
"metric" => Self::Metric,
"gauge" => Self::Gauge,
"markdown" => Self::Markdown,
"list" => Self::List,
"area_chart" => Self::AreaChart,
"heatmap" => Self::Heatmap,
"timeline" => Self::Timeline,
"funnel" => Self::Funnel,
"game" => Self::Game,
"doc" => Self::Doc,
"dashboard" => Self::Dashboard,
"form" => Self::Form,
"text_input" => Self::TextInput,
"textarea" => Self::TextArea,
"dropdown" => Self::Dropdown,
"select" => Self::Select,
"checkbox" => Self::Checkbox,
"radio" => Self::Radio,
"button" => Self::Button,
"number_input" => Self::NumberInput,
"date_input" => Self::DateInput,
"file_upload" => Self::FileUpload,
"rich_text" => Self::RichText,
other => Self::Unknown(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::BarChart => "bar_chart",
Self::LineChart => "line_chart",
Self::PieChart => "pie_chart",
Self::Scatter => "scatter",
Self::Table => "table",
Self::Metric => "metric",
Self::Gauge => "gauge",
Self::Markdown => "markdown",
Self::List => "list",
Self::AreaChart => "area_chart",
Self::Heatmap => "heatmap",
Self::Timeline => "timeline",
Self::Funnel => "funnel",
Self::Game => "game",
Self::Doc => "doc",
Self::Dashboard => "dashboard",
Self::Form => "form",
Self::TextInput => "text_input",
Self::TextArea => "textarea",
Self::Dropdown => "dropdown",
Self::Select => "select",
Self::Checkbox => "checkbox",
Self::Radio => "radio",
Self::Button => "button",
Self::NumberInput => "number_input",
Self::DateInput => "date_input",
Self::FileUpload => "file_upload",
Self::RichText => "rich_text",
Self::Unknown(s) => s,
}
}
pub fn is_data_kind(&self) -> bool {
matches!(
self,
Self::Table
| Self::BarChart
| Self::LineChart
| Self::PieChart
| Self::Scatter
| Self::Metric
| Self::Gauge
| Self::List
| Self::AreaChart
| Self::Heatmap
| Self::Timeline
| Self::Funnel
)
}
pub fn is_form_kind(&self) -> bool {
matches!(
self,
Self::Form
| Self::TextInput
| Self::TextArea
| Self::Dropdown
| Self::Select
| Self::Checkbox
| Self::Radio
| Self::Button
| Self::NumberInput
| Self::DateInput
| Self::FileUpload
| Self::RichText
)
}
pub fn is_composite(&self) -> bool {
matches!(self, Self::Dashboard | Self::Form)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TriggerType {
Once,
Repeat,
OnEvent,
Asap,
Always,
Auto,
Unknown(String),
}
impl std::str::FromStr for TriggerType {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(s))
}
}
impl TriggerType {
pub fn parse(s: &str) -> Self {
match s {
"once" => Self::Once,
"repeat" => Self::Repeat,
"on_event" => Self::OnEvent,
"asap" => Self::Asap,
"always" => Self::Always,
"auto" => Self::Auto,
other => Self::Unknown(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Once => "once",
Self::Repeat => "repeat",
Self::OnEvent => "on_event",
Self::Asap => "asap",
Self::Always => "always",
Self::Auto => "auto",
Self::Unknown(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TriggerEvent {
Submit,
Change,
Focus,
Manual,
Unknown(String),
}
impl std::str::FromStr for TriggerEvent {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(s))
}
}
impl TriggerEvent {
pub fn parse(s: &str) -> Self {
match s {
"submit" => Self::Submit,
"change" => Self::Change,
"focus" => Self::Focus,
"manual" => Self::Manual,
other => Self::Unknown(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Submit => "submit",
Self::Change => "change",
Self::Focus => "focus",
Self::Manual => "manual",
Self::Unknown(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldTrigger {
pub trigger_type: TriggerType,
pub event: TriggerEvent,
}
impl FieldTrigger {
pub fn fires_on(&self, event: &TriggerEvent) -> bool {
&self.event == event
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FieldEmit {
Replacement,
Trigger,
Redirection,
Event,
Unknown(String),
}
impl std::str::FromStr for FieldEmit {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(s))
}
}
impl FieldEmit {
pub fn parse(s: &str) -> Self {
match s {
"replacement" => Self::Replacement,
"trigger" => Self::Trigger,
"redirection" => Self::Redirection,
"event" => Self::Event,
other => Self::Unknown(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Replacement => "replacement",
Self::Trigger => "trigger",
Self::Redirection => "redirection",
Self::Event => "event",
Self::Unknown(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelSource {
pub namespace: String,
pub query: String,
}
pub type Props = BTreeMap<String, Value>;
pub fn prop_str(props: &Props, key: &str) -> Option<String> {
match props.get(key)? {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
pub fn prop_list(props: &Props, key: &str) -> Vec<String> {
match props.get(key) {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
})
.collect(),
Some(Value::String(s)) => s
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
pub fn prop_bool(props: &Props, key: &str) -> Option<bool> {
match props.get(key)? {
Value::Bool(b) => Some(*b),
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
pub fn prop_f64(props: &Props, key: &str) -> Option<f64> {
match props.get(key)? {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse().ok(),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Field {
pub id: String,
pub kind: PanelKind,
pub props: Props,
pub inputs: Vec<String>,
pub trigger: Option<FieldTrigger>,
pub emit: Option<FieldEmit>,
pub source: Option<PanelSource>,
}
impl Field {
pub fn label(&self) -> String {
prop_str(&self.props, "label").unwrap_or_else(|| self.id.clone())
}
pub fn placeholder(&self) -> String {
prop_str(&self.props, "placeholder").unwrap_or_default()
}
pub fn required(&self) -> bool {
prop_bool(&self.props, "required").unwrap_or(false)
}
pub fn default_value(&self) -> Option<String> {
prop_str(&self.props, "default")
}
pub fn fires_on(&self, event: &TriggerEvent) -> bool {
self.trigger
.as_ref()
.is_some_and(|trigger| trigger.fires_on(event))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Panel {
pub id: String,
pub kind: PanelKind,
pub namespace: String,
pub props: Props,
pub rows: Vec<Vec<Value>>,
pub source: Option<PanelSource>,
pub fields: Vec<Field>,
pub children: Vec<Panel>,
pub published: bool,
}
impl Panel {
pub fn title(&self) -> String {
prop_str(&self.props, "title").unwrap_or_else(|| self.id.clone())
}
pub fn description(&self) -> Option<String> {
prop_str(&self.props, "description")
}
pub fn slot(&self) -> String {
prop_str(&self.props, "slot").unwrap_or_else(|| "main".to_string())
}
pub fn parent(&self) -> Option<String> {
prop_str(&self.props, "parent")
}
pub fn columns(&self) -> Vec<String> {
prop_list(&self.props, "columns")
}
pub fn prop_str(&self, key: &str) -> Option<String> {
prop_str(&self.props, key)
}
pub fn prop_list(&self, key: &str) -> Vec<String> {
prop_list(&self.props, key)
}
pub fn prop_bool(&self, key: &str) -> Option<bool> {
prop_bool(&self.props, key)
}
pub fn prop_f64(&self, key: &str) -> Option<f64> {
prop_f64(&self.props, key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn props(pairs: &[(&str, Value)]) -> Props {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), v.clone()))
.collect()
}
#[test]
fn every_known_kind_round_trips_through_its_wire_name() {
for name in [
"bar_chart",
"line_chart",
"pie_chart",
"scatter",
"table",
"metric",
"gauge",
"markdown",
"list",
"area_chart",
"heatmap",
"timeline",
"funnel",
"game",
"doc",
"dashboard",
"form",
"text_input",
"textarea",
"dropdown",
"select",
"checkbox",
"radio",
"button",
"number_input",
"date_input",
"file_upload",
"rich_text",
] {
let kind = PanelKind::parse(name);
assert_eq!(name.parse::<PanelKind>().unwrap(), kind);
assert!(
!matches!(kind, PanelKind::Unknown(_)),
"{name} should be a known kind"
);
assert_eq!(kind.as_str(), name);
}
}
#[test]
fn dashboard_is_composite_not_data() {
assert!(PanelKind::Dashboard.is_composite());
assert!(!PanelKind::Dashboard.is_data_kind());
assert!(!PanelKind::Dashboard.is_form_kind());
}
#[test]
fn prop_str_coerces_scalars_and_refuses_lists() {
let p = props(&[
("title", json!("Pipeline")),
("limit", json!(40)),
("published", json!(true)),
("columns", json!(["a", "b"])),
]);
assert_eq!(prop_str(&p, "title").as_deref(), Some("Pipeline"));
assert_eq!(prop_str(&p, "limit").as_deref(), Some("40"));
assert_eq!(prop_str(&p, "published").as_deref(), Some("true"));
assert_eq!(prop_str(&p, "columns"), None);
}
#[test]
fn prop_list_reads_a_json_list_or_a_comma_string() {
let p = props(&[
("columns", json!(["Invoice", "Days", 3])),
("legacy", json!("a, b ,c")),
]);
assert_eq!(prop_list(&p, "columns"), vec!["Invoice", "Days", "3"]);
assert_eq!(prop_list(&p, "legacy"), vec!["a", "b", "c"]);
assert!(prop_list(&p, "missing").is_empty());
}
#[test]
fn prop_bool_accepts_both_wire_forms() {
let p = props(&[
("a", json!(true)),
("b", json!("false")),
("c", json!("maybe")),
]);
assert_eq!(prop_bool(&p, "a"), Some(true));
assert_eq!(prop_bool(&p, "b"), Some(false));
assert_eq!(prop_bool(&p, "c"), None);
}
#[test]
fn slot_defaults_to_main_like_the_server() {
let panel = Panel {
id: "x".into(),
kind: PanelKind::Metric,
namespace: "ns".into(),
props: Props::new(),
rows: vec![],
source: None,
fields: vec![],
children: vec![],
published: false,
};
assert_eq!(panel.slot(), "main");
assert_eq!(panel.title(), "x");
}
}