Skip to main content

max / makenotwork

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