Skip to main content

datagrout_panels/
model.rs

1//! The panel model — a typed view of the `_panels` fact schema.
2//!
3//! Kind lists mirror DataGrout's `smart_panel.publish` (`@display_kinds` /
4//! `@form_kinds`). Keeping them in sync is what lets [`PanelKind::parse`] be
5//! total rather than lossy; a kind this crate predates still parses, as
6//! [`PanelKind::Unknown`].
7//!
8//! # Composition
9//!
10//! A panel's parts — a form's fields, a dashboard's child panels — are not
11//! listed on the parent. Each part is its own `panel/3` fact carrying a
12//! `parent` prop that names the container:
13//!
14//! ```prolog
15//! panel(pipeline_dashboard, dashboard, pipeline_pulse).
16//! panel(stage_mix, bar_chart, pipeline_pulse).
17//! panel_prop(stage_mix, parent, pipeline_dashboard).
18//! ```
19//!
20//! The parser inverts that edge, so a resolved [`Panel`] carries its
21//! [`children`](Panel::children) (for dashboards) or [`fields`](Panel::fields)
22//! (for forms) and the parts do not appear at the top level.
23
24use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29/// A panel kind. Display kinds render data; form kinds collect it.
30///
31/// `Unknown` is deliberate: DataGrout may publish a kind this crate predates,
32/// and a renderer that panics on an unrecognized panel is worse than one that
33/// draws a labelled placeholder.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum PanelKind {
36    // ── display ──────────────────────────────────────────────────────────
37    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    /// A grid of the data panels whose `parent` prop names it.
53    Dashboard,
54    // ── form ─────────────────────────────────────────────────────────────
55    Form,
56    TextInput,
57    TextArea,
58    Dropdown,
59    Select,
60    Checkbox,
61    Radio,
62    Button,
63    NumberInput,
64    DateInput,
65    FileUpload,
66    RichText,
67    /// A kind this crate does not know about. Rendered as a placeholder.
68    Unknown(String),
69}
70
71impl std::str::FromStr for PanelKind {
72    type Err = std::convert::Infallible;
73
74    /// Every string parses; an unrecognized one becomes [`PanelKind::Unknown`].
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        Ok(Self::parse(s))
77    }
78}
79
80impl PanelKind {
81    /// Parse a wire name such as `"bar_chart"`. Total: unknown names become
82    /// [`PanelKind::Unknown`] rather than failing.
83    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    /// The wire name, e.g. `"bar_chart"`.
118    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    /// Kinds whose `panel_source` is a live DATA query rather than a submit
153    /// goal. Matches DataGrout's `PanelData.data_kinds/0` — the distinction
154    /// decides whether a source is safe to evaluate on render.
155    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    /// Kinds that compose other panels rather than showing data of their own.
192    pub fn is_composite(&self) -> bool {
193        matches!(self, Self::Dashboard | Self::Form)
194    }
195}
196
197/// How often a field's goal may run — the second argument of
198/// `field_trigger(FieldId, TriggerType, Event)`.
199///
200/// The cadence is the *host's* obligation, not the renderer's: `Once` means at
201/// most one run per form, `Repeat` and `Always` mean every occurrence, and
202/// `Auto` leaves it to the host's own policy.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub enum TriggerType {
205    Once,
206    Repeat,
207    OnEvent,
208    Asap,
209    Always,
210    Auto,
211    /// A cadence this crate predates. Carried through rather than dropped.
212    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    /// Parse a wire name such as `"on_event"`. Total: an unrecognized name
225    /// becomes [`TriggerType::Unknown`].
226    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    /// The wire name, e.g. `"on_event"`.
239    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/// What fires a field's goal — the third argument of `field_trigger/3`.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub enum TriggerEvent {
255    Submit,
256    Change,
257    Focus,
258    Manual,
259    /// An event this crate predates.
260    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    /// Parse a wire name such as `"change"`. Total: an unrecognized name
273    /// becomes [`TriggerEvent::Unknown`].
274    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    /// The wire name, e.g. `"change"`.
285    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/// When a form field fires. From `field_trigger(FieldId, TriggerType, Event)`.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct FieldTrigger {
299    pub trigger_type: TriggerType,
300    pub event: TriggerEvent,
301}
302
303impl FieldTrigger {
304    /// Whether this trigger names `event` as what fires it.
305    ///
306    /// Says nothing about the cadence — a `Once` trigger that fires on
307    /// `Change` still answers `true` here, and honouring "once" is the host's
308    /// job, since only the host knows what has already run.
309    pub fn fires_on(&self, event: &TriggerEvent) -> bool {
310        &self.event == event
311    }
312}
313
314/// What happens with a field's output. From `field_emit(FieldId, EmitType)`.
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub enum FieldEmit {
317    /// The output replaces the field's own value.
318    Replacement,
319    /// The output fires dependent fields.
320    Trigger,
321    /// The output is a destination to send the viewer to.
322    Redirection,
323    /// The output is an event for the host to route.
324    Event,
325    /// An emit kind this crate predates.
326    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    /// Parse a wire name such as `"replacement"`. Total: an unrecognized name
339    /// becomes [`FieldEmit::Unknown`].
340    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    /// The wire name, e.g. `"replacement"`.
351    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/// A live query backing — `panel_source(Id, SourceNamespace, PrologQuery)`.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct PanelSource {
365    pub namespace: String,
366    pub query: String,
367}
368
369/// Prop values as they cross the wire — strings, booleans, numbers, lists.
370///
371/// Props are **not** all strings: `columns` is a list and `published` a
372/// boolean in real cells. Storing them as [`Value`] keeps what the server sent;
373/// the accessors on [`Panel`] and [`Field`] do the coercion a renderer wants.
374pub type Props = BTreeMap<String, Value>;
375
376/// Read a prop as text, coercing scalars the way the server does.
377///
378/// Mirrors `PanelData.prop_value/1`: strings pass through, numbers and
379/// booleans become their text form, lists and maps are not text and yield
380/// `None` — a renderer that wants a list asks [`prop_list`].
381pub 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
390/// Read a prop as a list of strings.
391///
392/// Accepts a JSON list (the wire form for `columns`) and, tolerantly, a
393/// comma-separated string.
394pub 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
415/// Read a prop as a boolean. Accepts `true`/`false` and their string forms,
416/// which is how `published` arrives depending on the publishing client.
417pub 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
429/// Read a prop as a number, from a JSON number or numeric string.
430pub 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/// A form field: a part of a `form` panel, linked to it by the field's own
439/// `parent` prop.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct Field {
442    pub id: String,
443    pub kind: PanelKind,
444    pub props: Props,
445    /// `field_input(FieldId, DependsOnFieldId)` — dependency edges.
446    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    /// Default value, if the field declares one.
466    pub fn default_value(&self) -> Option<String> {
467        prop_str(&self.props, "default")
468    }
469
470    /// Whether this field asks to fire its goal on `event`.
471    ///
472    /// A field with no `field_trigger` fact declares no cadence of its own, so
473    /// this is `false` for it: its value travels with the form's submit rather
474    /// than firing anything independently.
475    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/// A resolved panel: definition facts plus whatever rows were loaded for it.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct Panel {
485    pub id: String,
486    pub kind: PanelKind,
487    pub namespace: String,
488    /// `panel_prop(Id, Key, Value)` pairs, values as sent.
489    pub props: Props,
490    /// Rows: either the live `panel_source` result or the `panel_data`
491    /// snapshot. Resolution happens outside this crate — see [`PanelSource`].
492    pub rows: Vec<Vec<Value>>,
493    pub source: Option<PanelSource>,
494    /// Form fields — parts of a `form` panel.
495    pub fields: Vec<Field>,
496    /// Child panels — parts of a `dashboard` (or any non-form container).
497    pub children: Vec<Panel>,
498    /// Whether the publisher marked this panel published.
499    ///
500    /// Matches the server: `published` must be `true` (or `"true"`); an
501    /// absent prop means **not** published.
502    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    /// Layout slot hint. The server defaults this to `"main"`.
515    pub fn slot(&self) -> String {
516        prop_str(&self.props, "slot").unwrap_or_else(|| "main".to_string())
517    }
518
519    /// The container this panel is a part of, if any.
520    pub fn parent(&self) -> Option<String> {
521        prop_str(&self.props, "parent")
522    }
523
524    /// Column headers for `table` panels — the `columns` prop, a list.
525    pub fn columns(&self) -> Vec<String> {
526        prop_list(&self.props, "columns")
527    }
528
529    /// Read a prop as text. See [`prop_str`].
530    pub fn prop_str(&self, key: &str) -> Option<String> {
531        prop_str(&self.props, key)
532    }
533
534    /// Read a prop as a list. See [`prop_list`].
535    pub fn prop_list(&self, key: &str) -> Vec<String> {
536        prop_list(&self.props, key)
537    }
538
539    /// Read a prop as a boolean. See [`prop_bool`].
540    pub fn prop_bool(&self, key: &str) -> Option<bool> {
541        prop_bool(&self.props, key)
542    }
543
544    /// Read a prop as a number. See [`prop_f64`].
545    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        // A list is not text; a caller that wants it asks prop_list.
623        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}