Skip to main content

max / makenotwork

12.5 KB · 335 lines History Blame Raw
1 //! The project dashboard's tab strip, described.
2 //!
3 //! Shape 2, step 3 (`6b24f2df`), and the third described strip after
4 //! [`super::library_tabs`] and [`super::item_tabs`]. Same Askama entry point
5 //! shape as both: `dashboards/dashboard-project.html` is still an Askama
6 //! document and this is one region inside it.
7 //!
8 //! # Seven tabs, and eleven routes
9 //!
10 //! Four project tab routes are registered and are deliberately not strip tabs.
11 //! Monetization is a composite: `partials/tabs/project_monetization.html` is nine
12 //! lines including `project_subscriptions.html`, `project_promotions.html` and
13 //! `project_members.html`, each of which also has its own route used only as a
14 //! self-refresh target from inside itself. Blog is the fourth, reached from the
15 //! Content panel. Describing seven tabs and leaving those four alone is the
16 //! whole of the distinction; a tab here that no button ever had is the failure
17 //! this strip is most likely to produce.
18 //!
19 //! # Two conditional tabs
20 //!
21 //! Code on `git_enabled` (the server's `build.git_repos_path`) and Cloud Sync on
22 //! the project carrying the `cloud_sync` feature. Both were conditional before
23 //! they were described.
24 //!
25 //! # What a deep link costs, and what it buys
26 //!
27 //! Three tabs can be opened directly, and the page fills whichever it opens:
28 //! overview, content and synckit. That is not a guess about which ones are worth
29 //! it: those are the three anything in the tree links to.
30 //!
31 //! - overview, the tab the page opens on;
32 //! - content, from the Go to Content button in the overview panel;
33 //! - synckit, from `routes::synckit::billing`, which sends a creator back here
34 //! after Stripe.
35 //!
36 //! The other four are pressed rather than linked, so nothing renders them twice.
37 //! An unknown or unfillable name answers the first tab: a stale link should land
38 //! somewhere sensible, and every panel is the same project.
39 //!
40 //! The three fillable ones are rendered from
41 //! `routes::pages::dashboard::project_tabs`' builders, split out of the tab
42 //! handlers for this, so the page and the route answer the same markup rather
43 //! than two copies drifting.
44 //!
45 //! # This page also stops fetching on load
46 //!
47 //! `dashboard-project.html` gave `#tab-content` an `hx-trigger="load"` and fetched
48 //! Overview after the document arrived, which is `9b958e7b`'s placeholder before
49 //! first content. It renders inline now, and the page handler's own duplicate
50 //! `stats` vector -- built for a template field nothing read -- goes with it.
51 //!
52 //! # The tail outside the strip
53 //!
54 //! Sixteen sites named `#tab-content` and meant one of the panels: four analytics
55 //! range buttons, three content refreshes, two members and two subscriptions
56 //! refreshes inside the monetization composite, four `htmx.ajax` calls in the
57 //! deleted `static/tab-project-content.js`, and three more in the code tab's JS
58 //! files. Under a described strip that id is the strip itself, so each now names
59 //! the panel it meant. This is the same class of tail step 2 found three of, and
60 //! it is the reason each strip wants a grep before it is described rather than
61 //! after.
62
63 use makeover_layout as layout;
64 use quasi_router::{Action, Node, RegionKind, Slot};
65 use quasi_webview::Webview;
66
67 /// The region the whole strip occupies, keeping the id the page already used.
68 const STRIP: &str = "tab-content";
69
70 /// What a tab is conditional on.
71 #[derive(PartialEq, Eq)]
72 enum Gate {
73 /// Every project shows it.
74 Always,
75 /// Only where the server has a git repositories path.
76 Git,
77 /// Only a project carrying the `cloud_sync` feature.
78 SyncKit,
79 }
80
81 /// One tab: what it is called, where its panel lives, and who sees it.
82 struct Tab {
83 label: &'static str,
84 /// The id the panel's answer lands in.
85 panel: &'static str,
86 /// The tail of the route, under `/dashboard/project/{slug}/tabs/`.
87 route: &'static str,
88 gate: Gate,
89 }
90
91 /// Every tab the project dashboard can show, in the order the strip draws them.
92 const TABS: &[Tab] = &[
93 Tab {
94 label: "Overview",
95 panel: super::project_overview::REGION,
96 route: "overview",
97 gate: Gate::Always,
98 },
99 Tab {
100 label: "Content",
101 panel: "project-content",
102 route: "content",
103 gate: Gate::Always,
104 },
105 Tab {
106 label: "Analytics",
107 panel: super::project_analytics::REGION,
108 route: "analytics",
109 gate: Gate::Always,
110 },
111 Tab {
112 label: "Monetization",
113 panel: "project-monetization",
114 route: "monetization",
115 gate: Gate::Always,
116 },
117 Tab {
118 label: "Code",
119 panel: "project-code",
120 route: "code",
121 gate: Gate::Git,
122 },
123 Tab {
124 label: "Cloud Sync",
125 panel: "project-synckit",
126 route: "synckit",
127 gate: Gate::SyncKit,
128 },
129 Tab {
130 label: "Settings",
131 panel: "project-settings",
132 route: "settings",
133 gate: Gate::Always,
134 },
135 ];
136
137 /// The tabs whose panel the page handler can fill.
138 ///
139 /// A `?tab=` naming anything else answers the first tab rather than opening an
140 /// empty panel: the shown tab is the one that does not fetch, so a name the page
141 /// cannot render is a blank screen, not a slow one.
142 const FILLABLE: &[&str] = &["overview", "content", "synckit"];
143
144 /// Which tab a `?tab=` asks for, or the first one.
145 #[must_use]
146 pub fn shown_at(asked: Option<&str>, git: bool, synckit: bool) -> usize {
147 let Some(asked) = asked else { return 0 };
148 if !FILLABLE.contains(&asked) {
149 return 0;
150 }
151 visible(git, synckit)
152 .iter()
153 .position(|tab| tab.route == asked)
154 .unwrap_or(0)
155 }
156
157 /// The route name of a tab by index, so the caller knows which panel to fill.
158 #[must_use]
159 pub fn route_at(shown: usize, git: bool, synckit: bool) -> &'static str {
160 visible(git, synckit)
161 .get(shown)
162 .map_or(TABS[0].route, |tab| tab.route)
163 }
164
165 /// The tabs this project shows, in strip order.
166 fn visible(git: bool, synckit: bool) -> Vec<&'static Tab> {
167 TABS.iter()
168 .filter(|tab| match tab.gate {
169 Gate::Always => true,
170 Gate::Git => git,
171 Gate::SyncKit => synckit,
172 })
173 .collect()
174 }
175
176 /// The markup, for `dashboards/dashboard-project.html` to drop in.
177 #[must_use]
178 pub fn html(slug: &str, shown: usize, panel: &str, git: bool, synckit: bool) -> String {
179 let tabs = visible(git, synckit);
180 let shown = shown.min(tabs.len().saturating_sub(1));
181
182 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
183 // `Run` has no `Default`: a strip cannot be described while staying
184 // silent about what it does when it runs out of room. Seven tabs is the
185 // widest strip in the tree, so this is the one where `Menu` earns its
186 // keep rather than being a formality.
187 .across(layout::Fallback::Menu)
188 .showing_one(shown);
189
190 for (at, tab) in tabs.iter().enumerate() {
191 let mut region = Slot::handover(tab.panel, "project-panel");
192 if at != shown {
193 // Every panel is an Askama route naming no region, so each is told
194 // where its answer goes or htmx swaps it into the pressed button.
195 region = region.fed_by(
196 Action::get(format!("/dashboard/project/{slug}/tabs/{}", tab.route))
197 .awaiting()
198 .replacing(tab.panel),
199 );
200 }
201 strip = strip.frame(tab.label, Node::Region(region));
202 }
203
204 use quasi_axum::Serves as _;
205
206 Webview::new()
207 .with_fill(tabs[shown].panel, panel)
208 .fragment(&Node::Region(strip))
209 }
210
211 #[cfg(test)]
212 mod tests {
213 use super::*;
214
215 fn strip(git: bool, synckit: bool) -> String {
216 html("a-project", 0, "<p>the overview</p>", git, synckit)
217 }
218
219 #[test]
220 fn the_page_asks_for_nothing_on_load() {
221 let html = strip(true, true);
222
223 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
224 assert!(html.contains("<p>the overview</p>"), "{html}");
225 // Seven tabs, six of them unshown and each fetched only when pressed.
226 assert_eq!(html.matches("hx-get=").count(), 6, "{html}");
227 }
228
229 #[test]
230 fn every_unshown_tab_says_where_its_answer_lands() {
231 let html = strip(true, true);
232
233 for panel in [
234 "project-content",
235 "project-analytics",
236 "project-monetization",
237 "project-code",
238 "project-synckit",
239 "project-settings",
240 ] {
241 assert!(html.contains(&format!("hx-target=\"#{panel}\"")), "{html}");
242 assert!(html.contains(&format!("id=\"{panel}\"")), "{html}");
243 }
244 assert!(!html.contains("hx-target=\"#project-overview\""), "{html}");
245 }
246
247 #[test]
248 fn the_two_routes_that_are_not_tabs_stay_out() {
249 // Subscriptions and members are the monetization composite's own
250 // self-refresh routes: each partial names its own address in a
251 // `data-after="refresh"`. A button for either is a tab the page never
252 // had.
253 //
254 // This list was four until 2026-08-26, and the comment vouched for all
255 // of them. Two were not what it said. `/tabs/blog` was a route nothing
256 // reached and is deleted (`6077d0d9`); `/tabs/promotions` was the same
257 // and is deleted too (`f698064d`) -- its partial is live but the
258 // monetization composite carries the data, so the route rendering that
259 // partial alone had no caller. Only these two were ever self-refresh
260 // targets, and the way to tell is to grep the partial for its own
261 // address rather than to trust this comment.
262 let html = strip(true, true);
263
264 for route in ["/tabs/subscriptions", "/tabs/members"] {
265 assert!(!html.contains(route), "{route} is not a tab:\n{html}");
266 }
267 }
268
269 #[test]
270 fn the_two_gated_tabs_leave_when_their_test_fails() {
271 let both = strip(true, true);
272 assert!(both.contains(">Code</button>"), "{both}");
273 assert!(both.contains(">Cloud Sync</button>"), "{both}");
274
275 let neither = strip(false, false);
276 assert!(!neither.contains(">Code</button>"), "{neither}");
277 assert!(!neither.contains(">Cloud Sync</button>"), "{neither}");
278 assert!(neither.contains("role=\"tablist\""), "{neither}");
279 assert_eq!(neither.matches("hx-get=").count(), 4, "{neither}");
280 }
281
282 #[test]
283 fn a_deep_link_arrives_showing_what_it_asked_for() {
284 // The Stripe return path. Cloud Sync is index 5 with both gates open.
285 let shown = shown_at(Some("synckit"), true, true);
286 assert_eq!(shown, 5);
287 assert_eq!(route_at(shown, true, true), "synckit");
288
289 let html = html("a-project", shown, "<p>the apps</p>", true, true);
290 assert!(html.contains("<p>the apps</p>"), "{html}");
291 assert!(!html.contains("hx-target=\"#project-synckit\""), "{html}");
292 assert!(html.contains("hx-target=\"#project-overview\""), "{html}");
293 }
294
295 #[test]
296 fn a_tab_the_page_cannot_fill_is_not_opened() {
297 // Analytics, monetization, code and settings are real tabs and are not
298 // fillable, so asking for one by name lands on the first tab rather than
299 // on an empty panel. Pressing them still works; this is the link path.
300 for asked in ["analytics", "monetization", "code", "settings"] {
301 assert_eq!(shown_at(Some(asked), true, true), 0, "{asked}");
302 }
303 // And the three that are fillable resolve to themselves.
304 assert_eq!(shown_at(Some("overview"), true, true), 0);
305 assert_eq!(shown_at(Some("content"), true, true), 1);
306 }
307
308 #[test]
309 fn a_closed_gate_moves_the_index_of_what_follows_it() {
310 // Cloud Sync sits after Code, so a project without a git path has it one
311 // place earlier. Resolving by name rather than by a written-down number
312 // is what keeps the Stripe return correct on both.
313 assert_eq!(shown_at(Some("synckit"), false, true), 4);
314 // And with the feature off there is no such tab at all.
315 assert_eq!(shown_at(Some("synckit"), true, false), 0);
316 }
317
318 #[test]
319 fn an_unknown_or_absent_tab_is_the_first_one() {
320 assert_eq!(shown_at(None, true, true), 0);
321 assert_eq!(shown_at(Some("nonsense"), true, true), 0);
322 }
323
324 #[test]
325 fn a_shown_index_past_the_end_cannot_panic() {
326 let html = html("a-project", 99, "<p>the overview</p>", false, false);
327 assert!(html.contains("<p>the overview</p>"), "{html}");
328 }
329
330 #[test]
331 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
332 assert!(strip(true, true).contains("run-menu"));
333 }
334 }
335