Skip to main content

max / makenotwork

12.5 KB · 289 lines History Blame Raw
1 //! The library page's tab strip, described.
2 //!
3 //! Shape 2, step 1 (`6b24f2df`), and the first described tab strip in the tree.
4 //! An Askama entry point rather than a mounted screen, the same shape
5 //! `widgets::carousel` has: `pages/library.html` is still an Askama document and
6 //! this is one region inside it.
7 //!
8 //! # The panels are not described and are not meant to be
9 //!
10 //! Max's ruling: describe the strip, leave the panels as routes. So every tab is
11 //! a [`RegionKind::Handover`], a place and nothing else, and what lands in one
12 //! is whatever its route already answered with. That is the honest kind for a
13 //! region whose contents are Askama's, and it is what lets the five panel routes
14 //! in `routes::pages::public` stay untouched.
15 //!
16 //! # Who fetches, and why the shown tab is different
17 //!
18 //! `dfbc88ce`: the strip button carries its panel's address and the panel emits
19 //! no load trigger, so a reader downloads the tab they pressed and not the four
20 //! they did not. The shown tab carries no address at all and arrives with its
21 //! contents already in it, which is `9b958e7b`: a screen renders once at final
22 //! geometry, and a placeholder on the panel being looked at is the one place
23 //! that rule bites hardest. It is also what `library.html` already did with its
24 //! `{% include %}`, so this conversion changes the page's request count by zero.
25 //!
26 //! # Why each tab says what it replaces
27 //!
28 //! Three of the five panels are plain Askama routes that quasi never sees, and a
29 //! route that cannot name a region leaves the answer wherever htmx's default
30 //! puts it, which is inside the button that was pressed. [`Action::replacing`]
31 //! is exactly this case and its own doc says so. The other two are described
32 //! screens that name their region themselves, so they are left to, and their
33 //! regions were renamed to match the ids here. See `library_contacts::REGION`
34 //! and `forum_memberships::LIBRARY_REGION`.
35 //!
36 //! # What changed for a reader, and it is one thing
37 //!
38 //! The overflow control. `Fallback::Menu` is declared and makeover's own CSS
39 //! renders a menu run as `flex-wrap: wrap`, so a narrow viewport wraps the strip
40 //! instead of folding the last tabs behind a More button. `core/tabs.ts`'s
41 //! `tabOverflow` still serves the four Askama strips and does not see this one:
42 //! it looks for `.tabs`, and the renderer writes `.selector[data-selector=tab]`.
43 //!
44 //! # Two things the markup said and the description cannot
45 //!
46 //! `aria-label="Library sections"` on the strip, and `title="Updates from
47 //! creators you follow"` on the Feed tab. A region's own accessible name and a
48 //! control's tooltip are both absent from the vocabulary: [`Slot::label`] is a
49 //! child's tab name, which is a different thing, and nothing carries a hint.
50 //!
51 //! Dropped rather than worked around, and filed, because a `Node::Text` smuggled
52 //! in to stand for a label is how a vocabulary stops being one. The strip sits
53 //! under the page's `<h1>`, so a reader is not lost; they are told less than they
54 //! were.
55 //!
56 //! That is the ruling working rather than a gap in it. The menu's construction
57 //! and its measurement policy were explicitly left undescribed, and a renderer
58 //! is free to honour `Menu` as wrapping. Worth knowing before someone reads the
59 //! missing More button as a bug.
60
61 use makeover_layout as layout;
62 use quasi_router::{Action, Node, RegionKind, Slot};
63 use quasi_webview::Webview;
64
65 /// The region the whole strip occupies: the id `library.html` used for its
66 /// single panel container, kept so nothing that aims at the library's tab area
67 /// has to learn a new name.
68 const STRIP: &str = "tab-content";
69
70 /// What a tab is conditional on.
71 ///
72 /// A field rather than a match on the label, which is what this was until the
73 /// first person to reword a tab would have silently changed who could see it.
74 #[derive(PartialEq, Eq)]
75 enum Gate {
76 /// Every reader sees it.
77 Always,
78 /// Only where the Multithreaded integration is configured.
79 Communities,
80 /// Only a reader who can create projects, since only they have buyers.
81 Creators,
82 }
83
84 /// One tab: what it is called, where its panel lives, and what serves it.
85 struct Tab {
86 label: &'static str,
87 /// Who sees it. Membership has been conditional since before the strip was
88 /// described, for two of the five.
89 gate: Gate,
90 /// The id the panel's answer lands in. Also the described screen's own
91 /// region name, for the two tabs that have one.
92 panel: &'static str,
93 route: &'static str,
94 /// The described screen behind this panel, when there is one. `None` means
95 /// an Askama route, which is what [`Action::replacing`] is for.
96 screen: Option<&'static str>,
97 }
98
99 /// Every tab the library can show, in the order the strip draws them.
100 ///
101 /// Membership is conditional for two of the five and always has been:
102 /// Communities needs the Multithreaded integration configured and Contacts
103 /// needs a reader who can create projects. The strip is built from what
104 /// survives those two tests rather than written out flat.
105 const TABS: &[Tab] = &[
106 Tab {
107 label: "Purchases",
108 gate: Gate::Always,
109 panel: "library-purchases",
110 route: "/library/tabs/purchases",
111 screen: None,
112 },
113 Tab {
114 label: "Feed",
115 gate: Gate::Always,
116 panel: "library-feed",
117 route: "/library/tabs/feed",
118 screen: None,
119 },
120 Tab {
121 label: "Collections",
122 gate: Gate::Always,
123 panel: "library-collections",
124 route: "/library/tabs/collections",
125 screen: None,
126 },
127 Tab {
128 label: "Communities",
129 gate: Gate::Communities,
130 panel: super::forum_memberships::LIBRARY_REGION,
131 route: super::forum_memberships::LIBRARY_PATH,
132 screen: Some(super::forum_memberships::LIBRARY_SCREEN),
133 },
134 Tab {
135 label: "Contacts",
136 gate: Gate::Creators,
137 panel: super::library_contacts::REGION,
138 route: super::library_contacts::PATH,
139 screen: Some(super::library_contacts::SCREEN),
140 },
141 ];
142
143 /// The markup, for `pages/library.html` to drop in.
144 ///
145 /// `purchases` is the first panel's contents, rendered by the caller: the page
146 /// handler already has the rows, and the shown panel arriving with the document
147 /// is the whole of what keeps this page at one request.
148 #[must_use]
149 pub fn html(purchases: &str, has_mt_memberships: bool, can_create_projects: bool) -> String {
150 let shown: Vec<&Tab> = TABS
151 .iter()
152 .filter(|tab| match tab.gate {
153 Gate::Always => true,
154 Gate::Communities => has_mt_memberships,
155 Gate::Creators => can_create_projects,
156 })
157 .collect();
158
159 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
160 // A run with no members, only a fallback. `Run` has no `Default` on
161 // purpose, so a strip cannot be described while staying silent about
162 // what it does when it runs out of room. `Menu` is what the page means
163 // -- the last tabs are worth less than the first ones and should fold
164 // away rather than squeeze -- and what this renderer currently does with
165 // that is wrap. See the module header.
166 .across(layout::Fallback::Menu)
167 .showing_one(0);
168
169 for (at, tab) in shown.iter().enumerate() {
170 let mut panel = Slot::handover(tab.panel, "library-panel").label(tab.label);
171 if at > 0 {
172 let mut call = Action::get(tab.route).awaiting();
173 // Described routes name their own region and must be left to;
174 // setting it here would override an answer that already knew
175 // better. See `Action::replaces`.
176 if tab.screen.is_none() {
177 call = call.replacing(tab.panel);
178 }
179 panel = panel.fed_by(call);
180 }
181 strip = strip.with(Node::Region(panel));
182 }
183
184 use quasi_axum::Serves as _;
185
186 // No shell: this is a fragment landing inside a document Askama already
187 // built, which is exactly what `fragment` is for.
188 Webview::new()
189 .with_fill(TABS[0].panel, purchases)
190 .fragment(&Node::Region(strip))
191 }
192
193 #[cfg(test)]
194 mod tests {
195 use super::*;
196
197 fn strip(has_mt: bool, can_create: bool) -> String {
198 html("<p>your purchases</p>", has_mt, can_create)
199 }
200
201 #[test]
202 fn the_page_makes_no_more_requests_than_it_did_before() {
203 // The whole safety argument for this conversion. `library.html` rendered
204 // the shown panel inline and fetched the rest on a press; if the
205 // described strip fetched on load instead, the page would go from zero
206 // panel requests to four, each its own set of queries.
207 let html = strip(true, true);
208
209 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
210 assert!(html.contains("<p>your purchases</p>"), "{html}");
211 assert_eq!(html.matches("hx-get=").count(), 4, "{html}");
212 }
213
214 #[test]
215 fn every_tab_but_the_shown_one_says_where_its_answer_lands() {
216 // Three of these routes are Askama and name no region, so without this
217 // the answer swaps into the button that was pressed -- which is what
218 // `DELETE /api/users/me/ssh-keys` did before the described screens
219 // served their own writes.
220 let html = strip(true, true);
221
222 // The Askama tabs. `library-communities` and `library-contacts` are
223 // deliberately absent: both are described screens that name their own
224 // region, so the strip must NOT retarget them. Until `64b33b26` they
225 // were here, because the switch was off in tests and every tab was
226 // Askama.
227 for panel in ["library-feed", "library-collections"] {
228 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
229 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
230 }
231 // Every tab still gets its frame, described or not.
232 for panel in ["library-communities", "library-contacts"] {
233 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
234 assert!(
235 !html.contains(&format!("hx-target=\"#{panel}\"")),
236 "{panel} is described and names its own region:\n{html}"
237 );
238 }
239 // The shown panel is not fetched, so it has nowhere to aim and says so
240 // by carrying no transport at all.
241 assert!(!html.contains("hx-target=\"#library-purchases\""), "{html}");
242 }
243
244 #[test]
245 fn a_described_panel_is_left_to_name_its_own_region() {
246 // Decision 7. A described route answers with a fragment naming what it
247 // changed, so a target written here would override an answer that
248 // already knew better. Both described panels, and it follows the panel
249 // rather than a switch since `64b33b26` deleted the switch --
250 // `library-communities` used to be asserted the other way here, because
251 // it was described but not switched on in this test.
252 let html = html("<p>your purchases</p>", true, true);
253
254 for panel in ["library-contacts", "library-communities"] {
255 assert!(
256 !html.contains(&format!("hx-target=\"#{panel}\"")),
257 "a described panel retargets its own answer:\n{html}"
258 );
259 }
260 // And the ones still served by Askama are untouched by that.
261 assert!(html.contains("hx-target=\"#library-feed\""), "{html}");
262 }
263
264 #[test]
265 fn the_two_gated_tabs_leave_when_their_test_fails() {
266 // Membership was conditional before it was described and stays so. The
267 // recon that planned this shape recorded the library strip as five
268 // unconditional buttons, which the template contradicts twice.
269 let both = strip(true, true);
270 assert!(both.contains(">Communities</button>"), "{both}");
271 assert!(both.contains(">Contacts</button>"), "{both}");
272
273 let neither = strip(false, false);
274 assert!(!neither.contains(">Communities</button>"), "{neither}");
275 assert!(!neither.contains(">Contacts</button>"), "{neither}");
276 // And the strip is still a strip, with the shown panel where it was.
277 assert!(neither.contains("role=\"tablist\""), "{neither}");
278 assert!(neither.contains("<p>your purchases</p>"), "{neither}");
279 }
280
281 #[test]
282 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
283 // `Run` has no `Default`, so this is not something a strip can forget;
284 // what it can do is pick the wrong one. `Menu` is the page's own
285 // behaviour today -- the overflow goes behind a More control.
286 assert!(strip(true, true).contains("run-menu"));
287 }
288 }
289