Skip to main content

max / makenotwork

12.1 KB · 331 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: "project-overview",
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: "project-analytics",
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_four_routes_that_are_not_tabs_stay_out() {
250 // Subscriptions, promotions and members are the monetization composite's
251 // own self-refresh routes, and blog is reached from the content panel.
252 // A button for any of them is a tab the page never had.
253 let html = strip(true, true);
254
255 for route in [
256 "/tabs/subscriptions",
257 "/tabs/promotions",
258 "/tabs/members",
259 "/tabs/blog",
260 ] {
261 assert!(!html.contains(route), "{route} is not a tab:\n{html}");
262 }
263 }
264
265 #[test]
266 fn the_two_gated_tabs_leave_when_their_test_fails() {
267 let both = strip(true, true);
268 assert!(both.contains(">Code</button>"), "{both}");
269 assert!(both.contains(">Cloud Sync</button>"), "{both}");
270
271 let neither = strip(false, false);
272 assert!(!neither.contains(">Code</button>"), "{neither}");
273 assert!(!neither.contains(">Cloud Sync</button>"), "{neither}");
274 assert!(neither.contains("role=\"tablist\""), "{neither}");
275 assert_eq!(neither.matches("hx-get=").count(), 4, "{neither}");
276 }
277
278 #[test]
279 fn a_deep_link_arrives_showing_what_it_asked_for() {
280 // The Stripe return path. Cloud Sync is index 5 with both gates open.
281 let shown = shown_at(Some("synckit"), true, true);
282 assert_eq!(shown, 5);
283 assert_eq!(route_at(shown, true, true), "synckit");
284
285 let html = html("a-project", shown, "<p>the apps</p>", true, true);
286 assert!(html.contains("<p>the apps</p>"), "{html}");
287 assert!(!html.contains("hx-target=\"#project-synckit\""), "{html}");
288 assert!(html.contains("hx-target=\"#project-overview\""), "{html}");
289 }
290
291 #[test]
292 fn a_tab_the_page_cannot_fill_is_not_opened() {
293 // Analytics, monetization, code and settings are real tabs and are not
294 // fillable, so asking for one by name lands on the first tab rather than
295 // on an empty panel. Pressing them still works; this is the link path.
296 for asked in ["analytics", "monetization", "code", "settings"] {
297 assert_eq!(shown_at(Some(asked), true, true), 0, "{asked}");
298 }
299 // And the three that are fillable resolve to themselves.
300 assert_eq!(shown_at(Some("overview"), true, true), 0);
301 assert_eq!(shown_at(Some("content"), true, true), 1);
302 }
303
304 #[test]
305 fn a_closed_gate_moves_the_index_of_what_follows_it() {
306 // Cloud Sync sits after Code, so a project without a git path has it one
307 // place earlier. Resolving by name rather than by a written-down number
308 // is what keeps the Stripe return correct on both.
309 assert_eq!(shown_at(Some("synckit"), false, true), 4);
310 // And with the feature off there is no such tab at all.
311 assert_eq!(shown_at(Some("synckit"), true, false), 0);
312 }
313
314 #[test]
315 fn an_unknown_or_absent_tab_is_the_first_one() {
316 assert_eq!(shown_at(None, true, true), 0);
317 assert_eq!(shown_at(Some("nonsense"), true, true), 0);
318 }
319
320 #[test]
321 fn a_shown_index_past_the_end_cannot_panic() {
322 let html = html("a-project", 99, "<p>the overview</p>", false, false);
323 assert!(html.contains("<p>the overview</p>"), "{html}");
324 }
325
326 #[test]
327 fn the_strip_says_what_it_does_when_it_runs_out_of_room() {
328 assert!(strip(true, true).contains("run-menu"));
329 }
330 }
331