Skip to main content

max / makenotwork

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