Skip to main content

max / makenotwork

13.6 KB · 348 lines History Blame Raw
1 //! The user dashboard's Projects panel, described.
2 //!
3 //! Second of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab
4 //! inventory"): 53 lines, four `hx-` attributes, no `data-action`, no
5 //! `<details>`, no `{% include %}`, and nothing in `static/` or `frontend/src`
6 //! reaches for any id it writes.
7 //!
8 //! It replaces `UserProjectsTabTemplate` and the template, and leaves
9 //! `routes::pages::dashboard::tabs::user::build_projects` in place answering
10 //! described markup instead of an Askama render.
11 //!
12 //! # A fill, not a mounted screen, and the ETag is why
13 //!
14 //! Every conversion before this one took its address off the Askama router and
15 //! answered it through `super::mount`. This one must not: `dashboard_tab_projects`
16 //! answers a conditional GET keyed on the user's cache generation
17 //! (`helpers::check_etag` / `with_etag`), and `mount` has no way to say
18 //! "304 if the generation has not moved". Mounting it would trade a working
19 //! conditional GET for a tidier route table.
20 //!
21 //! So this follows [`super::project_content`] instead, which stayed on its
22 //! Askama handler for the same reason (see `project_tabs::project_tab_content`,
23 //! where the ETag is read before the panel is built). The handler and the route
24 //! survive; what dies is the template.
25 //!
26 //! # It has two callers, which is why there are two entry points
27 //!
28 //! Projects is the tab the dashboard opens on, so it is rendered *inline* into
29 //! the page as well as answering its own address when the strip fetches it.
30 //! [`fill`] is the inline half and [`fragment`] the addressed half. The strip
31 //! already draws a region carrying [`REGION`], so the inline markup must not
32 //! wrap itself in a second one, and a route's answer must.
33 //!
34 //! # The empty state moves, and stops being reachable at the same time as the list
35 //!
36 //! The template renders the project loop and *then* asks whether the list was
37 //! empty, so an account with no projects drew an empty `role="list"` followed by
38 //! a Getting Started box. Described, the two are alternatives: a
39 //! [`layout::Readiness::Empty`] stand-in carries the same four steps and the
40 //! same call to action, and the list is simply not there. Nothing a reader can
41 //! see changes, and the `<div role="list">` with no rows in it does.
42 //!
43 //! # What the description says that the markup did not
44 //!
45 //! Every project card was an `<article>` holding an `<h3>`, two `<div>`s of
46 //! metadata, a badge and three controls, and the three controls were an anchor,
47 //! an anchor and a `<button>` on no shared footing. As a [`Row`] it is a
48 //! primary, a meta line, a token and three acts, and which of them draw as
49 //! links is the renderer's business rather than the template's.
50 //!
51 //! The delete control keeps its confirm and gains nothing else: it already
52 //! addressed a real route with a real target, which is rarer in this tree than
53 //! it sounds (see `super::mount`'s note on the two screens that did not).
54
55 use makeover_layout as layout;
56 use quasi_router::screen::{Act, Row, Tag};
57 use quasi_router::{Action, Node, RegionKind, Slot};
58 use quasi_webview::Webview;
59
60 use crate::types::ProjectCard;
61
62 /// The region the answer replaces, keeping the id the page already used.
63 ///
64 /// `super::user_tabs::TABS` names this rather than transcribing it, the way
65 /// `user_analytics` already did.
66 pub const REGION: &str = "user-projects";
67
68 /// Where a reader who cannot create projects is sent to ask.
69 const APPLY: &str = "/dashboard?tab=settings&section=creator";
70
71 /// Where a reader who can create one starts.
72 const NEW_PROJECT: &str = "/dashboard/new-project";
73
74 /// The panel as the route answers it: the region, carrying its own id.
75 ///
76 /// The wrapper matters for the same reason it does on `project_content`: the
77 /// strip aims the fetch at `#user-projects` and swaps it, so an answer without
78 /// the id replaces the panel with markup nothing can target afterwards.
79 #[must_use]
80 pub fn fragment(projects: &[ProjectCard], can_create_projects: bool) -> String {
81 use quasi_axum::Serves as _;
82
83 let mut slot = Slot::new(REGION, RegionKind::Pane);
84 for node in body(projects, can_create_projects) {
85 slot = slot.with(node);
86 }
87 Webview::new().fragment(&Node::Region(slot))
88 }
89
90 /// The panel's contents as the page embeds them, without a region wrapper.
91 ///
92 /// The strip draws the region; this goes inside it. See the module header.
93 #[must_use]
94 pub fn fill(projects: &[ProjectCard], can_create_projects: bool) -> String {
95 use quasi_axum::Serves as _;
96
97 let mut out = String::new();
98 for node in body(projects, can_create_projects) {
99 out.push_str(&Webview::new().fragment(&node));
100 }
101 out
102 }
103
104 /// The panel's contents, in order.
105 fn body(projects: &[ProjectCard], can_create_projects: bool) -> Vec<Node> {
106 let mut out = vec![
107 Node::Link {
108 text: "Docs: Projects".into(),
109 action: Action::get("/docs/projects").navigating(),
110 },
111 Node::section("Your Projects"),
112 start_act(can_create_projects),
113 ];
114
115 if projects.is_empty() {
116 out.push(getting_started(can_create_projects));
117 return out;
118 }
119
120 out.push(Node::list(projects.iter().map(card)));
121 out
122 }
123
124 /// The one control at the top, which is a different offer per reader.
125 ///
126 /// Two spellings in the template and one idea: a creator starts a project, and
127 /// everyone else asks to become one.
128 fn start_act(can_create_projects: bool) -> Node {
129 if can_create_projects {
130 // `external` because it is a whole page rather than a fragment, so it
131 // leaves. `super::project_content`'s New Item says it the same way and
132 // for the same reason: an internal `Action::get` would fetch the wizard
133 // into this panel.
134 Node::act("New Project", Action::external(NEW_PROJECT))
135 } else {
136 Node::act("Apply for Creator Access", Action::external(APPLY))
137 }
138 }
139
140 /// One project.
141 fn card(project: &ProjectCard) -> Row {
142 let mut meta = format!(
143 "{} · Created {}",
144 project.project_type, project.created_date
145 );
146 if let Some(updated) = &project.updated_date {
147 meta.push_str(" · Last updated ");
148 meta.push_str(updated);
149 }
150
151 let mut row = Row::new(project.title.clone()).meta(meta);
152
153 // The template puts `stats` and the badge on one line separated by a
154 // middot, and hides the middot when stats is empty. Said as two parts, the
155 // renderer decides the separator and the empty case stops being a
156 // conditional in the markup.
157 if !project.stats.is_empty() {
158 row = row.meta(project.stats.clone());
159 }
160
161 let mut badge = Tag::badge(project.status.clone());
162 badge.tone = tone(project.status_tone);
163
164 row.token(badge)
165 .act(Act::new(
166 "View",
167 Action::external(format!("/p/{}", project.slug)),
168 ))
169 .act(Act::new(
170 "Edit",
171 Action::external(format!("/dashboard/project/{}", project.slug)),
172 ))
173 .act(
174 Act::new(
175 "Delete",
176 Action::delete(format!("/api/projects/{}", project.id)),
177 )
178 .tone(layout::Tone::Danger)
179 .confirm("Delete this project? This cannot be undone."),
180 )
181 }
182
183 /// The badge tone, from the string `ProjectCard::from_db` picked.
184 ///
185 /// A `&'static str` on the type rather than a tone, because the template fed it
186 /// straight to `data-tone`. Mapped here rather than changed there: making
187 /// `ProjectCard::status_tone` a `layout::Tone` would put a description type on a
188 /// view struct four Askama templates still read.
189 fn tone(status_tone: &str) -> layout::Tone {
190 match status_tone {
191 "success" => layout::Tone::Success,
192 "warning" => layout::Tone::Warning,
193 "danger" => layout::Tone::Danger,
194 "info" => layout::Tone::Info,
195 _ => layout::Tone::Neutral,
196 }
197 }
198
199 /// What stands where the list would be, for an account with no projects.
200 fn getting_started(can_create_projects: bool) -> Node {
201 // The four steps are prose rather than a described list: they are one
202 // explanation of what the product is, not a set of things with addresses.
203 // `Node::Rich` carries the markdown source, which is what lets a terminal
204 // render the same four steps without being handed markup.
205 Node::Region(
206 Slot::new("user-projects-getting-started", RegionKind::Pane)
207 .with(Node::StandIn {
208 state: layout::Readiness::Empty,
209 message: "Welcome to Makenotwork. A project groups your work. Think of it as an \
210 album, podcast feed, or product line. Each project contains items: \
211 individual tracks, episodes, downloads, or posts."
212 .into(),
213 act: None,
214 })
215 .with(Node::rich(
216 "1. Create a project\n\
217 2. Add items: audio, video, text, or software\n\
218 3. Set prices (or keep them free) and publish\n\
219 4. Connect your payment account, 0% platform fee",
220 ))
221 .with(if can_create_projects {
222 Node::act("Create Your First Project", Action::external(NEW_PROJECT))
223 } else {
224 Node::act("Apply for Creator Access", Action::external(APPLY))
225 }),
226 )
227 }
228
229 #[cfg(test)]
230 mod tests {
231 use super::*;
232 use quasi_axum::Serves;
233
234 fn project(title: &str) -> ProjectCard {
235 ProjectCard {
236 id: crate::db::ProjectId::from(uuid::Uuid::nil()),
237 title: title.into(),
238 project_type: "Album".into(),
239 created_date: "Aug 1, 2026".into(),
240 updated_date: Some("Aug 20, 2026".into()),
241 stats: "3 items".into(),
242 status: "Published".into(),
243 status_tone: "success",
244 slug: "an-album".into(),
245 }
246 }
247
248 fn render(nodes: &[Node]) -> String {
249 let mut out = String::new();
250 for node in nodes {
251 out.push_str(&Webview::new().fragment(node));
252 }
253 out
254 }
255
256 #[test]
257 fn the_strip_and_this_panel_agree_about_the_region() {
258 // If these drift the panel's answer lands nowhere, and nothing else
259 // would notice. `user_analytics` asserts the same thing for its own.
260 let strip = include_str!("user_tabs.rs");
261 assert!(strip.contains("user_projects::REGION"), "{REGION}");
262 }
263
264 #[test]
265 fn no_quasi_route_answers_this_address_so_the_strip_must_still_fetch_it() {
266 // The other half of the region agreement, and the one that is easy to
267 // get wrong later. `screen: Some(..)` tells the strip a described route
268 // owns the address and to leave the region alone; this panel is a fill
269 // on the Askama handler, which is what keeps the ETag. The two facts
270 // have to move together, so if this address is ever mounted, the strip
271 // needs `screen: Some(..)` in the same change or the tab goes blank.
272 assert!(
273 !crate::quasi::PATHS.contains(&"/dashboard/tabs/projects"),
274 "mounted now: give the Projects tab `screen: Some(..)` in user_tabs"
275 );
276 }
277
278 #[test]
279 fn a_project_offers_view_edit_and_a_confirmed_delete() {
280 let html = render(&[Node::list([card(&project("An Album"))])]);
281
282 assert!(html.contains("An Album"), "{html}");
283 assert!(html.contains("href=\"/p/an-album\""), "{html}");
284 assert!(
285 html.contains("href=\"/dashboard/project/an-album\""),
286 "{html}"
287 );
288 assert!(
289 html.contains(&format!(
290 "hx-delete=\"/api/projects/{}\"",
291 crate::db::ProjectId::from(uuid::Uuid::nil())
292 )),
293 "{html}"
294 );
295 assert!(html.contains("This cannot be undone."), "{html}");
296 }
297
298 #[test]
299 fn the_meta_line_drops_the_stats_part_rather_than_drawing_an_empty_one() {
300 let mut bare = project("Bare");
301 bare.stats = String::new();
302
303 let with = render(&[Node::list([card(&project("With"))])]);
304 let without = render(&[Node::list([card(&bare)])]);
305
306 assert!(with.contains("3 items"), "{with}");
307 assert!(!without.contains("3 items"), "{without}");
308 // Both still say the rest of the meta line.
309 assert!(without.contains("Created Aug 1, 2026"), "{without}");
310 }
311
312 #[test]
313 fn an_account_with_no_projects_gets_the_guide_and_no_empty_list() {
314 let html = render(&body(&[], true));
315
316 assert!(html.contains("Welcome to Makenotwork."), "{html}");
317 assert!(html.contains("Create Your First Project"), "{html}");
318 // The template drew an empty `role="list"` above the guide. This is the
319 // half of the conversion that removes it.
320 assert!(!html.contains("role=\"list\""), "{html}");
321 }
322
323 #[test]
324 fn a_reader_who_cannot_create_projects_is_offered_the_application_instead() {
325 let listed = render(&body(&[project("An Album")], false));
326 let empty = render(&body(&[], false));
327
328 for html in [&listed, &empty] {
329 assert!(html.contains("Apply for Creator Access"), "{html}");
330 assert!(!html.contains("New Project"), "{html}");
331 assert!(!html.contains("Create Your First Project"), "{html}");
332 }
333 }
334
335 #[test]
336 fn a_project_title_cannot_smuggle_markup() {
337 let html = render(&[Node::list([card(&project("<script>x()</script>"))])]);
338 assert!(!html.contains("<script>x()"), "{html}");
339 }
340
341 #[test]
342 fn the_inline_fill_carries_no_region_because_the_strip_draws_one() {
343 let inline = fill(&[project("An Album")], true);
344 assert!(!inline.contains(&format!("id=\"{REGION}\"")), "{inline}");
345 assert!(inline.contains("An Album"), "{inline}");
346 }
347 }
348