Skip to main content

max / makenotwork

12.0 KB · 294 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::Bespoke`], 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 use crate::config::QuasiScreens;
66
67 /// The region the whole strip occupies: the id `library.html` used for its
68 /// single panel container, kept so nothing that aims at the library's tab area
69 /// has to learn a new name.
70 const STRIP: &str = "tab-content";
71
72 /// What a tab is conditional on.
73 ///
74 /// A field rather than a match on the label, which is what this was until the
75 /// first person to reword a tab would have silently changed who could see it.
76 #[derive(PartialEq, Eq)]
77 enum Gate {
78 /// Every reader sees it.
79 Always,
80 /// Only where the Multithreaded integration is configured.
81 Communities,
82 /// Only a reader who can create projects, since only they have buyers.
83 Creators,
84 }
85
86 /// One tab: what it is called, where its panel lives, and what serves it.
87 struct Tab {
88 label: &'static str,
89 /// Who sees it. Membership has been conditional since before the strip was
90 /// described, for two of the five.
91 gate: Gate,
92 /// The id the panel's answer lands in. Also the described screen's own
93 /// region name, for the two tabs that have one.
94 panel: &'static str,
95 route: &'static str,
96 /// The described screen behind this panel, when there is one. `None` means
97 /// an Askama route, which is what [`Action::replacing`] is for.
98 screen: Option<&'static str>,
99 }
100
101 /// Every tab the library can show, in the order the strip draws them.
102 ///
103 /// Membership is conditional for two of the five and always has been:
104 /// Communities needs the Multithreaded integration configured and Contacts
105 /// needs a reader who can create projects. The strip is built from what
106 /// survives those two tests rather than written out flat.
107 const TABS: &[Tab] = &[
108 Tab {
109 label: "Purchases",
110 gate: Gate::Always,
111 panel: "library-purchases",
112 route: "/library/tabs/purchases",
113 screen: None,
114 },
115 Tab {
116 label: "Feed",
117 gate: Gate::Always,
118 panel: "library-feed",
119 route: "/library/tabs/feed",
120 screen: None,
121 },
122 Tab {
123 label: "Collections",
124 gate: Gate::Always,
125 panel: "library-collections",
126 route: "/library/tabs/collections",
127 screen: None,
128 },
129 Tab {
130 label: "Communities",
131 gate: Gate::Communities,
132 panel: super::forum_memberships::LIBRARY_REGION,
133 route: super::forum_memberships::LIBRARY_PATH,
134 screen: Some(super::forum_memberships::LIBRARY_SCREEN),
135 },
136 Tab {
137 label: "Contacts",
138 gate: Gate::Creators,
139 panel: super::library_contacts::REGION,
140 route: super::library_contacts::PATH,
141 screen: Some(super::library_contacts::SCREEN),
142 },
143 ];
144
145 /// The markup, for `pages/library.html` to drop in.
146 ///
147 /// `purchases` is the first panel's contents, rendered by the caller: the page
148 /// handler already has the rows, and the shown panel arriving with the document
149 /// is the whole of what keeps this page at one request.
150 #[must_use]
151 pub fn html(
152 screens: &QuasiScreens,
153 purchases: &str,
154 has_mt_memberships: bool,
155 can_create_projects: bool,
156 ) -> String {
157 let shown: Vec<&Tab> = TABS
158 .iter()
159 .filter(|tab| match tab.gate {
160 Gate::Always => true,
161 Gate::Communities => has_mt_memberships,
162 Gate::Creators => can_create_projects,
163 })
164 .collect();
165
166 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
167 // A run with no members, only a fallback. `Run` has no `Default` on
168 // purpose, so a strip cannot be described while staying silent about
169 // what it does when it runs out of room. `Menu` is what the page means
170 // -- the last tabs are worth less than the first ones and should fold
171 // away rather than squeeze -- and what this renderer currently does with
172 // that is wrap. See the module header.
173 .across(layout::Fallback::Menu)
174 .showing_one(0);
175
176 for (at, tab) in shown.iter().enumerate() {
177 let mut panel = Slot::bespoke(tab.panel, "library-panel").label(tab.label);
178 if at > 0 {
179 let mut call = Action::get(tab.route).awaiting();
180 // Described routes name their own region and must be left to;
181 // setting it here would override an answer that already knew
182 // better. See `Action::replaces`.
183 if !tab.screen.is_some_and(|name| screens.enabled(name)) {
184 call = call.replacing(tab.panel);
185 }
186 panel = panel.fed_by(call);
187 }
188 strip = strip.with(Node::Region(panel));
189 }
190
191 use quasi_axum::Serves as _;
192
193 // No shell: this is a fragment landing inside a document Askama already
194 // built, which is exactly what `fragment` is for.
195 Webview::new()
196 .with_fill(TABS[0].panel, purchases)
197 .fragment(&Node::Region(strip))
198 }
199
200 #[cfg(test)]
201 mod tests {
202 use super::*;
203
204 fn strip(has_mt: bool, can_create: bool) -> String {
205 html(
206 &QuasiScreens::default(),
207 "<p>your purchases</p>",
208 has_mt,
209 can_create,
210 )
211 }
212
213 #[test]
214 fn the_page_makes_no_more_requests_than_it_did_before() {
215 // The whole safety argument for this conversion. `library.html` rendered
216 // the shown panel inline and fetched the rest on a press; if the
217 // described strip fetched on load instead, the page would go from zero
218 // panel requests to four, each its own set of queries.
219 let html = strip(true, true);
220
221 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
222 assert!(html.contains("<p>your purchases</p>"), "{html}");
223 assert_eq!(html.matches("hx-get=").count(), 4, "{html}");
224 }
225
226 #[test]
227 fn every_tab_but_the_shown_one_says_where_its_answer_lands() {
228 // Three of these routes are Askama and name no region, so without this
229 // the answer swaps into the button that was pressed -- which is what
230 // `DELETE /api/users/me/ssh-keys` did before the described screens
231 // served their own writes.
232 let html = strip(true, true);
233
234 for panel in [
235 "library-feed",
236 "library-collections",
237 "library-communities",
238 "library-contacts",
239 ] {
240 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
241 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
242 }
243 // The shown panel is not fetched, so it has nowhere to aim and says so
244 // by carrying no transport at all.
245 assert!(!html.contains("hx-target=\"#library-purchases\""), "{html}");
246 }
247
248 #[test]
249 fn a_described_panel_is_left_to_name_its_own_region() {
250 // Decision 7. A described route answers with a fragment naming what it
251 // changed, so a target written here would override an answer that
252 // already knew better. The two switchable panels are the only tabs this
253 // can be true of, and it has to follow the switch rather than the tab.
254 let screens = QuasiScreens::parse(super::super::library_contacts::SCREEN);
255 let html = html(&screens, "<p>your purchases</p>", true, true);
256
257 assert!(
258 !html.contains("hx-target=\"#library-contacts\""),
259 "a described panel retargets its own answer:\n{html}"
260 );
261 // And the ones still served by Askama are untouched by that.
262 assert!(html.contains("hx-target=\"#library-feed\""), "{html}");
263 assert!(
264 html.contains("hx-target=\"#library-communities\""),
265 "{html}"
266 );
267 }
268
269 #[test]
270 fn the_two_gated_tabs_leave_when_their_test_fails() {
271 // Membership was conditional before it was described and stays so. The
272 // recon that planned this shape recorded the library strip as five
273 // unconditional buttons, which the template contradicts twice.
274 let both = strip(true, true);
275 assert!(both.contains(">Communities</button>"), "{both}");
276 assert!(both.contains(">Contacts</button>"), "{both}");
277
278 let neither = strip(false, false);
279 assert!(!neither.contains(">Communities</button>"), "{neither}");
280 assert!(!neither.contains(">Contacts</button>"), "{neither}");
281 // And the strip is still a strip, with the shown panel where it was.
282 assert!(neither.contains("role=\"tablist\""), "{neither}");
283 assert!(neither.contains("<p>your purchases</p>"), "{neither}");
284 }
285
286 #[test]
287 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
288 // `Run` has no `Default`, so this is not something a strip can forget;
289 // what it can do is pick the wrong one. `Menu` is the page's own
290 // behaviour today -- the overflow goes behind a More control.
291 assert!(strip(true, true).contains("run-menu"));
292 }
293 }
294