Skip to main content

max / makenotwork

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