Skip to main content

topcoat_htmx/
location.rs

1use http::HeaderValue;
2use http::response::Parts;
3use serde::Serialize;
4use serde_json::Value;
5use topcoat_core::{context::Cx, error::Result};
6use topcoat_router::IntoResponseParts;
7
8use crate::SwapOption;
9use crate::header;
10
11/// Performs a client-side redirect that does not trigger a full page reload,
12/// via the `HX-Location` header.
13///
14/// In its simplest form it carries just a path. Set any of the [`LocationOptions`]
15/// fields, through the builder methods, to control how htmx fetches and swaps
16/// the new content; when any option is set, the header is serialized as JSON.
17///
18/// # Examples
19///
20/// ```rust
21/// use topcoat::htmx::{HxLocation, SwapOption};
22///
23/// // Plain path: `HX-Location: /home`
24/// let simple = HxLocation::new("/home");
25///
26/// // With options, serialized as JSON.
27/// let detailed = HxLocation::new("/home")
28///     .target("#main")
29///     .swap(SwapOption::InnerHtml);
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct HxLocation {
33    /// The path (URL) to fetch the new content from.
34    pub path: String,
35    /// Optional context controlling the fetch and swap.
36    pub options: LocationOptions,
37}
38
39impl HxLocation {
40    /// Creates a location targeting `path` with no extra options.
41    #[must_use]
42    pub fn new(path: impl Into<String>) -> Self {
43        Self {
44            path: path.into(),
45            options: LocationOptions::default(),
46        }
47    }
48
49    /// Sets the source element for the request (`source`).
50    #[must_use]
51    pub fn source(mut self, source: impl Into<String>) -> Self {
52        self.options.source = Some(source.into());
53        self
54    }
55
56    /// Sets the event that triggered the request (`event`).
57    #[must_use]
58    pub fn event(mut self, event: impl Into<String>) -> Self {
59        self.options.event = Some(event.into());
60        self
61    }
62
63    /// Sets a callback that handles the response (`handler`).
64    #[must_use]
65    pub fn handler(mut self, handler: impl Into<String>) -> Self {
66        self.options.handler = Some(handler.into());
67        self
68    }
69
70    /// Sets the element to swap the response into (`target`), a CSS selector.
71    #[must_use]
72    pub fn target(mut self, target: impl Into<String>) -> Self {
73        self.options.target = Some(target.into());
74        self
75    }
76
77    /// Sets how the response is swapped in (`swap`).
78    #[must_use]
79    pub fn swap(mut self, swap: SwapOption) -> Self {
80        self.options.swap = Some(swap);
81        self
82    }
83
84    /// Sets a CSS selector choosing which part of the response is used (`select`).
85    #[must_use]
86    pub fn select(mut self, select: impl Into<String>) -> Self {
87        self.options.select = Some(select.into());
88        self
89    }
90
91    /// Sets values to submit with the request (`values`).
92    #[must_use]
93    pub fn values(mut self, values: Value) -> Self {
94        self.options.values = Some(values);
95        self
96    }
97
98    /// Sets headers to submit with the request (`headers`).
99    #[must_use]
100    pub fn headers(mut self, headers: Value) -> Self {
101        self.options.headers = Some(headers);
102        self
103    }
104
105    /// Renders the header value: the plain path when no options are set,
106    /// otherwise a JSON object combining `path` with the options.
107    fn header_value(self) -> Result<HeaderValue> {
108        if self.options.is_empty() {
109            return Ok(HeaderValue::from_str(&self.path)?);
110        }
111
112        let mut object = match serde_json::to_value(&self.options)? {
113            Value::Object(map) => map,
114            _ => serde_json::Map::new(),
115        };
116        object.insert("path".to_owned(), Value::String(self.path));
117        Ok(HeaderValue::from_str(&serde_json::to_string(&object)?)?)
118    }
119}
120
121impl From<&str> for HxLocation {
122    fn from(path: &str) -> Self {
123        Self::new(path)
124    }
125}
126
127impl From<String> for HxLocation {
128    fn from(path: String) -> Self {
129        Self::new(path)
130    }
131}
132
133impl IntoResponseParts for HxLocation {
134    fn into_response_parts(self, _cx: &Cx, parts: &mut Parts) -> Result<()> {
135        parts
136            .headers
137            .insert(header::HX_LOCATION, self.header_value()?);
138        Ok(())
139    }
140}
141
142/// The optional context of an [`HxLocation`].
143///
144/// Each field maps to a property of the htmx
145/// [`HX-Location` JSON form](https://htmx.org/headers/hx-location/). Fields left
146/// as [`None`] are omitted from the serialized header.
147#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
148pub struct LocationOptions {
149    /// The source element of the request.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub source: Option<String>,
152    /// An event that triggered the request.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub event: Option<String>,
155    /// A callback that handles the response.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub handler: Option<String>,
158    /// The target to swap the response into.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub target: Option<String>,
161    /// How the response is swapped in.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub swap: Option<SwapOption>,
164    /// A CSS selector choosing which part of the response is used.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub select: Option<String>,
167    /// Values to submit with the request.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub values: Option<Value>,
170    /// Headers to submit with the request.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub headers: Option<Value>,
173}
174
175impl LocationOptions {
176    /// Returns `true` when no option is set, so the location can be sent as a
177    /// plain path.
178    fn is_empty(&self) -> bool {
179        *self == Self::default()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn header_value(location: HxLocation) -> String {
188        let mut parts = http::Response::new(()).into_parts().0;
189        location
190            .into_response_parts(&Cx::default(), &mut parts)
191            .unwrap();
192        parts
193            .headers
194            .get(header::HX_LOCATION)
195            .unwrap()
196            .to_str()
197            .unwrap()
198            .to_owned()
199    }
200
201    #[test]
202    fn plain_path_when_no_options() {
203        assert_eq!(header_value(HxLocation::new("/home")), "/home");
204    }
205
206    #[test]
207    fn options_serialize_as_json_with_path() {
208        let value = header_value(
209            HxLocation::new("/home")
210                .target("#main")
211                .swap(SwapOption::InnerHtml),
212        );
213        let json: Value = serde_json::from_str(&value).unwrap();
214        assert_eq!(json["path"], "/home");
215        assert_eq!(json["target"], "#main");
216        assert_eq!(json["swap"], "innerHTML");
217        // Unset options are omitted.
218        assert!(json.get("source").is_none());
219    }
220}