//! 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_router::screen::{Act, Figure, Row, Tag}; use quasi_router::{Action, 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) } /// One step as a row. fn row(&self) -> Row { let mut row = Row::new(self.label); if self.done { let mut tick = Tag::badge("Done"); tick.tone = layout::Tone::Success; return row.token(tick); } if let Some((label, href)) = &self.act { row = row.act(Act::new(*label, Action::external(href.clone()))); } row } } /// 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 } /// The panel's contents, in order. fn body( slug: &str, stats: &[StatCard], stripe_connected: bool, has_items: bool, has_published: bool, ) -> Vec { let mut out = Vec::new(); let steps = Step::all(slug, stripe_connected, has_items, has_published); if !Step::all_done(&steps) { out.push(setup(&steps)); } out.push(Node::Link { text: "Docs: Projects".into(), action: Action::get("/docs/projects").navigating(), }); out.push(figures(stats)); out.push(Node::section("Quick Actions")); out.extend(quick_actions(slug)); out.push(tools()); out } /// What is left to do before the project can sell anything. fn setup(steps: &[Step]) -> Node { Node::Region( Slot::new("project-overview-setup", RegionKind::Pane) .with(Node::Heading { level: layout::Heading::Subsection, text: "Project Setup".into(), }) .with(Node::list(steps.iter().map(Step::row))), ) } /// The figures across the top. /// /// The same shape as `super::user_analytics::stats`, and toned the same way: /// the tone rides on the delta, so a card with nothing to report stays neutral /// rather than going green for having no news. fn figures(stats: &[StatCard]) -> Node { Node::Stats { figures: stats .iter() .map(|stat| { let mut figure = Figure::new(stat.value.clone(), stat.label.clone()); if let Some(change) = &stat.change { figure = figure.change(change.clone()).tone(if stat.is_positive { layout::Tone::Success } else { layout::Tone::Danger }); } (figure, None) }) .collect(), } } /// The three controls under Quick Actions. fn quick_actions(slug: &str) -> Vec { vec![ // Whole pages rather than fragments, so both leave. An internal // `Action::get` would fetch them into this panel. Node::act( "New Item", Action::external(format!("/dashboard/project/{slug}/new-item")), ), Node::act("View Public Page", Action::external(format!("/p/{slug}"))), // Through `export_act`, which is the described control five other sites // already use. See the module header. super::export_act::act("/api/export/projects", "projects.csv"), ] } /// The tour of the other tabs, behind a disclosure. fn tools() -> Node { const TOOLS_LIST: &[(&str, &str)] = &[ ("Content", "Upload items, manage versions, set prices."), ( "Blog", "Write posts that appear on your project page and RSS feed.", ), ( "Promo Codes", "Create discounts, free access codes, or trial periods.", ), ( "Membership Tiers", "Recurring subscriptions with gated content access.", ), ("Team", "Add collaborators and split revenue automatically."), ("Analytics", "Track sales, revenue, and views over time."), ]; // 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 slot 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. Node::Region( Slot::new(TOOLS, RegionKind::Group) .with(Node::Region( Slot::new(TOOLS_BODY, RegionKind::Pane) .label("Explore Your Project Tools") .with(Node::list(TOOLS_LIST.iter().map(|(name, description)| { Row::new(*name).secondary(*description) }))), )) // `None` is closed, which is where the template's `
` rests: // it carries no `open`. .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("