//! The item dashboard's tab strip, described. //! //! Shape 2, step 2 (`6b24f2df`), and the second described tab strip. Built as an //! Askama entry point the same way [`super::library_tabs`] is: //! `dashboards/dashboard-item.html` is still an Askama document and this is one //! region inside it. //! //! # What is different from the library strip, and it is the point of doing this //! # one second //! //! Every one of these five panels is still an Askama route. The library strip //! had two described panels among its five and so had to decide, per tab, //! whether to name the region the answer lands in. Here nothing is described //! downstream, so every unshown tab carries [`Action::replacing`] and there is //! no branch. This is the step that proves a strip converts without any of its //! panels converting. //! //! # The page loses a request //! //! `dashboard-item.html` did not include its first panel; it gave `#tab-content` //! an `hx-trigger="load"` and fetched Overview after the document arrived. So //! this page rendered a placeholder and then filled it, which is exactly what //! `9b958e7b` refuses -- a screen renders once, at final geometry. The described //! strip cannot emit that trigger on the shown tab, so the overview is rendered //! here instead, out of the `Item` the page handler already built. No extra //! query: `ItemOverviewTabTemplate` takes the same `Item` and nothing else. //! //! Two requests become one. That is a change to the page's behaviour rather than //! a preservation of it, unlike the library, and it is the direction the rule //! points. //! //! # The hash deep link becomes a query, and gets better for it //! //! `core/tabs.ts:145-149` restored a tab by reading `location.hash`, finding the //! button and clicking it, so `/dashboard/item/X#tab-files` cost a document, then //! the page's JS running, then a fetch. The described strip has no button ids for //! that to find, so the restore would simply have stopped working. //! //! It is answered rather than lost: [`shown_at`] reads a `?tab=` and the strip //! opens on it, filled, at first paint. One caller exists in the tree, //! `static/item-upload.js:133`, where a creator lands after an upload, and it //! asks for `?tab=files`. //! //! The handler fills two of the five panels: overview, which it already has, and //! files, which costs one query. Nothing links to the other three, so they are not //! paid for. An unknown or absent name answers the first tab. //! //! # The sixth route is not a tab //! //! `/dashboard/item/{id}/tabs/embed` is registered and handled, and no button in //! the strip has ever pointed at it: it is revealed from inside the overview //! panel (`partials/tabs/item_overview.html:29-33`, `hx-trigger="revealed"`). //! Promoting it here would put a tab on the page that never existed. It stays a //! bespoke in-panel fragment call. //! //! # Files is conditional, and was before it was described //! //! `item.item_type != "bundle"`. A bundle has no files of its own, so the tab is //! absent rather than empty. The recon that planned this step recorded the strip //! as five unconditional buttons; the template says four. //! //! # What the markup said and the description cannot //! //! A `title` on every one of the five buttons -- "Stats, quick actions, and embed //! codes" and its four siblings -- and `aria-label="Item sections"` on the strip. //! A control's hint and a region's own accessible name are both absent from the //! vocabulary, filed as quasicoherent `aad33ecc`, and dropped here for the reason //! the library dropped its two: a `Node::Text` smuggled in to stand for a label is //! how a vocabulary stops being one. This strip drops five hints where the library //! dropped one, which is worth knowing when that decision is answered. //! //! # What this retires //! //! Five `data-action="onSetActiveTab"` sites, the largest verb in the tree //! (Shape 6, `17050ff5`), and one of the five spellings of a spinner -- //! `tab-spinner-indicator` and its `` -- because //! [`Action::awaiting`] is what an act in flight says now (Shape 7, `736f45a5`). //! Neither file can be deleted for it; both counts go down by the sites here. use makeover_layout as layout; use quasi_router::{Action, Node, RegionKind, Slot}; use quasi_webview::Webview; /// The region the whole strip occupies. /// /// `dashboard-item.html` used this id for its single panel container, and three /// things outside the strip still name it: the refund control in /// `partials/tabs/item_sales.html` and the two `htmx:after:swap` re-init hooks in /// `static/item-details.js` and `static/item-upload.js`. Those three now name the /// panel they actually meant, since under a described strip each panel is its own /// region and a swap lands in one of five ids rather than in this one. const STRIP: &str = "tab-content"; /// One tab: what it is called and where its panel lives. struct Tab { label: &'static str, /// The id the panel's answer lands in. panel: &'static str, /// The tail of the route, under `/dashboard/item/{id}/tabs/`. route: &'static str, /// Whether every item shows it. Only Files is conditional. bundles_too: bool, } /// Every tab the item dashboard can show, in the order the strip draws them. const TABS: &[Tab] = &[ Tab { label: "Overview", panel: "item-overview", route: "overview", bundles_too: true, }, Tab { label: "Details", panel: "item-details", route: "details", bundles_too: true, }, Tab { label: "Pricing", panel: "item-pricing", route: "pricing", bundles_too: true, }, Tab { label: "Files", panel: "item-files", route: "files", // A bundle carries other items rather than files of its own. bundles_too: false, }, Tab { label: "Sales", panel: "item-sales", route: "sales", bundles_too: true, }, ]; /// Which tab a `?tab=` asks for, or the first one. /// /// The strip replaces the hash restore `core/tabs.ts:145-149` did: that read /// `location.hash`, found the button and clicked it, so a deep link cost a page /// load and then a fetch, and it needed the page's JS to have run. A described /// strip is chosen server-side and arrives already showing what was asked for. /// /// Unknown names answer the first tab rather than 404ing. A stale link should /// land somewhere sensible, and the panels are all on the same item. #[must_use] pub fn shown_at(asked: Option<&str>, is_bundle: bool) -> usize { let Some(asked) = asked else { return 0 }; visible(is_bundle) .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, is_bundle: bool) -> &'static str { visible(is_bundle) .get(shown) .map_or(TABS[0].route, |tab| tab.route) } /// The tabs this item shows, in strip order. fn visible(is_bundle: bool) -> Vec<&'static Tab> { TABS.iter() .filter(|tab| tab.bundles_too || !is_bundle) .collect() } /// The markup, for `dashboards/dashboard-item.html` to drop in. /// /// `panel` is the shown tab's contents, rendered by the caller. The shown tab is /// the only one with anything in it: the other four arrive when they are pressed, /// which is what the page did before, and this one arrives with the document, /// which is what the page did not. #[must_use] pub fn html(item_id: &str, shown: usize, panel: &str, is_bundle: bool) -> String { let tabs = visible(is_bundle); let shown = shown.min(tabs.len().saturating_sub(1)); let mut strip = Slot::new(STRIP, RegionKind::TabGroup) // `Run` has no `Default`, 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 and what the library strip picked; this renderer currently // honours it by wrapping. See `library_tabs`. .across(layout::Fallback::Menu) .showing_one(shown); for (at, tab) in tabs.iter().enumerate() { let mut region = Slot::handover(tab.panel, "item-panel"); if at != shown { // Every panel here is an Askama route, so every one of them needs to // be told where its answer goes: a route that names no region leaves // it wherever htmx's default puts it, which is inside the button that // was pressed. region = region.fed_by( Action::get(format!("/dashboard/item/{item_id}/tabs/{}", tab.route)) .awaiting() .replacing(tab.panel), ); } 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(is_bundle: bool) -> String { html("itm_1", 0, "

the overview

", is_bundle) } #[test] fn the_page_asks_for_nothing_on_load() { // The page fetched Overview after the document arrived, which is a // placeholder before first content. The described strip renders it // inline, so two requests become one and none of the other four panels // is fetched until it is pressed. let html = strip(false); assert!(!html.contains("hx-trigger=\"load\""), "{html}"); assert!(html.contains("

the overview

"), "{html}"); assert_eq!(html.matches("hx-get=").count(), 4, "{html}"); } #[test] fn every_unshown_tab_says_where_its_answer_lands() { // No panel of this strip is described, so unlike the library there is no // tab that may be left to name its own region. let html = strip(false); for panel in ["item-details", "item-pricing", "item-files", "item-sales"] { assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}"); assert!(html.contains(&format!("id=\"{panel}\"")), "{html}"); } assert!(!html.contains("hx-target=\"#item-overview\""), "{html}"); } #[test] fn the_routes_carry_the_item() { let html = strip(false); assert!( html.contains("hx-get=\"/dashboard/item/itm_1/tabs/details\""), "{html}" ); // The sixth route is reached from inside the overview panel and is not a // tab. If it ever appears here, a tab has been invented. assert!(!html.contains("/tabs/embed"), "{html}"); } #[test] fn a_bundle_has_no_files_tab() { let bundle = strip(true); assert!(!bundle.contains(">Files"), "{bundle}"); assert!(!bundle.contains("item-files"), "{bundle}"); // And it is still a strip, with the shown panel where it was. assert!(bundle.contains("role=\"tablist\""), "{bundle}"); assert!(bundle.contains("

the overview

"), "{bundle}"); assert_eq!(bundle.matches("hx-get=").count(), 3, "{bundle}"); assert!(strip(false).contains(">Files")); } #[test] fn the_strip_says_what_it_does_when_it_runs_out_of_room() { assert!(strip(false).contains("run-menu")); } #[test] fn a_deep_link_arrives_showing_what_it_asked_for() { // `static/item-upload.js` sends a creator here after an upload. It used // to send them to `#tab-files` and rely on the hash restore clicking the // button after load; the strip is chosen server-side now, so the panel // is already the shown one and is filled by the caller. let shown = shown_at(Some("files"), false); assert_eq!(shown, 3); assert_eq!(route_at(shown, false), "files"); let html = html("itm_1", shown, "

the versions

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

the versions

"), "{html}"); // The shown panel is the one that does not fetch, whichever it is. assert!(!html.contains("hx-target=\"#item-files\""), "{html}"); assert!(html.contains("hx-target=\"#item-overview\""), "{html}"); assert!(html.contains("data-shows=\"3\""), "{html}"); } #[test] fn a_bundle_asking_for_files_lands_on_the_first_tab() { // Files is absent for a bundle, so the index it would have had belongs to // Sales. Answering the first tab is the stale-link case, and it must not // silently open a different panel than the name asked for. assert_eq!(shown_at(Some("files"), true), 0); assert_eq!(route_at(shown_at(Some("files"), true), true), "overview"); // Sales still resolves for a bundle, at the index the missing tab left. assert_eq!(shown_at(Some("sales"), true), 3); } #[test] fn an_unknown_or_absent_tab_is_the_first_one() { assert_eq!(shown_at(None, false), 0); assert_eq!(shown_at(Some("nonsense"), false), 0); assert_eq!(shown_at(Some("embed"), false), 0); } #[test] fn a_shown_index_past_the_end_cannot_panic() { // `showing_one` and the fill both index the tab list, so a caller that // computed an index against a different bundle flag must clamp rather // than take the page down. let html = html("itm_1", 99, "

the overview

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

the overview

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