Skip to main content

max / makenotwork

14.6 KB · 386 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 when
30 //! `QUASI_SCREENS` names it, so leaving it unfillable costs nothing and paying
31 //! for it would buy a query 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 use crate::config::QuasiScreens;
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: "user-projects",
110 route: "projects",
111 gate: Gate::Creator,
112 screen: None,
113 },
114 Tab {
115 label: "Payments",
116 panel: "user-payments",
117 route: "payments",
118 gate: Gate::Live,
119 screen: None,
120 },
121 Tab {
122 label: "Analytics",
123 panel: super::user_analytics::REGION,
124 route: "analytics",
125 gate: Gate::Creator,
126 screen: Some(super::user_analytics::SCREEN),
127 },
128 Tab {
129 label: "Settings",
130 panel: "user-settings",
131 route: "settings",
132 gate: Gate::Live,
133 screen: None,
134 },
135 Tab {
136 label: "Support",
137 panel: "user-support",
138 route: "support",
139 gate: Gate::Always,
140 screen: None,
141 },
142 ];
143
144 /// The tabs whose panel the page handler can render inline.
145 ///
146 /// The shown tab is the one that does not fetch, so a name outside this list is
147 /// a blank screen rather than a slow one and answers the first tab instead.
148 /// These four are what the tree links to, measured 2026-08-19; analytics is the
149 /// one nothing links to.
150 const FILLABLE: &[&str] = &["projects", "payments", "settings", "support"];
151
152 /// Which tab a `?tab=` asks for, or the first one the reader can see.
153 ///
154 /// Replaces the hash restore `core/tabs.ts` did, which read `location.hash`,
155 /// found the button and clicked it: a deep link cost a document, then the page's
156 /// JS running, then a fetch, and it needed a button id to aim at. Chosen here,
157 /// the tab arrives already filled.
158 #[must_use]
159 pub fn shown_at(asked: Option<&str>, deactivated: bool, can_create_projects: bool) -> usize {
160 let Some(asked) = asked else { return 0 };
161 if !FILLABLE.contains(&asked) {
162 return 0;
163 }
164 visible(deactivated, can_create_projects)
165 .iter()
166 .position(|tab| tab.route == asked)
167 .unwrap_or(0)
168 }
169
170 /// The route name of a tab by index, so the caller knows which panel to fill.
171 #[must_use]
172 pub fn route_at(shown: usize, deactivated: bool, can_create_projects: bool) -> &'static str {
173 let tabs = visible(deactivated, can_create_projects);
174 tabs.get(shown).map_or(tabs[0].route, |tab| tab.route)
175 }
176
177 /// The tabs this reader sees, in strip order.
178 ///
179 /// Never empty: Support is [`Gate::Always`].
180 fn visible(deactivated: bool, can_create_projects: bool) -> Vec<&'static Tab> {
181 TABS.iter()
182 .filter(|tab| match tab.gate {
183 Gate::Always => true,
184 Gate::Live => !deactivated,
185 Gate::Creator => !deactivated && can_create_projects,
186 })
187 .collect()
188 }
189
190 /// The markup, for `dashboards/dashboard-user.html` to drop in.
191 ///
192 /// `panel` is the shown tab's contents, rendered by the caller. Both the strips
193 /// this replaces gave their panel container an `hx-trigger="load"` and fetched
194 /// after the document arrived, which is `9b958e7b`'s placeholder before first
195 /// content; the shown panel arrives with the document now.
196 #[must_use]
197 pub fn html(
198 screens: &QuasiScreens,
199 shown: usize,
200 panel: &str,
201 deactivated: bool,
202 can_create_projects: bool,
203 ) -> String {
204 let tabs = visible(deactivated, can_create_projects);
205 let shown = shown.min(tabs.len() - 1);
206
207 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
208 .across(layout::Fallback::Menu)
209 .showing_one(shown);
210
211 for (at, tab) in tabs.iter().enumerate() {
212 let mut region = Slot::bespoke(tab.panel, "user-panel").label(tab.label);
213 if at != shown {
214 let mut call = Action::get(format!("/dashboard/tabs/{}", tab.route)).awaiting();
215 // A described route names its own region and must be left to. The
216 // branch follows `QUASI_SCREENS` rather than the tab: with the screen
217 // off the Askama route answers and names nothing, so the strip has to
218 // say where its answer goes.
219 if !tab.screen.is_some_and(|name| screens.enabled(name)) {
220 call = call.replacing(tab.panel);
221 }
222 region = region.fed_by(call);
223 }
224 strip = strip.with(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 &QuasiScreens::default(),
242 shown_at(None, deactivated, creator),
243 "<p>the panel</p>",
244 deactivated,
245 creator,
246 )
247 }
248
249 #[test]
250 fn the_page_asks_for_nothing_on_load() {
251 // Both hand-written strips gave their panel an `hx-trigger="load"`.
252 let html = strip(false, true);
253
254 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
255 assert!(html.contains("<p>the panel</p>"), "{html}");
256 assert_eq!(html.matches("hx-get=").count(), 4, "{html}");
257 }
258
259 #[test]
260 fn every_unshown_tab_says_where_its_answer_lands() {
261 let html = strip(false, true);
262
263 for panel in [
264 "user-payments",
265 "user-analytics",
266 "user-settings",
267 "user-support",
268 ] {
269 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
270 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
271 }
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(
320 &QuasiScreens::default(),
321 shown,
322 "<p>your settings</p>",
323 false,
324 true,
325 );
326 assert!(html.contains("<p>your settings</p>"), "{html}");
327 assert!(!html.contains("hx-target=\"#user-settings\""), "{html}");
328 assert!(html.contains("hx-target=\"#user-projects\""), "{html}");
329 assert!(html.contains("data-shows=\"3\""), "{html}");
330 }
331
332 #[test]
333 fn a_tab_the_page_cannot_fill_answers_the_first_one() {
334 // Analytics is real and reachable by pressing it; it is not fillable, so
335 // asking for it in a query would open a panel the handler left empty.
336 assert_eq!(shown_at(Some("analytics"), false, true), 0);
337 assert_eq!(shown_at(Some("nonsense"), false, true), 0);
338 assert_eq!(shown_at(None, false, true), 0);
339 // A creator-only tab asked for by someone who cannot see it.
340 assert_eq!(shown_at(Some("projects"), false, false), 0);
341 assert_eq!(
342 route_at(shown_at(Some("projects"), false, false), false, false),
343 "payments"
344 );
345 // And by a deactivated account, whose one tab is Support.
346 assert_eq!(
347 route_at(shown_at(Some("settings"), true, true), true, true),
348 "support"
349 );
350 }
351
352 #[test]
353 fn the_described_screen_is_left_to_name_its_own_region() {
354 let screens = QuasiScreens::parse(super::super::user_analytics::SCREEN);
355 let html = html(&screens, 0, "<p>the panel</p>", false, true);
356
357 assert!(
358 !html.contains("hx-target=\"#user-analytics\""),
359 "a described screen retargets its own answer:\n{html}"
360 );
361 // Its neighbours are still told.
362 assert!(html.contains("hx-target=\"#user-settings\""), "{html}");
363 }
364
365 #[test]
366 fn the_screen_answers_into_the_frame_that_is_its_own() {
367 // It said `tab-content`, the single pane five tabs shared. If it drifts
368 // from the frame this strip draws for it, its answer lands nowhere.
369 assert_eq!(super::super::user_analytics::REGION, "user-analytics");
370 }
371
372 #[test]
373 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
374 assert!(strip(false, true).contains("run-menu"));
375 }
376
377 #[test]
378 fn a_shown_index_past_the_end_cannot_panic() {
379 // A caller that computed an index against a different membership must
380 // clamp rather than take the page down.
381 let html = html(&QuasiScreens::default(), 99, "<p>the panel</p>", true, true);
382 assert!(html.contains("<p>the panel</p>"), "{html}");
383 assert!(html.contains("data-shows=\"0\""), "{html}");
384 }
385 }
386