Skip to main content

max / makenotwork

15.3 KB · 381 lines History Blame Raw
1 //! The user dashboard's tab strip, described.
2 //!
3 //! Shape 2, step 5 (`6b24f2df`), the last of the five and the one that deletes
4 //! `frontend/src/core/tabs.ts`. Same Askama entry point as the three before it:
5 //! `dashboards/dashboard-user.html` is still an Askama document and this is one
6 //! region inside it.
7 //!
8 //! # Two strips, not one
9 //!
10 //! The page carried two mutually exclusive tab rows under `{% if deactivated %}`,
11 //! each with its own panel container. A deactivated account sees Support and
12 //! nothing else, because there is nothing else it can do. That is a membership
13 //! test like any other here, so it is [`Gate::Live`] rather than a second
14 //! function: the strip is one description whose membership happens to collapse
15 //! to one tab.
16 //!
17 //! # Which tab opens is computed, and always was
18 //!
19 //! The other four strips opened on their first tab. This one opened on Projects
20 //! for a creator and on Payments for everyone else, spelled as a `chosen` class
21 //! and an `aria-selected` computed twice in the markup. Gating Projects out for a
22 //! non-creator says the same thing once: the opening tab is the first one the
23 //! reader can see, whoever they are.
24 //!
25 //! # Four of the five panels are fillable
26 //!
27 //! Everything in the tree that links here asks for projects, payments or
28 //! settings, and a deactivated account opens on support. Analytics is
29 //! the one nothing links to, and it is also the one that answers for itself,
30 //! so leaving it unfillable costs nothing and paying for it would buy a query
31 //! nobody asked for.
32 //!
33 //! # Analytics is a described screen and names its own region
34 //!
35 //! [`super::user_analytics`] answers `/dashboard/tabs/analytics` when the switch
36 //! is on. Its `REGION` said `tab-content`, the single pane the hand-written strip
37 //! swapped into; there is no single pane now, so it moved to the frame that is
38 //! its own, exactly as `ssh_keys::REGION` did in step 4. A test here asserts the
39 //! two agree, because if they drift the screen's answer lands nowhere and nothing
40 //! else catches it.
41 //!
42 //! # The hash links become queries, and six of the ten were already broken
43 //!
44 //! Ten sites handed out a `/dashboard#tab-*` and relied on `core/tabs.ts`
45 //! reading the hash, finding the button and clicking it. The described buttons
46 //! carry no ids, so each is a `?tab=` read here instead and the tab arrives
47 //! filled at first paint.
48 //!
49 //! Grepping for them found four the recon had not, and six of the ten named an
50 //! id this page has never had: `#tab-profile` and `#tab-ssh-keys` are *settings
51 //! sections*, `#tab-plan` (twice) is the Creator Plan section under a name
52 //! nothing has ever spelled, `#tab-synckit` is a tab only the project dashboard
53 //! ever had, and `#tab-library` in a deletion email is `/library`, a page of its
54 //! own. `#tab-synckit` has a live destination since `47e67540`, and it is still
55 //! not a tab here: the user-level surface is the Cloud Sync settings section,
56 //! reached as `?tab=settings&section=synckit`. A hash restore that cannot find its button does nothing and says nothing,
57 //! which is how six dead links sat in the tree.
58 //!
59 //! The three that mean a settings section became `?tab=settings`, which is
60 //! Settings showing Profile, since Profile is what the sub-nav opens on. Landing
61 //! on the section that was asked for wanted a second level
62 //! (`?tab=settings&section=creator`) and a fillable builder behind it; five sites
63 //! wanted one, which earned it, and `3a7de032` built it in
64 //! [`super::settings_tabs`]. The link a section names is `&section=` now.
65 //!
66 //! # What this retires
67 //!
68 //! `frontend/src/core/tabs.ts` entirely, the last hand-written strip having gone:
69 //! the overflow menu, the hover preload, the hash restore and `setActiveTab` are
70 //! all what a described tab group does. Six `data-action="onSetActiveTab"` sites,
71 //! the last two `tab-spinner` spellings, the `onSetActiveTab` wrapper in
72 //! `actions-dashboards.js` and `blogTabNav` beside it go with it.
73
74 use makeover_layout as layout;
75 use quasi_router::{Action, Node, RegionKind, Slot};
76 use quasi_webview::Webview;
77
78 /// The region the whole strip occupies, keeping the id the page already used.
79 const STRIP: &str = "tab-content";
80
81 /// What a tab is conditional on.
82 #[derive(PartialEq, Eq)]
83 enum Gate {
84 /// Only an account that has not deactivated itself.
85 Live,
86 /// A live account whose reader can create projects.
87 Creator,
88 /// Every reader, deactivated included. Support is the only one.
89 Always,
90 }
91
92 /// One tab: what it is called, where its panel lives, and who sees it.
93 struct Tab {
94 label: &'static str,
95 /// The id the panel's answer lands in. Also the described screen's own
96 /// region name, for the one that has one.
97 panel: &'static str,
98 /// The tail of the route, under `/dashboard/tabs/`.
99 route: &'static str,
100 gate: Gate,
101 /// The described screen behind this panel, when there is one.
102 screen: Option<&'static str>,
103 }
104
105 /// Every tab the user dashboard can show, in the order the strip draws them.
106 const TABS: &[Tab] = &[
107 Tab {
108 label: "Projects",
109 panel: super::user_projects::REGION,
110 route: "projects",
111 gate: Gate::Creator,
112 // `None` although the panel is described: `screen` means "a quasi route
113 // answers this address", and Projects is a fill on the Askama handler
114 // that keeps the ETag. See `super::user_projects`.
115 screen: None,
116 },
117 Tab {
118 label: "Payments",
119 panel: "user-payments",
120 route: "payments",
121 gate: Gate::Live,
122 screen: None,
123 },
124 Tab {
125 label: "Analytics",
126 panel: super::user_analytics::REGION,
127 route: "analytics",
128 gate: Gate::Creator,
129 screen: Some(super::user_analytics::SCREEN),
130 },
131 Tab {
132 label: "Settings",
133 panel: "user-settings",
134 route: "settings",
135 gate: Gate::Live,
136 screen: None,
137 },
138 Tab {
139 label: "Support",
140 panel: super::user_support::REGION,
141 route: "support",
142 gate: Gate::Always,
143 // Described, but as a fill on the Askama handler rather than a quasi
144 // route, so the strip still fetches it. See `super::user_support`.
145 screen: None,
146 },
147 ];
148
149 /// The tabs whose panel the page handler can render inline.
150 ///
151 /// The shown tab is the one that does not fetch, so a name outside this list is
152 /// a blank screen rather than a slow one and answers the first tab instead.
153 /// These four are what the tree links to; analytics is the one nothing links
154 /// to.
155 const FILLABLE: &[&str] = &["projects", "payments", "settings", "support"];
156
157 /// Which tab a `?tab=` asks for, or the first one the reader can see.
158 ///
159 /// Replaces the hash restore `core/tabs.ts` did, which read `location.hash`,
160 /// found the button and clicked it: a deep link cost a document, then the page's
161 /// JS running, then a fetch, and it needed a button id to aim at. Chosen here,
162 /// the tab arrives already filled.
163 #[must_use]
164 pub fn shown_at(asked: Option<&str>, deactivated: bool, can_create_projects: bool) -> usize {
165 let Some(asked) = asked else { return 0 };
166 if !FILLABLE.contains(&asked) {
167 return 0;
168 }
169 visible(deactivated, can_create_projects)
170 .iter()
171 .position(|tab| tab.route == asked)
172 .unwrap_or(0)
173 }
174
175 /// The route name of a tab by index, so the caller knows which panel to fill.
176 #[must_use]
177 pub fn route_at(shown: usize, deactivated: bool, can_create_projects: bool) -> &'static str {
178 let tabs = visible(deactivated, can_create_projects);
179 tabs.get(shown).map_or(tabs[0].route, |tab| tab.route)
180 }
181
182 /// The tabs this reader sees, in strip order.
183 ///
184 /// Never empty: Support is [`Gate::Always`].
185 fn visible(deactivated: bool, can_create_projects: bool) -> Vec<&'static Tab> {
186 TABS.iter()
187 .filter(|tab| match tab.gate {
188 Gate::Always => true,
189 Gate::Live => !deactivated,
190 Gate::Creator => !deactivated && can_create_projects,
191 })
192 .collect()
193 }
194
195 /// The markup, for `dashboards/dashboard-user.html` to drop in.
196 ///
197 /// `panel` is the shown tab's contents, rendered by the caller. Both the strips
198 /// this replaces gave their panel container an `hx-trigger="load"` and fetched
199 /// after the document arrived, which is `9b958e7b`'s placeholder before first
200 /// content; the shown panel arrives with the document now.
201 #[must_use]
202 pub fn html(shown: usize, panel: &str, deactivated: bool, can_create_projects: bool) -> String {
203 let tabs = visible(deactivated, can_create_projects);
204 let shown = shown.min(tabs.len() - 1);
205
206 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
207 .across(layout::Fallback::Menu)
208 .showing_one(shown);
209
210 for (at, tab) in tabs.iter().enumerate() {
211 let mut region = Slot::handover(tab.panel, "user-panel");
212 if at != shown {
213 let mut call = Action::get(format!("/dashboard/tabs/{}", tab.route)).awaiting();
214 // A described route names its own region and must be left to; an
215 // Askama one names nothing, so the strip has to say where its answer
216 // goes. Not every tab is described, so this branch stays: what went
217 // with `QUASI_SCREENS` (`64b33b26`) is only the second half of the
218 // test, which used to ask whether the screen was switched on.
219 if tab.screen.is_none() {
220 call = call.replacing(tab.panel);
221 }
222 region = region.fed_by(call);
223 }
224 strip = strip.frame(tab.label, Node::Region(region));
225 }
226
227 use quasi_axum::Serves as _;
228
229 // No shell: a fragment landing inside a document Askama already built.
230 Webview::new()
231 .with_fill(tabs[shown].panel, panel)
232 .fragment(&Node::Region(strip))
233 }
234
235 #[cfg(test)]
236 mod tests {
237 use super::*;
238
239 fn strip(deactivated: bool, creator: bool) -> String {
240 html(
241 shown_at(None, deactivated, creator),
242 "<p>the panel</p>",
243 deactivated,
244 creator,
245 )
246 }
247
248 #[test]
249 fn the_page_asks_for_nothing_on_load() {
250 // Both hand-written strips gave their panel an `hx-trigger="load"`.
251 let html = strip(false, true);
252
253 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
254 assert!(html.contains("<p>the panel</p>"), "{html}");
255 assert_eq!(html.matches("hx-get=").count(), 4, "{html}");
256 }
257
258 #[test]
259 fn every_unshown_tab_says_where_its_answer_lands() {
260 let html = strip(false, true);
261
262 // The Askama tabs. `user-analytics` is deliberately absent: it is a
263 // described screen and names its own region, so the strip must NOT
264 // retarget it -- asserted by
265 // `the_described_screen_is_left_to_name_its_own_region` below. Until
266 // `64b33b26` it was here too, because the switch was off in tests and
267 // every tab was Askama.
268 for panel in ["user-payments", "user-settings", "user-support"] {
269 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
270 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
271 }
272 // Every tab still gets its frame, described or not.
273 assert!(html.contains("id=\"user-analytics\""), "{html}");
274 assert!(!html.contains("hx-target=\"#user-projects\""), "{html}");
275 }
276
277 #[test]
278 fn a_creator_opens_on_projects_and_everyone_else_on_payments() {
279 // The markup said this twice, as a `chosen` class and an `aria-selected`,
280 // both computed on `can_create_projects`. Gating Projects out says it
281 // once: the opening tab is the first one the reader can see.
282 let creator = strip(false, true);
283 assert!(creator.contains(">Projects</button>"), "{creator}");
284 assert!(
285 !creator.contains("hx-target=\"#user-projects\""),
286 "{creator}"
287 );
288
289 let fan = strip(false, false);
290 assert!(!fan.contains(">Projects</button>"), "{fan}");
291 assert!(!fan.contains(">Analytics</button>"), "{fan}");
292 assert!(!fan.contains("hx-target=\"#user-payments\""), "{fan}");
293 assert_eq!(fan.matches("hx-get=").count(), 2, "{fan}");
294 }
295
296 #[test]
297 fn a_deactivated_account_sees_support_and_nothing_else() {
298 let html = strip(true, true);
299
300 assert!(html.contains(">Support</button>"), "{html}");
301 for label in [
302 ">Projects</button>",
303 ">Payments</button>",
304 ">Analytics</button>",
305 ">Settings</button>",
306 ] {
307 assert!(!html.contains(label), "{html}");
308 }
309 // Still a strip, and the one tab it has arrives filled.
310 assert!(html.contains("role=\"tablist\""), "{html}");
311 assert!(html.contains("<p>the panel</p>"), "{html}");
312 assert_eq!(html.matches("hx-get=").count(), 0, "{html}");
313 }
314
315 #[test]
316 fn a_deep_link_arrives_showing_what_it_asked_for() {
317 let shown = shown_at(Some("settings"), false, true);
318 assert_eq!(shown, 3);
319 assert_eq!(route_at(shown, false, true), "settings");
320
321 let html = html(shown, "<p>your settings</p>", false, true);
322 assert!(html.contains("<p>your settings</p>"), "{html}");
323 assert!(!html.contains("hx-target=\"#user-settings\""), "{html}");
324 assert!(html.contains("hx-target=\"#user-projects\""), "{html}");
325 assert!(html.contains("data-shows=\"3\""), "{html}");
326 }
327
328 #[test]
329 fn a_tab_the_page_cannot_fill_answers_the_first_one() {
330 // Analytics is real and reachable by pressing it; it is not fillable, so
331 // asking for it in a query would open a panel the handler left empty.
332 assert_eq!(shown_at(Some("analytics"), false, true), 0);
333 assert_eq!(shown_at(Some("nonsense"), false, true), 0);
334 assert_eq!(shown_at(None, false, true), 0);
335 // A creator-only tab asked for by someone who cannot see it.
336 assert_eq!(shown_at(Some("projects"), false, false), 0);
337 assert_eq!(
338 route_at(shown_at(Some("projects"), false, false), false, false),
339 "payments"
340 );
341 // And by a deactivated account, whose one tab is Support.
342 assert_eq!(
343 route_at(shown_at(Some("settings"), true, true), true, true),
344 "support"
345 );
346 }
347
348 #[test]
349 fn the_described_screen_is_left_to_name_its_own_region() {
350 let html = html(0, "<p>the panel</p>", false, true);
351
352 assert!(
353 !html.contains("hx-target=\"#user-analytics\""),
354 "a described screen retargets its own answer:\n{html}"
355 );
356 // Its neighbours are still told.
357 assert!(html.contains("hx-target=\"#user-settings\""), "{html}");
358 }
359
360 #[test]
361 fn the_screen_answers_into_the_frame_that_is_its_own() {
362 // It said `tab-content`, the single pane five tabs shared. If it drifts
363 // from the frame this strip draws for it, its answer lands nowhere.
364 assert_eq!(super::super::user_analytics::REGION, "user-analytics");
365 }
366
367 #[test]
368 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
369 assert!(strip(false, true).contains("run-menu"));
370 }
371
372 #[test]
373 fn a_shown_index_past_the_end_cannot_panic() {
374 // A caller that computed an index against a different membership must
375 // clamp rather than take the page down.
376 let html = html(99, "<p>the panel</p>", true, true);
377 assert!(html.contains("<p>the panel</p>"), "{html}");
378 assert!(html.contains("data-shows=\"0\""), "{html}");
379 }
380 }
381