| 37 |
37 |
|
//! so both moved to the frame that is theirs, and the frame ids are theirs to
|
| 38 |
38 |
|
//! keep: `ssh_keys::REGION` and `forum_memberships::SETTINGS_REGION`.
|
| 39 |
39 |
|
//!
|
|
40 |
+ |
//! # The deep link nests, and this is the second level
|
|
41 |
+ |
//!
|
|
42 |
+ |
//! `3a7de032`. `?tab=settings` opens this strip and `§ion=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 |
+ |
//!
|
| 40 |
50 |
|
//! # What this retires
|
| 41 |
51 |
|
//!
|
| 42 |
52 |
|
//! `static/tab-user-settings.js` entirely, its `<script>` tag, the
|
| 72 |
82 |
|
/// One section: what it is called, where its frame is, and what serves it.
|
| 73 |
83 |
|
struct Section {
|
| 74 |
84 |
|
label: &'static str,
|
|
85 |
+ |
/// What a `?section=` names it, which is also the tail of its route.
|
|
86 |
+ |
name: &'static str,
|
| 75 |
87 |
|
gate: Gate,
|
| 76 |
88 |
|
/// The id this section's answer lands in. Also the described screen's own
|
| 77 |
89 |
|
/// region name, for the two that have one.
|
| 85 |
97 |
|
const SECTIONS: &[Section] = &[
|
| 86 |
98 |
|
Section {
|
| 87 |
99 |
|
label: "Profile",
|
|
100 |
+ |
name: "profile",
|
| 88 |
101 |
|
gate: Gate::Always,
|
| 89 |
102 |
|
panel: "settings-profile",
|
| 90 |
103 |
|
route: "/dashboard/tabs/profile",
|
| 92 |
105 |
|
},
|
| 93 |
106 |
|
Section {
|
| 94 |
107 |
|
label: "Account",
|
|
108 |
+ |
name: "account",
|
| 95 |
109 |
|
gate: Gate::Always,
|
| 96 |
110 |
|
panel: "settings-account",
|
| 97 |
111 |
|
route: "/dashboard/tabs/account",
|
| 99 |
113 |
|
},
|
| 100 |
114 |
|
Section {
|
| 101 |
115 |
|
label: "Creator Plan",
|
|
116 |
+ |
name: "creator",
|
| 102 |
117 |
|
gate: Gate::Always,
|
| 103 |
118 |
|
panel: "settings-creator",
|
| 104 |
119 |
|
route: "/dashboard/tabs/creator",
|
| 106 |
121 |
|
},
|
| 107 |
122 |
|
Section {
|
| 108 |
123 |
|
label: "Media",
|
|
124 |
+ |
name: "media",
|
| 109 |
125 |
|
gate: Gate::Media,
|
| 110 |
126 |
|
panel: "settings-media",
|
| 111 |
127 |
|
route: "/dashboard/tabs/media",
|
| 113 |
129 |
|
},
|
| 114 |
130 |
|
Section {
|
| 115 |
131 |
|
label: "SSH Keys",
|
|
132 |
+ |
name: "ssh-keys",
|
| 116 |
133 |
|
gate: Gate::Git,
|
| 117 |
134 |
|
panel: super::ssh_keys::REGION,
|
| 118 |
135 |
|
route: super::ssh_keys::PATH,
|
| 120 |
137 |
|
},
|
| 121 |
138 |
|
Section {
|
| 122 |
139 |
|
label: "Forums",
|
|
140 |
+ |
name: "forums",
|
| 123 |
141 |
|
gate: Gate::Forums,
|
| 124 |
142 |
|
panel: super::forum_memberships::SETTINGS_REGION,
|
| 125 |
143 |
|
route: super::forum_memberships::SETTINGS_PATH,
|
| 127 |
145 |
|
},
|
| 128 |
146 |
|
];
|
| 129 |
147 |
|
|
| 130 |
|
- |
/// The markup, for `partials/tabs/user_settings.html` to drop in.
|
|
148 |
+ |
/// The sections whose contents the settings builder can render inline.
|
| 131 |
149 |
|
///
|
| 132 |
|
- |
/// `profile` is the first section's contents, rendered by the caller, exactly as
|
| 133 |
|
- |
/// the `{% include %}` did. The settings tab has never fetched on open and still
|
| 134 |
|
- |
/// does not.
|
| 135 |
|
- |
#[must_use]
|
| 136 |
|
- |
pub fn html(
|
| 137 |
|
- |
screens: &QuasiScreens,
|
| 138 |
|
- |
profile: &str,
|
| 139 |
|
- |
has_media: bool,
|
| 140 |
|
- |
git_enabled: bool,
|
| 141 |
|
- |
has_mt_memberships: bool,
|
| 142 |
|
- |
) -> String {
|
| 143 |
|
- |
let shown: Vec<&Section> = SECTIONS
|
|
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
|
| 144 |
162 |
|
.iter()
|
| 145 |
163 |
|
.filter(|section| match section.gate {
|
| 146 |
164 |
|
Gate::Always => true,
|
| 148 |
166 |
|
Gate::Git => git_enabled,
|
| 149 |
167 |
|
Gate::Forums => has_mt_memberships,
|
| 150 |
168 |
|
})
|
| 151 |
|
- |
.collect();
|
|
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 `§ion=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);
|
| 152 |
234 |
|
|
| 153 |
235 |
|
let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
|
| 154 |
236 |
|
.across(layout::Fallback::Menu)
|
| 155 |
|
- |
.showing_one(0);
|
|
237 |
+ |
.showing_one(shown);
|
| 156 |
238 |
|
|
| 157 |
|
- |
for (at, section) in shown.iter().enumerate() {
|
|
239 |
+ |
for (at, section) in sections.iter().enumerate() {
|
| 158 |
240 |
|
let mut region = Slot::bespoke(section.panel, "settings-panel").label(section.label);
|
| 159 |
|
- |
if at > 0 {
|
|
241 |
+ |
if at != shown {
|
| 160 |
242 |
|
let mut call = Action::get(section.route).awaiting();
|
| 161 |
243 |
|
// A described route names its own region and must be left to. Same
|
| 162 |
244 |
|
// branch as the library strip, and it follows the switch rather than
|
| 173 |
255 |
|
use quasi_axum::Serves as _;
|
| 174 |
256 |
|
|
| 175 |
257 |
|
Webview::new()
|
| 176 |
|
- |
.with_fill(SECTIONS[0].panel, profile)
|
|
258 |
+ |
.with_fill(sections[shown].panel, section)
|
| 177 |
259 |
|
.fragment(&Node::Region(strip))
|
| 178 |
260 |
|
}
|
| 179 |
261 |
|
|
| 184 |
266 |
|
fn strip(media: bool, git: bool, forums: bool) -> String {
|
| 185 |
267 |
|
html(
|
| 186 |
268 |
|
&QuasiScreens::default(),
|
|
269 |
+ |
0,
|
| 187 |
270 |
|
"<p>your profile</p>",
|
| 188 |
271 |
|
media,
|
| 189 |
272 |
|
git,
|
| 226 |
309 |
|
// rather than the section: with the screen off the Askama route answers
|
| 227 |
310 |
|
// and names nothing, so the strip has to say where its answer goes.
|
| 228 |
311 |
|
let screens = QuasiScreens::parse(super::super::ssh_keys::SCREEN);
|
| 229 |
|
- |
let html = html(&screens, "<p>your profile</p>", true, true, true);
|
|
312 |
+ |
let html = html(&screens, 0, "<p>your profile</p>", true, true, true);
|
| 230 |
313 |
|
|
| 231 |
314 |
|
assert!(
|
| 232 |
315 |
|
!html.contains("hx-target=\"#settings-ssh-keys\""),
|
| 248 |
331 |
|
);
|
| 249 |
332 |
|
}
|
| 250 |
333 |
|
|
|
334 |
+ |
#[test]
|
|
335 |
+ |
fn a_deep_link_arrives_showing_the_section_it_asked_for() {
|
|
336 |
+ |
// The whole of `3a7de032`: `?tab=settings§ion=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 |
+ |
|
| 251 |
401 |
|
#[test]
|
| 252 |
402 |
|
fn the_three_gated_sections_leave_when_their_test_fails() {
|
| 253 |
403 |
|
let all = strip(true, true, true);
|