//! The library page's tab strip, described. //! //! Shape 2, step 1 (`6b24f2df`), and the first described tab strip in the tree. //! An Askama entry point rather than a mounted screen, the same shape //! `widgets::carousel` has: `pages/library.html` is still an Askama document and //! this is one region inside it. //! //! # The panels are not described and are not meant to be //! //! Max's ruling: describe the strip, leave the panels as routes. So every tab is //! a [`RegionKind::Bespoke`], a place and nothing else, and what lands in one //! is whatever its route already answered with. That is the honest kind for a //! region whose contents are Askama's, and it is what lets the five panel routes //! in `routes::pages::public` stay untouched. //! //! # Who fetches, and why the shown tab is different //! //! `dfbc88ce`: the strip button carries its panel's address and the panel emits //! no load trigger, so a reader downloads the tab they pressed and not the four //! they did not. The shown tab carries no address at all and arrives with its //! contents already in it, which is `9b958e7b`: a screen renders once at final //! geometry, and a placeholder on the panel being looked at is the one place //! that rule bites hardest. It is also what `library.html` already did with its //! `{% include %}`, so this conversion changes the page's request count by zero. //! //! # Why each tab says what it replaces //! //! Three of the five panels are plain Askama routes that quasi never sees, and a //! route that cannot name a region leaves the answer wherever htmx's default //! puts it, which is inside the button that was pressed. [`Action::replacing`] //! is exactly this case and its own doc says so. The other two are described //! screens that name their region themselves, so they are left to, and their //! regions were renamed to match the ids here. See `library_contacts::REGION` //! and `forum_memberships::LIBRARY_REGION`. //! //! # What changed for a reader, and it is one thing //! //! The overflow control. `Fallback::Menu` is declared and makeover's own CSS //! renders a menu run as `flex-wrap: wrap`, so a narrow viewport wraps the strip //! instead of folding the last tabs behind a More button. `core/tabs.ts`'s //! `tabOverflow` still serves the four Askama strips and does not see this one: //! it looks for `.tabs`, and the renderer writes `.selector[data-selector=tab]`. //! //! # Two things the markup said and the description cannot //! //! `aria-label="Library sections"` on the strip, and `title="Updates from //! creators you follow"` on the Feed tab. A region's own accessible name and a //! control's tooltip are both absent from the vocabulary: [`Slot::label`] is a //! child's tab name, which is a different thing, and nothing carries a hint. //! //! Dropped rather than worked around, and filed, because a `Node::Text` smuggled //! in to stand for a label is how a vocabulary stops being one. The strip sits //! under the page's `

`, so a reader is not lost; they are told less than they //! were. //! //! That is the ruling working rather than a gap in it. The menu's construction //! and its measurement policy were explicitly left undescribed, and a renderer //! is free to honour `Menu` as wrapping. Worth knowing before someone reads the //! missing More button as a bug. use makeover_layout as layout; use quasi_router::{Action, Node, RegionKind, Slot}; use quasi_webview::Webview; /// The region the whole strip occupies: the id `library.html` used for its /// single panel container, kept so nothing that aims at the library's tab area /// has to learn a new name. const STRIP: &str = "tab-content"; /// What a tab is conditional on. /// /// A field rather than a match on the label, which is what this was until the /// first person to reword a tab would have silently changed who could see it. #[derive(PartialEq, Eq)] enum Gate { /// Every reader sees it. Always, /// Only where the Multithreaded integration is configured. Communities, /// Only a reader who can create projects, since only they have buyers. Creators, } /// One tab: what it is called, where its panel lives, and what serves it. struct Tab { label: &'static str, /// Who sees it. Membership has been conditional since before the strip was /// described, for two of the five. gate: Gate, /// The id the panel's answer lands in. Also the described screen's own /// region name, for the two tabs that have one. panel: &'static str, route: &'static str, /// The described screen behind this panel, when there is one. `None` means /// an Askama route, which is what [`Action::replacing`] is for. screen: Option<&'static str>, } /// Every tab the library can show, in the order the strip draws them. /// /// Membership is conditional for two of the five and always has been: /// Communities needs the Multithreaded integration configured and Contacts /// needs a reader who can create projects. The strip is built from what /// survives those two tests rather than written out flat. const TABS: &[Tab] = &[ Tab { label: "Purchases", gate: Gate::Always, panel: "library-purchases", route: "/library/tabs/purchases", screen: None, }, Tab { label: "Feed", gate: Gate::Always, panel: "library-feed", route: "/library/tabs/feed", screen: None, }, Tab { label: "Collections", gate: Gate::Always, panel: "library-collections", route: "/library/tabs/collections", screen: None, }, Tab { label: "Communities", gate: Gate::Communities, panel: super::forum_memberships::LIBRARY_REGION, route: super::forum_memberships::LIBRARY_PATH, screen: Some(super::forum_memberships::LIBRARY_SCREEN), }, Tab { label: "Contacts", gate: Gate::Creators, panel: super::library_contacts::REGION, route: super::library_contacts::PATH, screen: Some(super::library_contacts::SCREEN), }, ]; /// The markup, for `pages/library.html` to drop in. /// /// `purchases` is the first panel's contents, rendered by the caller: the page /// handler already has the rows, and the shown panel arriving with the document /// is the whole of what keeps this page at one request. #[must_use] pub fn html(purchases: &str, has_mt_memberships: bool, can_create_projects: bool) -> String { let shown: Vec<&Tab> = TABS .iter() .filter(|tab| match tab.gate { Gate::Always => true, Gate::Communities => has_mt_memberships, Gate::Creators => can_create_projects, }) .collect(); let mut strip = Slot::new(STRIP, RegionKind::TabGroup) // A run with no members, only a fallback. `Run` has no `Default` on // purpose, so a strip cannot be described while staying silent about // what it does when it runs out of room. `Menu` is what the page means // -- the last tabs are worth less than the first ones and should fold // away rather than squeeze -- and what this renderer currently does with // that is wrap. See the module header. .across(layout::Fallback::Menu) .showing_one(0); for (at, tab) in shown.iter().enumerate() { let mut panel = Slot::bespoke(tab.panel, "library-panel").label(tab.label); if at > 0 { let mut call = Action::get(tab.route).awaiting(); // Described routes name their own region and must be left to; // setting it here would override an answer that already knew // better. See `Action::replaces`. if tab.screen.is_none() { call = call.replacing(tab.panel); } panel = panel.fed_by(call); } strip = strip.with(Node::Region(panel)); } use quasi_axum::Serves as _; // No shell: this is a fragment landing inside a document Askama already // built, which is exactly what `fragment` is for. Webview::new() .with_fill(TABS[0].panel, purchases) .fragment(&Node::Region(strip)) } #[cfg(test)] mod tests { use super::*; fn strip(has_mt: bool, can_create: bool) -> String { html("

your purchases

", has_mt, can_create) } #[test] fn the_page_makes_no_more_requests_than_it_did_before() { // The whole safety argument for this conversion. `library.html` rendered // the shown panel inline and fetched the rest on a press; if the // described strip fetched on load instead, the page would go from zero // panel requests to four, each its own set of queries. let html = strip(true, true); assert!(!html.contains("hx-trigger=\"load\""), "{html}"); assert!(html.contains("

your purchases

"), "{html}"); assert_eq!(html.matches("hx-get=").count(), 4, "{html}"); } #[test] fn every_tab_but_the_shown_one_says_where_its_answer_lands() { // Three of these routes are Askama and name no region, so without this // the answer swaps into the button that was pressed -- which is what // `DELETE /api/users/me/ssh-keys` did before the described screens // served their own writes. let html = strip(true, true); // The Askama tabs. `library-communities` and `library-contacts` are // deliberately absent: both are described screens that name their own // region, so the strip must NOT retarget them. Until `64b33b26` they // were here, because the switch was off in tests and every tab was // Askama. for panel in ["library-feed", "library-collections"] { assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}"); assert!(html.contains(&format!("id=\"{panel}\"")), "{html}"); } // Every tab still gets its frame, described or not. for panel in ["library-communities", "library-contacts"] { assert!(html.contains(&format!("id=\"{panel}\"")), "{html}"); assert!( !html.contains(&format!("hx-target=\"#{panel}\"")), "{panel} is described and names its own region:\n{html}" ); } // The shown panel is not fetched, so it has nowhere to aim and says so // by carrying no transport at all. assert!(!html.contains("hx-target=\"#library-purchases\""), "{html}"); } #[test] fn a_described_panel_is_left_to_name_its_own_region() { // Decision 7. A described route answers with a fragment naming what it // changed, so a target written here would override an answer that // already knew better. Both described panels, and it follows the panel // rather than a switch since `64b33b26` deleted the switch -- // `library-communities` used to be asserted the other way here, because // it was described but not switched on in this test. let html = html("

your purchases

", true, true); for panel in ["library-contacts", "library-communities"] { assert!( !html.contains(&format!("hx-target=\"#{panel}\"")), "a described panel retargets its own answer:\n{html}" ); } // And the ones still served by Askama are untouched by that. assert!(html.contains("hx-target=\"#library-feed\""), "{html}"); } #[test] fn the_two_gated_tabs_leave_when_their_test_fails() { // Membership was conditional before it was described and stays so. The // recon that planned this shape recorded the library strip as five // unconditional buttons, which the template contradicts twice. let both = strip(true, true); assert!(both.contains(">Communities"), "{both}"); assert!(both.contains(">Contacts"), "{both}"); let neither = strip(false, false); assert!(!neither.contains(">Communities"), "{neither}"); assert!(!neither.contains(">Contacts"), "{neither}"); // And the strip is still a strip, with the shown panel where it was. assert!(neither.contains("role=\"tablist\""), "{neither}"); assert!(neither.contains("

your purchases

"), "{neither}"); } #[test] fn the_strip_says_what_it_does_when_it_runs_out_of_room() { // `Run` has no `Default`, so this is not something a strip can forget; // what it can do is pick the wrong one. `Menu` is the page's own // behaviour today -- the overflow goes behind a More control. assert!(strip(true, true).contains("run-menu")); } }