Skip to main content

max / makenotwork

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