| 26 |
26 |
|
//! like a constraint and is not one -- it is a renderer's habit written into a
|
| 27 |
27 |
|
//! doc comment. Worth correcting there rather than working around here.
|
| 28 |
28 |
|
//!
|
| 29 |
|
- |
//! # Two of the six sections are described screens
|
|
29 |
+ |
//! # Two of the sections are described screens
|
| 30 |
30 |
|
//!
|
| 31 |
31 |
|
//! SSH Keys and Forums answer for themselves when `QUASI_SCREENS` names them, so
|
| 32 |
32 |
|
//! they name the region their answer replaces and this strip must not override
|
| 47 |
47 |
|
//! the project code tab. The last three spelled it as `/dashboard#tab-plan` or
|
| 48 |
48 |
|
//! `#tab-ssh-keys`, ids this page has never had, so they did nothing at all.
|
| 49 |
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 |
+ |
//!
|
| 50 |
60 |
|
//! # What this retires
|
| 51 |
61 |
|
//!
|
| 52 |
62 |
|
//! `static/tab-user-settings.js` entirely, its `<script>` tag, the
|
| 75 |
85 |
|
Git,
|
| 76 |
86 |
|
/// Only where the Multithreaded integration is configured.
|
| 77 |
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,
|
| 78 |
110 |
|
}
|
| 79 |
111 |
|
|
| 80 |
112 |
|
/// One section: what it is called, where its frame is, and what serves it.
|
| 141 |
173 |
|
route: super::forum_memberships::SETTINGS_PATH,
|
| 142 |
174 |
|
screen: Some(super::forum_memberships::SETTINGS_SCREEN),
|
| 143 |
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 |
+ |
},
|
| 144 |
184 |
|
];
|
| 145 |
185 |
|
|
| 146 |
186 |
|
/// The sections whose contents the settings builder can render inline.
|
| 149 |
189 |
|
/// would open a blank frame rather than a slow one and answers Profile instead.
|
| 150 |
190 |
|
/// Profile is here because it is what the tab has always opened on; Creator Plan
|
| 151 |
191 |
|
/// and SSH Keys because five sites link to one of the two, measured 2026-08-19.
|
|
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`).
|
| 152 |
195 |
|
/// Account, Media and Forums are pressed rather than linked to.
|
| 153 |
|
- |
const FILLABLE: &[&str] = &["profile", "creator", "ssh-keys"];
|
|
196 |
+ |
const FILLABLE: &[&str] = &["profile", "creator", "ssh-keys", "synckit"];
|
| 154 |
197 |
|
|
| 155 |
198 |
|
/// The sections this reader sees, in strip order.
|
| 156 |
199 |
|
///
|
| 157 |
200 |
|
/// Never empty: Profile and Account are [`Gate::Always`].
|
| 158 |
|
- |
fn visible(has_media: bool, git_enabled: bool, has_mt_memberships: bool) -> Vec<&'static Section> {
|
|
201 |
+ |
fn visible(gates: Gates) -> Vec<&'static Section> {
|
| 159 |
202 |
|
SECTIONS
|
| 160 |
203 |
|
.iter()
|
| 161 |
204 |
|
.filter(|section| match section.gate {
|
| 162 |
205 |
|
Gate::Always => true,
|
| 163 |
|
- |
Gate::Media => has_media,
|
| 164 |
|
- |
Gate::Git => git_enabled,
|
| 165 |
|
- |
Gate::Forums => has_mt_memberships,
|
|
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,
|
| 166 |
210 |
|
})
|
| 167 |
211 |
|
.collect()
|
| 168 |
212 |
|
}
|
| 180 |
224 |
|
/// switch exists to replace. The link opens Profile there and the section is a
|
| 181 |
225 |
|
/// press away. Unset, which is every deployment today, it fills.
|
| 182 |
226 |
|
#[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 {
|
|
227 |
+ |
pub fn shown_at(asked: Option<&str>, gates: Gates) -> usize {
|
| 189 |
228 |
|
let Some(asked) = asked else { return 0 };
|
| 190 |
229 |
|
if !FILLABLE.contains(&asked) {
|
| 191 |
230 |
|
return 0;
|
| 192 |
231 |
|
}
|
| 193 |
|
- |
visible(has_media, git_enabled, has_mt_memberships)
|
|
232 |
+ |
visible(gates)
|
| 194 |
233 |
|
.iter()
|
| 195 |
234 |
|
.position(|section| section.name == asked && section.screen.is_none())
|
| 196 |
235 |
|
.unwrap_or(0)
|
| 198 |
237 |
|
|
| 199 |
238 |
|
/// The name of a section by index, so the caller knows which one to render.
|
| 200 |
239 |
|
#[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);
|
|
240 |
+ |
pub fn section_at(shown: usize, gates: Gates) -> &'static str {
|
|
241 |
+ |
let sections = visible(gates);
|
| 208 |
242 |
|
sections
|
| 209 |
243 |
|
.get(shown)
|
| 210 |
244 |
|
.map_or(sections[0].name, |section| section.name)
|
| 216 |
250 |
|
/// the `{% include %}` did for Profile. The settings tab has never fetched on
|
| 217 |
251 |
|
/// open and still does not.
|
| 218 |
252 |
|
#[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);
|
|
253 |
+ |
pub fn html(shown: usize, section: &str, gates: Gates) -> String {
|
|
254 |
+ |
let sections = visible(gates);
|
| 227 |
255 |
|
let shown = shown.min(sections.len() - 1);
|
| 228 |
256 |
|
|
| 229 |
257 |
|
let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
|
| 257 |
285 |
|
mod tests {
|
| 258 |
286 |
|
use super::*;
|
| 259 |
287 |
|
|
| 260 |
|
- |
fn strip(media: bool, git: bool, forums: bool) -> String {
|
| 261 |
|
- |
html(0, "<p>your profile</p>", media, git, forums)
|
|
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)
|
| 262 |
313 |
|
}
|
| 263 |
314 |
|
|
| 264 |
315 |
|
#[test]
|
| 266 |
317 |
|
// The nav included its first section rather than fetching it, and the
|
| 267 |
318 |
|
// described strip keeps that: the shown frame arrives filled and the
|
| 268 |
319 |
|
// other five are fetched on a press.
|
| 269 |
|
- |
let html = strip(true, true, true);
|
|
320 |
+ |
let html = strip(NO_SYNC);
|
| 270 |
321 |
|
|
| 271 |
322 |
|
assert!(!html.contains("hx-trigger=\"load\""), "{html}");
|
| 272 |
323 |
|
assert!(html.contains("<p>your profile</p>"), "{html}");
|
| 275 |
326 |
|
|
| 276 |
327 |
|
#[test]
|
| 277 |
328 |
|
fn every_unshown_section_says_where_its_answer_lands() {
|
| 278 |
|
- |
let html = strip(true, true, true);
|
|
329 |
+ |
let html = strip(NO_SYNC);
|
| 279 |
330 |
|
|
| 280 |
331 |
|
// The Askama sections. `settings-ssh-keys` and `settings-forums` are
|
| 281 |
332 |
|
// deliberately absent: both are described and name their own region, so
|
| 302 |
353 |
|
// than the section, because with a screen off the Askama route answered
|
| 303 |
354 |
|
// and named nothing; `64b33b26` deleted the switch, so being described
|
| 304 |
355 |
|
// is the whole test now.
|
| 305 |
|
- |
let html = html(0, "<p>your profile</p>", true, true, true);
|
|
356 |
+ |
let html = strip(NO_SYNC);
|
| 306 |
357 |
|
|
| 307 |
358 |
|
for panel in ["settings-ssh-keys", "settings-forums"] {
|
| 308 |
359 |
|
assert!(
|
| 331 |
382 |
|
// The whole of `3a7de032`: `?tab=settings§ion=creator` opens the
|
| 332 |
383 |
|
// Creator Plan filled rather than opening Profile and making the reader
|
| 333 |
384 |
|
// find it.
|
| 334 |
|
- |
let shown = shown_at(Some("creator"), true, true, true);
|
|
385 |
+ |
let all = NO_SYNC;
|
|
386 |
+ |
let shown = shown_at(Some("creator"), all);
|
| 335 |
387 |
|
assert_eq!(shown, 2);
|
| 336 |
|
- |
assert_eq!(section_at(shown, true, true, true), "creator");
|
|
388 |
+ |
assert_eq!(section_at(shown, all), "creator");
|
| 337 |
389 |
|
|
| 338 |
|
- |
let html = html(shown, "<p>your plan</p>", true, true, true);
|
|
390 |
+ |
let html = html(shown, "<p>your plan</p>", all);
|
| 339 |
391 |
|
assert!(html.contains("<p>your plan</p>"), "{html}");
|
| 340 |
392 |
|
assert!(!html.contains("hx-target=\"#settings-creator\""), "{html}");
|
| 341 |
393 |
|
// Profile is a press now, and it is the one the strip used to fill.
|
| 346 |
398 |
|
#[test]
|
| 347 |
399 |
|
fn a_section_the_page_cannot_fill_answers_profile() {
|
| 348 |
400 |
|
// 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);
|
|
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);
|
| 352 |
405 |
|
// 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);
|
|
406 |
+ |
assert_eq!(shown_at(Some("nonsense"), all), 0);
|
|
407 |
+ |
assert_eq!(shown_at(None, all), 0);
|
| 355 |
408 |
|
// A gated section asked for by a reader who cannot see it. The index
|
| 356 |
409 |
|
// 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");
|
|
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 |
+ |
);
|
| 359 |
430 |
|
// ssh-keys is a described section, so `shown_at` answers profile for it
|
| 360 |
431 |
|
// whether or not the reader can see it -- see the test below. The index
|
| 361 |
432 |
|
// shift is still asserted, through `section_at`, which is what actually
|
| 362 |
433 |
|
// 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");
|
|
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");
|
| 365 |
440 |
|
}
|
| 366 |
441 |
|
|
| 367 |
442 |
|
#[test]
|
| 375 |
450 |
|
// for the switch being unset, where it filled at index 4. `64b33b26`
|
| 376 |
451 |
|
// deleted the switch and the Askama rendering with it, so that case has
|
| 377 |
452 |
|
// no way to arise.
|
| 378 |
|
- |
assert_eq!(shown_at(Some("ssh-keys"), true, true, true), 0);
|
|
453 |
+ |
assert_eq!(shown_at(Some("ssh-keys"), NO_SYNC), 0);
|
| 379 |
454 |
|
}
|
| 380 |
455 |
|
|
| 381 |
456 |
|
#[test]
|
| 382 |
457 |
|
fn a_shown_index_past_the_end_cannot_panic() {
|
| 383 |
458 |
|
// A caller that computed an index against a different membership must
|
| 384 |
459 |
|
// clamp rather than take the page down.
|
| 385 |
|
- |
let html = html(99, "<p>your profile</p>", false, false, false);
|
|
460 |
+ |
let html = html(99, "<p>your profile</p>", NONE);
|
| 386 |
461 |
|
assert!(html.contains("<p>your profile</p>"), "{html}");
|
| 387 |
462 |
|
assert!(html.contains("data-shows=\"1\""), "{html}");
|
| 388 |
463 |
|
}
|
| 389 |
464 |
|
|
| 390 |
465 |
|
#[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>"] {
|
|
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 {
|
| 394 |
476 |
|
assert!(all.contains(label), "{all}");
|
| 395 |
477 |
|
}
|
| 396 |
478 |
|
|
| 397 |
|
- |
let none = strip(false, false, false);
|
| 398 |
|
- |
for label in [">Media</button>", ">SSH Keys</button>", ">Forums</button>"] {
|
|
479 |
+ |
let none = strip(NONE);
|
|
480 |
+ |
for label in GATED {
|
| 399 |
481 |
|
assert!(!none.contains(label), "{none}");
|
| 400 |
482 |
|
}
|
| 401 |
483 |
|
// The three ungated ones are still there, and the profile still arrives
|
| 404 |
486 |
|
assert!(none.contains("<p>your profile</p>"), "{none}");
|
| 405 |
487 |
|
assert!(none.contains("role=\"tablist\""), "{none}");
|
| 406 |
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 `§ion=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 |
+ |
}
|
| 407 |
536 |
|
}
|