Skip to main content

max / makenotwork

16.3 KB · 419 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 six 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 //! # What this retires
51 //!
52 //! `static/tab-user-settings.js` entirely, its `<script>` tag, the
53 //! `window.setSettingsSectionBtn` wrapper in `actions-tabs.js`, and the six
54 //! `data-action` sites. The first whole file Shape 2 has deleted.
55
56 use makeover_layout as layout;
57 use quasi_router::{Action, Node, RegionKind, Slot};
58 use quasi_webview::Webview;
59
60 use crate::config::QuasiScreens;
61
62 /// The region the whole strip occupies.
63 ///
64 /// A new id: the pane it replaces was `settings-body`, which was one pane for
65 /// six sections, and that name now belongs to no single thing. Nothing outside
66 /// pointed at the layout wrapper, so this is not a rename anything follows.
67 const STRIP: &str = "settings-sections";
68
69 /// What a section is conditional on.
70 #[derive(PartialEq, Eq)]
71 enum Gate {
72 /// Every reader sees it.
73 Always,
74 /// Only a reader who can create projects, since only they have media.
75 Media,
76 /// Only where the server has a git repositories path.
77 Git,
78 /// Only where the Multithreaded integration is configured.
79 Forums,
80 }
81
82 /// One section: what it is called, where its frame is, and what serves it.
83 struct Section {
84 label: &'static str,
85 /// What a `?section=` names it, which is also the tail of its route.
86 name: &'static str,
87 gate: Gate,
88 /// The id this section's answer lands in. Also the described screen's own
89 /// region name, for the two that have one.
90 panel: &'static str,
91 route: &'static str,
92 /// The described screen behind this section, when there is one.
93 screen: Option<&'static str>,
94 }
95
96 /// Every section the settings tab can show, in the order the strip draws them.
97 const SECTIONS: &[Section] = &[
98 Section {
99 label: "Profile",
100 name: "profile",
101 gate: Gate::Always,
102 panel: "settings-profile",
103 route: "/dashboard/tabs/profile",
104 screen: None,
105 },
106 Section {
107 label: "Account",
108 name: "account",
109 gate: Gate::Always,
110 panel: "settings-account",
111 route: "/dashboard/tabs/account",
112 screen: None,
113 },
114 Section {
115 label: "Creator Plan",
116 name: "creator",
117 gate: Gate::Always,
118 panel: "settings-creator",
119 route: "/dashboard/tabs/creator",
120 screen: None,
121 },
122 Section {
123 label: "Media",
124 name: "media",
125 gate: Gate::Media,
126 panel: "settings-media",
127 route: "/dashboard/tabs/media",
128 screen: None,
129 },
130 Section {
131 label: "SSH Keys",
132 name: "ssh-keys",
133 gate: Gate::Git,
134 panel: super::ssh_keys::REGION,
135 route: super::ssh_keys::PATH,
136 screen: Some(super::ssh_keys::SCREEN),
137 },
138 Section {
139 label: "Forums",
140 name: "forums",
141 gate: Gate::Forums,
142 panel: super::forum_memberships::SETTINGS_REGION,
143 route: super::forum_memberships::SETTINGS_PATH,
144 screen: Some(super::forum_memberships::SETTINGS_SCREEN),
145 },
146 ];
147
148 /// The sections whose contents the settings builder can render inline.
149 ///
150 /// The shown section is the one that does not fetch, so a name outside this list
151 /// would open a blank frame rather than a slow one and answers Profile instead.
152 /// Profile is here because it is what the tab has always opened on; Creator Plan
153 /// and SSH Keys because five sites link to one of the two, measured 2026-08-19.
154 /// Account, Media and Forums are pressed rather than linked to.
155 const FILLABLE: &[&str] = &["profile", "creator", "ssh-keys"];
156
157 /// The sections this reader sees, in strip order.
158 ///
159 /// Never empty: Profile and Account are [`Gate::Always`].
160 fn visible(has_media: bool, git_enabled: bool, has_mt_memberships: bool) -> Vec<&'static Section> {
161 SECTIONS
162 .iter()
163 .filter(|section| match section.gate {
164 Gate::Always => true,
165 Gate::Media => has_media,
166 Gate::Git => git_enabled,
167 Gate::Forums => has_mt_memberships,
168 })
169 .collect()
170 }
171
172 /// Which section a `?section=` asks for, or Profile.
173 ///
174 /// The second level of the deep link the tab strip reads: `?tab=settings` opens
175 /// this strip and `&section=creator` chooses within it, so a link that means the
176 /// Creator Plan lands on it filled rather than on Profile.
177 ///
178 /// Fillability depends on the switch as well as on the name. A described section
179 /// answers for itself through `quasi_router`, which the page handler is not in a
180 /// position to call, so with `QUASI_SCREENS` naming SSH Keys the page cannot fill
181 /// it: the alternative would be filling the frame with the Askama rendering the
182 /// switch exists to replace. The link opens Profile there and the section is a
183 /// press away. Unset, which is every deployment today, it fills.
184 #[must_use]
185 pub fn shown_at(
186 asked: Option<&str>,
187 screens: &QuasiScreens,
188 has_media: bool,
189 git_enabled: bool,
190 has_mt_memberships: bool,
191 ) -> usize {
192 let Some(asked) = asked else { return 0 };
193 if !FILLABLE.contains(&asked) {
194 return 0;
195 }
196 visible(has_media, git_enabled, has_mt_memberships)
197 .iter()
198 .position(|section| {
199 section.name == asked && !section.screen.is_some_and(|name| screens.enabled(name))
200 })
201 .unwrap_or(0)
202 }
203
204 /// The name of a section by index, so the caller knows which one to render.
205 #[must_use]
206 pub fn section_at(
207 shown: usize,
208 has_media: bool,
209 git_enabled: bool,
210 has_mt_memberships: bool,
211 ) -> &'static str {
212 let sections = visible(has_media, git_enabled, has_mt_memberships);
213 sections
214 .get(shown)
215 .map_or(sections[0].name, |section| section.name)
216 }
217
218 /// The markup, for `partials/tabs/user_settings.html` to drop in.
219 ///
220 /// `section` is the shown section's contents, rendered by the caller, exactly as
221 /// the `{% include %}` did for Profile. The settings tab has never fetched on
222 /// open and still does not.
223 #[must_use]
224 pub fn html(
225 screens: &QuasiScreens,
226 shown: usize,
227 section: &str,
228 has_media: bool,
229 git_enabled: bool,
230 has_mt_memberships: bool,
231 ) -> String {
232 let sections = visible(has_media, git_enabled, has_mt_memberships);
233 let shown = shown.min(sections.len() - 1);
234
235 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
236 .across(layout::Fallback::Menu)
237 .showing_one(shown);
238
239 for (at, section) in sections.iter().enumerate() {
240 let mut region = Slot::bespoke(section.panel, "settings-panel").label(section.label);
241 if at != shown {
242 let mut call = Action::get(section.route).awaiting();
243 // A described route names its own region and must be left to. Same
244 // branch as the library strip, and it follows the switch rather than
245 // the section: with `QUASI_SCREENS` unset the Askama route answers
246 // and names nothing.
247 if !section.screen.is_some_and(|name| screens.enabled(name)) {
248 call = call.replacing(section.panel);
249 }
250 region = region.fed_by(call);
251 }
252 strip = strip.with(Node::Region(region));
253 }
254
255 use quasi_axum::Serves as _;
256
257 Webview::new()
258 .with_fill(sections[shown].panel, section)
259 .fragment(&Node::Region(strip))
260 }
261
262 #[cfg(test)]
263 mod tests {
264 use super::*;
265
266 fn strip(media: bool, git: bool, forums: bool) -> String {
267 html(
268 &QuasiScreens::default(),
269 0,
270 "<p>your profile</p>",
271 media,
272 git,
273 forums,
274 )
275 }
276
277 #[test]
278 fn the_settings_tab_still_opens_without_fetching() {
279 // The nav included its first section rather than fetching it, and the
280 // described strip keeps that: the shown frame arrives filled and the
281 // other five are fetched on a press.
282 let html = strip(true, true, true);
283
284 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
285 assert!(html.contains("<p>your profile</p>"), "{html}");
286 assert_eq!(html.matches("hx-get=").count(), 5, "{html}");
287 }
288
289 #[test]
290 fn every_unshown_section_says_where_its_answer_lands() {
291 let html = strip(true, true, true);
292
293 for panel in [
294 "settings-account",
295 "settings-creator",
296 "settings-media",
297 "settings-ssh-keys",
298 "settings-forums",
299 ] {
300 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
301 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
302 }
303 assert!(!html.contains("hx-target=\"#settings-profile\""), "{html}");
304 }
305
306 #[test]
307 fn a_described_section_is_left_to_name_its_own_region() {
308 // The two switchable sections, and the branch has to follow the switch
309 // rather than the section: with the screen off the Askama route answers
310 // and names nothing, so the strip has to say where its answer goes.
311 let screens = QuasiScreens::parse(super::super::ssh_keys::SCREEN);
312 let html = html(&screens, 0, "<p>your profile</p>", true, true, true);
313
314 assert!(
315 !html.contains("hx-target=\"#settings-ssh-keys\""),
316 "a described section retargets its own answer:\n{html}"
317 );
318 // Forums is not switched on here and is still told.
319 assert!(html.contains("hx-target=\"#settings-forums\""), "{html}");
320 }
321
322 #[test]
323 fn the_two_screens_answer_into_the_frame_that_is_theirs() {
324 // Both constants used to say `settings-body`, the single pane six
325 // sections shared. If either drifts from the frame this strip draws for
326 // it, that screen's answer lands nowhere.
327 assert_eq!(super::super::ssh_keys::REGION, "settings-ssh-keys");
328 assert_eq!(
329 super::super::forum_memberships::SETTINGS_REGION,
330 "settings-forums"
331 );
332 }
333
334 #[test]
335 fn a_deep_link_arrives_showing_the_section_it_asked_for() {
336 // The whole of `3a7de032`: `?tab=settings&section=creator` opens the
337 // Creator Plan filled rather than opening Profile and making the reader
338 // find it.
339 let screens = QuasiScreens::default();
340 let shown = shown_at(Some("creator"), &screens, true, true, true);
341 assert_eq!(shown, 2);
342 assert_eq!(section_at(shown, true, true, true), "creator");
343
344 let html = html(&screens, shown, "<p>your plan</p>", true, true, true);
345 assert!(html.contains("<p>your plan</p>"), "{html}");
346 assert!(!html.contains("hx-target=\"#settings-creator\""), "{html}");
347 // Profile is a press now, and it is the one the strip used to fill.
348 assert!(html.contains("hx-target=\"#settings-profile\""), "{html}");
349 assert!(html.contains("data-shows=\"2\""), "{html}");
350 }
351
352 #[test]
353 fn a_section_the_page_cannot_fill_answers_profile() {
354 let screens = QuasiScreens::default();
355 // Real sections nothing links to, so nothing pays to render them inline.
356 assert_eq!(shown_at(Some("account"), &screens, true, true, true), 0);
357 assert_eq!(shown_at(Some("media"), &screens, true, true, true), 0);
358 assert_eq!(shown_at(Some("forums"), &screens, true, true, true), 0);
359 // Not a section at all, and no section asked for.
360 assert_eq!(shown_at(Some("nonsense"), &screens, true, true, true), 0);
361 assert_eq!(shown_at(None, &screens, true, true, true), 0);
362 // A gated section asked for by a reader who cannot see it. The index
363 // shifts under the gate, so the answer has to be read as a name.
364 assert_eq!(shown_at(Some("ssh-keys"), &screens, true, false, true), 0);
365 assert_eq!(section_at(0, true, false, true), "profile");
366 assert_eq!(shown_at(Some("ssh-keys"), &screens, false, true, false), 3);
367 assert_eq!(section_at(3, false, true, false), "ssh-keys");
368 }
369
370 #[test]
371 fn a_switched_on_screen_is_not_fillable() {
372 // The described screen answers its own address through `quasi_router`,
373 // which the page handler cannot call. Filling the frame with the Askama
374 // rendering instead would show a screen the deployment switched off, so
375 // the deep link opens Profile and the section is a press away.
376 let screens = QuasiScreens::parse(super::super::ssh_keys::SCREEN);
377 assert_eq!(shown_at(Some("ssh-keys"), &screens, true, true, true), 0);
378 // Unset, which is every deployment today, it fills.
379 assert_eq!(
380 shown_at(Some("ssh-keys"), &QuasiScreens::default(), true, true, true),
381 4
382 );
383 }
384
385 #[test]
386 fn a_shown_index_past_the_end_cannot_panic() {
387 // A caller that computed an index against a different membership must
388 // clamp rather than take the page down.
389 let html = html(
390 &QuasiScreens::default(),
391 99,
392 "<p>your profile</p>",
393 false,
394 false,
395 false,
396 );
397 assert!(html.contains("<p>your profile</p>"), "{html}");
398 assert!(html.contains("data-shows=\"1\""), "{html}");
399 }
400
401 #[test]
402 fn the_three_gated_sections_leave_when_their_test_fails() {
403 let all = strip(true, true, true);
404 for label in [">Media</button>", ">SSH Keys</button>", ">Forums</button>"] {
405 assert!(all.contains(label), "{all}");
406 }
407
408 let none = strip(false, false, false);
409 for label in [">Media</button>", ">SSH Keys</button>", ">Forums</button>"] {
410 assert!(!none.contains(label), "{none}");
411 }
412 // The three ungated ones are still there, and the profile still arrives
413 // with the document.
414 assert_eq!(none.matches("hx-get=").count(), 2, "{none}");
415 assert!(none.contains("<p>your profile</p>"), "{none}");
416 assert!(none.contains("role=\"tablist\""), "{none}");
417 }
418 }
419