//! The project dashboard's Overview panel, described. //! //! Fourth of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab //! inventory"): 93 lines, three `hx-` attributes, no `data-action`, and nothing //! in `static/` or `frontend/src` reaches for any id it writes. //! //! A fill on `project_tabs::build_overview` rather than a mounted screen, for //! the reason the batch before it established: `project_tab_overview` answers a //! conditional GET through `resolve_project_etag`, and `super::mount` has no //! way to say "304 if the project's cache generation has not moved". See //! [`super::user_projects`] for the rule and why it is the ETag that decides. //! //! # The disclosure is sayable now, and this is the first screen to say it //! //! `super::buyer_contacts` gave up its `
` in August and filed the //! gap: 51 sites, and nothing named a disclosure. It is named now, as a //! selective region -- `Slot::widget(id, "disclosure").showing_at_most_one(..)` //! -- where `None` is the closed state and is a legal resting place. So the //! tools panel keeps its collapse instead of becoming a heading and six //! paragraphs, and `buyer_contacts` can take its own back whenever somebody is //! in there. //! //! # The setup checklist is a list of three steps, not three copies of one row //! //! The template writes each step twice, once done and once not, and the two //! branches differ in a tick, a label class, and whether a CTA is there at all. //! Said once, a step is a row that carries a token when it is finished and an //! act when there is something to do about it, and the six branches collapse to //! three [`Step`]s built from three booleans. //! //! The whole block is conditional on at least one step being unfinished, which //! is [`Step::all_done`] here rather than a three-way `||` in the caller. //! //! # Quick Actions loses its third spelling of Export //! //! `hx-post="/api/export/projects"` with `hx-target="body" hx-swap="beforeend"` //! was a sixth hand-written export control. [`super::export_act`] is the one //! that already exists, and it says what the answer *is* -- a file the reader //! keeps -- rather than where to staple the response. //! //! **It renames the button, from "Export Data" to "Export CSV".** That is the //! cost of taking the shared control rather than spelling a sixth one, and it //! is the right way round: five other sites already say "Export CSV" and this //! was the only one that did not. Noted rather than hidden, since a conversion //! changing user-visible copy should say so. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::{Figure, Tag}; use quasi_router::{Node, RegionKind, Slot}; use quasi_webview::Webview; use crate::types::StatCard; /// The region the answer replaces, keeping the id the page already used. pub const REGION: &str = "project-overview"; /// The disclosure holding the tour of the other tabs. const TOOLS: &str = "project-overview-tools"; /// The one frame inside it, which is what carries the summary line. const TOOLS_BODY: &str = "project-overview-tools-body"; /// One line of the setup checklist. struct Step { /// What the reader is being asked to do. label: &'static str, /// Whether they have done it. done: bool, /// Where to go and do it, when there is somewhere and it is not done. act: Option<(&'static str, String)>, } impl Step { /// The three steps, in the order the template drew them. fn all(slug: &str, stripe_connected: bool, has_items: bool, has_published: bool) -> Vec { vec![ Self { label: "Add your first item: upload files, set a price", done: has_items, act: Some(("New Item", format!("/dashboard/project/{slug}/new-item"))), }, Self { label: "Connect Stripe: required to receive payments (3% processing only)", done: stripe_connected, act: Some(("Go to Payments", "/dashboard?tab=payments".to_owned())), }, Self { label: "Publish an item: make it visible on your public page", done: has_published, // The template offers this only once there is something to // publish, which is a real condition and not an oversight: // Content is an empty screen before the first item exists. act: has_items.then(|| { ( "Go to Content", format!("/dashboard/project/{slug}?tab=content"), ) }), }, ] } /// Whether the checklist has anything left to say. fn all_done(steps: &[Self]) -> bool { steps.iter().all(|step| step.done) } /// Whether this step offers somewhere to go. /// /// A predicate rather than an `Option` the description reaches into, which /// is the call `embeds::ItemView::has_cover` records: the form has no /// binding pattern, and a finished step offers nothing however its `act` /// reads. fn offers_act(&self) -> bool { !self.done && self.act.is_some() } /// What that control says, or nothing. /// /// R9: the control is built whether or not [`Self::offers_act`] places it, /// so the case that is not drawn is answered rather than panicked on. fn act_label(&self) -> &'static str { self.act.as_ref().map_or("", |(label, _)| *label) } /// Where it goes, or nowhere. See [`Self::act_label`]. fn act_href(&self) -> &str { self.act.as_ref().map_or("", |(_, href)| href.as_str()) } } declare! { /// One step as a row. /// /// The template wrote each step twice, once done and once not, and the two /// branches differ in a tick, a label class, and whether a CTA is there at /// all. Said once, that is two guarded settings over one row: a token when /// it is finished, a control when there is something to do about it. shape step_row(step: &Step) -> Row; row step.label { token Tag::badge("Done").tone(layout::Tone::Success) when step.done; act step.act_label() to external step.act_href() when step.offers_act(); } } /// The panel as the route answers it: the region, carrying its own id. #[must_use] pub fn fragment( slug: &str, stats: &[StatCard], stripe_connected: bool, has_items: bool, has_published: bool, ) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(REGION, RegionKind::Pane); for node in body(slug, stats, stripe_connected, has_items, has_published) { slot = slot.with(node); } Webview::new().fragment(&Node::Region(slot)) } /// The panel's contents as the page embeds them, without a region wrapper. #[must_use] pub fn fill( slug: &str, stats: &[StatCard], stripe_connected: bool, has_items: bool, has_published: bool, ) -> String { use quasi_axum::Serves as _; let mut out = String::new(); for node in body(slug, stats, stripe_connected, has_items, has_published) { out.push_str(&Webview::new().fragment(&node)); } out } declare! { /// The panel's contents, in order. shape body( slug: &str, stats: &[StatCard], stripe_connected: bool, has_items: bool, has_published: bool, ) -> Vec; let steps = Step::all(slug, stripe_connected, has_items, has_published); include setup(&steps) unless Step::all_done(&steps); link "Docs: Projects" to get "/docs/projects" navigating; include figures(stats); section "Quick Actions"; for node in quick_actions(slug) { include node; } include tools(); } declare! { /// What is left to do before the project can sell anything. shape setup(steps: &[Step]) -> Node; region "project-overview-setup" as Pane { subsection "Project Setup"; list { for step in steps.iter() { include step_row(step); } } } } /// The delta a card reports, or nothing. fn change(stat: &StatCard) -> &str { stat.change.as_deref().unwrap_or_default() } /// The tone rides on the delta, so a card with nothing to report stays neutral /// rather than going green for having no news. fn delta_tone(stat: &StatCard) -> layout::Tone { if stat.is_positive { layout::Tone::Success } else { layout::Tone::Danger } } declare! { /// The figures across the top. /// /// The same shape as `super::user_analytics::stats`, and toned the same /// way: see [`delta_tone`]. /// /// The empty list is what the figures accrete onto, which is the wart /// `super::project_analytics` recorded and this is its second site. shape figures(stats: &[StatCard]) -> Node; stats [] { for stat in stats.iter() { figure Figure::new(stat.value.clone(), stat.label.clone()) when stat.change.is_none(); figure Figure::new(stat.value.clone(), stat.label.clone()) .change(change(stat)) .tone(delta_tone(stat)) unless stat.change.is_none(); } } } declare! { /// The three controls under Quick Actions. shape quick_actions(slug: &str) -> Vec; // Whole pages rather than fragments, so both leave. An internal // `get` would fetch them into this panel. act "New Item" to external "/dashboard/project/{slug}/new-item"; act "View Public Page" to external "/p/{slug}"; // Through `export_act`, which is the described control five other sites // already use. See the module header. include super::export_act::act("/api/export/projects", "projects.csv"); } /// One tab the disclosure tours. /// /// Named members rather than a tuple, for `policy`'s reason: a description /// names what it draws, and `.1` is not a name. struct Tool { /// What the tab is called. name: &'static str, /// What it is for. description: &'static str, } /// The six tabs the disclosure tours, in the order it draws them. const TOOLS_LIST: &[Tool] = &[ Tool { name: "Content", description: "Upload items, manage versions, set prices.", }, Tool { name: "Blog", description: "Write posts that appear on your project page and RSS feed.", }, Tool { name: "Promo Codes", description: "Create discounts, free access codes, or trial periods.", }, Tool { name: "Membership Tiers", description: "Recurring subscriptions with gated content access.", }, Tool { name: "Team", description: "Add collaborators and split revenue automatically.", }, Tool { name: "Analytics", description: "Track sales, revenue, and views over time.", }, ]; declare! { /// The tour of the other tabs, behind a disclosure. /// /// The shape is what makes this a disclosure, and it is exact: a region /// showing at most one frame, whose single frame is a LABELLED sub-region. /// The label is the summary line. Put it on the outer region instead and /// the renderer finds no labels, falls through to the frame-stepper branch, /// and draws Prev/Next buttons and a "0 / 1" counter. Measured 2026-08-26 /// by doing exactly that. /// /// `None` is closed, which is where the template's `
` rests: it /// carries no `open`. shape tools() -> Node; region TOOLS as Group { region TOOLS_BODY as Pane { label "Explore Your Project Tools"; list { for tool in TOOLS_LIST { row tool.name { secondary tool.description; } } } } showing_at_most_one None; } } #[cfg(test)] mod tests { use super::*; use quasi_axum::Serves; fn stat(label: &str, change: Option<&str>, positive: bool) -> StatCard { StatCard { label: label.into(), value: "12".into(), change: change.map(Into::into), is_positive: positive, } } fn render(slug: &str, stripe: bool, items: bool, published: bool) -> String { let mut out = String::new(); for node in &body(slug, &[stat("Items", None, true)], stripe, items, published) { out.push_str(&Webview::new().fragment(node)); } out } #[test] fn a_finished_project_is_not_shown_the_setup_checklist() { let done = render("an-album", true, true, true); assert!(!done.contains("Project Setup"), "{done}"); let unfinished = render("an-album", false, true, true); assert!(unfinished.contains("Project Setup"), "{unfinished}"); } #[test] fn a_finished_step_says_done_and_offers_nothing() { let html = render("an-album", false, true, false); // Items is done, so its CTA is gone and the tick is there. assert!(html.contains("Done"), "{html}"); assert!( !html.contains("/dashboard/project/an-album/new-item\">New Item"), "a finished step still offers its CTA: {html}" ); // Stripe is not, so its CTA is there. assert!(html.contains("/dashboard?tab=payments"), "{html}"); } #[test] fn publish_offers_content_only_once_there_is_something_to_publish() { let empty = render("an-album", false, false, false); let stocked = render("an-album", false, true, false); assert!(!empty.contains("Go to Content"), "{empty}"); assert!(stocked.contains("Go to Content"), "{stocked}"); } #[test] fn the_tools_disclosure_is_closed_and_holds_all_six() { let html = render("an-album", true, true, true); assert!(html.contains("Explore Your Project Tools"), "{html}"); // It is a disclosure and not a frame-stepper. Both are legal renderings // of a selective region and only one of them is this screen; getting // the shape wrong draws Prev/Next and a counter, which is what happened // on the first attempt. assert!(html.contains("aria-expanded=\"false\""), "{html}"); assert!(!html.contains("data-shows=\"next\""), "{html}"); assert!(!html.contains("data-shows=\"previous\""), "{html}"); for tool in [ "Content", "Blog", "Promo Codes", "Membership Tiers", "Team", "Analytics", ] { assert!(html.contains(tool), "missing {tool}: {html}"); } } #[test] fn the_export_is_the_described_one_and_not_a_sixth_spelling() { let html = render("an-album", true, true, true); assert!(html.contains("data-saves=\"projects.csv\""), "{html}"); assert!(html.contains("hx-post=\"/api/export/projects\""), "{html}"); // What the template did instead: staple the answer onto the document. assert!(!html.contains("hx-swap=\"beforeend\""), "{html}"); assert!(!html.contains("data-action"), "{html}"); } #[test] fn a_figure_without_a_delta_stays_neutral() { let out = Webview::new().fragment(&figures(&[stat("Items", None, true)])); assert!(!out.contains("data-tone=\"success\""), "{out}"); } #[test] fn the_slug_cannot_smuggle_markup() { let html = render("", false, false, false); assert!(!html.contains("