Skip to main content

max / makenotwork

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