Skip to main content

max / makenotwork

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