//! Multi-step creation wizards for projects and items. //! //! Each wizard is a full page with a sidebar step indicator + content area. //! Steps are HTMX partials swapped into `#wizard-step`. Each step form POSTs //! to save, and the server responds with the next step partial. The DB record //! IS the wizard state; step 1 creates it, subsequent steps update it. pub(crate) mod item; pub(crate) mod project; use crate::{ AppState, csrf::{CsrfRouter, post_csrf, with_csrf}, error::{AppError, Result}, }; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::get; /// Convert a validation error on an HTMX wizard-step submit into an inline toast /// with `HX-Reswap: none`, so a failed submit doesn't swap the full-page 422 /// template into `#wizard-step` and wipe the user's typed input. Non-validation /// errors and non-HTMX requests propagate unchanged. Shared by every step /// handler, the create handlers regressed by omitting it. pub(crate) fn wizard_validation_toast(headers: &HeaderMap, e: AppError) -> Result { if crate::helpers::is_htmx_request(headers) && let AppError::Validation(ref v) = e { let mut resp = StatusCode::OK.into_response(); resp.headers_mut().insert( "HX-Trigger", crate::helpers::hx_toast(&v.to_string(), "error"), ); resp.headers_mut() .insert("HX-Reswap", HeaderValue::from_static("none")); return Ok(resp); } Err(e) } /// Step navigation helpers shared by both wizards. pub(crate) fn next_step<'a>(steps: &'a [&str], current: &str) -> Option<&'a str> { steps .iter() .position(|&s| s == current) .and_then(|i| steps.get(i + 1)) .copied() } fn step_index(steps: &[&str], current: &str) -> Option { steps.iter().position(|&s| s == current) } use crate::templates::StepNavItem; /// Build the list of step display info for the sidebar nav. pub(crate) fn build_step_nav( steps: &[&'static str], labels: &[&'static str], current: &str, ) -> Vec { let current_idx = step_index(steps, current).unwrap_or(0); steps .iter() .zip(labels.iter()) .enumerate() .map(|(i, (&name, &label))| StepNavItem { name, label, state: match i.cmp(¤t_idx) { std::cmp::Ordering::Less => "completed", std::cmp::Ordering::Equal => "active", std::cmp::Ordering::Greater => "pending", }, }) .collect() } /// Register all wizard routes. pub(crate) fn wizard_routes() -> CsrfRouter { CsrfRouter::new() // Project wizard .route_get("/dashboard/new-project", get(project::wizard_page)) .route( "/dashboard/new-project/step/basics", post_csrf(project::step_basics_create), ) .route( "/dashboard/new-project/{slug}/step/{step}", with_csrf(get(project::step_load).post(project::step_save)), ) // Item wizard .route_get("/dashboard/project/{slug}/new-item", get(item::wizard_page)) .route( "/dashboard/project/{slug}/new-item/step/type", post_csrf(item::step_type_create), ) .route( "/dashboard/project/{slug}/new-item/{id}/step/{step}", with_csrf(get(item::step_load).post(item::step_save)), ) }