Skip to main content

max / makenotwork

21.0 KB · 537 lines History Blame Raw
1 //! The dashboard settings sub-nav, described.
2 //!
3 //! Shape 2, step 4 (`6b24f2df`), and the fourth described strip. Unlike the
4 //! three before it this one is a strip inside a panel: `user_settings.html` is
5 //! itself the Settings tab of the user dashboard, whose own strip is step 5.
6 //!
7 //! # It did not look like a tab strip, and it is one
8 //!
9 //! `<nav class="settings-nav">` of six `<a>` links, not `.tabs[role=tablist]`,
10 //! which is why a grep for the tablist role never found it. Behaviourally it is
11 //! the folder semantic exactly: one section showing at a time, the chosen link
12 //! carrying `is-selected`, each link fetching its section. The four lines of
13 //! `static/tab-user-settings.js` existed to move that one class, and they are
14 //! what a described strip does for free.
15 //!
16 //! # The reader-visible change, and why it is the ruling working
17 //!
18 //! The nav was a column beside the content on a wide viewport
19 //! (`.settings-layout { display: flex }`) and a wrapped row of links below
20 //! `--break-*`. Described, it is the renderer's tab strip on every viewport,
21 //! which is the narrow-viewport look everywhere.
22 //!
23 //! That is Max's Shape 2 ruling applied rather than bent: the strip is described
24 //! and how it is drawn is the renderer's, the same way the overflow menu was.
25 //! `RegionKind::TabGroup`'s own doc prose says "with tabs above", which reads
26 //! like a constraint and is not one -- it is a renderer's habit written into a
27 //! doc comment. Worth correcting there rather than working around here.
28 //!
29 //! # Two of the sections are described screens
30 //!
31 //! SSH Keys and Forums answer for themselves when `QUASI_SCREENS` names them, so
32 //! they name the region their answer replaces and this strip must not override
33 //! it. That is the library strip's branch, back after step 2 and step 3 had none.
34 //!
35 //! Both constants said `settings-body`, the single pane the hand-written nav
36 //! swapped into. There is no single pane now -- each section is its own frame --
37 //! so both moved to the frame that is theirs, and the frame ids are theirs to
38 //! keep: `ssh_keys::REGION` and `forum_memberships::SETTINGS_REGION`.
39 //!
40 //! # The deep link nests, and this is the second level
41 //!
42 //! `3a7de032`. `?tab=settings` opens this strip and `&section=creator` chooses
43 //! within it, the same `shown_at` / `section_at` / [`FILLABLE`] shape the five
44 //! tab strips have, one level down. Five sites wanted a section rather than a
45 //! tab: two "Apply for Creator Access" buttons, the join wizard's "I want to
46 //! sell", the creators page's "Apply from Dashboard", and "Manage SSH Keys" on
47 //! the project code tab. The last three spelled it as `/dashboard#tab-plan` or
48 //! `#tab-ssh-keys`, ids this page has never had, so they did nothing at all.
49 //!
50 //! # The seventh section, and why it is here rather than a tab
51 //!
52 //! Cloud Sync (`47e67540`). The user-level SyncKit surface has always existed
53 //! and has never been reachable: the strip above never carried a button for it
54 //! and the `#tab-synckit` links that meant it named an id this page has never
55 //! had. It is a section rather than a dashboard tab because account-level
56 //! integrations live here, and it is gated on owning a sync app because Cloud
57 //! Sync is for developers shipping their own software and would otherwise be an
58 //! empty section on every account.
59 //!
60 //! # What this retires
61 //!
62 //! `static/tab-user-settings.js` entirely, its `<script>` tag, the
63 //! `window.setSettingsSectionBtn` wrapper in `actions-tabs.js`, and the six
64 //! `data-action` sites. The first whole file Shape 2 has deleted.
65
66 use makeover_layout as layout;
67 use quasi_router::{Action, Node, RegionKind, Slot};
68 use quasi_webview::Webview;
69
70 /// The region the whole strip occupies.
71 ///
72 /// A new id: the pane it replaces was `settings-body`, which was one pane for
73 /// six sections, and that name now belongs to no single thing. Nothing outside
74 /// pointed at the layout wrapper, so this is not a rename anything follows.
75 const STRIP: &str = "settings-sections";
76
77 /// What a section is conditional on.
78 #[derive(PartialEq, Eq)]
79 enum Gate {
80 /// Every reader sees it.
81 Always,
82 /// Only a reader who can create projects, since only they have media.
83 Media,
84 /// Only where the server has a git repositories path.
85 Git,
86 /// Only where the Multithreaded integration is configured.
87 Forums,
88 /// Only a reader who owns at least one sync app.
89 ///
90 /// Cloud Sync is for developers shipping their own software, so on nearly
91 /// every account this section is absent rather than empty (`47e67540`).
92 SyncKit,
93 }
94
95 /// What this reader is entitled to see, gathered once by the caller.
96 ///
97 /// Four gates were four positional `bool`s until the fifth section arrived, at
98 /// which point every call site read as a row of anonymous `true`s. Naming them
99 /// costs one struct and makes a transposed pair impossible.
100 #[derive(Clone, Copy)]
101 pub struct Gates {
102 /// The reader can create projects, so they have media.
103 pub has_media: bool,
104 /// The server has a git repositories path.
105 pub git_enabled: bool,
106 /// The Multithreaded integration is configured.
107 pub has_mt_memberships: bool,
108 /// The reader owns at least one sync app.
109 pub has_sync_apps: bool,
110 }
111
112 /// One section: what it is called, where its frame is, and what serves it.
113 struct Section {
114 label: &'static str,
115 /// What a `?section=` names it, which is also the tail of its route.
116 name: &'static str,
117 gate: Gate,
118 /// The id this section's answer lands in. Also the described screen's own
119 /// region name, for the two that have one.
120 panel: &'static str,
121 route: &'static str,
122 /// The described screen behind this section, when there is one.
123 screen: Option<&'static str>,
124 }
125
126 /// Every section the settings tab can show, in the order the strip draws them.
127 const SECTIONS: &[Section] = &[
128 Section {
129 label: "Profile",
130 name: "profile",
131 gate: Gate::Always,
132 panel: "settings-profile",
133 route: "/dashboard/tabs/profile",
134 screen: None,
135 },
136 Section {
137 label: "Account",
138 name: "account",
139 gate: Gate::Always,
140 panel: "settings-account",
141 route: "/dashboard/tabs/account",
142 screen: None,
143 },
144 Section {
145 label: "Creator Plan",
146 name: "creator",
147 gate: Gate::Always,
148 panel: "settings-creator",
149 route: "/dashboard/tabs/creator",
150 screen: None,
151 },
152 Section {
153 label: "Media",
154 name: "media",
155 gate: Gate::Media,
156 panel: "settings-media",
157 route: "/dashboard/tabs/media",
158 screen: None,
159 },
160 Section {
161 label: "SSH Keys",
162 name: "ssh-keys",
163 gate: Gate::Git,
164 panel: super::ssh_keys::REGION,
165 route: super::ssh_keys::PATH,
166 screen: Some(super::ssh_keys::SCREEN),
167 },
168 Section {
169 label: "Forums",
170 name: "forums",
171 gate: Gate::Forums,
172 panel: super::forum_memberships::SETTINGS_REGION,
173 route: super::forum_memberships::SETTINGS_PATH,
174 screen: Some(super::forum_memberships::SETTINGS_SCREEN),
175 },
176 Section {
177 label: "Cloud Sync",
178 name: "synckit",
179 gate: Gate::SyncKit,
180 panel: "settings-synckit",
181 route: "/dashboard/tabs/synckit",
182 screen: None,
183 },
184 ];
185
186 /// The sections whose contents the settings builder can render inline.
187 ///
188 /// The shown section is the one that does not fetch, so a name outside this list
189 /// would open a blank frame rather than a slow one and answers Profile instead.
190 /// Profile is here because it is what the tab has always opened on; Creator Plan
191 /// and SSH Keys because five sites link to one of the two.
192 /// Cloud Sync because `synckit_return_url` links to it: a developer coming back
193 /// from Stripe must land on the app they were just billed for, which is the
194 /// whole reason the section exists (`47e67540`).
195 /// Account, Media and Forums are pressed rather than linked to.
196 const FILLABLE: &[&str] = &["profile", "creator", "ssh-keys", "synckit"];
197
198 /// The sections this reader sees, in strip order.
199 ///
200 /// Never empty: Profile and Account are [`Gate::Always`].
201 fn visible(gates: Gates) -> Vec<&'static Section> {
202 SECTIONS
203 .iter()
204 .filter(|section| match section.gate {
205 Gate::Always => true,
206 Gate::Media => gates.has_media,
207 Gate::Git => gates.git_enabled,
208 Gate::Forums => gates.has_mt_memberships,
209 Gate::SyncKit => gates.has_sync_apps,
210 })
211 .collect()
212 }
213
214 /// Which section a `?section=` asks for, or Profile.
215 ///
216 /// The second level of the deep link the tab strip reads: `?tab=settings` opens
217 /// this strip and `&section=creator` chooses within it, so a link that means the
218 /// Creator Plan lands on it filled rather than on Profile.
219 ///
220 /// Fillability depends on the switch as well as on the name. A described section
221 /// answers for itself through `quasi_router`, which the page handler is not in a
222 /// position to call, so with `QUASI_SCREENS` naming SSH Keys the page cannot fill
223 /// it: the alternative would be filling the frame with the Askama rendering the
224 /// switch exists to replace. The link opens Profile there and the section is a
225 /// press away. Unset, which is every deployment today, it fills.
226 #[must_use]
227 pub fn shown_at(asked: Option<&str>, gates: Gates) -> usize {
228 let Some(asked) = asked else { return 0 };
229 if !FILLABLE.contains(&asked) {
230 return 0;
231 }
232 visible(gates)
233 .iter()
234 .position(|section| section.name == asked && section.screen.is_none())
235 .unwrap_or(0)
236 }
237
238 /// The name of a section by index, so the caller knows which one to render.
239 #[must_use]
240 pub fn section_at(shown: usize, gates: Gates) -> &'static str {
241 let sections = visible(gates);
242 sections
243 .get(shown)
244 .map_or(sections[0].name, |section| section.name)
245 }
246
247 /// The markup, for `partials/tabs/user_settings.html` to drop in.
248 ///
249 /// `section` is the shown section's contents, rendered by the caller, exactly as
250 /// the `{% include %}` did for Profile. The settings tab has never fetched on
251 /// open and still does not.
252 #[must_use]
253 pub fn html(shown: usize, section: &str, gates: Gates) -> String {
254 let sections = visible(gates);
255 let shown = shown.min(sections.len() - 1);
256
257 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
258 .across(layout::Fallback::Menu)
259 .showing_one(shown);
260
261 for (at, section) in sections.iter().enumerate() {
262 let mut region = Slot::handover(section.panel, "settings-panel");
263 if at != shown {
264 let mut call = Action::get(section.route).awaiting();
265 // A described route names its own region and must be left to. Same
266 // branch as the library strip, and it follows the switch rather than
267 // the section: with `QUASI_SCREENS` unset the Askama route answers
268 // and names nothing.
269 if section.screen.is_none() {
270 call = call.replacing(section.panel);
271 }
272 region = region.fed_by(call);
273 }
274 strip = strip.frame(section.label, Node::Region(region));
275 }
276
277 use quasi_axum::Serves as _;
278
279 Webview::new()
280 .with_fill(sections[shown].panel, section)
281 .fragment(&Node::Region(strip))
282 }
283
284 #[cfg(test)]
285 mod tests {
286 use super::*;
287
288 /// A reader every gate opens for.
289 const ALL: Gates = Gates {
290 has_media: true,
291 git_enabled: true,
292 has_mt_memberships: true,
293 has_sync_apps: true,
294 };
295
296 /// A reader no gate opens for: Profile, Account and Creator Plan only.
297 const NONE: Gates = Gates {
298 has_media: false,
299 git_enabled: false,
300 has_mt_memberships: false,
301 has_sync_apps: false,
302 };
303
304 /// The reader the older tests were written against: the three gates that
305 /// existed before Cloud Sync, all open.
306 const NO_SYNC: Gates = Gates {
307 has_sync_apps: false,
308 ..ALL
309 };
310
311 fn strip(gates: Gates) -> String {
312 html(0, "<p>your profile</p>", gates)
313 }
314
315 #[test]
316 fn the_settings_tab_still_opens_without_fetching() {
317 // The nav included its first section rather than fetching it, and the
318 // described strip keeps that: the shown frame arrives filled and the
319 // other five are fetched on a press.
320 let html = strip(NO_SYNC);
321
322 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
323 assert!(html.contains("<p>your profile</p>"), "{html}");
324 assert_eq!(html.matches("hx-get=").count(), 5, "{html}");
325 }
326
327 #[test]
328 fn every_unshown_section_says_where_its_answer_lands() {
329 let html = strip(NO_SYNC);
330
331 // The Askama sections. `settings-ssh-keys` and `settings-forums` are
332 // deliberately absent: both are described and name their own region, so
333 // the strip must NOT retarget them. Until `64b33b26` they were here,
334 // because the switch was off in tests and every section was Askama.
335 for panel in ["settings-account", "settings-creator", "settings-media"] {
336 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
337 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
338 }
339 // Every section still gets its frame, described or not.
340 for panel in ["settings-ssh-keys", "settings-forums"] {
341 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
342 assert!(
343 !html.contains(&format!("hx-target=\"#{panel}\"")),
344 "{panel} is described and names its own region:\n{html}"
345 );
346 }
347 assert!(!html.contains("hx-target=\"#settings-profile\""), "{html}");
348 }
349
350 #[test]
351 fn a_described_section_is_left_to_name_its_own_region() {
352 // Both described sections. The branch used to follow the switch rather
353 // than the section, because with a screen off the Askama route answered
354 // and named nothing; `64b33b26` deleted the switch, so being described
355 // is the whole test now.
356 let html = strip(NO_SYNC);
357
358 for panel in ["settings-ssh-keys", "settings-forums"] {
359 assert!(
360 !html.contains(&format!("hx-target=\"#{panel}\"")),
361 "a described section retargets its own answer:\n{html}"
362 );
363 }
364 // An Askama neighbour is still told where its answer goes.
365 assert!(html.contains("hx-target=\"#settings-account\""), "{html}");
366 }
367
368 #[test]
369 fn the_two_screens_answer_into_the_frame_that_is_theirs() {
370 // Both constants used to say `settings-body`, the single pane six
371 // sections shared. If either drifts from the frame this strip draws for
372 // it, that screen's answer lands nowhere.
373 assert_eq!(super::super::ssh_keys::REGION, "settings-ssh-keys");
374 assert_eq!(
375 super::super::forum_memberships::SETTINGS_REGION,
376 "settings-forums"
377 );
378 }
379
380 #[test]
381 fn a_deep_link_arrives_showing_the_section_it_asked_for() {
382 // The whole of `3a7de032`: `?tab=settings&section=creator` opens the
383 // Creator Plan filled rather than opening Profile and making the reader
384 // find it.
385 let all = NO_SYNC;
386 let shown = shown_at(Some("creator"), all);
387 assert_eq!(shown, 2);
388 assert_eq!(section_at(shown, all), "creator");
389
390 let html = html(shown, "<p>your plan</p>", all);
391 assert!(html.contains("<p>your plan</p>"), "{html}");
392 assert!(!html.contains("hx-target=\"#settings-creator\""), "{html}");
393 // Profile is a press now, and it is the one the strip used to fill.
394 assert!(html.contains("hx-target=\"#settings-profile\""), "{html}");
395 assert!(html.contains("data-shows=\"2\""), "{html}");
396 }
397
398 #[test]
399 fn a_section_the_page_cannot_fill_answers_profile() {
400 // Real sections nothing links to, so nothing pays to render them inline.
401 let all = ALL;
402 assert_eq!(shown_at(Some("account"), all), 0);
403 assert_eq!(shown_at(Some("media"), all), 0);
404 assert_eq!(shown_at(Some("forums"), all), 0);
405 // Not a section at all, and no section asked for.
406 assert_eq!(shown_at(Some("nonsense"), all), 0);
407 assert_eq!(shown_at(None, all), 0);
408 // A gated section asked for by a reader who cannot see it. The index
409 // shifts under the gate, so the answer has to be read as a name.
410 assert_eq!(
411 shown_at(
412 Some("ssh-keys"),
413 Gates {
414 git_enabled: false,
415 ..NO_SYNC
416 }
417 ),
418 0
419 );
420 assert_eq!(
421 section_at(
422 0,
423 Gates {
424 git_enabled: false,
425 ..NO_SYNC
426 }
427 ),
428 "profile"
429 );
430 // ssh-keys is a described section, so `shown_at` answers profile for it
431 // whether or not the reader can see it -- see the test below. The index
432 // shift is still asserted, through `section_at`, which is what actually
433 // does the reading.
434 let git_only = Gates {
435 git_enabled: true,
436 ..NONE
437 };
438 assert_eq!(shown_at(Some("ssh-keys"), git_only), 0);
439 assert_eq!(section_at(3, git_only), "ssh-keys");
440 }
441
442 #[test]
443 fn a_described_section_is_not_fillable() {
444 // A described section answers its own address through `quasi_router`,
445 // which the page handler cannot call. Filling the frame with an Askama
446 // rendering instead is not an option any more -- there is none -- so the
447 // deep link opens Profile and the section is a press away.
448 //
449 // Was `a_switched_on_screen_is_not_fillable`, and carried a second case
450 // for the switch being unset, where it filled at index 4. `64b33b26`
451 // deleted the switch and the Askama rendering with it, so that case has
452 // no way to arise.
453 assert_eq!(shown_at(Some("ssh-keys"), NO_SYNC), 0);
454 }
455
456 #[test]
457 fn a_shown_index_past_the_end_cannot_panic() {
458 // A caller that computed an index against a different membership must
459 // clamp rather than take the page down.
460 let html = html(99, "<p>your profile</p>", NONE);
461 assert!(html.contains("<p>your profile</p>"), "{html}");
462 assert!(html.contains("data-shows=\"1\""), "{html}");
463 }
464
465 #[test]
466 fn the_four_gated_sections_leave_when_their_test_fails() {
467 const GATED: [&str; 4] = [
468 ">Media</button>",
469 ">SSH Keys</button>",
470 ">Forums</button>",
471 ">Cloud Sync</button>",
472 ];
473
474 let all = strip(ALL);
475 for label in GATED {
476 assert!(all.contains(label), "{all}");
477 }
478
479 let none = strip(NONE);
480 for label in GATED {
481 assert!(!none.contains(label), "{none}");
482 }
483 // The three ungated ones are still there, and the profile still arrives
484 // with the document.
485 assert_eq!(none.matches("hx-get=").count(), 2, "{none}");
486 assert!(none.contains("<p>your profile</p>"), "{none}");
487 assert!(none.contains("role=\"tablist\""), "{none}");
488 }
489
490 #[test]
491 fn cloud_sync_is_absent_for_a_reader_who_owns_no_sync_app() {
492 // The gate is the whole point of `47e67540` choosing a settings section
493 // over a dashboard tab: Cloud Sync is for a developer shipping their own
494 // software, so on nearly every account it is not there at all rather
495 // than there and empty.
496 let without = strip(NO_SYNC);
497 assert!(!without.contains(">Cloud Sync</button>"), "{without}");
498 assert!(!without.contains("id=\"settings-synckit\""), "{without}");
499
500 let with = strip(ALL);
501 assert!(with.contains(">Cloud Sync</button>"), "{with}");
502 assert!(with.contains("id=\"settings-synckit\""), "{with}");
503 // Askama, not a described screen, so the strip tells it where to land.
504 assert!(with.contains("hx-target=\"#settings-synckit\""), "{with}");
505 assert!(
506 with.contains("hx-get=\"/dashboard/tabs/synckit\""),
507 "{with}"
508 );
509 }
510
511 #[test]
512 fn the_stripe_return_lands_on_cloud_sync_filled() {
513 // `synckit_return_url` sends a developer back to `&section=synckit`
514 // after billing, and the point of that link is landing on the app they
515 // were just billed for rather than on Profile.
516 let owner = ALL;
517 let shown = shown_at(Some("synckit"), owner);
518 assert_eq!(section_at(shown, owner), "synckit");
519
520 let html = html(shown, "<p>your apps</p>", owner);
521 assert!(html.contains("<p>your apps</p>"), "{html}");
522 assert!(!html.contains("hx-target=\"#settings-synckit\""), "{html}");
523 }
524
525 #[test]
526 fn asking_for_cloud_sync_without_one_answers_profile() {
527 // The gate shifts the index, so the reader who cannot see the section
528 // gets Profile rather than whatever sits at Cloud Sync's old position.
529 let stranger = NO_SYNC;
530 assert_eq!(shown_at(Some("synckit"), stranger), 0);
531 assert_eq!(
532 section_at(shown_at(Some("synckit"), stranger), stranger),
533 "profile"
534 );
535 }
536 }
537