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.
114pub struct Ctx {
115    pub container: Option<anvil_core::Container>,
116    pub dispatched: Vec<BrowserDispatch>,
117    pub emitted: Vec<ComponentEmit>,
118    pub redirect: Option<String>,
119    pub errors: HashMap<String, Vec<String>>,
120    pub island: Option<String>,
121}
122
123impl Default for Ctx {
124    fn default() -> Self {
125        Self {
126            container: None,
127            dispatched: Vec::new(),
128            emitted: Vec::new(),
129            redirect: None,
130            errors: HashMap::new(),
131            island: None,
132        }
133    }
134}
135
136impl Ctx {
137    pub fn new(container: Option<anvil_core::Container>) -> Self {
138        Self {
139            container,
140            ..Default::default()
141        }
142    }
143
144    pub fn dispatch_browser(&mut self, event: impl Into<String>, payload: serde_json::Value) {
145        self.dispatched.push(BrowserDispatch {
146            event: event.into(),
147            payload,
148        });
149    }
150
151    pub fn emit(&mut self, event: impl Into<String>, payload: serde_json::Value) {
152        self.emitted.push(ComponentEmit {
153            event: event.into(),
154            payload,
155        });
156    }
157
158    pub fn redirect(&mut self, to: impl Into<String>) {
159        self.redirect = Some(to.into());
160    }
161
162    pub fn add_error(&mut self, field: impl Into<String>, message: impl Into<String>) {
163        self.errors
164            .entry(field.into())
165            .or_default()
166            .push(message.into());
167    }
168
169    pub fn request_island(&mut self, name: impl Into<String>) {
170        self.island = Some(name.into());
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct BrowserDispatch {
176    pub event: String,
177    pub payload: serde_json::Value,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ComponentEmit {
182    pub event: String,
183    pub payload: serde_json::Value,
184}