Skip to main content

max / makenotwork

15.6 KB · 446 lines History Blame Raw
1 //! The project dashboard's Overview panel, described.
2 //!
3 //! Fourth of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab
4 //! inventory"): 93 lines, three `hx-` attributes, no `data-action`, and nothing
5 //! in `static/` or `frontend/src` reaches for any id it writes.
6 //!
7 //! A fill on `project_tabs::build_overview` rather than a mounted screen, for
8 //! the reason the batch before it established: `project_tab_overview` answers a
9 //! conditional GET through `resolve_project_etag`, and `super::mount` has no
10 //! way to say "304 if the project's cache generation has not moved". See
11 //! [`super::user_projects`] for the rule and why it is the ETag that decides.
12 //!
13 //! # The disclosure is sayable now, and this is the first screen to say it
14 //!
15 //! `super::buyer_contacts` gave up its `<details open>` in August and filed the
16 //! gap: 51 sites, and nothing named a disclosure. It is named now, as a
17 //! selective region -- `Slot::widget(id, "disclosure").showing_at_most_one(..)`
18 //! -- where `None` is the closed state and is a legal resting place. So the
19 //! tools panel keeps its collapse instead of becoming a heading and six
20 //! paragraphs, and `buyer_contacts` can take its own back whenever somebody is
21 //! in there.
22 //!
23 //! # The setup checklist is a list of three steps, not three copies of one row
24 //!
25 //! The template writes each step twice, once done and once not, and the two
26 //! branches differ in a tick, a label class, and whether a CTA is there at all.
27 //! Said once, a step is a row that carries a token when it is finished and an
28 //! act when there is something to do about it, and the six branches collapse to
29 //! three [`Step`]s built from three booleans.
30 //!
31 //! The whole block is conditional on at least one step being unfinished, which
32 //! is [`Step::all_done`] here rather than a three-way `||` in the caller.
33 //!
34 //! # Quick Actions loses its third spelling of Export
35 //!
36 //! `hx-post="/api/export/projects"` with `hx-target="body" hx-swap="beforeend"`
37 //! was a sixth hand-written export control. [`super::export_act`] is the one
38 //! that already exists, and it says what the answer *is* -- a file the reader
39 //! keeps -- rather than where to staple the response.
40 //!
41 //! **It renames the button, from "Export Data" to "Export CSV".** That is the
42 //! cost of taking the shared control rather than spelling a sixth one, and it
43 //! is the right way round: five other sites already say "Export CSV" and this
44 //! was the only one that did not. Noted rather than hidden, since a conversion
45 //! changing user-visible copy should say so.
46
47 use makeover_layout as layout;
48 use quasi_declare::declare;
49 use quasi_router::screen::{Figure, Tag};
50 use quasi_router::{Node, RegionKind, Slot};
51 use quasi_webview::Webview;
52
53 use crate::types::StatCard;
54
55 /// The region the answer replaces, keeping the id the page already used.
56 pub const REGION: &str = "project-overview";
57
58 /// The disclosure holding the tour of the other tabs.
59 const TOOLS: &str = "project-overview-tools";
60
61 /// The one frame inside it, which is what carries the summary line.
62 const TOOLS_BODY: &str = "project-overview-tools-body";
63
64 /// One line of the setup checklist.
65 struct Step {
66 /// What the reader is being asked to do.
67 label: &'static str,
68 /// Whether they have done it.
69 done: bool,
70 /// Where to go and do it, when there is somewhere and it is not done.
71 act: Option<(&'static str, String)>,
72 }
73
74 impl Step {
75 /// The three steps, in the order the template drew them.
76 fn all(slug: &str, stripe_connected: bool, has_items: bool, has_published: bool) -> Vec<Self> {
77 vec![
78 Self {
79 label: "Add your first item: upload files, set a price",
80 done: has_items,
81 act: Some(("New Item", format!("/dashboard/project/{slug}/new-item"))),
82 },
83 Self {
84 label: "Connect Stripe: required to receive payments (3% processing only)",
85 done: stripe_connected,
86 act: Some(("Go to Payments", "/dashboard?tab=payments".to_owned())),
87 },
88 Self {
89 label: "Publish an item: make it visible on your public page",
90 done: has_published,
91 // The template offers this only once there is something to
92 // publish, which is a real condition and not an oversight:
93 // Content is an empty screen before the first item exists.
94 act: has_items.then(|| {
95 (
96 "Go to Content",
97 format!("/dashboard/project/{slug}?tab=content"),
98 )
99 }),
100 },
101 ]
102 }
103
104 /// Whether the checklist has anything left to say.
105 fn all_done(steps: &[Self]) -> bool {
106 steps.iter().all(|step| step.done)
107 }
108
109 /// Whether this step offers somewhere to go.
110 ///
111 /// A predicate rather than an `Option` the description reaches into, which
112 /// is the call `embeds::ItemView::has_cover` records: the form has no
113 /// binding pattern, and a finished step offers nothing however its `act`
114 /// reads.
115 fn offers_act(&self) -> bool {
116 !self.done && self.act.is_some()
117 }
118
119 /// What that control says, or nothing.
120 ///
121 /// R9: the control is built whether or not [`Self::offers_act`] places it,
122 /// so the case that is not drawn is answered rather than panicked on.
123 fn act_label(&self) -> &'static str {
124 self.act.as_ref().map_or("", |(label, _)| *label)
125 }
126
127 /// Where it goes, or nowhere. See [`Self::act_label`].
128 fn act_href(&self) -> &str {
129 self.act.as_ref().map_or("", |(_, href)| href.as_str())
130 }
131 }
132
133 declare! {
134 /// One step as a row.
135 ///
136 /// The template wrote each step twice, once done and once not, and the two
137 /// branches differ in a tick, a label class, and whether a CTA is there at
138 /// all. Said once, that is two guarded settings over one row: a token when
139 /// it is finished, a control when there is something to do about it.
140 shape step_row(step: &Step) -> Row;
141
142 row step.label {
143 token Tag::badge("Done").tone(layout::Tone::Success) when step.done;
144 act step.act_label() to external step.act_href() when step.offers_act();
145 }
146 }
147
148 /// The panel as the route answers it: the region, carrying its own id.
149 #[must_use]
150 pub fn fragment(
151 slug: &str,
152 stats: &[StatCard],
153 stripe_connected: bool,
154 has_items: bool,
155 has_published: bool,
156 ) -> String {
157 use quasi_axum::Serves as _;
158
159 let mut slot = Slot::new(REGION, RegionKind::Pane);
160 for node in body(slug, stats, stripe_connected, has_items, has_published) {
161 slot = slot.with(node);
162 }
163 Webview::new().fragment(&Node::Region(slot))
164 }
165
166 /// The panel's contents as the page embeds them, without a region wrapper.
167 #[must_use]
168 pub fn fill(
169 slug: &str,
170 stats: &[StatCard],
171 stripe_connected: bool,
172 has_items: bool,
173 has_published: bool,
174 ) -> String {
175 use quasi_axum::Serves as _;
176
177 let mut out = String::new();
178 for node in body(slug, stats, stripe_connected, has_items, has_published) {
179 out.push_str(&Webview::new().fragment(&node));
180 }
181 out
182 }
183
184 declare! {
185 /// The panel's contents, in order.
186 shape body(
187 slug: &str,
188 stats: &[StatCard],
189 stripe_connected: bool,
190 has_items: bool,
191 has_published: bool,
192 ) -> Vec<Node>;
193
194 let steps = Step::all(slug, stripe_connected, has_items, has_published);
195 include setup(&steps) unless Step::all_done(&steps);
196
197 link "Docs: Projects" to get "/docs/projects" navigating;
198 include figures(stats);
199 section "Quick Actions";
200 for node in quick_actions(slug) {
201 include node;
202 }
203 include tools();
204 }
205
206 declare! {
207 /// What is left to do before the project can sell anything.
208 shape setup(steps: &[Step]) -> Node;
209
210 region "project-overview-setup" as Pane {
211 subsection "Project Setup";
212 list {
213 for step in steps.iter() {
214 include step_row(step);
215 }
216 }
217 }
218 }
219
220 /// The delta a card reports, or nothing.
221 fn change(stat: &StatCard) -> &str {
222 stat.change.as_deref().unwrap_or_default()
223 }
224
225 /// The tone rides on the delta, so a card with nothing to report stays neutral
226 /// rather than going green for having no news.
227 fn delta_tone(stat: &StatCard) -> layout::Tone {
228 if stat.is_positive {
229 layout::Tone::Success
230 } else {
231 layout::Tone::Danger
232 }
233 }
234
235 declare! {
236 /// The figures across the top.
237 ///
238 /// The same shape as `super::user_analytics::stats`, and toned the same
239 /// way: see [`delta_tone`].
240 ///
241 /// The empty list is what the figures accrete onto, which is the wart
242 /// `super::project_analytics` recorded and this is its second site.
243 shape figures(stats: &[StatCard]) -> Node;
244
245 stats [] {
246 for stat in stats.iter() {
247 figure Figure::new(stat.value.clone(), stat.label.clone())
248 when stat.change.is_none();
249 figure Figure::new(stat.value.clone(), stat.label.clone())
250 .change(change(stat))
251 .tone(delta_tone(stat))
252 unless stat.change.is_none();
253 }
254 }
255 }
256
257 declare! {
258 /// The three controls under Quick Actions.
259 shape quick_actions(slug: &str) -> Vec<Node>;
260
261 // Whole pages rather than fragments, so both leave. An internal
262 // `get` would fetch them into this panel.
263 act "New Item" to external "/dashboard/project/{slug}/new-item";
264 act "View Public Page" to external "/p/{slug}";
265 // Through `export_act`, which is the described control five other sites
266 // already use. See the module header.
267 include super::export_act::act("/api/export/projects", "projects.csv");
268 }
269
270 /// One tab the disclosure tours.
271 ///
272 /// Named members rather than a tuple, for `policy`'s reason: a description
273 /// names what it draws, and `.1` is not a name.
274 struct Tool {
275 /// What the tab is called.
276 name: &'static str,
277 /// What it is for.
278 description: &'static str,
279 }
280
281 /// The six tabs the disclosure tours, in the order it draws them.
282 const TOOLS_LIST: &[Tool] = &[
283 Tool {
284 name: "Content",
285 description: "Upload items, manage versions, set prices.",
286 },
287 Tool {
288 name: "Blog",
289 description: "Write posts that appear on your project page and RSS feed.",
290 },
291 Tool {
292 name: "Promo Codes",
293 description: "Create discounts, free access codes, or trial periods.",
294 },
295 Tool {
296 name: "Membership Tiers",
297 description: "Recurring subscriptions with gated content access.",
298 },
299 Tool {
300 name: "Team",
301 description: "Add collaborators and split revenue automatically.",
302 },
303 Tool {
304 name: "Analytics",
305 description: "Track sales, revenue, and views over time.",
306 },
307 ];
308
309 declare! {
310 /// The tour of the other tabs, behind a disclosure.
311 ///
312 /// The shape is what makes this a disclosure, and it is exact: a region
313 /// showing at most one frame, whose single frame is a LABELLED sub-region.
314 /// The label is the summary line. Put it on the outer region instead and
315 /// the renderer finds no labels, falls through to the frame-stepper branch,
316 /// and draws Prev/Next buttons and a "0 / 1" counter. Measured 2026-08-26
317 /// by doing exactly that.
318 ///
319 /// `None` is closed, which is where the template's `<details>` rests: it
320 /// carries no `open`.
321 shape tools() -> Node;
322
323 region TOOLS as Group {
324 region TOOLS_BODY as Pane {
325 label "Explore Your Project Tools";
326 list {
327 for tool in TOOLS_LIST {
328 row tool.name {
329 secondary tool.description;
330 }
331 }
332 }
333 }
334 showing_at_most_one None;
335 }
336 }
337
338 #[cfg(test)]
339 mod tests {
340 use super::*;
341 use quasi_axum::Serves;
342
343 fn stat(label: &str, change: Option<&str>, positive: bool) -> StatCard {
344 StatCard {
345 label: label.into(),
346 value: "12".into(),
347 change: change.map(Into::into),
348 is_positive: positive,
349 }
350 }
351
352 fn render(slug: &str, stripe: bool, items: bool, published: bool) -> String {
353 let mut out = String::new();
354 for node in &body(slug, &[stat("Items", None, true)], stripe, items, published) {
355 out.push_str(&Webview::new().fragment(node));
356 }
357 out
358 }
359
360 #[test]
361 fn a_finished_project_is_not_shown_the_setup_checklist() {
362 let done = render("an-album", true, true, true);
363 assert!(!done.contains("Project Setup"), "{done}");
364
365 let unfinished = render("an-album", false, true, true);
366 assert!(unfinished.contains("Project Setup"), "{unfinished}");
367 }
368
369 #[test]
370 fn a_finished_step_says_done_and_offers_nothing() {
371 let html = render("an-album", false, true, false);
372
373 // Items is done, so its CTA is gone and the tick is there.
374 assert!(html.contains("Done"), "{html}");
375 assert!(
376 !html.contains("/dashboard/project/an-album/new-item\">New Item"),
377 "a finished step still offers its CTA: {html}"
378 );
379 // Stripe is not, so its CTA is there.
380 assert!(html.contains("/dashboard?tab=payments"), "{html}");
381 }
382
383 #[test]
384 fn publish_offers_content_only_once_there_is_something_to_publish() {
385 let empty = render("an-album", false, false, false);
386 let stocked = render("an-album", false, true, false);
387
388 assert!(!empty.contains("Go to Content"), "{empty}");
389 assert!(stocked.contains("Go to Content"), "{stocked}");
390 }
391
392 #[test]
393 fn the_tools_disclosure_is_closed_and_holds_all_six() {
394 let html = render("an-album", true, true, true);
395
396 assert!(html.contains("Explore Your Project Tools"), "{html}");
397 // It is a disclosure and not a frame-stepper. Both are legal renderings
398 // of a selective region and only one of them is this screen; getting
399 // the shape wrong draws Prev/Next and a counter, which is what happened
400 // on the first attempt.
401 assert!(html.contains("aria-expanded=\"false\""), "{html}");
402 assert!(!html.contains("data-shows=\"next\""), "{html}");
403 assert!(!html.contains("data-shows=\"previous\""), "{html}");
404 for tool in [
405 "Content",
406 "Blog",
407 "Promo Codes",
408 "Membership Tiers",
409 "Team",
410 "Analytics",
411 ] {
412 assert!(html.contains(tool), "missing {tool}: {html}");
413 }
414 }
415
416 #[test]
417 fn the_export_is_the_described_one_and_not_a_sixth_spelling() {
418 let html = render("an-album", true, true, true);
419
420 assert!(html.contains("data-saves=\"projects.csv\""), "{html}");
421 assert!(html.contains("hx-post=\"/api/export/projects\""), "{html}");
422 // What the template did instead: staple the answer onto the document.
423 assert!(!html.contains("hx-swap=\"beforeend\""), "{html}");
424 assert!(!html.contains("data-action"), "{html}");
425 }
426
427 #[test]
428 fn a_figure_without_a_delta_stays_neutral() {
429 let out = Webview::new().fragment(&figures(&[stat("Items", None, true)]));
430 assert!(!out.contains("data-tone=\"success\""), "{out}");
431 }
432
433 #[test]
434 fn the_slug_cannot_smuggle_markup() {
435 let html = render("<script>x()</script>", false, false, false);
436 assert!(!html.contains("<script>x()"), "{html}");
437 }
438
439 #[test]
440 fn the_inline_fill_carries_no_region_because_the_strip_draws_one() {
441 let inline = fill("an-album", &[], true, true, true);
442 assert!(!inline.contains(&format!("id=\"{REGION}\"")), "{inline}");
443 assert!(inline.contains("Quick Actions"), "{inline}");
444 }
445 }
446