max / quasi
3 files changed,
+103 insertions,
-4 deletions
| @@ -148,6 +148,29 @@ | |||
| 148 | 148 | /// belongs: the link is then the view, and a middle-click reaches the same | |
| 149 | 149 | /// place the control does. | |
| 150 | 150 | pub carried: Params, | |
| 151 | + | /// The region this call's answer replaces, when the responder cannot say. | |
| 152 | + | /// | |
| 153 | + | /// Normally nothing sets this and nothing should: a described route answers | |
| 154 | + | /// with a `Response::Fragment` naming the region it changed, `quasi-http` | |
| 155 | + | /// turns that into the transport's retarget header, and the router is the | |
| 156 | + | /// only party that knows what it just changed. That is decision 7 and it is | |
| 157 | + | /// unchanged. | |
| 158 | + | /// | |
| 159 | + | /// **Decision 7 assumes the responder is described, and a control may call | |
| 160 | + | /// a route that is not.** Every write on the MNW server's dashboard goes to | |
| 161 | + | /// a plain API route that quasi never sees and that answers with a status or | |
| 162 | + | /// a hand-rendered fragment. Those routes cannot name a region, so if the | |
| 163 | + | /// control does not either, nobody does: the answer lands wherever the | |
| 164 | + | /// transport's default puts it, which for htmx is inside the button that was | |
| 165 | + | /// pressed. That is not a second party deciding one thing. It is the only | |
| 166 | + | /// party that can decide, because the other one is outside the description | |
| 167 | + | /// layer. | |
| 168 | + | /// | |
| 169 | + | /// So: leave it unset when calling a described route, and set it when | |
| 170 | + | /// calling something else. A screen that sets it against a described route | |
| 171 | + | /// is overriding an answer that already knew better, and that is the misuse | |
| 172 | + | /// decision 7 was guarding against. | |
| 173 | + | pub replaces: Option<String>, | |
| 151 | 174 | /// The name to keep the answer under, when the answer is a file. | |
| 152 | 175 | /// | |
| 153 | 176 | /// `Some` means the response is not a view: nothing swaps, and the reader | |
| @@ -179,6 +202,7 @@ | |||
| 179 | 202 | params: Params::new(), | |
| 180 | 203 | carried: Params::new(), | |
| 181 | 204 | saves: None, | |
| 205 | + | replaces: None, | |
| 182 | 206 | } | |
| 183 | 207 | } | |
| 184 | 208 | ||
| @@ -190,6 +214,7 @@ | |||
| 190 | 214 | params: Params::new(), | |
| 191 | 215 | carried: Params::new(), | |
| 192 | 216 | saves: None, | |
| 217 | + | replaces: None, | |
| 193 | 218 | } | |
| 194 | 219 | } | |
| 195 | 220 | ||
| @@ -206,6 +231,7 @@ | |||
| 206 | 231 | params: Params::new(), | |
| 207 | 232 | carried: Params::new(), | |
| 208 | 233 | saves: None, | |
| 234 | + | replaces: None, | |
| 209 | 235 | } | |
| 210 | 236 | } | |
| 211 | 237 | ||
| @@ -217,6 +243,7 @@ | |||
| 217 | 243 | params: Params::new(), | |
| 218 | 244 | carried: Params::new(), | |
| 219 | 245 | saves: None, | |
| 246 | + | replaces: None, | |
| 220 | 247 | } | |
| 221 | 248 | } | |
| 222 | 249 | ||
| @@ -231,9 +258,20 @@ | |||
| 231 | 258 | params: Params::new(), | |
| 232 | 259 | carried: Params::new(), | |
| 233 | 260 | saves: None, | |
| 261 | + | replaces: None, | |
| 234 | 262 | } | |
| 235 | 263 | } | |
| 236 | 264 | ||
| 265 | + | /// Put this call's answer into the region with this id. | |
| 266 | + | /// | |
| 267 | + | /// For a route the description layer does not serve. See | |
| 268 | + | /// [`replaces`](Self::replaces) before reaching for it. | |
| 269 | + | #[must_use] | |
| 270 | + | pub fn replacing(mut self, region: impl Into<String>) -> Self { | |
| 271 | + | self.replaces = Some(region.into()); | |
| 272 | + | self | |
| 273 | + | } | |
| 274 | + | ||
| 237 | 275 | /// Keep the answer as a file with this name, rather than showing it. | |
| 238 | 276 | #[must_use] | |
| 239 | 277 | pub fn saving(mut self, filename: impl Into<String>) -> Self { |
| @@ -13,11 +13,16 @@ | |||
| 13 | 13 | //! this file knows the word htmx, so decision 13's claim that the transport is | |
| 14 | 14 | //! replaceable is a claim about one function rather than about the crate. | |
| 15 | 15 | //! | |
| 16 | - | //! No `hx-target` is ever emitted. Decision 7 puts the target on the response, | |
| 17 | - | //! where `quasi-http` sets `HX-Retarget` from | |
| 16 | + | //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set | |
| 17 | + | //! when a control calls a route the description layer does not serve. Decision 7 | |
| 18 | + | //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from | |
| 18 | 19 | //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router | |
| 19 | - | //! is the only party that knows what it just changed. A control that also named | |
| 20 | - | //! a target would be a second party deciding one thing. | |
| 20 | + | //! is the only party that knows what it just changed, and a control that also | |
| 21 | + | //! named a target would be a second party deciding one thing. That reasoning | |
| 22 | + | //! assumes the responder is described. A plain API route is not, cannot name a | |
| 23 | + | //! region, and leaves the answer to land wherever the transport defaults, which | |
| 24 | + | //! for htmx is inside the pressed button. See `Action::replaces` for the whole | |
| 25 | + | //! of it. | |
| 21 | 26 | ||
| 22 | 27 | use std::collections::HashMap; | |
| 23 | 28 | use std::fmt::Write as _; | |
| @@ -300,6 +305,15 @@ | |||
| 300 | 305 | out.push('"'); | |
| 301 | 306 | } | |
| 302 | 307 | ||
| 308 | + | // Where the answer goes, when the responder is not ours to ask. Emitted | |
| 309 | + | // before the verb so the attributes read in the order they are reasoned | |
| 310 | + | // about: where it lands, then what is sent. | |
| 311 | + | if let Some(region) = &action.replaces { | |
| 312 | + | out.push_str(" hx-target=\"#"); | |
| 313 | + | out.push_str(&escape(region)); | |
| 314 | + | out.push('"'); | |
| 315 | + | } | |
| 316 | + | ||
| 303 | 317 | // The answer is a file the reader keeps, not a view. On a link the browser | |
| 304 | 318 | // does the whole job from the attribute, so nothing else is needed and the | |
| 305 | 319 | // control still works with JS off. On a write it cannot: a response has to |
| @@ -759,6 +759,53 @@ | |||
| 759 | 759 | assert!(!plain.contains("figure-change"), "{plain}"); | |
| 760 | 760 | } | |
| 761 | 761 | ||
| 762 | + | #[test] | |
| 763 | + | fn a_control_calling_an_undescribed_route_can_say_where_the_answer_goes() { | |
| 764 | + | // Decision 7 holds for a described responder and cannot for any other. A | |
| 765 | + | // plain API route answers with a status or a hand-rendered fragment and | |
| 766 | + | // names no region, so without this htmx swaps that answer into the button | |
| 767 | + | // that was pressed. Which is what the MNW server's described Remove button | |
| 768 | + | // did: the delete endpoint answers with the whole re-rendered list, and it | |
| 769 | + | // landed inside the control. | |
| 770 | + | let html = fragment(&Node::list([Row::new("fw13").act(Act::new( | |
| 771 | + | "Remove", | |
| 772 | + | Action::delete("/api/users/me/ssh-keys/7").replacing("ssh-keys-list"), | |
| 773 | + | ))])); | |
| 774 | + | ||
| 775 | + | assert!(html.contains("hx-target=\"#ssh-keys-list\""), "{html}"); | |
| 776 | + | assert!( | |
| 777 | + | html.contains("hx-delete=\"/api/users/me/ssh-keys/7\""), | |
| 778 | + | "{html}" | |
| 779 | + | ); | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | #[test] | |
| 783 | + | fn nothing_else_ever_emits_a_target() { | |
| 784 | + | // The rule the carve-out is carved out of. A control calling a described | |
| 785 | + | // route says nothing about where its answer lands, because the answer says | |
| 786 | + | // so itself. | |
| 787 | + | let html = fragment(&Node::list([Row::new("fw13") | |
| 788 | + | .act(Act::new("Open", Action::get("/keys/7"))) | |
| 789 | + | .activate(Action::get("/keys/7"))])); | |
| 790 | + | assert!(!html.contains("hx-target"), "{html}"); | |
| 791 | + | ||
| 792 | + | let form = fragment(&Node::Form { | |
| 793 | + | action: Action::post("/keys"), | |
| 794 | + | submit: "Add".into(), | |
| 795 | + | fields: vec![Field::new(layout::FieldKind::Text, "label", "Label")], | |
| 796 | + | }); | |
| 797 | + | assert!(!form.contains("hx-target"), "{form}"); | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | #[test] | |
| 801 | + | fn a_region_id_cannot_break_out_of_the_target_attribute() { | |
| 802 | + | let html = fragment(&Node::act( | |
| 803 | + | "Remove", | |
| 804 | + | Action::delete("/api/x").replacing("a\" onload=\"x()"), | |
| 805 | + | )); | |
| 806 | + | assert!(!html.contains("\" onload="), "{html}"); | |
| 807 | + | } | |
| 808 | + | ||
| 762 | 809 | #[test] | |
| 763 | 810 | fn a_control_whose_answer_is_a_file_says_so() { | |
| 764 | 811 | // Nine sites in MNW: five CSV exports across four templates, a sixth in the |