Skip to main content

spark/
component.rs

1//! Component trait + per-call context types.
2
3use std::collections::HashMap;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use crate::error::Result;
9
10/// User-facing trait — implemented for every Spark component by the
11/// `#[spark::component]` attribute macro on the struct + `#[spark::actions]` on
12/// the impl block.
13///
14/// Render is sync because the template engine is sync and the typical render
15/// path is pure data → HTML; async work (DB hits, API calls) belongs in actions
16/// and `mount`, not in render.
17#[async_trait]
18pub trait Component: Send + Sync + 'static {
19    fn class_name() -> &'static str
20    where
21        Self: Sized;
22
23    fn view_path() -> &'static str
24    where
25        Self: Sized;
26
27    fn listeners() -> Vec<String>
28    where
29        Self: Sized,
30    {
31        Vec::new()
32    }
33
34    fn snapshot_data(&self) -> serde_json::Value;
35
36    fn load_snapshot(data: &serde_json::Value) -> Result<Self>
37    where
38        Self: Sized;
39
40    fn mount(props: MountProps) -> Self
41    where
42        Self: Sized + Default,
43    {
44        let _ = props;
45        Self::default()
46    }
47
48    async fn apply_writes(&mut self, writes: &[PropertyWrite], ctx: &mut Ctx) -> Result<()>;
49
50    async fn dispatch_call(
51        &mut self,
52        method: &str,
53        args: Vec<serde_json::Value>,
54        ctx: &mut Ctx,
55    ) -> Result<()>;
56
57    fn render(&self) -> Result<String>
58    where
59        Self: Sized,
60    {
61        let data = self.snapshot_data();
62        let view = Self::view_path();
63        crate::template::render(view, &data)
64    }
65}
66
67/// Mount-time props — the JSON object passed via the `@spark("name", { ... })` directive.
68#[derive(Debug, Clone, Default)]
69pub struct MountProps {
70    pub raw: serde_json::Value,
71}
72
73impl MountProps {
74    pub fn new(v: serde_json::Value) -> Self {
75        Self { raw: v }
76    }
77
78    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
79        self.raw.get(key)
80    }
81
82    pub fn string(&self, key: &str) -> Option<String> {
83        self.get(key).and_then(|v| v.as_str()).map(String::from)
84    }
85
86    pub fn i32(&self, key: &str) -> Option<i32> {
87        self.get(key)
88            .and_then(|v| v.as_i64())
89            .and_then(|v| i32::try_from(v).ok())
90    }
91
92    pub fn i64(&self, key: &str) -> Option<i64> {
93        self.get(key).and_then(|v| v.as_i64())
94    }
95
96    pub fn bool(&self, key: &str) -> Option<bool> {
97        self.get(key).and_then(|v| v.as_bool())
98    }
99
100    pub fn parse<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
101        self.get(key)
102            .and_then(|v| serde_json::from_value(v.clone()).ok())
103    }
104}
105
106/// A property write from the browser: `{ name: "draft", value: "hello" }`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PropertyWrite {
109    pub name: String,
110    pub value: serde_json::Value,
111}
112
113/// Per-call context carried through action dispatch.
114#[derive(Default)]
115pub struct Ctx {
116    pub container: Option<anvil_core::Container>,
117    pub dispatched: Vec<BrowserDispatch>,
118    pub emitted: Vec<ComponentEmit>,
119    pub redirect: Option<String>,
120    pub errors: HashMap<String, Vec<String>>,
121    pub island: Option<String>,
122}
123
124impl Ctx {
125    pub fn new(container: Option<anvil_core::Container>) -> Self {
126        Self {
127            container,
128            ..Default::default()
129        }
130    }
131
132    pub fn dispatch_browser(&mut self, event: impl Into<String>, payload: serde_json::Value) {
133        self.dispatched.push(BrowserDispatch {
134            event: event.into(),
135            payload,
136        });
137    }
138
139    pub fn emit(&mut self, event: impl Into<String>, payload: serde_json::Value) {
140        self.emitted.push(ComponentEmit {
141            event: event.into(),
142            payload,
143        });
144    }
145
146    pub fn redirect(&mut self, to: impl Into<String>) {
147        self.redirect = Some(to.into());
148    }
149
150    pub fn add_error(&mut self, field: impl Into<String>, message: impl Into<String>) {
151        self.errors
152            .entry(field.into())
153            .or_default()
154            .push(message.into());
155    }
156
157    pub fn request_island(&mut self, name: impl Into<String>) {
158        self.island = Some(name.into());
159    }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct BrowserDispatch {
164    pub event: String,
165    pub payload: serde_json::Value,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ComponentEmit {
170    pub event: String,
171    pub payload: serde_json::Value,
172}