1use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum PanelKind {
36 BarChart,
38 LineChart,
39 PieChart,
40 Scatter,
41 Table,
42 Metric,
43 Gauge,
44 Markdown,
45 List,
46 AreaChart,
47 Heatmap,
48 Timeline,
49 Funnel,
50 Game,
51 Doc,
52 Dashboard,
54 Form,
56 TextInput,
57 TextArea,
58 Dropdown,
59 Select,
60 Checkbox,
61 Radio,
62 Button,
63 NumberInput,
64 DateInput,
65 FileUpload,
66 RichText,
67 Unknown(String),
69}
70
71impl std::str::FromStr for PanelKind {
72 type Err = std::convert::Infallible;
73
74 fn from_str(s: &str) -> Result<Self, Self::Err> {
76 Ok(Self::parse(s))
77 }
78}
79
80impl PanelKind {
81 pub fn parse(s: &str) -> Self {
84 match s {
85 "bar_chart" => Self::BarChart,
86 "line_chart" => Self::LineChart,
87 "pie_chart" => Self::PieChart,
88 "scatter" => Self::Scatter,
89 "table" => Self::Table,
90 "metric" => Self::Metric,
91 "gauge" => Self::Gauge,
92 "markdown" => Self::Markdown,
93 "list" => Self::List,
94 "area_chart" => Self::AreaChart,
95 "heatmap" => Self::Heatmap,
96 "timeline" => Self::Timeline,
97 "funnel" => Self::Funnel,
98 "game" => Self::Game,
99 "doc" => Self::Doc,
100 "dashboard" => Self::Dashboard,
101 "form" => Self::Form,
102 "text_input" => Self::TextInput,
103 "textarea" => Self::TextArea,
104 "dropdown" => Self::Dropdown,
105 "select" => Self::Select,
106 "checkbox" => Self::Checkbox,
107 "radio" => Self::Radio,
108 "button" => Self::Button,
109 "number_input" => Self::NumberInput,
110 "date_input" => Self::DateInput,
111 "file_upload" => Self::FileUpload,
112 "rich_text" => Self::RichText,
113 other => Self::Unknown(other.to_string()),
114 }
115 }
116
117 pub fn as_str(&self) -> &str {
119 match self {
120 Self::BarChart => "bar_chart",
121 Self::LineChart => "line_chart",
122 Self::PieChart => "pie_chart",
123 Self::Scatter => "scatter",
124 Self::Table => "table",
125 Self::Metric => "metric",
126 Self::Gauge => "gauge",
127 Self::Markdown => "markdown",
128 Self::List => "list",
129 Self::AreaChart => "area_chart",
130 Self::Heatmap => "heatmap",
131 Self::Timeline => "timeline",
132 Self::Funnel => "funnel",
133 Self::Game => "game",
134 Self::Doc => "doc",
135 Self::Dashboard => "dashboard",
136 Self::Form => "form",
137 Self::TextInput => "text_input",
138 Self::TextArea => "textarea",
139 Self::Dropdown => "dropdown",
140 Self::Select => "select",
141 Self::Checkbox => "checkbox",
142 Self::Radio => "radio",
143 Self::Button => "button",
144 Self::NumberInput => "number_input",
145 Self::DateInput => "date_input",
146 Self::FileUpload => "file_upload",
147 Self::RichText => "rich_text",
148 Self::Unknown(s) => s,
149 }
150 }
151
152 pub fn is_data_kind(&self) -> bool {
156 matches!(
157 self,
158 Self::Table
159 | Self::BarChart
160 | Self::LineChart
161 | Self::PieChart
162 | Self::Scatter
163 | Self::Metric
164 | Self::Gauge
165 | Self::List
166 | Self::AreaChart
167 | Self::Heatmap
168 | Self::Timeline
169 | Self::Funnel
170 )
171 }
172
173 pub fn is_form_kind(&self) -> bool {
174 matches!(
175 self,
176 Self::Form
177 | Self::TextInput
178 | Self::TextArea
179 | Self::Dropdown
180 | Self::Select
181 | Self::Checkbox
182 | Self::Radio
183 | Self::Button
184 | Self::NumberInput
185 | Self::DateInput
186 | Self::FileUpload
187 | Self::RichText
188 )
189 }
190
191 pub fn is_composite(&self) -> bool {
193 matches!(self, Self::Dashboard | Self::Form)
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub enum TriggerType {
205 Once,
206 Repeat,
207 OnEvent,
208 Asap,
209 Always,
210 Auto,
211 Unknown(String),
213}
214
215impl std::str::FromStr for TriggerType {
216 type Err = std::convert::Infallible;
217
218 fn from_str(s: &str) -> Result<Self, Self::Err> {
219 Ok(Self::parse(s))
220 }
221}
222
223impl TriggerType {
224 pub fn parse(s: &str) -> Self {
227 match s {
228 "once" => Self::Once,
229 "repeat" => Self::Repeat,
230 "on_event" => Self::OnEvent,
231 "asap" => Self::Asap,
232 "always" => Self::Always,
233 "auto" => Self::Auto,
234 other => Self::Unknown(other.to_string()),
235 }
236 }
237
238 pub fn as_str(&self) -> &str {
240 match self {
241 Self::Once => "once",
242 Self::Repeat => "repeat",
243 Self::OnEvent => "on_event",
244 Self::Asap => "asap",
245 Self::Always => "always",
246 Self::Auto => "auto",
247 Self::Unknown(s) => s,
248 }
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub enum TriggerEvent {
255 Submit,
256 Change,
257 Focus,
258 Manual,
259 Unknown(String),
261}
262
263impl std::str::FromStr for TriggerEvent {
264 type Err = std::convert::Infallible;
265
266 fn from_str(s: &str) -> Result<Self, Self::Err> {
267 Ok(Self::parse(s))
268 }
269}
270
271impl TriggerEvent {
272 pub fn parse(s: &str) -> Self {
275 match s {
276 "submit" => Self::Submit,
277 "change" => Self::Change,
278 "focus" => Self::Focus,
279 "manual" => Self::Manual,
280 other => Self::Unknown(other.to_string()),
281 }
282 }
283
284 pub fn as_str(&self) -> &str {
286 match self {
287 Self::Submit => "submit",
288 Self::Change => "change",
289 Self::Focus => "focus",
290 Self::Manual => "manual",
291 Self::Unknown(s) => s,
292 }
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct FieldTrigger {
299 pub trigger_type: TriggerType,
300 pub event: TriggerEvent,
301}
302
303impl FieldTrigger {
304 pub fn fires_on(&self, event: &TriggerEvent) -> bool {
310 &self.event == event
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub enum FieldEmit {
317 Replacement,
319 Trigger,
321 Redirection,
323 Event,
325 Unknown(String),
327}
328
329impl std::str::FromStr for FieldEmit {
330 type Err = std::convert::Infallible;
331
332 fn from_str(s: &str) -> Result<Self, Self::Err> {
333 Ok(Self::parse(s))
334 }
335}
336
337impl FieldEmit {
338 pub fn parse(s: &str) -> Self {
341 match s {
342 "replacement" => Self::Replacement,
343 "trigger" => Self::Trigger,
344 "redirection" => Self::Redirection,
345 "event" => Self::Event,
346 other => Self::Unknown(other.to_string()),
347 }
348 }
349
350 pub fn as_str(&self) -> &str {
352 match self {
353 Self::Replacement => "replacement",
354 Self::Trigger => "trigger",
355 Self::Redirection => "redirection",
356 Self::Event => "event",
357 Self::Unknown(s) => s,
358 }
359 }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct PanelSource {
365 pub namespace: String,
366 pub query: String,
367}
368
369pub type Props = BTreeMap<String, Value>;
375
376pub fn prop_str(props: &Props, key: &str) -> Option<String> {
382 match props.get(key)? {
383 Value::String(s) => Some(s.clone()),
384 Value::Number(n) => Some(n.to_string()),
385 Value::Bool(b) => Some(b.to_string()),
386 _ => None,
387 }
388}
389
390pub fn prop_list(props: &Props, key: &str) -> Vec<String> {
395 match props.get(key) {
396 Some(Value::Array(items)) => items
397 .iter()
398 .filter_map(|v| match v {
399 Value::String(s) => Some(s.clone()),
400 Value::Number(n) => Some(n.to_string()),
401 Value::Bool(b) => Some(b.to_string()),
402 _ => None,
403 })
404 .collect(),
405 Some(Value::String(s)) => s
406 .split(',')
407 .map(str::trim)
408 .filter(|s| !s.is_empty())
409 .map(str::to_string)
410 .collect(),
411 _ => Vec::new(),
412 }
413}
414
415pub fn prop_bool(props: &Props, key: &str) -> Option<bool> {
418 match props.get(key)? {
419 Value::Bool(b) => Some(*b),
420 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
421 "true" => Some(true),
422 "false" => Some(false),
423 _ => None,
424 },
425 _ => None,
426 }
427}
428
429pub fn prop_f64(props: &Props, key: &str) -> Option<f64> {
431 match props.get(key)? {
432 Value::Number(n) => n.as_f64(),
433 Value::String(s) => s.trim().parse().ok(),
434 _ => None,
435 }
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct Field {
442 pub id: String,
443 pub kind: PanelKind,
444 pub props: Props,
445 pub inputs: Vec<String>,
447 pub trigger: Option<FieldTrigger>,
448 pub emit: Option<FieldEmit>,
449 pub source: Option<PanelSource>,
450}
451
452impl Field {
453 pub fn label(&self) -> String {
454 prop_str(&self.props, "label").unwrap_or_else(|| self.id.clone())
455 }
456
457 pub fn placeholder(&self) -> String {
458 prop_str(&self.props, "placeholder").unwrap_or_default()
459 }
460
461 pub fn required(&self) -> bool {
462 prop_bool(&self.props, "required").unwrap_or(false)
463 }
464
465 pub fn default_value(&self) -> Option<String> {
467 prop_str(&self.props, "default")
468 }
469
470 pub fn fires_on(&self, event: &TriggerEvent) -> bool {
476 self.trigger
477 .as_ref()
478 .is_some_and(|trigger| trigger.fires_on(event))
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct Panel {
485 pub id: String,
486 pub kind: PanelKind,
487 pub namespace: String,
488 pub props: Props,
490 pub rows: Vec<Vec<Value>>,
493 pub source: Option<PanelSource>,
494 pub fields: Vec<Field>,
496 pub children: Vec<Panel>,
498 pub published: bool,
503}
504
505impl Panel {
506 pub fn title(&self) -> String {
507 prop_str(&self.props, "title").unwrap_or_else(|| self.id.clone())
508 }
509
510 pub fn description(&self) -> Option<String> {
511 prop_str(&self.props, "description")
512 }
513
514 pub fn slot(&self) -> String {
516 prop_str(&self.props, "slot").unwrap_or_else(|| "main".to_string())
517 }
518
519 pub fn parent(&self) -> Option<String> {
521 prop_str(&self.props, "parent")
522 }
523
524 pub fn columns(&self) -> Vec<String> {
526 prop_list(&self.props, "columns")
527 }
528
529 pub fn prop_str(&self, key: &str) -> Option<String> {
531 prop_str(&self.props, key)
532 }
533
534 pub fn prop_list(&self, key: &str) -> Vec<String> {
536 prop_list(&self.props, key)
537 }
538
539 pub fn prop_bool(&self, key: &str) -> Option<bool> {
541 prop_bool(&self.props, key)
542 }
543
544 pub fn prop_f64(&self, key: &str) -> Option<f64> {
546 prop_f64(&self.props, key)
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use serde_json::json;
554
555 fn props(pairs: &[(&str, Value)]) -> Props {
556 pairs
557 .iter()
558 .map(|(k, v)| ((*k).to_string(), v.clone()))
559 .collect()
560 }
561
562 #[test]
563 fn every_known_kind_round_trips_through_its_wire_name() {
564 for name in [
565 "bar_chart",
566 "line_chart",
567 "pie_chart",
568 "scatter",
569 "table",
570 "metric",
571 "gauge",
572 "markdown",
573 "list",
574 "area_chart",
575 "heatmap",
576 "timeline",
577 "funnel",
578 "game",
579 "doc",
580 "dashboard",
581 "form",
582 "text_input",
583 "textarea",
584 "dropdown",
585 "select",
586 "checkbox",
587 "radio",
588 "button",
589 "number_input",
590 "date_input",
591 "file_upload",
592 "rich_text",
593 ] {
594 let kind = PanelKind::parse(name);
595 assert_eq!(name.parse::<PanelKind>().unwrap(), kind);
596 assert!(
597 !matches!(kind, PanelKind::Unknown(_)),
598 "{name} should be a known kind"
599 );
600 assert_eq!(kind.as_str(), name);
601 }
602 }
603
604 #[test]
605 fn dashboard_is_composite_not_data() {
606 assert!(PanelKind::Dashboard.is_composite());
607 assert!(!PanelKind::Dashboard.is_data_kind());
608 assert!(!PanelKind::Dashboard.is_form_kind());
609 }
610
611 #[test]
612 fn prop_str_coerces_scalars_and_refuses_lists() {
613 let p = props(&[
614 ("title", json!("Pipeline")),
615 ("limit", json!(40)),
616 ("published", json!(true)),
617 ("columns", json!(["a", "b"])),
618 ]);
619 assert_eq!(prop_str(&p, "title").as_deref(), Some("Pipeline"));
620 assert_eq!(prop_str(&p, "limit").as_deref(), Some("40"));
621 assert_eq!(prop_str(&p, "published").as_deref(), Some("true"));
622 assert_eq!(prop_str(&p, "columns"), None);
624 }
625
626 #[test]
627 fn prop_list_reads_a_json_list_or_a_comma_string() {
628 let p = props(&[
629 ("columns", json!(["Invoice", "Days", 3])),
630 ("legacy", json!("a, b ,c")),
631 ]);
632 assert_eq!(prop_list(&p, "columns"), vec!["Invoice", "Days", "3"]);
633 assert_eq!(prop_list(&p, "legacy"), vec!["a", "b", "c"]);
634 assert!(prop_list(&p, "missing").is_empty());
635 }
636
637 #[test]
638 fn prop_bool_accepts_both_wire_forms() {
639 let p = props(&[
640 ("a", json!(true)),
641 ("b", json!("false")),
642 ("c", json!("maybe")),
643 ]);
644 assert_eq!(prop_bool(&p, "a"), Some(true));
645 assert_eq!(prop_bool(&p, "b"), Some(false));
646 assert_eq!(prop_bool(&p, "c"), None);
647 }
648
649 #[test]
650 fn slot_defaults_to_main_like_the_server() {
651 let panel = Panel {
652 id: "x".into(),
653 kind: PanelKind::Metric,
654 namespace: "ns".into(),
655 props: Props::new(),
656 rows: vec![],
657 source: None,
658 fields: vec![],
659 children: vec![],
660 published: false,
661 };
662 assert_eq!(panel.slot(), "main");
663 assert_eq!(panel.title(), "x");
664 }
665}