Skip to main content

topcoat_htmx/
trigger.rs

1use http::HeaderValue;
2use http::header::HeaderName;
3use http::response::Parts;
4use serde::Serialize;
5use serde_json::{Map, Value};
6use topcoat_core::{context::Cx, error::Result};
7use topcoat_router::IntoResponseParts;
8
9use crate::header;
10
11/// A client-side event for htmx to trigger, optionally carrying a JSON detail.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HxEvent {
14    /// The event name.
15    pub name: String,
16    /// An optional detail payload delivered to the event listener.
17    pub data: Option<Value>,
18}
19
20impl HxEvent {
21    /// Creates an event that triggers with no detail.
22    #[must_use]
23    pub fn new(name: impl Into<String>) -> Self {
24        Self {
25            name: name.into(),
26            data: None,
27        }
28    }
29
30    /// Creates an event carrying `data` as its detail.
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if `data` cannot be serialized to JSON.
35    pub fn with_data(name: impl Into<String>, data: impl Serialize) -> Result<Self> {
36        Ok(Self {
37            name: name.into(),
38            data: Some(serde_json::to_value(data)?),
39        })
40    }
41}
42
43impl From<&str> for HxEvent {
44    fn from(name: &str) -> Self {
45        Self::new(name)
46    }
47}
48
49impl From<String> for HxEvent {
50    fn from(name: String) -> Self {
51        Self::new(name)
52    }
53}
54
55/// When htmx triggers the events of an [`HxResponseTrigger`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum TriggerTiming {
58    /// Trigger as soon as the response is received (`HX-Trigger`).
59    Receive,
60    /// Trigger after the settle step (`HX-Trigger-After-Settle`).
61    AfterSettle,
62    /// Trigger after the swap step (`HX-Trigger-After-Swap`).
63    AfterSwap,
64}
65
66impl TriggerTiming {
67    fn header(self) -> HeaderName {
68        match self {
69            Self::Receive => header::HX_TRIGGER,
70            Self::AfterSettle => header::HX_TRIGGER_AFTER_SETTLE,
71            Self::AfterSwap => header::HX_TRIGGER_AFTER_SWAP,
72        }
73    }
74}
75
76/// Triggers client-side events via one of the `HX-Trigger` response headers.
77///
78/// When every event has no detail, the header is a comma-separated list of
79/// names. As soon as one event carries data, the whole header is serialized as
80/// a JSON object mapping each event name to its detail (or `null`).
81///
82/// # Examples
83///
84/// ```rust
85/// use topcoat::htmx::{HxEvent, HxResponseTrigger};
86///
87/// // `HX-Trigger: refresh, close-modal`
88/// let simple = HxResponseTrigger::receive(["refresh", "close-modal"]);
89///
90/// // JSON form, fired after the swap step.
91/// let detailed =
92///     HxResponseTrigger::after_swap([HxEvent::with_data("show-toast", "Saved!").unwrap()]);
93/// ```
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct HxResponseTrigger {
96    /// When the events fire.
97    pub timing: TriggerTiming,
98    /// The events to trigger.
99    pub events: Vec<HxEvent>,
100}
101
102impl HxResponseTrigger {
103    /// Triggers `events` with the given `timing`.
104    pub fn new(
105        timing: TriggerTiming,
106        events: impl IntoIterator<Item = impl Into<HxEvent>>,
107    ) -> Self {
108        Self {
109            timing,
110            events: events.into_iter().map(Into::into).collect(),
111        }
112    }
113
114    /// Triggers `events` as soon as the response is received (`HX-Trigger`).
115    pub fn receive(events: impl IntoIterator<Item = impl Into<HxEvent>>) -> Self {
116        Self::new(TriggerTiming::Receive, events)
117    }
118
119    /// Triggers `events` after the settle step (`HX-Trigger-After-Settle`).
120    pub fn after_settle(events: impl IntoIterator<Item = impl Into<HxEvent>>) -> Self {
121        Self::new(TriggerTiming::AfterSettle, events)
122    }
123
124    /// Triggers `events` after the swap step (`HX-Trigger-After-Swap`).
125    pub fn after_swap(events: impl IntoIterator<Item = impl Into<HxEvent>>) -> Self {
126        Self::new(TriggerTiming::AfterSwap, events)
127    }
128
129    /// Renders the header value, using the bare name list when no event carries
130    /// a detail and the JSON object form otherwise.
131    fn header_value(self) -> Result<HeaderValue> {
132        if self.events.iter().all(|event| event.data.is_none()) {
133            let names = self
134                .events
135                .iter()
136                .map(|event| event.name.as_str())
137                .collect::<Vec<_>>()
138                .join(", ");
139            return Ok(HeaderValue::from_str(&names)?);
140        }
141
142        let object = self
143            .events
144            .into_iter()
145            .map(|event| (event.name, event.data.unwrap_or(Value::Null)))
146            .collect::<Map<_, _>>();
147        Ok(HeaderValue::from_str(&serde_json::to_string(&object)?)?)
148    }
149}
150
151impl IntoResponseParts for HxResponseTrigger {
152    fn into_response_parts(self, _cx: &Cx, parts: &mut Parts) -> Result<()> {
153        let name = self.timing.header();
154        parts.headers.insert(name, self.header_value()?);
155        Ok(())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn header_value(trigger: HxResponseTrigger) -> (HeaderName, String) {
164        let name = trigger.timing.header();
165        let mut parts = http::Response::new(()).into_parts().0;
166        trigger
167            .into_response_parts(&Cx::default(), &mut parts)
168            .unwrap();
169        let value = parts
170            .headers
171            .get(&name)
172            .unwrap()
173            .to_str()
174            .unwrap()
175            .to_owned();
176        (name, value)
177    }
178
179    #[test]
180    fn name_only_events_join_with_commas() {
181        let (name, value) = header_value(HxResponseTrigger::receive(["refresh", "close"]));
182        assert_eq!(name, header::HX_TRIGGER);
183        assert_eq!(value, "refresh, close");
184    }
185
186    #[test]
187    fn events_with_data_serialize_as_json() {
188        let trigger = HxResponseTrigger::after_swap([
189            HxEvent::with_data("show-toast", "Saved!").unwrap(),
190            HxEvent::new("refresh"),
191        ]);
192        let (name, value) = header_value(trigger);
193        assert_eq!(name, header::HX_TRIGGER_AFTER_SWAP);
194        let json: Value = serde_json::from_str(&value).unwrap();
195        assert_eq!(json["show-toast"], "Saved!");
196        assert_eq!(json["refresh"], Value::Null);
197    }
198
199    #[test]
200    fn timing_selects_header() {
201        let (name, _) = header_value(HxResponseTrigger::after_settle(["x"]));
202        assert_eq!(name, header::HX_TRIGGER_AFTER_SETTLE);
203    }
204}