Skip to main content

max / makenotwork

3.5 KB · 103 lines History Blame Raw
1 //! Multi-step creation wizards for projects and items.
2 //!
3 //! Each wizard is a full page with a sidebar step indicator + content area.
4 //! Steps are HTMX partials swapped into `#wizard-step`. Each step form POSTs
5 //! to save, and the server responds with the next step partial. The DB record
6 //! IS the wizard state; step 1 creates it, subsequent steps update it.
7
8 pub(crate) mod item;
9 pub(crate) mod project;
10
11 use crate::{
12 AppState,
13 csrf::{CsrfRouter, post_csrf, with_csrf},
14 error::{AppError, Result},
15 };
16 use axum::http::{HeaderMap, HeaderValue, StatusCode};
17 use axum::response::{IntoResponse, Response};
18 use axum::routing::get;
19
20 /// Convert a validation error on an HTMX wizard-step submit into an inline toast
21 /// with `HX-Reswap: none`, so a failed submit doesn't swap the full-page 422
22 /// template into `#wizard-step` and wipe the user's typed input. Non-validation
23 /// errors and non-HTMX requests propagate unchanged. Shared by every step
24 /// handler, the create handlers regressed by omitting it (ultra-fuzz Run 10 UX S2).
25 pub(crate) fn wizard_validation_toast(headers: &HeaderMap, e: AppError) -> Result<Response> {
26 if crate::helpers::is_htmx_request(headers)
27 && let AppError::Validation(ref v) = e
28 {
29 let mut resp = StatusCode::OK.into_response();
30 resp.headers_mut().insert(
31 "HX-Trigger",
32 crate::helpers::hx_toast(&v.to_string(), "error"),
33 );
34 resp.headers_mut()
35 .insert("HX-Reswap", HeaderValue::from_static("none"));
36 return Ok(resp);
37 }
38 Err(e)
39 }
40
41 /// Step navigation helpers shared by both wizards.
42 pub(crate) fn next_step<'a>(steps: &'a [&str], current: &str) -> Option<&'a str> {
43 steps
44 .iter()
45 .position(|&s| s == current)
46 .and_then(|i| steps.get(i + 1))
47 .copied()
48 }
49
50 fn step_index(steps: &[&str], current: &str) -> Option<usize> {
51 steps.iter().position(|&s| s == current)
52 }
53
54 use crate::templates::StepNavItem;
55
56 /// Build the list of step display info for the sidebar nav.
57 pub(crate) fn build_step_nav(
58 steps: &[&'static str],
59 labels: &[&'static str],
60 current: &str,
61 ) -> Vec<StepNavItem> {
62 let current_idx = step_index(steps, current).unwrap_or(0);
63 steps
64 .iter()
65 .zip(labels.iter())
66 .enumerate()
67 .map(|(i, (&name, &label))| StepNavItem {
68 name,
69 label,
70 state: match i.cmp(&current_idx) {
71 std::cmp::Ordering::Less => "completed",
72 std::cmp::Ordering::Equal => "active",
73 std::cmp::Ordering::Greater => "pending",
74 },
75 })
76 .collect()
77 }
78
79 /// Register all wizard routes.
80 pub(crate) fn wizard_routes() -> CsrfRouter<AppState> {
81 CsrfRouter::new()
82 // Project wizard
83 .route_get("/dashboard/new-project", get(project::wizard_page))
84 .route(
85 "/dashboard/new-project/step/basics",
86 post_csrf(project::step_basics_create),
87 )
88 .route(
89 "/dashboard/new-project/{slug}/step/{step}",
90 with_csrf(get(project::step_load).post(project::step_save)),
91 )
92 // Item wizard
93 .route_get("/dashboard/project/{slug}/new-item", get(item::wizard_page))
94 .route(
95 "/dashboard/project/{slug}/new-item/step/type",
96 post_csrf(item::step_type_create),
97 )
98 .route(
99 "/dashboard/project/{slug}/new-item/{id}/step/{step}",
100 with_csrf(get(item::step_load).post(item::step_save)),
101 )
102 }
103