//! The project dashboard's tab strip, described. //! //! Shape 2, step 3 (`6b24f2df`), and the third described strip after //! [`super::library_tabs`] and [`super::item_tabs`]. Same Askama entry point //! shape as both: `dashboards/dashboard-project.html` is still an Askama //! document and this is one region inside it. //! //! # Seven tabs, and eleven routes //! //! Four project tab routes are registered and are deliberately not strip tabs. //! Monetization is a composite: `partials/tabs/project_monetization.html` is nine //! lines including `project_subscriptions.html`, `project_promotions.html` and //! `project_members.html`, each of which also has its own route used only as a //! self-refresh target from inside itself. Blog is the fourth, reached from the //! Content panel. Describing seven tabs and leaving those four alone is the //! whole of the distinction; a tab here that no button ever had is the failure //! this strip is most likely to produce. //! //! # Two conditional tabs //! //! Code on `git_enabled` (the server's `build.git_repos_path`) and Cloud Sync on //! the project carrying the `cloud_sync` feature. Both were conditional before //! they were described. //! //! # What a deep link costs, and what it buys //! //! Three tabs can be opened directly, and the page fills whichever it opens: //! overview, content and synckit. That is not a guess about which ones are worth //! it: those are the three anything in the tree links to. //! //! - overview, the tab the page opens on; //! - content, from the Go to Content button in the overview panel; //! - synckit, from `routes::synckit::billing`, which sends a creator back here //! after Stripe. //! //! The other four are pressed rather than linked, so nothing renders them twice. //! An unknown or unfillable name answers the first tab: a stale link should land //! somewhere sensible, and every panel is the same project. //! //! The three fillable ones are rendered from //! `routes::pages::dashboard::project_tabs`' builders, split out of the tab //! handlers for this, so the page and the route answer the same markup rather //! than two copies drifting. //! //! # This page also stops fetching on load //! //! `dashboard-project.html` gave `#tab-content` an `hx-trigger="load"` and fetched //! Overview after the document arrived, which is `9b958e7b`'s placeholder before //! first content. It renders inline now, and the page handler's own duplicate //! `stats` vector -- built for a template field nothing read -- goes with it. //! //! # The tail outside the strip //! //! Sixteen sites named `#tab-content` and meant one of the panels: four analytics //! range buttons, three content refreshes, two members and two subscriptions //! refreshes inside the monetization composite, four `htmx.ajax` calls in the //! deleted `static/tab-project-content.js`, and three more in the code tab's JS //! files. Under a described strip that id is the strip itself, so each now names //! the panel it meant. This is the same class of tail step 2 found three of, and //! it is the reason each strip wants a grep before it is described rather than //! after. 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 { /// Every project shows it. Always, /// Only where the server has a git repositories path. Git, /// Only a project carrying the `cloud_sync` feature. SyncKit, } /// 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. panel: &'static str, /// The tail of the route, under `/dashboard/project/{slug}/tabs/`. route: &'static str, gate: Gate, } /// Every tab the project dashboard can show, in the order the strip draws them. const TABS: &[Tab] = &[ Tab { label: "Overview", panel: super::project_overview::REGION, route: "overview", gate: Gate::Always, }, Tab { label: "Content", panel: "project-content", route: "content", gate: Gate::Always, }, Tab { label: "Analytics", panel: super::project_analytics::REGION, route: "analytics", gate: Gate::Always, }, Tab { label: "Monetization", panel: "project-monetization", route: "monetization", gate: Gate::Always, }, Tab { label: "Code", panel: "project-code", route: "code", gate: Gate::Git, }, Tab { label: "Cloud Sync", panel: "project-synckit", route: "synckit", gate: Gate::SyncKit, }, Tab { label: "Settings", panel: "project-settings", route: "settings", gate: Gate::Always, }, ]; /// The tabs whose panel the page handler can fill. /// /// A `?tab=` naming anything else answers the first tab rather than opening an /// empty panel: the shown tab is the one that does not fetch, so a name the page /// cannot render is a blank screen, not a slow one. const FILLABLE: &[&str] = &["overview", "content", "synckit"]; /// Which tab a `?tab=` asks for, or the first one. #[must_use] pub fn shown_at(asked: Option<&str>, git: bool, synckit: bool) -> usize { let Some(asked) = asked else { return 0 }; if !FILLABLE.contains(&asked) { return 0; } visible(git, synckit) .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, git: bool, synckit: bool) -> &'static str { visible(git, synckit) .get(shown) .map_or(TABS[0].route, |tab| tab.route) } /// The tabs this project shows, in strip order. fn visible(git: bool, synckit: bool) -> Vec<&'static Tab> { TABS.iter() .filter(|tab| match tab.gate { Gate::Always => true, Gate::Git => git, Gate::SyncKit => synckit, }) .collect() } /// The markup, for `dashboards/dashboard-project.html` to drop in. #[must_use] pub fn html(slug: &str, shown: usize, panel: &str, git: bool, synckit: bool) -> String { let tabs = visible(git, synckit); let shown = shown.min(tabs.len().saturating_sub(1)); let mut strip = Slot::new(STRIP, RegionKind::TabGroup) // `Run` has no `Default`: a strip cannot be described while staying // silent about what it does when it runs out of room. Seven tabs is the // widest strip in the tree, so this is the one where `Menu` earns its // keep rather than being a formality. .across(layout::Fallback::Menu) .showing_one(shown); for (at, tab) in tabs.iter().enumerate() { let mut region = Slot::handover(tab.panel, "project-panel"); if at != shown { // Every panel is an Askama route naming no region, so each is told // where its answer goes or htmx swaps it into the pressed button. region = region.fed_by( Action::get(format!("/dashboard/project/{slug}/tabs/{}", tab.route)) .awaiting() .replacing(tab.panel), ); } strip = strip.frame(tab.label, Node::Region(region)); } use quasi_axum::Serves as _; Webview::new() .with_fill(tabs[shown].panel, panel) .fragment(&Node::Region(strip)) } #[cfg(test)] mod tests { use super::*; fn strip(git: bool, synckit: bool) -> String { html("a-project", 0, "
the overview
", git, synckit) } #[test] fn the_page_asks_for_nothing_on_load() { let html = strip(true, true); assert!(!html.contains("hx-trigger=\"load\""), "{html}"); assert!(html.contains("the overview
"), "{html}"); // Seven tabs, six of them unshown and each fetched only when pressed. assert_eq!(html.matches("hx-get=").count(), 6, "{html}"); } #[test] fn every_unshown_tab_says_where_its_answer_lands() { let html = strip(true, true); for panel in [ "project-content", "project-analytics", "project-monetization", "project-code", "project-synckit", "project-settings", ] { assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}"); assert!(html.contains(&format!("id=\"{panel}\"")), "{html}"); } assert!(!html.contains("hx-target=\"#project-overview\""), "{html}"); } #[test] fn the_two_routes_that_are_not_tabs_stay_out() { // Subscriptions and members are the monetization composite's own // self-refresh routes: each partial names its own address in a // `data-after="refresh"`. A button for either is a tab the page never // had. // // This list was four until 2026-08-26, and the comment vouched for all // of them. Two were not what it said. `/tabs/blog` was a route nothing // reached and is deleted (`6077d0d9`); `/tabs/promotions` was the same // and is deleted too (`f698064d`) -- its partial is live but the // monetization composite carries the data, so the route rendering that // partial alone had no caller. Only these two were ever self-refresh // targets, and the way to tell is to grep the partial for its own // address rather than to trust this comment. let html = strip(true, true); for route in ["/tabs/subscriptions", "/tabs/members"] { assert!(!html.contains(route), "{route} is not a tab:\n{html}"); } } #[test] fn the_two_gated_tabs_leave_when_their_test_fails() { let both = strip(true, true); assert!(both.contains(">Code"), "{both}"); assert!(both.contains(">Cloud Sync"), "{both}"); let neither = strip(false, false); assert!(!neither.contains(">Code"), "{neither}"); assert!(!neither.contains(">Cloud Sync"), "{neither}"); assert!(neither.contains("role=\"tablist\""), "{neither}"); assert_eq!(neither.matches("hx-get=").count(), 4, "{neither}"); } #[test] fn a_deep_link_arrives_showing_what_it_asked_for() { // The Stripe return path. Cloud Sync is index 5 with both gates open. let shown = shown_at(Some("synckit"), true, true); assert_eq!(shown, 5); assert_eq!(route_at(shown, true, true), "synckit"); let html = html("a-project", shown, "the apps
", true, true); assert!(html.contains("the apps
"), "{html}"); assert!(!html.contains("hx-target=\"#project-synckit\""), "{html}"); assert!(html.contains("hx-target=\"#project-overview\""), "{html}"); } #[test] fn a_tab_the_page_cannot_fill_is_not_opened() { // Analytics, monetization, code and settings are real tabs and are not // fillable, so asking for one by name lands on the first tab rather than // on an empty panel. Pressing them still works; this is the link path. for asked in ["analytics", "monetization", "code", "settings"] { assert_eq!(shown_at(Some(asked), true, true), 0, "{asked}"); } // And the three that are fillable resolve to themselves. assert_eq!(shown_at(Some("overview"), true, true), 0); assert_eq!(shown_at(Some("content"), true, true), 1); } #[test] fn a_closed_gate_moves_the_index_of_what_follows_it() { // Cloud Sync sits after Code, so a project without a git path has it one // place earlier. Resolving by name rather than by a written-down number // is what keeps the Stripe return correct on both. assert_eq!(shown_at(Some("synckit"), false, true), 4); // And with the feature off there is no such tab at all. assert_eq!(shown_at(Some("synckit"), true, false), 0); } #[test] fn an_unknown_or_absent_tab_is_the_first_one() { assert_eq!(shown_at(None, true, true), 0); assert_eq!(shown_at(Some("nonsense"), true, true), 0); } #[test] fn a_shown_index_past_the_end_cannot_panic() { let html = html("a-project", 99, "the overview
", false, false); assert!(html.contains("the overview
"), "{html}"); } #[test] fn the_strip_says_what_it_does_when_it_runs_out_of_room() { assert!(strip(true, true).contains("run-menu")); } }