Skip to main content

max / makenotwork

48.9 KB · 1359 lines History Blame Raw
1 //! The project dashboard's Content panel, described.
2 //!
3 //! B4's centrepiece, and the close condition makeover-layout `N12` has been
4 //! waiting on since 2026-08-18. That task decided two things about this screen
5 //! and then had nowhere to spend them: the ticks are a payload the renderer
6 //! assembles rather than something the layer names, and the three filter
7 //! controls are a **server round trip** rather than a client-side pass over
8 //! rendered rows. Both are what this module is built out of.
9 //!
10 //! Compare `routes::pages::dashboard::project_tabs::build_content`, which
11 //! answers the same address from Askama when the screen is switched off, and
12 //! `static/tab-project-content.js`, whose 334 lines are what a described panel
13 //! replaces.
14 //!
15 //! # A view is an address
16 //!
17 //! Every narrowing the shipped screen holds in module scope -- the search box,
18 //! the two selects, the sort column and its direction, which bundles are
19 //! expanded -- is a query param here, and [`View`] is the whole of that state.
20 //! `filterContentTable` walked the DOM and toggled a `hidden` class on rows;
21 //! `sortContentTable` reordered `<tr>` nodes and kept `contentSortState` in a
22 //! closure. Neither survives a fragment swap, which is why the JS re-applied
23 //! both after every refresh, and neither is reachable with JS off.
24 //!
25 //! The cost is a round trip per keystroke-settle and per press, which is what
26 //! `N12` weighed and accepted: this is one table in one project, already fetched
27 //! from Postgres on every tab press, and the count that decided it found no
28 //! other local filter in the tree to generalise from.
29 //!
30 //! # What the selection costs, and what it does not
31 //!
32 //! Nothing here counts the ticks. A tick is the host's until something submits
33 //! it, so "3 selected" and the disabled-until-non-empty bar are the renderer's
34 //! (quasi ships `quasi-selection.js` for the webview) and no description says
35 //! them. Select-all is the other half and is an address --
36 //! [`View::ticked`] -- for goingson's reason: a renderer that ticked its own
37 //! boxes would need a script per host, and the answer to a filter change is a
38 //! new set of rows anyway.
39 //!
40 //! # The parity harness does not apply to this one
41 //!
42 //! Every batch before this asserted the described screen rendered what Askama
43 //! rendered, normalized. This screen cannot: `N12` decided the filters are a
44 //! round trip, so the two renderings answer *different sets of rows* for the
45 //! same address, and the tick column is a gutter rather than a `<th>`. The
46 //! safety argument is the pressed-controls suite instead
47 //! (`tests/workflows/project_content_panel.rs`), which is the harness the
48 //! earlier batches added when they found that an address is not an answer.
49 //!
50 //! # Findings
51 //!
52 //! **1. Sales and Revenue are zero for every row, and always have been.**
53 //! `ContentItem::from_db` writes `sales: 0` and `revenue: "$0"`, and nothing on
54 //! the content path fills them in; only the analytics tab computes real
55 //! numbers. So two of the table's eight columns have shown the same two
56 //! constants since they were added, and two of the JS sort modes (`num`,
57 //! `money`) sorted a column of identical values. Carried forward here rather
58 //! than fixed, because fixing it is a query this conversion should not be
59 //! choosing, and filed instead.
60 //!
61 //! **2. The status filter could not name every status a row can wear.** The
62 //! template offers Active and Draft; `ContentItem::from_db` also produces
63 //! Scheduled, for an item with a `publish_at`. Under the JS the filter compared
64 //! the badge text exactly, so picking either option hid every scheduled item
65 //! and nothing offered a way back to them. [`STATUSES`] offers all three.
66 //!
67 //! **3. Inline rename survives, as a control that asks for a value.** The JS
68 //! swapped the title cell for an `<input>` and saved on blur, which no
69 //! description says. [`Act::asking`] is the shape that does -- the same member
70 //! "Set Price" uses -- so Rename is a row control that reveals one box and
71 //! applies it. What is lost is editing in place; what is gained is a rename
72 //! that works with JS off and in a terminal.
73 //!
74 //! **4. Bundle children expand through the address.** `toggleBundleChildren`
75 //! toggled a class on rows already in the document. There is no disclosure in
76 //! the vocabulary for a *row group*, and rather than reach for one this carries
77 //! the open bundles on the view: pressing Expand is a round trip that answers
78 //! the panel with the children in it. Cheap here (they are already loaded) and
79 //! it survives a swap, which the class did not.
80
81 use makeover_layout as layout;
82 use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Consult, Field, Tag};
83 use quasi_router::{Action, Node, RegionKind, Slot};
84 use quasi_webview::Webview;
85
86 use crate::templates::DeletedItemRow;
87 use crate::types::{BlogPostDashboardRow, ContentItem};
88
89 /// The region the answer replaces: the panel the project tab strip targets.
90 ///
91 /// The same id `quasi::project_tabs` gives the Content tab's bespoke region, so
92 /// a described answer lands where a pressed tab lands.
93 pub const REGION: &str = "project-content";
94
95 /// The screen's one selection, named once.
96 ///
97 /// See [`Act::over`] for why a renderer does not match this against the screen:
98 /// a fragment carries no screen, so the name is what makes a control readable
99 /// rather than what binds it to a set.
100 const SELECTION: &str = "chosen";
101
102 /// How long the search box waits before asking, and how little it will ask about.
103 ///
104 /// The shipped box filters on every keystroke because filtering was local and
105 /// free. It is a query now, so it waits: `page-discover.js`'s numbers, which are
106 /// the corpus' own for a search box over a catalogue.
107 const SEARCH_WAIT: std::time::Duration = std::time::Duration::from_millis(200);
108 const SEARCH_FLOOR: usize = 2;
109
110 /// The statuses the filter offers.
111 ///
112 /// All three a row can wear. See finding 2: the template offered two of them.
113 const STATUSES: [&str; 3] = ["Active", "Draft", "Scheduled"];
114
115 /// What the table can be ordered by.
116 ///
117 /// The six sortable headings, in column order. `#` is the project's own order
118 /// and is what the reorder arrows change, so it is not one of these: sorting by
119 /// it and then pressing an arrow would move a row against an order the reader
120 /// cannot see.
121 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
122 pub enum SortBy {
123 Item,
124 Kind,
125 Price,
126 Sales,
127 Revenue,
128 Status,
129 }
130
131 impl SortBy {
132 /// The word it travels as.
133 const fn word(self) -> &'static str {
134 match self {
135 Self::Item => "item",
136 Self::Kind => "type",
137 Self::Price => "price",
138 Self::Sales => "sales",
139 Self::Revenue => "revenue",
140 Self::Status => "status",
141 }
142 }
143
144 /// The column it orders, or `None` for a word no heading carries.
145 ///
146 /// Strict rather than defaulting: an address naming a column that does not
147 /// exist is a wiring mistake, and answering it with the default order hides
148 /// one.
149 fn of(word: &str) -> Option<Self> {
150 [
151 Self::Item,
152 Self::Kind,
153 Self::Price,
154 Self::Sales,
155 Self::Revenue,
156 Self::Status,
157 ]
158 .into_iter()
159 .find(|sort| sort.word() == word)
160 }
161 }
162
163 /// Which rows, in what order, and which bundles are open.
164 ///
165 /// Query params rather than module state, per quasi's decision 2 and `N12`'s
166 /// ruling. Every member here is a fact `tab-project-content.js` held in a
167 /// closure and re-applied by hand after each swap.
168 #[derive(Debug, Clone, Default, PartialEq, Eq)]
169 pub struct View {
170 /// What the search box holds. Blank is absent.
171 pub query: Option<String>,
172 /// The status being looked at. `None` is every status.
173 pub status: Option<String>,
174 /// The item type being looked at. `None` is every type.
175 pub kind: Option<String>,
176 /// What the table is ordered by. `None` is the project's own order.
177 pub sort: Option<SortBy>,
178 /// Which way, when there is a sort at all.
179 pub descending: bool,
180 /// The bundles whose children are showing.
181 pub open: Vec<String>,
182 /// Whether the rows arrive ticked.
183 ///
184 /// Select-all, and it is an address for the reason goingson's task list
185 /// gives: a webview renderer would need a script to tick its own boxes and a
186 /// terminal would need a key it invented, where a server answers it for
187 /// every host at once. Only the arriving state; what the reader unticks
188 /// afterwards is the host's.
189 pub ticked: bool,
190 }
191
192 impl View {
193 /// The view a request asked for.
194 ///
195 /// A blank param is an absent one, which is what the "All statuses" option
196 /// sends. An unknown sort column is a 404 rather than a silent default; see
197 /// [`SortBy::of`].
198 #[must_use]
199 pub fn of(
200 query: Option<&str>,
201 status: Option<&str>,
202 kind: Option<&str>,
203 sort: Option<&str>,
204 direction: Option<&str>,
205 open: Option<&str>,
206 ticked: Option<&str>,
207 ) -> Option<Self> {
208 let text = |raw: Option<&str>| {
209 raw.map(str::trim)
210 .filter(|value| !value.is_empty())
211 .map(str::to_owned)
212 };
213
214 let sort = match text(sort) {
215 None => None,
216 Some(word) => Some(SortBy::of(&word)?),
217 };
218
219 Some(Self {
220 query: text(query),
221 status: text(status).filter(|word| STATUSES.contains(&word.as_str())),
222 kind: text(kind),
223 sort,
224 descending: matches!(direction, Some("desc")),
225 open: text(open)
226 .map(|raw| raw.split(' ').map(str::to_owned).collect())
227 .unwrap_or_default(),
228 ticked: matches!(ticked, Some("all")),
229 })
230 }
231
232 /// The address of the panel under this view, aimed at the region it fills.
233 fn panel(&self, slug: &str) -> Action {
234 self.carry(Action::get(base(slug))).replacing(REGION)
235 }
236
237 /// A write under this view, answering the panel it was pressed on.
238 ///
239 /// Every described write here carries the view for one reason: the answer is
240 /// the whole panel, so a bulk publish under a filter has to come back under
241 /// that filter or the reader is moved somewhere they did not ask to go.
242 fn write(&self, slug: &str, tail: &str) -> Action {
243 self.carry(Action::post(format!("{}/{tail}", base(slug))))
244 .replacing(REGION)
245 .awaiting()
246 }
247
248 /// The same action, still pointed at the view it was offered under.
249 ///
250 /// A default is never written, so two addresses for one view cannot exist.
251 fn carry(&self, action: Action) -> Action {
252 let mut action = action;
253 if let Some(query) = &self.query {
254 action = action.carrying("q", query);
255 }
256 if let Some(status) = &self.status {
257 action = action.carrying("status", status);
258 }
259 if let Some(kind) = &self.kind {
260 action = action.carrying("type", kind);
261 }
262 if let Some(sort) = self.sort {
263 action = action.carrying("sort", sort.word());
264 if self.descending {
265 action = action.carrying("direction", "desc");
266 }
267 }
268 if !self.open.is_empty() {
269 action = action.carrying("open", self.open.join(" "));
270 }
271 if self.ticked {
272 action = action.carrying("ticked", "all");
273 }
274 action
275 }
276
277 /// The view ordered by this column: flipped if it is already the sort,
278 /// ascending if it is not. `sortContentTable`'s own rule.
279 fn sorted_by(&self, sort: SortBy) -> Self {
280 Self {
281 sort: Some(sort),
282 descending: self.sort == Some(sort) && !self.descending,
283 ..self.narrowed()
284 }
285 }
286
287 /// The same view with one bundle's children shown, or hidden if they were.
288 fn toggling(&self, bundle: &str) -> Self {
289 let mut open = self.open.clone();
290 if let Some(at) = open.iter().position(|id| id == bundle) {
291 open.remove(at);
292 } else {
293 open.push(bundle.to_owned());
294 }
295 Self {
296 open,
297 ..self.clone()
298 }
299 }
300
301 /// The same view with the arriving ticks dropped.
302 ///
303 /// What a narrowing keeps. The ticks a reader made go with the rows they
304 /// were on, which is the answer being a new list; `ticked=all` is carried on
305 /// the address and would silently come to mean a different everything.
306 /// `tab-project-content.js` clears the selection on a filter change by hand
307 /// for the same reason.
308 fn narrowed(&self) -> Self {
309 Self {
310 ticked: false,
311 ..self.clone()
312 }
313 }
314
315 /// The same view with one control's own value dropped.
316 ///
317 /// What a control's address has to leave out. A field sends its value under
318 /// its own name, so an address that also carried the value it was offered
319 /// under would send the old one beside the new one and the handler would
320 /// have to guess which was meant.
321 fn without_query(&self) -> Self {
322 Self {
323 query: None,
324 ..self.clone()
325 }
326 }
327
328 fn without_status(&self) -> Self {
329 Self {
330 status: None,
331 ..self.clone()
332 }
333 }
334
335 fn without_kind(&self) -> Self {
336 Self {
337 kind: None,
338 ..self.clone()
339 }
340 }
341
342 /// Whether anything is hidden. What "Clear filters" is offered for, and it
343 /// ignores the sort and the open bundles: neither hides a row.
344 #[must_use]
345 pub fn filtered(&self) -> bool {
346 self.query.is_some() || self.status.is_some() || self.kind.is_some()
347 }
348
349 /// Whether this is the view an unadorned address asks for.
350 ///
351 /// Read by the route to decide whether the panel may carry the project's
352 /// ETag: the tag is the project's cache generation, which does not move when
353 /// a filter does, so a narrowed panel must not be cached under it.
354 #[must_use]
355 pub fn is_default(&self) -> bool {
356 *self == Self::default()
357 }
358
359 /// Whether a row survives the narrowing.
360 fn keeps(&self, item: &ContentItem) -> bool {
361 let matches_query = self.query.as_ref().is_none_or(|query| {
362 item.title
363 .to_lowercase()
364 .contains(&query.trim().to_lowercase())
365 });
366 let matches_status = self
367 .status
368 .as_ref()
369 .is_none_or(|status| &item.status == status);
370 let matches_kind = self
371 .kind
372 .as_ref()
373 .is_none_or(|kind| &item.item_type == kind);
374 matches_query && matches_status && matches_kind
375 }
376
377 /// The rows this view shows, in the order it shows them.
378 fn shown<'a>(&self, items: &'a [ContentItem]) -> Vec<&'a ContentItem> {
379 let mut shown: Vec<&ContentItem> = items.iter().filter(|item| self.keeps(item)).collect();
380 if let Some(sort) = self.sort {
381 // Stable, so rows that tie keep the project's own order rather than
382 // a different one each press.
383 shown.sort_by(|a, b| {
384 let ordering = match sort {
385 SortBy::Item => a.title.to_lowercase().cmp(&b.title.to_lowercase()),
386 SortBy::Kind => a.item_type.to_lowercase().cmp(&b.item_type.to_lowercase()),
387 SortBy::Price => a.price_cents.cmp(&b.price_cents),
388 SortBy::Sales => a.sales.cmp(&b.sales),
389 // Finding 1: every row's revenue is the same string, so this
390 // orders nothing until the numbers are real. Sorted on the
391 // text rather than on a parse of it, which would be a parse
392 // of our own formatting.
393 SortBy::Revenue => a.revenue.cmp(&b.revenue),
394 SortBy::Status => a.status.cmp(&b.status),
395 };
396 if self.descending {
397 ordering.reverse()
398 } else {
399 ordering
400 }
401 });
402 }
403 shown
404 }
405 }
406
407 /// The address every control on this panel is written against.
408 fn base(slug: &str) -> String {
409 format!("/dashboard/project/{slug}/tabs/content")
410 }
411
412 /// The tone a badge wears, from the tone word the view model already picked.
413 ///
414 /// The template writes `data-tone="{{ status_tone }}"` and the described badge
415 /// says the same thing in the vocabulary's words, so the two renderings agree
416 /// about which item looks live.
417 fn tone(word: &str) -> layout::Tone {
418 match word {
419 "success" => layout::Tone::Success,
420 "warning" => layout::Tone::Warning,
421 "danger" => layout::Tone::Danger,
422 _ => layout::Tone::Neutral,
423 }
424 }
425
426 /// The ghost text a field shows while it is empty.
427 ///
428 /// `Field::placeholder` is a public member with no builder beside it, so this is
429 /// the one line that would otherwise be a `let mut` in three places.
430 fn ghost(mut field: Field, text: &str) -> Field {
431 field.placeholder = Some(text.to_owned());
432 field
433 }
434
435 /// Everything inside the Content panel, as the tab strip's fill.
436 ///
437 /// Without the region wrapper, because the strip already draws one carrying
438 /// [`REGION`] and the panel goes inside it. [`fragment`] is the same content
439 /// wrapped, which is what a route answers.
440 #[must_use]
441 pub fn fill(
442 slug: &str,
443 items: &[ContentItem],
444 deleted: &[DeletedItemRow],
445 posts: &[BlogPostDashboardRow],
446 view: &View,
447 ) -> String {
448 use quasi_axum::Serves as _;
449
450 let mut out = String::new();
451 for node in body(slug, items, deleted, posts, view) {
452 out.push_str(&Webview::new().fragment(&node));
453 }
454 out
455 }
456
457 /// The panel as a route answers it: the region, carrying its own id.
458 ///
459 /// The wrapper matters. A control here aims its answer with
460 /// `hx-target="#project-content"` and htmx swaps `outerMorph`, so an answer
461 /// without the id would replace the panel with markup nothing can target
462 /// afterwards.
463 #[must_use]
464 pub fn fragment(
465 slug: &str,
466 items: &[ContentItem],
467 deleted: &[DeletedItemRow],
468 posts: &[BlogPostDashboardRow],
469 view: &View,
470 ) -> String {
471 use quasi_axum::Serves as _;
472
473 let mut slot = Slot::new(REGION, RegionKind::Pane);
474 for node in body(slug, items, deleted, posts, view) {
475 slot = slot.with(node);
476 }
477 Webview::new().fragment(&Node::Region(slot))
478 }
479
480 /// The panel's contents, in order.
481 fn body(
482 slug: &str,
483 items: &[ContentItem],
484 deleted: &[DeletedItemRow],
485 posts: &[BlogPostDashboardRow],
486 view: &View,
487 ) -> Vec<Node> {
488 let mut out = vec![
489 Node::section("Items"),
490 Node::act(
491 "New Item",
492 // A whole page rather than a fragment, so it leaves: see
493 // `quasi::forum_memberships` for the same spelling. An internal
494 // `Action::get` would fetch the wizard into this panel.
495 Action::external(format!("/dashboard/project/{slug}/new-item")),
496 ),
497 ];
498
499 if items.is_empty() {
500 // The project's own empty state, which is a different sentence from a
501 // filter matching nothing and is the only one that offers a way to make
502 // the first item.
503 out.push(
504 Node::empty("No items in this project yet. Add your first item to start publishing.")
505 .offering(Act::new(
506 "Add First Item",
507 Action::external(format!("/dashboard/project/{slug}/new-item")),
508 )),
509 );
510 out.push(Node::text(
511 "After creating an item, set its pricing and publish it to make it available to fans.",
512 ));
513 } else {
514 out.push(Node::Region(filters(slug, items, view)));
515 out.push(Node::Region(bulk(slug, view)));
516
517 let shown = view.shown(items);
518 if shown.is_empty() {
519 out.push(
520 Node::empty("No items match these filters.").offering(Act::new(
521 "Clear filters",
522 View {
523 sort: view.sort,
524 descending: view.descending,
525 ..View::default()
526 }
527 .panel(slug),
528 )),
529 );
530 } else {
531 out.push(table(slug, view, &shown));
532 }
533 }
534
535 if !deleted.is_empty() {
536 out.push(Node::section(format!(
537 "Recently Deleted ({})",
538 deleted.len()
539 )));
540 out.push(Node::text(
541 "Deleted items are permanently removed after 7 days.",
542 ));
543 out.push(deleted_table(slug, view, deleted));
544 }
545
546 out.push(Node::section("Blog Posts"));
547 out.push(Node::act(
548 "New Post",
549 Action::external(format!("/dashboard/project/{slug}/blog/new")),
550 ));
551 if posts.is_empty() {
552 out.push(Node::empty(
553 "No blog posts yet. Share updates, release notes, or stories with your audience.",
554 ));
555 } else {
556 out.push(posts_table(slug, view, posts));
557 }
558
559 out
560 }
561
562 /// The three narrowing controls.
563 ///
564 /// `N12`'s decision in one function: each is a control with a route, and the
565 /// answer is the panel under the new view. The search box asks as it is typed
566 /// ([`Consult`]) and the two pickers write when they settle
567 /// ([`Field::changes`]) -- which is the difference between a value that is
568 /// still being written and one that is chosen in a single gesture.
569 fn filters(slug: &str, items: &[ContentItem], view: &View) -> Slot {
570 let narrowed = view.narrowed();
571
572 let search = ghost(
573 Field::new(layout::FieldKind::Text, "q", "Search items"),
574 "Search items...",
575 )
576 .value(view.query.clone().unwrap_or_default())
577 .consulting(
578 Consult::new(narrowed.without_query().panel(slug))
579 .after(SEARCH_WAIT)
580 .at_least(SEARCH_FLOOR),
581 );
582
583 let mut statuses = vec![Choice::new("", "All statuses")];
584 statuses.extend(STATUSES.iter().map(|status| Choice::new(*status, *status)));
585
586 // The type options are the types this project actually holds. The template
587 // emitted one `<option>` per item and deleted the duplicates in JS on load;
588 // counted once here instead.
589 let mut kinds: Vec<&str> = items.iter().map(|item| item.item_type.as_str()).collect();
590 // The type in force stays on the list even when nothing carries it any
591 // more, which happens when the last item of a kind is deleted or renamed
592 // under the filter. Without it the picker reads "All types" while the table
593 // is narrowed, and the control disagrees with the rows beside it.
594 if let Some(kind) = &view.kind {
595 kinds.push(kind.as_str());
596 }
597 kinds.sort_unstable();
598 kinds.dedup();
599 let mut types = vec![Choice::new("", "All types")];
600 types.extend(kinds.iter().map(|kind| Choice::new(*kind, *kind)));
601
602 Slot::new("content-filters", RegionKind::Group)
603 .with(Node::field(search))
604 .with(Node::field(
605 Field::select("status", "Status", statuses)
606 .value(view.status.clone().unwrap_or_default())
607 .changes(narrowed.without_status().panel(slug)),
608 ))
609 .with(Node::field(
610 Field::select("type", "Type", types)
611 .value(view.kind.clone().unwrap_or_default())
612 .changes(narrowed.without_kind().panel(slug)),
613 ))
614 }
615
616 /// The controls over the selection.
617 ///
618 /// The shipped bar's five verbs, and its two halves are the two shapes a
619 /// selection control has: Publish, Unpublish and Delete act on the set, while
620 /// Set Price and Add Tag apply a value to it. The second pair is
621 /// [`Act::asking`], which is the member `033ff3ca` grew off this exact bar.
622 ///
623 /// What is not here is the count and the disabling, for [`Act::over`]'s stated
624 /// reason: the ticks are the host's until something submits them.
625 fn bulk(slug: &str, view: &View) -> Slot {
626 let mut slot = Slot::new("content-bulk", RegionKind::Group)
627 .with(Node::Act(
628 Act::new("Publish", view.write(slug, "bulk/publish")).over(SELECTION),
629 ))
630 .with(Node::Act(
631 Act::new("Unpublish", view.write(slug, "bulk/unpublish")).over(SELECTION),
632 ))
633 .with(Node::Act(
634 Act::new("Set Price", view.write(slug, "bulk/price"))
635 .over(SELECTION)
636 .asking(
637 Field::new(layout::FieldKind::Number, "price_dollars", "New price ($)")
638 .hint("Enter 0 to make items free."),
639 ),
640 ))
641 .with(Node::Act(
642 Act::new("Add Tag", view.write(slug, "bulk/tag"))
643 .over(SELECTION)
644 .asking(
645 ghost(
646 Field::new(layout::FieldKind::Text, "tag_slug", "Tag slug"),
647 "e.g. audio.genre.ambient",
648 )
649 .hint("Use dot-notation: type.category.value"),
650 ),
651 ))
652 .with(Node::Act(
653 Act::new("Delete", view.write(slug, "bulk/delete"))
654 .over(SELECTION)
655 .tone(layout::Tone::Danger)
656 // The JS listed the titles in a `confirm()`. A description
657 // carries no list of what is ticked -- it does not know -- so
658 // the question is the one every host can ask.
659 .confirm("Delete every selected item? This cannot be undone."),
660 ));
661
662 // Select-all is an address. Its opposite is the same address without it, and
663 // is only offered when there is something to clear.
664 slot = slot.with(Node::act(
665 "Select all",
666 View {
667 ticked: true,
668 ..view.clone()
669 }
670 .panel(slug),
671 ));
672 if view.ticked {
673 slot = slot.with(Node::act("Deselect", view.narrowed().panel(slug)));
674 }
675 slot
676 }
677
678 /// The columns, each carrying what pressing it does.
679 fn columns(slug: &str, view: &View) -> Vec<Column> {
680 let sortable = [
681 (
682 "Item",
683 SortBy::Item,
684 layout::Width::Fill,
685 layout::Priority::Essential,
686 ),
687 (
688 "Type",
689 SortBy::Kind,
690 layout::Width::Content,
691 layout::Priority::Secondary,
692 ),
693 (
694 "Price",
695 SortBy::Price,
696 layout::Width::Content,
697 layout::Priority::Secondary,
698 ),
699 (
700 "Sales",
701 SortBy::Sales,
702 layout::Width::Content,
703 layout::Priority::Optional,
704 ),
705 (
706 "Revenue",
707 SortBy::Revenue,
708 layout::Width::Content,
709 layout::Priority::Optional,
710 ),
711 (
712 "Status",
713 SortBy::Status,
714 layout::Width::Content,
715 layout::Priority::Essential,
716 ),
717 ];
718
719 let mut out = vec![Column::new("#").width(layout::Width::Content)];
720 for (name, sort, width, priority) in sortable {
721 let mut column = Column::new(name)
722 .width(width)
723 .priority(priority)
724 .reorder(view.sorted_by(sort).panel(slug));
725 if view.sort == Some(sort) {
726 column = column.sorted(if view.descending {
727 layout::Sort::Descending
728 } else {
729 layout::Sort::Ascending
730 });
731 }
732 out.push(column);
733 }
734 out.push(
735 Column::new("Actions")
736 .width(layout::Width::Content)
737 .priority(layout::Priority::Essential),
738 );
739 out
740 }
741
742 /// The items table, with each open bundle's children under it.
743 fn table(slug: &str, view: &View, shown: &[&ContentItem]) -> Node {
744 let mut rows = Vec::new();
745 for (at, item) in shown.iter().enumerate() {
746 rows.push(item_row(slug, view, item, at, shown.len()));
747 if view.open.iter().any(|open| open == &item.id) {
748 rows.extend(item.children.iter().map(|child| child_row(view, child)));
749 }
750 }
751
752 Node::Table {
753 columns: columns(slug, view),
754 rows,
755 // No paging: the handler reads the project's whole catalogue and the
756 // table shows what the filters left.
757 more: None,
758 }
759 }
760
761 /// One item, and everything that can be done to it.
762 fn item_row(slug: &str, view: &View, item: &ContentItem, at: usize, of: usize) -> Cells {
763 let mut position = Cell::new(item.position.to_string());
764 // The order is the project's, so the arrows only mean something under it.
765 // Under a sort they would move a row against an order the reader cannot see.
766 if view.sort.is_none() {
767 if at > 0 {
768 position = position.act(Act::new(
769 "Move up",
770 view.write(slug, &format!("move/{}", item.id))
771 .carrying("direction", "up"),
772 ));
773 }
774 if at + 1 < of {
775 position = position.act(Act::new(
776 "Move down",
777 view.write(slug, &format!("move/{}", item.id))
778 .carrying("direction", "down"),
779 ));
780 }
781 }
782
783 let mut title = Cell::new(item.title.clone())
784 .activate(Action::external(format!("/dashboard/item/{}", item.id)));
785 if item.is_unlisted {
786 title = title.token(Tag::badge("Unlisted"));
787 }
788 if !item.children.is_empty() {
789 let open = view.open.iter().any(|id| id == &item.id);
790 title = title.act(Act::new(
791 if open { "Collapse" } else { "Expand" },
792 view.toggling(&item.id).panel(slug),
793 ));
794 }
795
796 let kind = if item.children.is_empty() {
797 item.item_type.clone()
798 } else {
799 format!("{} ({})", item.item_type, item.children.len())
800 };
801
802 Cells::new([
803 position,
804 title,
805 Cell::new(kind),
806 Cell::new(item.price.clone()),
807 Cell::new(item.sales.to_string()),
808 Cell::new(item.revenue.clone()),
809 Cell::tag(Tag::badge(item.status.clone()).tone(tone(item.status_tone))),
810 Cell::acts(acts_for(slug, view, item)),
811 ])
812 .ticking(item.id.clone(), view.ticked)
813 }
814
815 /// A bundle's child, which is a row of the same table and not a table of its own.
816 ///
817 /// Every cell muted in the template, which is presentation and is the
818 /// renderer's; what the description says is that the row is a child, and it says
819 /// it by the arrow the title carries. It is tickable for the reason the shipped
820 /// checkbox is: a child can be published or repriced with the rest.
821 fn child_row(view: &View, child: &ContentItem) -> Cells {
822 Cells::new([
823 Cell::new(""),
824 Cell::new(format!("\u{21b3} {}", child.title))
825 .activate(Action::external(format!("/dashboard/item/{}", child.id))),
826 Cell::new(child.item_type.clone()),
827 Cell::new(child.price.clone()),
828 Cell::new(child.sales.to_string()),
829 Cell::new(child.revenue.clone()),
830 Cell::tag(Tag::badge(child.status.clone()).tone(tone(child.status_tone))),
831 Cell::acts([Act::new(
832 "Edit",
833 Action::external(format!("/dashboard/item/{}", child.id)),
834 )]),
835 ])
836 .ticking(child.id.clone(), view.ticked)
837 }
838
839 /// The row's own controls, which are not the selection's.
840 fn acts_for(slug: &str, view: &View, item: &ContentItem) -> Vec<Act> {
841 let mut acts = Vec::new();
842 if item.status == "Draft" {
843 acts.push(Act::new(
844 "Continue",
845 Action::external(format!(
846 "/dashboard/project/{slug}/new-item/{}/step/details",
847 item.id
848 )),
849 ));
850 acts.push(Act::new(
851 "Publish",
852 view.write(slug, &format!("publish/{}", item.id)),
853 ));
854 } else {
855 acts.push(Act::new(
856 "View",
857 Action::external(format!("/i/{}", item.id)),
858 ));
859 }
860 acts.push(Act::new(
861 "Edit",
862 Action::external(format!("/dashboard/item/{}", item.id)),
863 ));
864 // Finding 3: the inline editor becomes a control that asks for a value.
865 acts.push(
866 Act::new("Rename", view.write(slug, &format!("rename/{}", item.id))).asking(
867 Field::new(layout::FieldKind::Text, "title", "Title").value(item.title.clone()),
868 ),
869 );
870 acts
871 }
872
873 /// What is recoverable, and for how long.
874 fn deleted_table(slug: &str, view: &View, deleted: &[DeletedItemRow]) -> Node {
875 Node::Table {
876 columns: vec![
877 Column::new("Title")
878 .width(layout::Width::Fill)
879 .priority(layout::Priority::Essential),
880 Column::new("Deleted").width(layout::Width::Content),
881 Column::new("")
882 .width(layout::Width::Content)
883 .priority(layout::Priority::Essential),
884 ],
885 rows: deleted
886 .iter()
887 .map(|item| {
888 Cells::new([
889 Cell::new(item.title.clone()),
890 Cell::new(item.deleted_at.clone()),
891 Cell::acts([Act::new(
892 "Restore",
893 view.write(slug, &format!("restore/{}", item.id)),
894 )]),
895 ])
896 })
897 .collect(),
898 more: None,
899 }
900 }
901
902 /// The project's blog posts, which share this tab and not much else.
903 fn posts_table(slug: &str, view: &View, posts: &[BlogPostDashboardRow]) -> Node {
904 Node::Table {
905 columns: vec![
906 Column::new("Title")
907 .width(layout::Width::Fill)
908 .priority(layout::Priority::Essential),
909 Column::new("Status").width(layout::Width::Content),
910 Column::new("Published")
911 .width(layout::Width::Content)
912 .priority(layout::Priority::Optional),
913 Column::new("Actions")
914 .width(layout::Width::Content)
915 .priority(layout::Priority::Essential),
916 ],
917 rows: posts
918 .iter()
919 .map(|post| {
920 Cells::new([
921 Cell::new(post.title.clone())
922 .activate(Action::external(format!("/p/{slug}/blog/{}", post.slug))),
923 Cell::tag(Tag::badge(post.status.clone()).tone(tone(post.status_tone))),
924 Cell::new(post.published_at.clone()),
925 Cell::acts([
926 Act::new(
927 "View",
928 Action::external(format!("/p/{slug}/blog/{}", post.slug)),
929 ),
930 Act::new(
931 "Edit",
932 Action::external(format!(
933 "/dashboard/project/{slug}/blog/new?post={}",
934 post.id
935 )),
936 ),
937 Act::new(
938 "Delete",
939 view.write(slug, &format!("blog/{}/delete", post.id)),
940 )
941 .tone(layout::Tone::Danger)
942 .confirm("Delete this blog post?"),
943 ]),
944 ])
945 })
946 .collect(),
947 more: None,
948 }
949 }
950
951 #[cfg(test)]
952 mod tests {
953 use super::*;
954
955 const SLUG: &str = "a-project";
956
957 fn item(id: &str, title: &str, kind: &str, status: &str, cents: i32) -> ContentItem {
958 ContentItem {
959 position: 1,
960 title: title.into(),
961 item_type: kind.into(),
962 price: format!("${}", cents / 100),
963 price_cents: cents,
964 price_dollars: format!("{}.00", cents / 100),
965 sales: 0,
966 revenue: "$0".into(),
967 status: status.into(),
968 status_tone: if status == "Active" {
969 "success"
970 } else {
971 "warning"
972 },
973 id: id.into(),
974 is_unlisted: false,
975 children: Vec::new(),
976 }
977 }
978
979 fn catalogue() -> Vec<ContentItem> {
980 vec![
981 item("i-1", "Kick Pack", "audio", "Active", 500),
982 item("i-2", "Snare Pack", "audio", "Draft", 0),
983 item("i-3", "Field Notes", "text", "Active", 1200),
984 ]
985 }
986
987 fn panel(view: &View) -> String {
988 fragment(SLUG, &catalogue(), &[], &[], view)
989 }
990
991 #[test]
992 fn a_filter_is_a_route_and_not_a_pass_over_rendered_rows() {
993 // `N12`'s decision, read off the document. Each of the three controls
994 // carries an address; none of them names a client-side function, which
995 // is what `data-input="filterContentTable"` was.
996 let html = panel(&View::default());
997
998 assert!(html.contains("name=\"q\""), "{html}");
999 assert!(html.contains("name=\"status\""), "{html}");
1000 assert!(html.contains("name=\"type\""), "{html}");
1001 assert!(!html.contains("filterContentTable"), "{html}");
1002 assert!(
1003 html.contains("/dashboard/project/a-project/tabs/content"),
1004 "{html}"
1005 );
1006 }
1007
1008 #[test]
1009 fn a_control_does_not_carry_the_value_it_is_asking_for() {
1010 // A field sends its own value under its own name. An address that also
1011 // carried the value it was offered under would send both, and the
1012 // handler would have to guess.
1013 let view = View {
1014 query: Some("kick".into()),
1015 status: Some("Active".into()),
1016 ..View::default()
1017 };
1018 let filters = filters(SLUG, &catalogue(), &view);
1019 let html = {
1020 use quasi_axum::Serves as _;
1021 Webview::new().fragment(&Node::Region(filters))
1022 };
1023
1024 // Each control's own address, read off the three divs the section holds.
1025 let controls: Vec<&str> = html.split("<div class=\"field-").skip(1).collect();
1026 assert_eq!(controls.len(), 3, "{html}");
1027
1028 // The search box carries the status it was offered under and not the
1029 // query it is about to send.
1030 assert!(controls[0].contains("status=Active"), "{html}");
1031 assert!(!controls[0].contains("q=kick"), "{html}");
1032
1033 // The status picker, the other way round.
1034 assert!(controls[1].contains("q=kick"), "{html}");
1035 assert!(!controls[1].contains("status=Active"), "{html}");
1036
1037 // And the type picker, which is asking about neither, carries both.
1038 assert!(controls[2].contains("q=kick"), "{html}");
1039 assert!(controls[2].contains("status=Active"), "{html}");
1040 }
1041
1042 #[test]
1043 fn every_row_joins_the_selection_under_its_own_id() {
1044 let html = panel(&View::default());
1045
1046 for id in ["i-1", "i-2", "i-3"] {
1047 assert!(
1048 html.contains(&format!("name=\"ticked\" value=\"{id}\"")),
1049 "{html}"
1050 );
1051 }
1052 // Nothing arrives ticked until the address says so.
1053 assert!(!html.contains("checked"), "{html}");
1054 }
1055
1056 #[test]
1057 fn select_all_is_an_address() {
1058 let html = panel(&View::default());
1059 assert!(html.contains("ticked=all"), "{html}");
1060
1061 let all = panel(&View {
1062 ticked: true,
1063 ..View::default()
1064 });
1065 assert_eq!(all.matches("checked").count(), 3, "{all}");
1066 assert!(all.contains(">Deselect<"), "{all}");
1067 }
1068
1069 #[test]
1070 fn the_bar_holds_five_verbs_over_the_selection() {
1071 let html = panel(&View::default());
1072
1073 // Five controls act on the set, and the two that need a value reveal a
1074 // box rather than being a form of their own.
1075 assert_eq!(html.matches("data-over=\"chosen\"").count(), 5, "{html}");
1076 for verb in ["Publish", "Unpublish", "Set Price", "Add Tag", "Delete"] {
1077 assert!(html.contains(verb), "{verb} missing:\n{html}");
1078 }
1079 assert!(html.contains("name=\"price_dollars\""), "{html}");
1080 assert!(html.contains("name=\"tag_slug\""), "{html}");
1081 assert!(html.contains("Enter 0 to make items free."), "{html}");
1082 }
1083
1084 #[test]
1085 fn a_destructive_verb_asks_before_it_runs() {
1086 let html = panel(&View::default());
1087 assert!(
1088 html.contains("Delete every selected item? This cannot be undone."),
1089 "{html}"
1090 );
1091 }
1092
1093 #[test]
1094 fn pressing_a_heading_orders_by_it_and_pressing_it_again_flips() {
1095 let first = View::default().sorted_by(SortBy::Price);
1096 assert_eq!(first.sort, Some(SortBy::Price));
1097 assert!(!first.descending);
1098
1099 let again = first.sorted_by(SortBy::Price);
1100 assert!(again.descending);
1101
1102 // A different heading starts ascending rather than inheriting.
1103 let elsewhere = again.sorted_by(SortBy::Item);
1104 assert_eq!(elsewhere.sort, Some(SortBy::Item));
1105 assert!(!elsewhere.descending);
1106 }
1107
1108 #[test]
1109 fn the_sorted_column_says_so_where_a_reader_can_hear_it() {
1110 let html = panel(&View {
1111 sort: Some(SortBy::Item),
1112 descending: true,
1113 ..View::default()
1114 });
1115
1116 assert!(html.contains("aria-sort=\"descending\""), "{html}");
1117 assert_eq!(html.matches("aria-sort=").count(), 1, "{html}");
1118 }
1119
1120 #[test]
1121 fn ordering_is_the_view_and_not_the_documents_order() {
1122 let items = catalogue();
1123 let by_price = View {
1124 sort: Some(SortBy::Price),
1125 ..View::default()
1126 };
1127 let titles: Vec<&str> = by_price
1128 .shown(&items)
1129 .iter()
1130 .map(|item| item.title.as_str())
1131 .collect();
1132 assert_eq!(titles, ["Snare Pack", "Kick Pack", "Field Notes"]);
1133
1134 let down = View {
1135 descending: true,
1136 ..by_price
1137 };
1138 let titles: Vec<&str> = down
1139 .shown(&items)
1140 .iter()
1141 .map(|item| item.title.as_str())
1142 .collect();
1143 assert_eq!(titles, ["Field Notes", "Kick Pack", "Snare Pack"]);
1144 }
1145
1146 #[test]
1147 fn the_reorder_arrows_leave_when_a_sort_is_in_force() {
1148 // They move a row within the project's own order, which is the order
1149 // the `#` column shows. Under a sort that order is not on screen.
1150 let plain = panel(&View::default());
1151 assert!(
1152 plain.contains("Move up") || plain.contains("Move down"),
1153 "{plain}"
1154 );
1155
1156 let sorted = panel(&View {
1157 sort: Some(SortBy::Item),
1158 ..View::default()
1159 });
1160 assert!(!sorted.contains("Move up"), "{sorted}");
1161 assert!(!sorted.contains("Move down"), "{sorted}");
1162 }
1163
1164 #[test]
1165 fn narrowing_drops_the_rows_it_hides_rather_than_hiding_them() {
1166 let items = catalogue();
1167
1168 let searched = View {
1169 query: Some("pack".into()),
1170 ..View::default()
1171 };
1172 assert_eq!(searched.shown(&items).len(), 2);
1173
1174 let drafts = View {
1175 status: Some("Draft".into()),
1176 ..View::default()
1177 };
1178 assert_eq!(drafts.shown(&items).len(), 1);
1179
1180 let text = View {
1181 kind: Some("text".into()),
1182 ..View::default()
1183 };
1184 assert_eq!(text.shown(&items).len(), 1);
1185
1186 // And the document holds only what survived, where the JS kept every row
1187 // and put a class on the rest.
1188 let html = panel(&drafts);
1189 assert!(html.contains("Snare Pack"), "{html}");
1190 assert!(!html.contains("Kick Pack"), "{html}");
1191 }
1192
1193 #[test]
1194 fn the_status_filter_can_name_every_status_a_row_wears() {
1195 // Finding 2: the template offered two of the three, so a scheduled item
1196 // was hidden by either choice and nothing offered a way back.
1197 let html = panel(&View::default());
1198 for status in STATUSES {
1199 assert!(html.contains(&format!("value=\"{status}\"")), "{html}");
1200 }
1201 }
1202
1203 #[test]
1204 fn a_filter_that_matches_nothing_says_so_and_offers_a_way_out() {
1205 let html = panel(&View {
1206 query: Some("nothing here".into()),
1207 ..View::default()
1208 });
1209
1210 assert!(html.contains("No items match these filters."), "{html}");
1211 assert!(html.contains("Clear filters"), "{html}");
1212 }
1213
1214 #[test]
1215 fn an_empty_project_is_a_different_sentence_from_an_empty_filter() {
1216 let html = fragment(SLUG, &[], &[], &[], &View::default());
1217
1218 assert!(html.contains("No items in this project yet."), "{html}");
1219 assert!(html.contains("Add First Item"), "{html}");
1220 // Nothing to narrow, so no bar and no boxes.
1221 assert!(!html.contains("data-over"), "{html}");
1222 assert!(!html.contains("name=\"q\""), "{html}");
1223 }
1224
1225 #[test]
1226 fn a_bundles_children_arrive_when_the_address_says_they_are_open() {
1227 let mut items = catalogue();
1228 items[0].children = vec![item("i-1a", "Kick 01", "audio", "Active", 0)];
1229
1230 let shut = fragment(SLUG, &items, &[], &[], &View::default());
1231 assert!(shut.contains("Expand"), "{shut}");
1232 assert!(!shut.contains("Kick 01"), "{shut}");
1233
1234 let open = fragment(
1235 SLUG,
1236 &items,
1237 &[],
1238 &[],
1239 &View {
1240 open: vec!["i-1".into()],
1241 ..View::default()
1242 },
1243 );
1244 assert!(open.contains("Kick 01"), "{open}");
1245 assert!(open.contains("Collapse"), "{open}");
1246 // A child can be published with the rest, as its checkbox always could.
1247 assert!(open.contains("name=\"ticked\" value=\"i-1a\""), "{open}");
1248 }
1249
1250 #[test]
1251 fn a_rename_is_a_control_that_asks_for_the_new_name() {
1252 // Finding 3. The box arrives holding the current title, which is what
1253 // the inline editor did before it was a round trip.
1254 let html = panel(&View::default());
1255
1256 assert!(html.contains("Rename"), "{html}");
1257 assert!(html.contains("value=\"Kick Pack\""), "{html}");
1258 assert!(html.contains("/tabs/content/rename/i-1"), "{html}");
1259 }
1260
1261 #[test]
1262 fn a_route_answers_with_the_id_its_answer_lands_on() {
1263 // Every control here aims at `#project-content` and htmx swaps
1264 // `outerMorph`, so an answer without the id would replace the panel with
1265 // markup nothing can target afterwards.
1266 let answer = panel(&View::default());
1267 assert!(answer.contains("id=\"project-content\""), "{answer}");
1268 assert!(
1269 answer.contains("hx-target=\"#project-content\""),
1270 "{answer}"
1271 );
1272
1273 // The page's copy goes inside the strip's own div, so it must not carry
1274 // the id a second time.
1275 let inline = fill(SLUG, &catalogue(), &[], &[], &View::default());
1276 assert!(!inline.contains("id=\"project-content\""), "{inline}");
1277 }
1278
1279 #[test]
1280 fn a_write_comes_back_under_the_view_it_was_pressed_on() {
1281 let view = View {
1282 query: Some("pack".into()),
1283 status: Some("Active".into()),
1284 ..View::default()
1285 };
1286 let html = panel(&view);
1287
1288 // The bulk verbs post to an address that still names the filter, so a
1289 // publish under a search answers the same rows.
1290 assert!(html.contains("bulk/publish?"), "{html}");
1291 assert!(html.contains("q=pack"), "{html}");
1292 }
1293
1294 #[test]
1295 fn an_address_naming_a_column_no_heading_carries_is_refused() {
1296 // Answering it with the default order would hide the wiring mistake.
1297 assert!(View::of(None, None, None, Some("urgency"), None, None, None).is_none());
1298 assert!(View::of(None, None, None, Some("price"), None, None, None).is_some());
1299 }
1300
1301 #[test]
1302 fn a_blank_param_is_an_absent_one() {
1303 // What "All statuses" sends.
1304 let view = View::of(Some(" "), Some(""), Some(""), None, None, None, None)
1305 .expect("a blank view parses");
1306 assert!(view.is_default());
1307 assert!(!view.filtered());
1308 }
1309
1310 #[test]
1311 fn a_status_the_table_cannot_show_narrows_nothing() {
1312 // The filter offers three; a hand-typed fourth is dropped rather than
1313 // answering an empty table.
1314 let view =
1315 View::of(None, Some("Retired"), None, None, None, None, None).expect("the view parses");
1316 assert_eq!(view.status, None);
1317 }
1318
1319 #[test]
1320 fn a_narrowing_does_not_carry_everything_forward() {
1321 // `ticked=all` under a new filter would silently come to mean a
1322 // different everything, which is the rule the JS enforced by hand.
1323 let view = View {
1324 ticked: true,
1325 query: Some("pack".into()),
1326 ..View::default()
1327 };
1328 assert!(!view.sorted_by(SortBy::Item).ticked);
1329 assert!(!view.narrowed().ticked);
1330 // Opening a bundle is not a narrowing and keeps them.
1331 assert!(view.toggling("i-1").ticked);
1332 }
1333
1334 #[test]
1335 fn the_deleted_and_blog_tables_are_the_panels_own() {
1336 let deleted = [DeletedItemRow {
1337 id: "i-9".into(),
1338 title: "Old Pack".into(),
1339 deleted_at: "Aug 10, 2026".into(),
1340 }];
1341 let posts = [BlogPostDashboardRow {
1342 id: "p-1".into(),
1343 title: "Release notes".into(),
1344 slug: "release-notes".into(),
1345 status: "Published".into(),
1346 status_tone: "success",
1347 published_at: "Aug 12, 2026".into(),
1348 }];
1349
1350 let html = fragment(SLUG, &catalogue(), &deleted, &posts, &View::default());
1351
1352 assert!(html.contains("Recently Deleted (1)"), "{html}");
1353 assert!(html.contains("/tabs/content/restore/i-9"), "{html}");
1354 assert!(html.contains("Release notes"), "{html}");
1355 assert!(html.contains("/tabs/content/blog/p-1/delete"), "{html}");
1356 assert!(html.contains("Delete this blog post?"), "{html}");
1357 }
1358 }
1359