Skip to main content

max / makenotwork

13.4 KB · 327 lines History Blame Raw
1 //! The item dashboard's tab strip, described.
2 //!
3 //! Shape 2, step 2 (`6b24f2df`), and the second described tab strip. Built as an
4 //! Askama entry point the same way [`super::library_tabs`] is:
5 //! `dashboards/dashboard-item.html` is still an Askama document and this is one
6 //! region inside it.
7 //!
8 //! # What is different from the library strip, and it is the point of doing this
9 //! # one second
10 //!
11 //! Every one of these five panels is still an Askama route. The library strip
12 //! had two described panels among its five and so had to decide, per tab,
13 //! whether to name the region the answer lands in. Here nothing is described
14 //! downstream, so every unshown tab carries [`Action::replacing`] and there is
15 //! no branch. This is the step that proves a strip converts without any of its
16 //! panels converting.
17 //!
18 //! # The page loses a request
19 //!
20 //! `dashboard-item.html` did not include its first panel; it gave `#tab-content`
21 //! an `hx-trigger="load"` and fetched Overview after the document arrived. So
22 //! this page rendered a placeholder and then filled it, which is exactly what
23 //! `9b958e7b` refuses -- a screen renders once, at final geometry. The described
24 //! strip cannot emit that trigger on the shown tab, so the overview is rendered
25 //! here instead, out of the `Item` the page handler already built. No extra
26 //! query: `ItemOverviewTabTemplate` takes the same `Item` and nothing else.
27 //!
28 //! Two requests become one. That is a change to the page's behaviour rather than
29 //! a preservation of it, unlike the library, and it is the direction the rule
30 //! points.
31 //!
32 //! # The hash deep link becomes a query, and gets better for it
33 //!
34 //! `core/tabs.ts:145-149` restored a tab by reading `location.hash`, finding the
35 //! button and clicking it, so `/dashboard/item/X#tab-files` cost a document, then
36 //! the page's JS running, then a fetch. The described strip has no button ids for
37 //! that to find, so the restore would simply have stopped working.
38 //!
39 //! It is answered rather than lost: [`shown_at`] reads a `?tab=` and the strip
40 //! opens on it, filled, at first paint. One caller exists in the tree,
41 //! `static/item-upload.js:133`, where a creator lands after an upload, and it
42 //! asks for `?tab=files`.
43 //!
44 //! The handler fills two of the five panels: overview, which it already has, and
45 //! files, which costs one query. Nothing links to the other three, so they are not
46 //! paid for. An unknown or absent name answers the first tab.
47 //!
48 //! # The sixth route is not a tab
49 //!
50 //! `/dashboard/item/{id}/tabs/embed` is registered and handled, and no button in
51 //! the strip has ever pointed at it: it is revealed from inside the overview
52 //! panel (`partials/tabs/item_overview.html:29-33`, `hx-trigger="revealed"`).
53 //! Promoting it here would put a tab on the page that never existed. It stays a
54 //! bespoke in-panel fragment call.
55 //!
56 //! # Files is conditional, and was before it was described
57 //!
58 //! `item.item_type != "bundle"`. A bundle has no files of its own, so the tab is
59 //! absent rather than empty. The recon that planned this step recorded the strip
60 //! as five unconditional buttons; the template says four.
61 //!
62 //! # What the markup said and the description cannot
63 //!
64 //! A `title` on every one of the five buttons -- "Stats, quick actions, and embed
65 //! codes" and its four siblings -- and `aria-label="Item sections"` on the strip.
66 //! A control's hint and a region's own accessible name are both absent from the
67 //! vocabulary, filed as quasicoherent `aad33ecc`, and dropped here for the reason
68 //! the library dropped its two: a `Node::Text` smuggled in to stand for a label is
69 //! how a vocabulary stops being one. This strip drops five hints where the library
70 //! dropped one, which is worth knowing when that decision is answered.
71 //!
72 //! # What this retires
73 //!
74 //! Five `data-action="onSetActiveTab"` sites, the largest verb in the tree
75 //! (Shape 6, `17050ff5`), and one of the five spellings of a spinner --
76 //! `tab-spinner-indicator` and its `<span id="tab-spinner">` -- because
77 //! [`Action::awaiting`] is what an act in flight says now (Shape 7, `736f45a5`).
78 //! Neither file can be deleted for it; both counts go down by the sites here.
79
80 use makeover_layout as layout;
81 use quasi_router::{Action, Node, RegionKind, Slot};
82 use quasi_webview::Webview;
83
84 /// The region the whole strip occupies.
85 ///
86 /// `dashboard-item.html` used this id for its single panel container, and three
87 /// things outside the strip still name it: the refund control in
88 /// `partials/tabs/item_sales.html` and the two `htmx:after:swap` re-init hooks in
89 /// `static/item-details.js` and `static/item-upload.js`. Those three now name the
90 /// panel they actually meant, since under a described strip each panel is its own
91 /// region and a swap lands in one of five ids rather than in this one.
92 const STRIP: &str = "tab-content";
93
94 /// One tab: what it is called and where its panel lives.
95 struct Tab {
96 label: &'static str,
97 /// The id the panel's answer lands in.
98 panel: &'static str,
99 /// The tail of the route, under `/dashboard/item/{id}/tabs/`.
100 route: &'static str,
101 /// Whether every item shows it. Only Files is conditional.
102 bundles_too: bool,
103 }
104
105 /// Every tab the item dashboard can show, in the order the strip draws them.
106 const TABS: &[Tab] = &[
107 Tab {
108 label: "Overview",
109 panel: "item-overview",
110 route: "overview",
111 bundles_too: true,
112 },
113 Tab {
114 label: "Details",
115 panel: "item-details",
116 route: "details",
117 bundles_too: true,
118 },
119 Tab {
120 label: "Pricing",
121 panel: "item-pricing",
122 route: "pricing",
123 bundles_too: true,
124 },
125 Tab {
126 label: "Files",
127 panel: "item-files",
128 route: "files",
129 // A bundle carries other items rather than files of its own.
130 bundles_too: false,
131 },
132 Tab {
133 label: "Sales",
134 panel: "item-sales",
135 route: "sales",
136 bundles_too: true,
137 },
138 ];
139
140 /// Which tab a `?tab=` asks for, or the first one.
141 ///
142 /// The strip replaces the hash restore `core/tabs.ts:145-149` did: that read
143 /// `location.hash`, found the button and clicked it, so a deep link cost a page
144 /// load and then a fetch, and it needed the page's JS to have run. A described
145 /// strip is chosen server-side and arrives already showing what was asked for.
146 ///
147 /// Unknown names answer the first tab rather than 404ing. A stale link should
148 /// land somewhere sensible, and the panels are all on the same item.
149 #[must_use]
150 pub fn shown_at(asked: Option<&str>, is_bundle: bool) -> usize {
151 let Some(asked) = asked else { return 0 };
152 visible(is_bundle)
153 .iter()
154 .position(|tab| tab.route == asked)
155 .unwrap_or(0)
156 }
157
158 /// The route name of a tab by index, so the caller knows which panel to fill.
159 #[must_use]
160 pub fn route_at(shown: usize, is_bundle: bool) -> &'static str {
161 visible(is_bundle)
162 .get(shown)
163 .map_or(TABS[0].route, |tab| tab.route)
164 }
165
166 /// The tabs this item shows, in strip order.
167 fn visible(is_bundle: bool) -> Vec<&'static Tab> {
168 TABS.iter()
169 .filter(|tab| tab.bundles_too || !is_bundle)
170 .collect()
171 }
172
173 /// The markup, for `dashboards/dashboard-item.html` to drop in.
174 ///
175 /// `panel` is the shown tab's contents, rendered by the caller. The shown tab is
176 /// the only one with anything in it: the other four arrive when they are pressed,
177 /// which is what the page did before, and this one arrives with the document,
178 /// which is what the page did not.
179 #[must_use]
180 pub fn html(item_id: &str, shown: usize, panel: &str, is_bundle: bool) -> String {
181 let tabs = visible(is_bundle);
182 let shown = shown.min(tabs.len().saturating_sub(1));
183
184 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
185 // `Run` has no `Default`, so a strip cannot be described while staying
186 // silent about what it does when it runs out of room. `Menu` is what the
187 // page means and what the library strip picked; this renderer currently
188 // honours it by wrapping. See `library_tabs`.
189 .across(layout::Fallback::Menu)
190 .showing_one(shown);
191
192 for (at, tab) in tabs.iter().enumerate() {
193 let mut region = Slot::handover(tab.panel, "item-panel");
194 if at != shown {
195 // Every panel here is an Askama route, so every one of them needs to
196 // be told where its answer goes: a route that names no region leaves
197 // it wherever htmx's default puts it, which is inside the button that
198 // was pressed.
199 region = region.fed_by(
200 Action::get(format!("/dashboard/item/{item_id}/tabs/{}", tab.route))
201 .awaiting()
202 .replacing(tab.panel),
203 );
204 }
205 strip = strip.frame(tab.label, Node::Region(region));
206 }
207
208 use quasi_axum::Serves as _;
209
210 // No shell: a fragment landing inside a document Askama already built.
211 Webview::new()
212 .with_fill(tabs[shown].panel, panel)
213 .fragment(&Node::Region(strip))
214 }
215
216 #[cfg(test)]
217 mod tests {
218 use super::*;
219
220 fn strip(is_bundle: bool) -> String {
221 html("itm_1", 0, "<p>the overview</p>", is_bundle)
222 }
223
224 #[test]
225 fn the_page_asks_for_nothing_on_load() {
226 // The page fetched Overview after the document arrived, which is a
227 // placeholder before first content. The described strip renders it
228 // inline, so two requests become one and none of the other four panels
229 // is fetched until it is pressed.
230 let html = strip(false);
231
232 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
233 assert!(html.contains("<p>the overview</p>"), "{html}");
234 assert_eq!(html.matches("hx-get=").count(), 4, "{html}");
235 }
236
237 #[test]
238 fn every_unshown_tab_says_where_its_answer_lands() {
239 // No panel of this strip is described, so unlike the library there is no
240 // tab that may be left to name its own region.
241 let html = strip(false);
242
243 for panel in ["item-details", "item-pricing", "item-files", "item-sales"] {
244 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
245 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
246 }
247 assert!(!html.contains("hx-target=\"#item-overview\""), "{html}");
248 }
249
250 #[test]
251 fn the_routes_carry_the_item() {
252 let html = strip(false);
253
254 assert!(
255 html.contains("hx-get=\"/dashboard/item/itm_1/tabs/details\""),
256 "{html}"
257 );
258 // The sixth route is reached from inside the overview panel and is not a
259 // tab. If it ever appears here, a tab has been invented.
260 assert!(!html.contains("/tabs/embed"), "{html}");
261 }
262
263 #[test]
264 fn a_bundle_has_no_files_tab() {
265 let bundle = strip(true);
266 assert!(!bundle.contains(">Files</button>"), "{bundle}");
267 assert!(!bundle.contains("item-files"), "{bundle}");
268 // And it is still a strip, with the shown panel where it was.
269 assert!(bundle.contains("role=\"tablist\""), "{bundle}");
270 assert!(bundle.contains("<p>the overview</p>"), "{bundle}");
271 assert_eq!(bundle.matches("hx-get=").count(), 3, "{bundle}");
272
273 assert!(strip(false).contains(">Files</button>"));
274 }
275
276 #[test]
277 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
278 assert!(strip(false).contains("run-menu"));
279 }
280
281 #[test]
282 fn a_deep_link_arrives_showing_what_it_asked_for() {
283 // `static/item-upload.js` sends a creator here after an upload. It used
284 // to send them to `#tab-files` and rely on the hash restore clicking the
285 // button after load; the strip is chosen server-side now, so the panel
286 // is already the shown one and is filled by the caller.
287 let shown = shown_at(Some("files"), false);
288 assert_eq!(shown, 3);
289 assert_eq!(route_at(shown, false), "files");
290
291 let html = html("itm_1", shown, "<p>the versions</p>", false);
292 assert!(html.contains("<p>the versions</p>"), "{html}");
293 // The shown panel is the one that does not fetch, whichever it is.
294 assert!(!html.contains("hx-target=\"#item-files\""), "{html}");
295 assert!(html.contains("hx-target=\"#item-overview\""), "{html}");
296 assert!(html.contains("data-shows=\"3\""), "{html}");
297 }
298
299 #[test]
300 fn a_bundle_asking_for_files_lands_on_the_first_tab() {
301 // Files is absent for a bundle, so the index it would have had belongs to
302 // Sales. Answering the first tab is the stale-link case, and it must not
303 // silently open a different panel than the name asked for.
304 assert_eq!(shown_at(Some("files"), true), 0);
305 assert_eq!(route_at(shown_at(Some("files"), true), true), "overview");
306 // Sales still resolves for a bundle, at the index the missing tab left.
307 assert_eq!(shown_at(Some("sales"), true), 3);
308 }
309
310 #[test]
311 fn an_unknown_or_absent_tab_is_the_first_one() {
312 assert_eq!(shown_at(None, false), 0);
313 assert_eq!(shown_at(Some("nonsense"), false), 0);
314 assert_eq!(shown_at(Some("embed"), false), 0);
315 }
316
317 #[test]
318 fn a_shown_index_past_the_end_cannot_panic() {
319 // `showing_one` and the fill both index the tab list, so a caller that
320 // computed an index against a different bundle flag must clamp rather
321 // than take the page down.
322 let html = html("itm_1", 99, "<p>the overview</p>", true);
323 assert!(html.contains("<p>the overview</p>"), "{html}");
324 assert!(html.contains("data-shows=\"3\""), "{html}");
325 }
326 }
327