//! The project dashboard's Content panel, described. //! //! B4's centrepiece, and the close condition makeover-layout `N12` has been //! waiting on since 2026-08-18. That task decided two things about this screen //! and then had nowhere to spend them: the ticks are a payload the renderer //! assembles rather than something the layer names, and the three filter //! controls are a **server round trip** rather than a client-side pass over //! rendered rows. Both are what this module is built out of. //! //! Compare `routes::pages::dashboard::project_tabs::build_content`, which //! answers the same address from Askama when the screen is switched off, and //! `static/tab-project-content.js`, whose 334 lines are what a described panel //! replaces. //! //! # A view is an address //! //! Every narrowing the shipped screen holds in module scope -- the search box, //! the two selects, the sort column and its direction, which bundles are //! expanded -- is a query param here, and [`View`] is the whole of that state. //! `filterContentTable` walked the DOM and toggled a `hidden` class on rows; //! `sortContentTable` reordered `` nodes and kept `contentSortState` in a //! closure. Neither survives a fragment swap, which is why the JS re-applied //! both after every refresh, and neither is reachable with JS off. //! //! The cost is a round trip per keystroke-settle and per press, which is what //! `N12` weighed and accepted: this is one table in one project, already fetched //! from Postgres on every tab press, and the count that decided it found no //! other local filter in the tree to generalise from. //! //! # What the selection costs, and what it does not //! //! Nothing here counts the ticks. A tick is the host's until something submits //! it, so "3 selected" and the disabled-until-non-empty bar are the renderer's //! (quasi ships `quasi-selection.js` for the webview) and no description says //! them. Select-all is the other half and is an address -- //! [`View::ticked`] -- for goingson's reason: a renderer that ticked its own //! boxes would need a script per host, and the answer to a filter change is a //! new set of rows anyway. //! //! # The parity harness does not apply to this one //! //! Every batch before this asserted the described screen rendered what Askama //! rendered, normalized. This screen cannot: `N12` decided the filters are a //! round trip, so the two renderings answer *different sets of rows* for the //! same address, and the tick column is a gutter rather than a ``. The //! safety argument is the pressed-controls suite instead //! (`tests/workflows/project_content_panel.rs`), which is the harness the //! earlier batches added when they found that an address is not an answer. //! //! # Findings //! //! **1. Sales and Revenue are zero for every row, and always have been.** //! `ContentItem::from_db` writes `sales: 0` and `revenue: "$0"`, and nothing on //! the content path fills them in; only the analytics tab computes real //! numbers. So two of the table's eight columns have shown the same two //! constants since they were added, and two of the JS sort modes (`num`, //! `money`) sorted a column of identical values. Carried forward here rather //! than fixed, because fixing it is a query this conversion should not be //! choosing, and filed instead. //! //! **2. The status filter could not name every status a row can wear.** The //! template offers Active and Draft; `ContentItem::from_db` also produces //! Scheduled, for an item with a `publish_at`. Under the JS the filter compared //! the badge text exactly, so picking either option hid every scheduled item //! and nothing offered a way back to them. [`STATUSES`] offers all three. //! //! **3. Inline rename survives, as a control that asks for a value.** The JS //! swapped the title cell for an `` and saved on blur, which no //! description says. [`Act::asking`] is the shape that does -- the same member //! "Set Price" uses -- so Rename is a row control that reveals one box and //! applies it. What is lost is editing in place; what is gained is a rename //! that works with JS off and in a terminal. //! //! **4. Bundle children expand through the address.** `toggleBundleChildren` //! toggled a class on rows already in the document. There is no disclosure in //! the vocabulary for a *row group*, and rather than reach for one this carries //! the open bundles on the view: pressing Expand is a round trip that answers //! the panel with the children in it. Cheap here (they are already loaded) and //! it survives a swap, which the class did not. use makeover_layout as layout; use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Consult, Field, Tag}; use quasi_router::{Action, Node, RegionKind, Slot}; use quasi_webview::Webview; use crate::templates::DeletedItemRow; use crate::types::{BlogPostDashboardRow, ContentItem}; /// The region the answer replaces: the panel the project tab strip targets. /// /// The same id `quasi::project_tabs` gives the Content tab's bespoke region, so /// a described answer lands where a pressed tab lands. pub const REGION: &str = "project-content"; /// The screen's one selection, named once. /// /// See [`Act::over`] for why a renderer does not match this against the screen: /// a fragment carries no screen, so the name is what makes a control readable /// rather than what binds it to a set. const SELECTION: &str = "chosen"; /// How long the search box waits before asking, and how little it will ask about. /// /// The shipped box filters on every keystroke because filtering was local and /// free. It is a query now, so it waits: `page-discover.js`'s numbers, which are /// the corpus' own for a search box over a catalogue. const SEARCH_WAIT: std::time::Duration = std::time::Duration::from_millis(200); const SEARCH_FLOOR: usize = 2; /// The statuses the filter offers. /// /// All three a row can wear. See finding 2: the template offered two of them. const STATUSES: [&str; 3] = ["Active", "Draft", "Scheduled"]; /// What the table can be ordered by. /// /// The six sortable headings, in column order. `#` is the project's own order /// and is what the reorder arrows change, so it is not one of these: sorting by /// it and then pressing an arrow would move a row against an order the reader /// cannot see. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortBy { Item, Kind, Price, Sales, Revenue, Status, } impl SortBy { /// The word it travels as. const fn word(self) -> &'static str { match self { Self::Item => "item", Self::Kind => "type", Self::Price => "price", Self::Sales => "sales", Self::Revenue => "revenue", Self::Status => "status", } } /// The column it orders, or `None` for a word no heading carries. /// /// Strict rather than defaulting: an address naming a column that does not /// exist is a wiring mistake, and answering it with the default order hides /// one. fn of(word: &str) -> Option { [ Self::Item, Self::Kind, Self::Price, Self::Sales, Self::Revenue, Self::Status, ] .into_iter() .find(|sort| sort.word() == word) } } /// Which rows, in what order, and which bundles are open. /// /// Query params rather than module state, per quasi's decision 2 and `N12`'s /// ruling. Every member here is a fact `tab-project-content.js` held in a /// closure and re-applied by hand after each swap. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct View { /// What the search box holds. Blank is absent. pub query: Option, /// The status being looked at. `None` is every status. pub status: Option, /// The item type being looked at. `None` is every type. pub kind: Option, /// What the table is ordered by. `None` is the project's own order. pub sort: Option, /// Which way, when there is a sort at all. pub descending: bool, /// The bundles whose children are showing. pub open: Vec, /// Whether the rows arrive ticked. /// /// Select-all, and it is an address for the reason goingson's task list /// gives: a webview renderer would need a script to tick its own boxes and a /// terminal would need a key it invented, where a server answers it for /// every host at once. Only the arriving state; what the reader unticks /// afterwards is the host's. pub ticked: bool, } impl View { /// The view a request asked for. /// /// A blank param is an absent one, which is what the "All statuses" option /// sends. An unknown sort column is a 404 rather than a silent default; see /// [`SortBy::of`]. #[must_use] pub fn of( query: Option<&str>, status: Option<&str>, kind: Option<&str>, sort: Option<&str>, direction: Option<&str>, open: Option<&str>, ticked: Option<&str>, ) -> Option { let text = |raw: Option<&str>| { raw.map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) }; let sort = match text(sort) { None => None, Some(word) => Some(SortBy::of(&word)?), }; Some(Self { query: text(query), status: text(status).filter(|word| STATUSES.contains(&word.as_str())), kind: text(kind), sort, descending: matches!(direction, Some("desc")), open: text(open) .map(|raw| raw.split(' ').map(str::to_owned).collect()) .unwrap_or_default(), ticked: matches!(ticked, Some("all")), }) } /// The address of the panel under this view, aimed at the region it fills. fn panel(&self, slug: &str) -> Action { self.carry(Action::get(base(slug))).replacing(REGION) } /// A write under this view, answering the panel it was pressed on. /// /// Every described write here carries the view for one reason: the answer is /// the whole panel, so a bulk publish under a filter has to come back under /// that filter or the reader is moved somewhere they did not ask to go. fn write(&self, slug: &str, tail: &str) -> Action { self.carry(Action::post(format!("{}/{tail}", base(slug)))) .replacing(REGION) .awaiting() } /// The same action, still pointed at the view it was offered under. /// /// A default is never written, so two addresses for one view cannot exist. fn carry(&self, action: Action) -> Action { let mut action = action; if let Some(query) = &self.query { action = action.carrying("q", query); } if let Some(status) = &self.status { action = action.carrying("status", status); } if let Some(kind) = &self.kind { action = action.carrying("type", kind); } if let Some(sort) = self.sort { action = action.carrying("sort", sort.word()); if self.descending { action = action.carrying("direction", "desc"); } } if !self.open.is_empty() { action = action.carrying("open", self.open.join(" ")); } if self.ticked { action = action.carrying("ticked", "all"); } action } /// The view ordered by this column: flipped if it is already the sort, /// ascending if it is not. `sortContentTable`'s own rule. fn sorted_by(&self, sort: SortBy) -> Self { Self { sort: Some(sort), descending: self.sort == Some(sort) && !self.descending, ..self.narrowed() } } /// The same view with one bundle's children shown, or hidden if they were. fn toggling(&self, bundle: &str) -> Self { let mut open = self.open.clone(); if let Some(at) = open.iter().position(|id| id == bundle) { open.remove(at); } else { open.push(bundle.to_owned()); } Self { open, ..self.clone() } } /// The same view with the arriving ticks dropped. /// /// What a narrowing keeps. The ticks a reader made go with the rows they /// were on, which is the answer being a new list; `ticked=all` is carried on /// the address and would silently come to mean a different everything. /// `tab-project-content.js` clears the selection on a filter change by hand /// for the same reason. fn narrowed(&self) -> Self { Self { ticked: false, ..self.clone() } } /// The same view with one control's own value dropped. /// /// What a control's address has to leave out. A field sends its value under /// its own name, so an address that also carried the value it was offered /// under would send the old one beside the new one and the handler would /// have to guess which was meant. fn without_query(&self) -> Self { Self { query: None, ..self.clone() } } fn without_status(&self) -> Self { Self { status: None, ..self.clone() } } fn without_kind(&self) -> Self { Self { kind: None, ..self.clone() } } /// Whether anything is hidden. What "Clear filters" is offered for, and it /// ignores the sort and the open bundles: neither hides a row. #[must_use] pub fn filtered(&self) -> bool { self.query.is_some() || self.status.is_some() || self.kind.is_some() } /// Whether this is the view an unadorned address asks for. /// /// Read by the route to decide whether the panel may carry the project's /// ETag: the tag is the project's cache generation, which does not move when /// a filter does, so a narrowed panel must not be cached under it. #[must_use] pub fn is_default(&self) -> bool { *self == Self::default() } /// Whether a row survives the narrowing. fn keeps(&self, item: &ContentItem) -> bool { let matches_query = self.query.as_ref().is_none_or(|query| { item.title .to_lowercase() .contains(&query.trim().to_lowercase()) }); let matches_status = self .status .as_ref() .is_none_or(|status| &item.status == status); let matches_kind = self .kind .as_ref() .is_none_or(|kind| &item.item_type == kind); matches_query && matches_status && matches_kind } /// The rows this view shows, in the order it shows them. fn shown<'a>(&self, items: &'a [ContentItem]) -> Vec<&'a ContentItem> { let mut shown: Vec<&ContentItem> = items.iter().filter(|item| self.keeps(item)).collect(); if let Some(sort) = self.sort { // Stable, so rows that tie keep the project's own order rather than // a different one each press. shown.sort_by(|a, b| { let ordering = match sort { SortBy::Item => a.title.to_lowercase().cmp(&b.title.to_lowercase()), SortBy::Kind => a.item_type.to_lowercase().cmp(&b.item_type.to_lowercase()), SortBy::Price => a.price_cents.cmp(&b.price_cents), SortBy::Sales => a.sales.cmp(&b.sales), // Finding 1: every row's revenue is the same string, so this // orders nothing until the numbers are real. Sorted on the // text rather than on a parse of it, which would be a parse // of our own formatting. SortBy::Revenue => a.revenue.cmp(&b.revenue), SortBy::Status => a.status.cmp(&b.status), }; if self.descending { ordering.reverse() } else { ordering } }); } shown } } /// The address every control on this panel is written against. fn base(slug: &str) -> String { format!("/dashboard/project/{slug}/tabs/content") } /// The tone a badge wears, from the tone word the view model already picked. /// /// The template writes `data-tone="{{ status_tone }}"` and the described badge /// says the same thing in the vocabulary's words, so the two renderings agree /// about which item looks live. fn tone(word: &str) -> layout::Tone { match word { "success" => layout::Tone::Success, "warning" => layout::Tone::Warning, "danger" => layout::Tone::Danger, _ => layout::Tone::Neutral, } } /// The ghost text a field shows while it is empty. /// /// `Field::placeholder` is a public member with no builder beside it, so this is /// the one line that would otherwise be a `let mut` in three places. fn ghost(mut field: Field, text: &str) -> Field { field.placeholder = Some(text.to_owned()); field } /// Everything inside the Content panel, as the tab strip's fill. /// /// Without the region wrapper, because the strip already draws one carrying /// [`REGION`] and the panel goes inside it. [`fragment`] is the same content /// wrapped, which is what a route answers. #[must_use] pub fn fill( slug: &str, items: &[ContentItem], deleted: &[DeletedItemRow], posts: &[BlogPostDashboardRow], view: &View, ) -> String { use quasi_axum::Serves as _; let mut out = String::new(); for node in body(slug, items, deleted, posts, view) { out.push_str(&Webview::new().fragment(&node)); } out } /// The panel as a route answers it: the region, carrying its own id. /// /// The wrapper matters. A control here aims its answer with /// `hx-target="#project-content"` and htmx swaps `outerMorph`, so an answer /// without the id would replace the panel with markup nothing can target /// afterwards. #[must_use] pub fn fragment( slug: &str, items: &[ContentItem], deleted: &[DeletedItemRow], posts: &[BlogPostDashboardRow], view: &View, ) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(REGION, RegionKind::Pane); for node in body(slug, items, deleted, posts, view) { slot = slot.with(node); } Webview::new().fragment(&Node::Region(slot)) } /// The panel's contents, in order. fn body( slug: &str, items: &[ContentItem], deleted: &[DeletedItemRow], posts: &[BlogPostDashboardRow], view: &View, ) -> Vec { let mut out = vec![ Node::section("Items"), Node::act( "New Item", // A whole page rather than a fragment, so it leaves: see // `quasi::forum_memberships` for the same spelling. An internal // `Action::get` would fetch the wizard into this panel. Action::external(format!("/dashboard/project/{slug}/new-item")), ), ]; if items.is_empty() { // The project's own empty state, which is a different sentence from a // filter matching nothing and is the only one that offers a way to make // the first item. out.push( Node::empty("No items in this project yet. Add your first item to start publishing.") .offering(Act::new( "Add First Item", Action::external(format!("/dashboard/project/{slug}/new-item")), )), ); out.push(Node::text( "After creating an item, set its pricing and publish it to make it available to fans.", )); } else { out.push(Node::Region(filters(slug, items, view))); out.push(Node::Region(bulk(slug, view))); let shown = view.shown(items); if shown.is_empty() { out.push( Node::empty("No items match these filters.").offering(Act::new( "Clear filters", View { sort: view.sort, descending: view.descending, ..View::default() } .panel(slug), )), ); } else { out.push(table(slug, view, &shown)); } } if !deleted.is_empty() { out.push(Node::section(format!( "Recently Deleted ({})", deleted.len() ))); out.push(Node::text( "Deleted items are permanently removed after 7 days.", )); out.push(deleted_table(slug, view, deleted)); } out.push(Node::section("Blog Posts")); out.push(Node::act( "New Post", Action::external(format!("/dashboard/project/{slug}/blog/new")), )); if posts.is_empty() { out.push(Node::empty( "No blog posts yet. Share updates, release notes, or stories with your audience.", )); } else { out.push(posts_table(slug, view, posts)); } out } /// The three narrowing controls. /// /// `N12`'s decision in one function: each is a control with a route, and the /// answer is the panel under the new view. The search box asks as it is typed /// ([`Consult`]) and the two pickers write when they settle /// ([`Field::changes`]) -- which is the difference between a value that is /// still being written and one that is chosen in a single gesture. fn filters(slug: &str, items: &[ContentItem], view: &View) -> Slot { let narrowed = view.narrowed(); let search = ghost( Field::new(layout::FieldKind::Text, "q", "Search items"), "Search items...", ) .value(view.query.clone().unwrap_or_default()) .consulting( Consult::new(narrowed.without_query().panel(slug)) .after(SEARCH_WAIT) .at_least(SEARCH_FLOOR), ); let mut statuses = vec![Choice::new("", "All statuses")]; statuses.extend(STATUSES.iter().map(|status| Choice::new(*status, *status))); // The type options are the types this project actually holds. The template // emitted one `