//! The user dashboard's tab strip, described. //! //! Shape 2, step 5 (`6b24f2df`), the last of the five and the one that deletes //! `frontend/src/core/tabs.ts`. Same Askama entry point as the three before it: //! `dashboards/dashboard-user.html` is still an Askama document and this is one //! region inside it. //! //! # Two strips, not one //! //! The page carried two mutually exclusive tab rows under `{% if deactivated %}`, //! each with its own panel container. A deactivated account sees Support and //! nothing else, because there is nothing else it can do. That is a membership //! test like any other here, so it is [`Gate::Live`] rather than a second //! function: the strip is one description whose membership happens to collapse //! to one tab. //! //! # Which tab opens is computed, and always was //! //! The other four strips opened on their first tab. This one opened on Projects //! for a creator and on Payments for everyone else, spelled as a `chosen` class //! and an `aria-selected` computed twice in the markup. Gating Projects out for a //! non-creator says the same thing once: the opening tab is the first one the //! reader can see, whoever they are. //! //! # Four of the five panels are fillable //! //! Everything in the tree that links here asks for projects, payments or //! settings, and a deactivated account opens on support. Analytics is //! the one nothing links to, and it is also the one that answers for itself, //! so leaving it unfillable costs nothing and paying for it would buy a query //! nobody asked for. //! //! # Analytics is a described screen and names its own region //! //! [`super::user_analytics`] answers `/dashboard/tabs/analytics` when the switch //! is on. Its `REGION` said `tab-content`, the single pane the hand-written strip //! swapped into; there is no single pane now, so it moved to the frame that is //! its own, exactly as `ssh_keys::REGION` did in step 4. A test here asserts the //! two agree, because if they drift the screen's answer lands nowhere and nothing //! else catches it. //! //! # The hash links become queries, and six of the ten were already broken //! //! Ten sites handed out a `/dashboard#tab-*` and relied on `core/tabs.ts` //! reading the hash, finding the button and clicking it. The described buttons //! carry no ids, so each is a `?tab=` read here instead and the tab arrives //! filled at first paint. //! //! Grepping for them found four the recon had not, and six of the ten named an //! id this page has never had: `#tab-profile` and `#tab-ssh-keys` are *settings //! sections*, `#tab-plan` (twice) is the Creator Plan section under a name //! nothing has ever spelled, `#tab-synckit` is a tab only the project dashboard //! ever had, and `#tab-library` in a deletion email is `/library`, a page of its //! own. `#tab-synckit` has a live destination since `47e67540`, and it is still //! not a tab here: the user-level surface is the Cloud Sync settings section, //! reached as `?tab=settings§ion=synckit`. A hash restore that cannot find its button does nothing and says nothing, //! which is how six dead links sat in the tree. //! //! The three that mean a settings section became `?tab=settings`, which is //! Settings showing Profile, since Profile is what the sub-nav opens on. Landing //! on the section that was asked for wanted a second level //! (`?tab=settings§ion=creator`) and a fillable builder behind it; five sites //! wanted one, which earned it, and `3a7de032` built it in //! [`super::settings_tabs`]. The link a section names is `§ion=` now. //! //! # What this retires //! //! `frontend/src/core/tabs.ts` entirely, the last hand-written strip having gone: //! the overflow menu, the hover preload, the hash restore and `setActiveTab` are //! all what a described tab group does. Six `data-action="onSetActiveTab"` sites, //! the last two `tab-spinner` spellings, the `onSetActiveTab` wrapper in //! `actions-dashboards.js` and `blogTabNav` beside it go with it. use makeover_layout as layout; use quasi_router::{Action, Node, RegionKind, Slot}; use quasi_webview::Webview; /// The region the whole strip occupies, keeping the id the page already used. const STRIP: &str = "tab-content"; /// What a tab is conditional on. #[derive(PartialEq, Eq)] enum Gate { /// Only an account that has not deactivated itself. Live, /// A live account whose reader can create projects. Creator, /// Every reader, deactivated included. Support is the only one. Always, } /// One tab: what it is called, where its panel lives, and who sees it. struct Tab { label: &'static str, /// The id the panel's answer lands in. Also the described screen's own /// region name, for the one that has one. panel: &'static str, /// The tail of the route, under `/dashboard/tabs/`. route: &'static str, gate: Gate, /// The described screen behind this panel, when there is one. screen: Option<&'static str>, } /// Every tab the user dashboard can show, in the order the strip draws them. const TABS: &[Tab] = &[ Tab { label: "Projects", panel: super::user_projects::REGION, route: "projects", gate: Gate::Creator, // `None` although the panel is described: `screen` means "a quasi route // answers this address", and Projects is a fill on the Askama handler // that keeps the ETag. See `super::user_projects`. screen: None, }, Tab { label: "Payments", panel: "user-payments", route: "payments", gate: Gate::Live, screen: None, }, Tab { label: "Analytics", panel: super::user_analytics::REGION, route: "analytics", gate: Gate::Creator, screen: Some(super::user_analytics::SCREEN), }, Tab { label: "Settings", panel: "user-settings", route: "settings", gate: Gate::Live, screen: None, }, Tab { label: "Support", panel: super::user_support::REGION, route: "support", gate: Gate::Always, // Described, but as a fill on the Askama handler rather than a quasi // route, so the strip still fetches it. See `super::user_support`. screen: None, }, ]; /// The tabs whose panel the page handler can render inline. /// /// The shown tab is the one that does not fetch, so a name outside this list is /// a blank screen rather than a slow one and answers the first tab instead. /// These four are what the tree links to; analytics is the one nothing links /// to. const FILLABLE: &[&str] = &["projects", "payments", "settings", "support"]; /// Which tab a `?tab=` asks for, or the first one the reader can see. /// /// Replaces the hash restore `core/tabs.ts` did, which read `location.hash`, /// found the button and clicked it: a deep link cost a document, then the page's /// JS running, then a fetch, and it needed a button id to aim at. Chosen here, /// the tab arrives already filled. #[must_use] pub fn shown_at(asked: Option<&str>, deactivated: bool, can_create_projects: bool) -> usize { let Some(asked) = asked else { return 0 }; if !FILLABLE.contains(&asked) { return 0; } visible(deactivated, can_create_projects) .iter() .position(|tab| tab.route == asked) .unwrap_or(0) } /// The route name of a tab by index, so the caller knows which panel to fill. #[must_use] pub fn route_at(shown: usize, deactivated: bool, can_create_projects: bool) -> &'static str { let tabs = visible(deactivated, can_create_projects); tabs.get(shown).map_or(tabs[0].route, |tab| tab.route) } /// The tabs this reader sees, in strip order. /// /// Never empty: Support is [`Gate::Always`]. fn visible(deactivated: bool, can_create_projects: bool) -> Vec<&'static Tab> { TABS.iter() .filter(|tab| match tab.gate { Gate::Always => true, Gate::Live => !deactivated, Gate::Creator => !deactivated && can_create_projects, }) .collect() } /// The markup, for `dashboards/dashboard-user.html` to drop in. /// /// `panel` is the shown tab's contents, rendered by the caller. Both the strips /// this replaces gave their panel container an `hx-trigger="load"` and fetched /// after the document arrived, which is `9b958e7b`'s placeholder before first /// content; the shown panel arrives with the document now. #[must_use] pub fn html(shown: usize, panel: &str, deactivated: bool, can_create_projects: bool) -> String { let tabs = visible(deactivated, can_create_projects); let shown = shown.min(tabs.len() - 1); let mut strip = Slot::new(STRIP, RegionKind::TabGroup) .across(layout::Fallback::Menu) .showing_one(shown); for (at, tab) in tabs.iter().enumerate() { let mut region = Slot::handover(tab.panel, "user-panel"); if at != shown { let mut call = Action::get(format!("/dashboard/tabs/{}", tab.route)).awaiting(); // A described route names its own region and must be left to; an // Askama one names nothing, so the strip has to say where its answer // goes. Not every tab is described, so this branch stays: what went // with `QUASI_SCREENS` (`64b33b26`) is only the second half of the // test, which used to ask whether the screen was switched on. if tab.screen.is_none() { call = call.replacing(tab.panel); } region = region.fed_by(call); } strip = strip.frame(tab.label, Node::Region(region)); } use quasi_axum::Serves as _; // No shell: a fragment landing inside a document Askama already built. Webview::new() .with_fill(tabs[shown].panel, panel) .fragment(&Node::Region(strip)) } #[cfg(test)] mod tests { use super::*; fn strip(deactivated: bool, creator: bool) -> String { html( shown_at(None, deactivated, creator), "

the panel

", deactivated, creator, ) } #[test] fn the_page_asks_for_nothing_on_load() { // Both hand-written strips gave their panel an `hx-trigger="load"`. let html = strip(false, true); assert!(!html.contains("hx-trigger=\"load\""), "{html}"); assert!(html.contains("

the panel

"), "{html}"); assert_eq!(html.matches("hx-get=").count(), 4, "{html}"); } #[test] fn every_unshown_tab_says_where_its_answer_lands() { let html = strip(false, true); // The Askama tabs. `user-analytics` is deliberately absent: it is a // described screen and names its own region, so the strip must NOT // retarget it -- asserted by // `the_described_screen_is_left_to_name_its_own_region` below. Until // `64b33b26` it was here too, because the switch was off in tests and // every tab was Askama. for panel in ["user-payments", "user-settings", "user-support"] { assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}"); assert!(html.contains(&format!("id=\"{panel}\"")), "{html}"); } // Every tab still gets its frame, described or not. assert!(html.contains("id=\"user-analytics\""), "{html}"); assert!(!html.contains("hx-target=\"#user-projects\""), "{html}"); } #[test] fn a_creator_opens_on_projects_and_everyone_else_on_payments() { // The markup said this twice, as a `chosen` class and an `aria-selected`, // both computed on `can_create_projects`. Gating Projects out says it // once: the opening tab is the first one the reader can see. let creator = strip(false, true); assert!(creator.contains(">Projects"), "{creator}"); assert!( !creator.contains("hx-target=\"#user-projects\""), "{creator}" ); let fan = strip(false, false); assert!(!fan.contains(">Projects"), "{fan}"); assert!(!fan.contains(">Analytics"), "{fan}"); assert!(!fan.contains("hx-target=\"#user-payments\""), "{fan}"); assert_eq!(fan.matches("hx-get=").count(), 2, "{fan}"); } #[test] fn a_deactivated_account_sees_support_and_nothing_else() { let html = strip(true, true); assert!(html.contains(">Support"), "{html}"); for label in [ ">Projects", ">Payments", ">Analytics", ">Settings", ] { assert!(!html.contains(label), "{html}"); } // Still a strip, and the one tab it has arrives filled. assert!(html.contains("role=\"tablist\""), "{html}"); assert!(html.contains("

the panel

"), "{html}"); assert_eq!(html.matches("hx-get=").count(), 0, "{html}"); } #[test] fn a_deep_link_arrives_showing_what_it_asked_for() { let shown = shown_at(Some("settings"), false, true); assert_eq!(shown, 3); assert_eq!(route_at(shown, false, true), "settings"); let html = html(shown, "

your settings

", false, true); assert!(html.contains("

your settings

"), "{html}"); assert!(!html.contains("hx-target=\"#user-settings\""), "{html}"); assert!(html.contains("hx-target=\"#user-projects\""), "{html}"); assert!(html.contains("data-shows=\"3\""), "{html}"); } #[test] fn a_tab_the_page_cannot_fill_answers_the_first_one() { // Analytics is real and reachable by pressing it; it is not fillable, so // asking for it in a query would open a panel the handler left empty. assert_eq!(shown_at(Some("analytics"), false, true), 0); assert_eq!(shown_at(Some("nonsense"), false, true), 0); assert_eq!(shown_at(None, false, true), 0); // A creator-only tab asked for by someone who cannot see it. assert_eq!(shown_at(Some("projects"), false, false), 0); assert_eq!( route_at(shown_at(Some("projects"), false, false), false, false), "payments" ); // And by a deactivated account, whose one tab is Support. assert_eq!( route_at(shown_at(Some("settings"), true, true), true, true), "support" ); } #[test] fn the_described_screen_is_left_to_name_its_own_region() { let html = html(0, "

the panel

", false, true); assert!( !html.contains("hx-target=\"#user-analytics\""), "a described screen retargets its own answer:\n{html}" ); // Its neighbours are still told. assert!(html.contains("hx-target=\"#user-settings\""), "{html}"); } #[test] fn the_screen_answers_into_the_frame_that_is_its_own() { // It said `tab-content`, the single pane five tabs shared. If it drifts // from the frame this strip draws for it, its answer lands nowhere. assert_eq!(super::super::user_analytics::REGION, "user-analytics"); } #[test] fn the_strip_says_what_it_does_when_it_runs_out_of_room() { assert!(strip(false, true).contains("run-menu")); } #[test] fn a_shown_index_past_the_end_cannot_panic() { // A caller that computed an index against a different membership must // clamp rather than take the page down. let html = html(99, "

the panel

", true, true); assert!(html.contains("

the panel

"), "{html}"); assert!(html.contains("data-shows=\"0\""), "{html}"); } }