Skip to main content

max / makenotwork

5.3 KB · 121 lines History Blame Raw
1 //! The Askama entry point for a described Export CSV button.
2 //!
3 //! The first act taken through the pattern `27d5e5b8` ruled on: a described
4 //! fragment inside an Askama page, at the granularity of one primitive, in a
5 //! glue module that is a sibling of `widgets/` rather than a member of it. The
6 //! whole of the reasoning is in [`super::widgets`]; what is here is the first
7 //! thing it buys.
8 //!
9 //! # What it replaces
10 //!
11 //! `data-action="exportCsvButton" data-arg="/api/export/sales"
12 //! data-arg2="sales.csv"` — a class naming a behaviour plus the two things the
13 //! behaviour needed, positionally, repeated at five sites in four templates,
14 //! backed by a `window.exportCsvButton` global that fetched the URL, built an
15 //! anchor, set `download`, clicked it, and put the button's own label back
16 //! afterwards.
17 //!
18 //! [`Action::saving`] says all of it in one sentence: the answer to this call
19 //! is a file the reader keeps rather than a view. `htmx-glue.ts` performs it
20 //! once for everything that says so, which it already did for
21 //! [`super::buyer_contacts`], the one screen described before this. A terminal
22 //! renderer writes the file to disk from the same sentence without being told
23 //! which button this is.
24 //!
25 //! # One act, five call sites, and why the URL is a parameter
26 //!
27 //! The five exports differ in exactly two things: where the rows come from and
28 //! what the file is called. Both are facts about the caller rather than about
29 //! the control, so they are arguments and there is one function. Compare
30 //! `upload_field`, where the accept list is a fact about MNW and belongs to the
31 //! module: the test is whether a template would be writing the same literal
32 //! twice if it were not here.
33 //!
34 //! # The visual change, which is the ruling's accepted cost
35 //!
36 //! The templates wrote `class="btn-secondary text-sm"`. A described act emits
37 //! `class="button"`, so these five land on the design system's button. The
38 //! layout-only classes a caller needs (`broadcast-export`, `nowrap`) stay in
39 //! Askama on the element around it, where they were always a fact about that
40 //! page's layout rather than about the control.
41
42 use quasi_router::screen::Act;
43 use quasi_router::{Action, Node};
44
45 /// The route each export posts to, and the name the file is kept under.
46 ///
47 /// A read would be a link the browser downloads by itself; these are posts,
48 /// because assembling the file is work the server does rather than a document
49 /// it already has, and `Action::saves` is independent of the verb for exactly
50 /// that reason.
51 ///
52 /// `awaiting` because the whole file is assembled before any of it comes back.
53 /// Nothing countable to say about the wait: the row count is often known at the
54 /// call site and the byte count never is, and `layout::Awaiting` takes a
55 /// measurement rather than a stand-in for one.
56 #[must_use]
57 pub fn act(route: &str, filename: &str) -> Node {
58 Node::Act(control("Export CSV", route, filename))
59 }
60
61 /// The same sentence under a caller's own label.
62 ///
63 /// `Export CSV` is right for a control standing alone among others, which is
64 /// every site [`act`] serves. It is wrong on the export portal, where six cards
65 /// each name their own subject and the format is already in the card's meta
66 /// line, so the control there reads `Download`. The label is the only thing
67 /// that varies: what a caller must not restate is the sentence, which is here
68 /// once and is what this module exists for.
69 ///
70 /// Returns the [`Act`] rather than a [`Node`] because the portal's controls sit
71 /// in a [`quasi_router::Row`], which holds acts rather than nodes.
72 #[must_use]
73 pub fn control(label: &str, route: &str, filename: &str) -> Act {
74 Act::new(label, Action::post(route).saving(filename).awaiting())
75 }
76
77 /// The same act as a fragment, for a template to drop in place.
78 ///
79 /// No shell and no region: this lands inside a document Askama has already
80 /// built, which is what every glue module here does.
81 #[must_use]
82 pub fn html(route: &str, filename: &str) -> String {
83 use quasi_axum::Serves as _;
84
85 quasi_webview::Webview::new().fragment(&act(route, filename))
86 }
87
88 #[cfg(test)]
89 mod tests {
90 /// The three things `data-action` + two positional arguments used to say,
91 /// said once. `data-saves` is the whole of the host's instruction.
92 #[test]
93 fn an_export_says_where_it_reads_from_and_what_the_file_is_called() {
94 let html = super::html("/api/export/sales", "sales.csv");
95
96 assert!(html.contains(r#"hx-post="/api/export/sales""#), "{html}");
97 assert!(html.contains(r#"data-saves="sales.csv""#), "{html}");
98 assert!(html.contains("Export CSV"), "{html}");
99 }
100
101 /// The mark that says the call waits. A file the server assembles is the
102 /// report case `Action::awaiting` exists for, and the button locking itself
103 /// is what the deleted global did by hand.
104 #[test]
105 fn an_export_says_it_waits() {
106 let html = super::html("/api/export/sales", "sales.csv");
107
108 assert!(html.contains("data-awaiting="), "{html}");
109 }
110
111 /// Nothing positional survives. The failure this replaced was a second
112 /// argument drifting from the first at one of five sites, silently.
113 #[test]
114 fn no_call_site_carries_a_behaviour_by_name() {
115 let html = super::html("/api/export/followers", "followers.csv");
116
117 assert!(!html.contains("data-action"), "{html}");
118 assert!(!html.contains("data-arg"), "{html}");
119 }
120 }
121