Skip to main content

max / makenotwork

13.5 KB · 346 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_declare::declare;
57 use quasi_router::screen::Tag;
58 use quasi_router::{Node, RegionKind, Slot};
59 use quasi_webview::Webview;
60
61 use crate::types::ProjectCard;
62
63 /// The region the answer replaces, keeping the id the page already used.
64 ///
65 /// `super::user_tabs::TABS` names this rather than transcribing it, the way
66 /// `user_analytics` already did.
67 pub const REGION: &str = "user-projects";
68
69 /// Where a reader who cannot create projects is sent to ask.
70 const APPLY: &str = "/dashboard?tab=settings&section=creator";
71
72 /// Where a reader who can create one starts.
73 const NEW_PROJECT: &str = "/dashboard/new-project";
74
75 /// The panel as the route answers it: the region, carrying its own id.
76 ///
77 /// The wrapper matters for the same reason it does on `project_content`: the
78 /// strip aims the fetch at `#user-projects` and swaps it, so an answer without
79 /// the id replaces the panel with markup nothing can target afterwards.
80 #[must_use]
81 pub fn fragment(projects: &[ProjectCard], can_create_projects: bool) -> String {
82 use quasi_axum::Serves as _;
83
84 let mut slot = Slot::new(REGION, RegionKind::Pane);
85 for node in body(projects, can_create_projects) {
86 slot = slot.with(node);
87 }
88 Webview::new().fragment(&Node::Region(slot))
89 }
90
91 /// The panel's contents as the page embeds them, without a region wrapper.
92 ///
93 /// The strip draws the region; this goes inside it. See the module header.
94 #[must_use]
95 pub fn fill(projects: &[ProjectCard], can_create_projects: bool) -> String {
96 use quasi_axum::Serves as _;
97
98 let mut out = String::new();
99 for node in body(projects, can_create_projects) {
100 out.push_str(&Webview::new().fragment(&node));
101 }
102 out
103 }
104
105 declare! {
106 /// The panel's contents, in order.
107 ///
108 /// A list of members and not a region: the strip draws the frame and its
109 /// `id`, so [`fill`] must add no second one, and [`fragment`] wraps the same
110 /// members itself.
111 shape body(projects: &[ProjectCard], can_create_projects: bool) -> Vec<Node>;
112
113 link "Docs: Projects" to get "/docs/projects" navigating;
114 section "Your Projects";
115 include start_act(can_create_projects);
116
117 include getting_started(can_create_projects) when projects.is_empty();
118
119 list {
120 for project in projects.iter() {
121 include card(project);
122 }
123 } unless projects.is_empty();
124 }
125
126 declare! {
127 /// The one control at the top, which is a different offer per reader.
128 ///
129 /// Two spellings in the template and one idea: a creator starts a project,
130 /// and everyone else asks to become one.
131 ///
132 /// `external` because it is a whole page rather than a fragment, so it
133 /// leaves. `super::project_content`'s New Item says it the same way and for
134 /// the same reason: an internal get would fetch the wizard into this panel.
135 shape start_act(can_create_projects: bool) -> Node;
136
137 given can_create_projects {
138 true -> act "New Project" to external NEW_PROJECT;
139 false -> act "Apply for Creator Access" to external APPLY;
140 }
141 }
142
143 /// The first meta line: what kind of project it is, when it was made, and when
144 /// it was last touched.
145 ///
146 /// A supplier because the last clause is optional and the three read as one
147 /// sentence, which is a string the form has no expression to build.
148 fn made(project: &ProjectCard) -> String {
149 let mut meta = format!(
150 "{} ยท Created {}",
151 project.project_type, project.created_date
152 );
153 if let Some(updated) = &project.updated_date {
154 meta.push_str(" ยท Last updated ");
155 meta.push_str(updated);
156 }
157 meta
158 }
159
160 declare! {
161 /// One project.
162 ///
163 /// The template puts `stats` and the badge on one line separated by a
164 /// middot, and hides the middot when stats is empty. Said as two parts, the
165 /// renderer decides the separator and the empty case stops being a
166 /// conditional in the markup.
167 shape card(project: &ProjectCard) -> Row;
168
169 row project.title.clone() {
170 meta made(project);
171 meta project.stats.clone() unless project.stats.is_empty();
172 token Tag::badge(project.status.clone()).tone(tone(project.status_tone));
173
174 act "View" to external "/p/{project.slug}";
175 act "Edit" to external "/dashboard/project/{project.slug}";
176 act "Delete" to delete "/api/projects/{project.id}" {
177 tone Danger;
178 confirm "Delete this project? This cannot be undone.";
179 }
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 declare! {
200 /// What stands where the list would be, for an account with no projects.
201 ///
202 /// The four steps are prose rather than a described list: they are one
203 /// explanation of what the product is, not a set of things with addresses.
204 /// `own_prose` carries the markdown source, which is what lets a terminal
205 /// render the same four steps without being handed markup.
206 shape getting_started(can_create_projects: bool) -> Node;
207
208 region "user-projects-getting-started" as Pane {
209 empty "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
213 include super::own_prose(
214 "1. Create a project\n\
215 2. Add items: audio, video, text, or software\n\
216 3. Set prices (or keep them free) and publish\n\
217 4. Connect your payment account, 0% platform fee"
218 );
219
220 given can_create_projects {
221 true -> act "Create Your First Project" to external NEW_PROJECT;
222 false -> act "Apply for Creator Access" to external APPLY;
223 }
224 }
225 }
226
227 #[cfg(test)]
228 mod tests {
229 use super::*;
230 use quasi_axum::Serves;
231
232 fn project(title: &str) -> ProjectCard {
233 ProjectCard {
234 id: crate::db::ProjectId::from(uuid::Uuid::nil()),
235 title: title.into(),
236 project_type: "Album".into(),
237 created_date: "Aug 1, 2026".into(),
238 updated_date: Some("Aug 20, 2026".into()),
239 stats: "3 items".into(),
240 status: "Published".into(),
241 status_tone: "success",
242 slug: "an-album".into(),
243 }
244 }
245
246 fn render(nodes: &[Node]) -> String {
247 let mut out = String::new();
248 for node in nodes {
249 out.push_str(&Webview::new().fragment(node));
250 }
251 out
252 }
253
254 #[test]
255 fn the_strip_and_this_panel_agree_about_the_region() {
256 // If these drift the panel's answer lands nowhere, and nothing else
257 // would notice. `user_analytics` asserts the same thing for its own.
258 let strip = include_str!("user_tabs.rs");
259 assert!(strip.contains("user_projects::REGION"), "{REGION}");
260 }
261
262 #[test]
263 fn no_quasi_route_answers_this_address_so_the_strip_must_still_fetch_it() {
264 // The other half of the region agreement, and the one that is easy to
265 // get wrong later. `screen: Some(..)` tells the strip a described route
266 // owns the address and to leave the region alone; this panel is a fill
267 // on the Askama handler, which is what keeps the ETag. The two facts
268 // have to move together, so if this address is ever mounted, the strip
269 // needs `screen: Some(..)` in the same change or the tab goes blank.
270 assert!(
271 !crate::quasi::PATHS.contains(&"/dashboard/tabs/projects"),
272 "mounted now: give the Projects tab `screen: Some(..)` in user_tabs"
273 );
274 }
275
276 #[test]
277 fn a_project_offers_view_edit_and_a_confirmed_delete() {
278 let html = render(&[Node::list([card(&project("An Album"))])]);
279
280 assert!(html.contains("An Album"), "{html}");
281 assert!(html.contains("href=\"/p/an-album\""), "{html}");
282 assert!(
283 html.contains("href=\"/dashboard/project/an-album\""),
284 "{html}"
285 );
286 assert!(
287 html.contains(&format!(
288 "hx-delete=\"/api/projects/{}\"",
289 crate::db::ProjectId::from(uuid::Uuid::nil())
290 )),
291 "{html}"
292 );
293 assert!(html.contains("This cannot be undone."), "{html}");
294 }
295
296 #[test]
297 fn the_meta_line_drops_the_stats_part_rather_than_drawing_an_empty_one() {
298 let mut bare = project("Bare");
299 bare.stats = String::new();
300
301 let with = render(&[Node::list([card(&project("With"))])]);
302 let without = render(&[Node::list([card(&bare)])]);
303
304 assert!(with.contains("3 items"), "{with}");
305 assert!(!without.contains("3 items"), "{without}");
306 // Both still say the rest of the meta line.
307 assert!(without.contains("Created Aug 1, 2026"), "{without}");
308 }
309
310 #[test]
311 fn an_account_with_no_projects_gets_the_guide_and_no_empty_list() {
312 let html = render(&body(&[], true));
313
314 assert!(html.contains("Welcome to Makenotwork."), "{html}");
315 assert!(html.contains("Create Your First Project"), "{html}");
316 // The template drew an empty `role="list"` above the guide. This is the
317 // half of the conversion that removes it.
318 assert!(!html.contains("role=\"list\""), "{html}");
319 }
320
321 #[test]
322 fn a_reader_who_cannot_create_projects_is_offered_the_application_instead() {
323 let listed = render(&body(&[project("An Album")], false));
324 let empty = render(&body(&[], false));
325
326 for html in [&listed, &empty] {
327 assert!(html.contains("Apply for Creator Access"), "{html}");
328 assert!(!html.contains("New Project"), "{html}");
329 assert!(!html.contains("Create Your First Project"), "{html}");
330 }
331 }
332
333 #[test]
334 fn a_project_title_cannot_smuggle_markup() {
335 let html = render(&[Node::list([card(&project("<script>x()</script>"))])]);
336 assert!(!html.contains("<script>x()"), "{html}");
337 }
338
339 #[test]
340 fn the_inline_fill_carries_no_region_because_the_strip_draws_one() {
341 let inline = fill(&[project("An Album")], true);
342 assert!(!inline.contains(&format!("id=\"{REGION}\"")), "{inline}");
343 assert!(inline.contains("An Album"), "{inline}");
344 }
345 }
346