max / quasi
4 files changed,
+109 insertions,
-8 deletions
| @@ -4894,14 +4894,6 @@ | |||
| 4894 | 4894 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4895 | 4895 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4896 | 4896 | ||
| 4897 | - | [[patch.unused]] | |
| 4898 | - | name = "synckit-client" | |
| 4899 | - | version = "0.8.0" | |
| 4900 | - | ||
| 4901 | - | [[patch.unused]] | |
| 4902 | - | name = "synckit-config" | |
| 4903 | - | version = "0.2.0" | |
| 4904 | - | ||
| 4905 | 4897 | [[patch.unused]] | |
| 4906 | 4898 | name = "kberg" | |
| 4907 | 4899 | version = "0.1.0" | |
| @@ -4913,3 +4905,11 @@ | |||
| 4913 | 4905 | [[patch.unused]] | |
| 4914 | 4906 | name = "tagtree" | |
| 4915 | 4907 | version = "0.4.0" | |
| 4908 | + | ||
| 4909 | + | [[patch.unused]] | |
| 4910 | + | name = "synckit-client" | |
| 4911 | + | version = "0.8.0" | |
| 4912 | + | ||
| 4913 | + | [[patch.unused]] | |
| 4914 | + | name = "synckit-config" | |
| 4915 | + | version = "0.2.0" |
| @@ -148,6 +148,26 @@ | |||
| 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 name to keep the answer under, when the answer is a file. | |
| 152 | + | /// | |
| 153 | + | /// `Some` means the response is not a view: nothing swaps, and the reader | |
| 154 | + | /// ends up holding a file called this. A webview makes that a browser | |
| 155 | + | /// download; a terminal writes it to disk; either way the screen said what | |
| 156 | + | /// it meant rather than a class name on a button implying it. | |
| 157 | + | /// | |
| 158 | + | /// Counted before adding it. Nine sites in the MNW server: five CSV export | |
| 159 | + | /// buttons across four dashboard templates, a sixth in the item-sales tab's | |
| 160 | + | /// own script, and three anchors carrying a `download` attribute. Six of the | |
| 161 | + | /// nine are writes, which is what makes this a property of the action rather | |
| 162 | + | /// than a kind of destination: a write cannot be a plain link, so the host | |
| 163 | + | /// has to be told, and until now it was told by | |
| 164 | + | /// `data-action="exportCsvButton"` plus two positional arguments. | |
| 165 | + | /// | |
| 166 | + | /// Independent of [`method`](Self::method). A read that saves is an anchor | |
| 167 | + | /// the browser downloads instead of navigating to; a write that saves has to | |
| 168 | + | /// be performed and then handed to the reader. Both are the same sentence | |
| 169 | + | /// here and differ only in the emitting. | |
| 170 | + | pub saves: Option<String>, | |
| 151 | 171 | } | |
| 152 | 172 | ||
| 153 | 173 | impl Action { | |
| @@ -158,6 +178,7 @@ | |||
| 158 | 178 | destination: Destination::Route(path.into()), | |
| 159 | 179 | params: Params::new(), | |
| 160 | 180 | carried: Params::new(), | |
| 181 | + | saves: None, | |
| 161 | 182 | } | |
| 162 | 183 | } | |
| 163 | 184 | ||
| @@ -168,6 +189,7 @@ | |||
| 168 | 189 | destination: Destination::Route(path.into()), | |
| 169 | 190 | params: Params::new(), | |
| 170 | 191 | carried: Params::new(), | |
| 192 | + | saves: None, | |
| 171 | 193 | } | |
| 172 | 194 | } | |
| 173 | 195 | ||
| @@ -183,6 +205,7 @@ | |||
| 183 | 205 | destination: Destination::Route(path.into()), | |
| 184 | 206 | params: Params::new(), | |
| 185 | 207 | carried: Params::new(), | |
| 208 | + | saves: None, | |
| 186 | 209 | } | |
| 187 | 210 | } | |
| 188 | 211 | ||
| @@ -193,6 +216,7 @@ | |||
| 193 | 216 | destination: Destination::Route(path.into()), | |
| 194 | 217 | params: Params::new(), | |
| 195 | 218 | carried: Params::new(), | |
| 219 | + | saves: None, | |
| 196 | 220 | } | |
| 197 | 221 | } | |
| 198 | 222 | ||
| @@ -206,9 +230,17 @@ | |||
| 206 | 230 | destination: Destination::External(url.into()), | |
| 207 | 231 | params: Params::new(), | |
| 208 | 232 | carried: Params::new(), | |
| 233 | + | saves: None, | |
| 209 | 234 | } | |
| 210 | 235 | } | |
| 211 | 236 | ||
| 237 | + | /// Keep the answer as a file with this name, rather than showing it. | |
| 238 | + | #[must_use] | |
| 239 | + | pub fn saving(mut self, filename: impl Into<String>) -> Self { | |
| 240 | + | self.saves = Some(filename.into()); | |
| 241 | + | self | |
| 242 | + | } | |
| 243 | + | ||
| 212 | 244 | /// The route this calls, if it calls one. | |
| 213 | 245 | #[must_use] | |
| 214 | 246 | pub fn route(&self) -> Option<&str> { |
| @@ -300,6 +300,23 @@ | |||
| 300 | 300 | out.push('"'); | |
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | + | // The answer is a file the reader keeps, not a view. On a link the browser | |
| 304 | + | // does the whole job from the attribute, so nothing else is needed and the | |
| 305 | + | // control still works with JS off. On a write it cannot: a response has to | |
| 306 | + | // be performed before it can be saved, so this is a named hook the host | |
| 307 | + | // acts on, in the same spirit as `data-act` and for the same reason it is an | |
| 308 | + | // attribute rather than a class. The host handles one attribute instead of | |
| 309 | + | // a per-button behaviour named by a class and two positional arguments. | |
| 310 | + | if let Some(filename) = &action.saves { | |
| 311 | + | if is_link(action) { | |
| 312 | + | out.push_str(" download=\""); | |
| 313 | + | } else { | |
| 314 | + | out.push_str(" data-saves=\""); | |
| 315 | + | } | |
| 316 | + | out.push_str(&escape(filename)); | |
| 317 | + | out.push('"'); | |
| 318 | + | } | |
| 319 | + | ||
| 303 | 320 | let verb = match action.method { | |
| 304 | 321 | Method::Get => " hx-get=\"", | |
| 305 | 322 | Method::Post => " hx-post=\"", |
| @@ -759,6 +759,58 @@ | |||
| 759 | 759 | assert!(!plain.contains("figure-change"), "{plain}"); | |
| 760 | 760 | } | |
| 761 | 761 | ||
| 762 | + | #[test] | |
| 763 | + | fn a_control_whose_answer_is_a_file_says_so() { | |
| 764 | + | // Nine sites in MNW: five CSV exports across four templates, a sixth in the | |
| 765 | + | // item-sales script, three anchors carrying `download`. Six are writes, | |
| 766 | + | // which is why this is a property of the action and not a kind of link. | |
| 767 | + | // | |
| 768 | + | // A read is the whole job: the browser saves it and the control still works | |
| 769 | + | // with JS off. | |
| 770 | + | let read = fragment(&Node::act( | |
| 771 | + | "Download LICENSE.txt", | |
| 772 | + | Action::get("/api/items/7/license.txt").saving("LICENSE.txt"), | |
| 773 | + | )); | |
| 774 | + | assert!(read.contains("download=\"LICENSE.txt\""), "{read}"); | |
| 775 | + | assert!( | |
| 776 | + | read.contains("<a "), | |
| 777 | + | "a read that saves is still a link: {read}" | |
| 778 | + | ); | |
| 779 | + | ||
| 780 | + | // A write cannot be a link, so the intent is a named hook the host acts on. | |
| 781 | + | let write = fragment(&Node::act( | |
| 782 | + | "Export CSV", | |
| 783 | + | Action::post("/api/export/contacts").saving("contacts.csv"), | |
| 784 | + | )); | |
| 785 | + | assert!(write.contains("data-saves=\"contacts.csv\""), "{write}"); | |
| 786 | + | assert!( | |
| 787 | + | write.contains("hx-post=\"/api/export/contacts\""), | |
| 788 | + | "{write}" | |
| 789 | + | ); | |
| 790 | + | assert!( | |
| 791 | + | !write.contains("download="), | |
| 792 | + | "a button is not a link: {write}" | |
| 793 | + | ); | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | #[test] | |
| 797 | + | fn a_control_that_saves_nothing_says_nothing() { | |
| 798 | + | let plain = fragment(&Node::act("Export", Action::post("/api/export/contacts"))); | |
| 799 | + | assert!(!plain.contains("data-saves"), "{plain}"); | |
| 800 | + | assert!(!plain.contains("download="), "{plain}"); | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | #[test] | |
| 804 | + | fn a_filename_cannot_break_out_of_its_attribute() { | |
| 805 | + | // A filename is chosen by the screen today, but it is a string in an | |
| 806 | + | // attribute and the escaping is not optional for that reason. | |
| 807 | + | let html = fragment(&Node::act( | |
| 808 | + | "Export", | |
| 809 | + | Action::post("/api/export").saving("\" onload=\"x()"), | |
| 810 | + | )); | |
| 811 | + | assert!(!html.contains("\" onload="), "{html}"); | |
| 812 | + | } | |
| 813 | + | ||
| 762 | 814 | #[test] | |
| 763 | 815 | fn a_cell_value_that_is_a_link_is_the_link() { | |
| 764 | 816 | // 35 cells across 18 of MNW's templates are a title that goes somewhere. |