max / makenotwork
5 files changed,
+66 insertions,
-14 deletions
| @@ -82,7 +82,10 @@ struct DiscoverData { | |||
| 82 | 82 | ||
| 83 | 83 | /// Fetch items or projects with pagination; shared by both handlers. | |
| 84 | 84 | async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> { | |
| 85 | - | let page = query.page.unwrap_or(1).max(1); | |
| 85 | + | // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a | |
| 86 | + | // giant OFFSET = one expensive deep scan per request. Matches the git/admin | |
| 87 | + | // list handlers. | |
| 88 | + | let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); | |
| 86 | 89 | let limit = constants::DISCOVER_PAGE_SIZE as i64; | |
| 87 | 90 | let offset = ((page - 1) as i64) * limit; | |
| 88 | 91 | let mode = query.mode.as_deref().unwrap_or("projects"); |
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | use axum::{ | |
| 8 | 8 | extract::{Path, Query, State}, | |
| 9 | 9 | http::header::HeaderMap, | |
| 10 | - | response::{Html, IntoResponse, Redirect, Response}, | |
| 10 | + | response::{IntoResponse, Redirect, Response}, | |
| 11 | 11 | Form, | |
| 12 | 12 | }; | |
| 13 | 13 | use serde::Deserialize; | |
| @@ -78,17 +78,24 @@ pub async fn step_account_create( | |||
| 78 | 78 | ||
| 79 | 79 | let return_error = |field: Option<&str>, summary: &str| -> Result<Response> { | |
| 80 | 80 | if is_htmx { | |
| 81 | - | Ok(Html( | |
| 82 | - | LoginErrorTemplate { | |
| 83 | - | message: summary.to_string(), | |
| 84 | - | } | |
| 85 | - | .render_string(), | |
| 86 | - | ) | |
| 81 | + | // HTMX swaps the response into #wizard-step. Re-render the account | |
| 82 | + | // STEP PARTIAL with the typed username/email preserved and the bad | |
| 83 | + | // field flagged — not a bare LoginErrorTemplate, which would replace | |
| 84 | + | // the whole form with a single error line and drop all input (UX-S1). | |
| 85 | + | Ok(WizardJoinAccountTemplate { | |
| 86 | + | nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), | |
| 87 | + | csrf_token: csrf_token.clone(), | |
| 88 | + | invite_code: form.invite_code.clone(), | |
| 89 | + | username: form.username.clone(), | |
| 90 | + | email: form.email.clone(), | |
| 91 | + | error: Some(summary.to_string()), | |
| 92 | + | error_field: field.map(|f| f.to_string()), | |
| 93 | + | } | |
| 87 | 94 | .into_response()) | |
| 88 | 95 | } else { | |
| 89 | - | // Non-HTMX (JS disabled): re-render the account step with the typed | |
| 90 | - | // username/email preserved and the offending field marked invalid, | |
| 91 | - | // instead of a generic 422 that drops everything the user entered. | |
| 96 | + | // Non-HTMX (JS disabled): re-render the full account step with the | |
| 97 | + | // typed username/email preserved and the offending field marked | |
| 98 | + | // invalid, instead of a generic 422 that drops everything entered. | |
| 92 | 99 | Ok(WizardJoinTemplate { | |
| 93 | 100 | csrf_token: csrf_token.clone(), | |
| 94 | 101 | nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), | |
| @@ -316,6 +323,10 @@ async fn render_step(step: &str, state: &AppState, user_id: db::UserId) -> Resul | |||
| 316 | 323 | nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), | |
| 317 | 324 | csrf_token: None, | |
| 318 | 325 | invite_code: None, | |
| 326 | + | username: String::new(), | |
| 327 | + | email: String::new(), | |
| 328 | + | error: None, | |
| 329 | + | error_field: None, | |
| 319 | 330 | } | |
| 320 | 331 | .into_response()) | |
| 321 | 332 | } |
| @@ -161,13 +161,20 @@ pub struct WizardJoinTemplate { | |||
| 161 | 161 | pub error_field: Option<String>, | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | - | /// Step 1 partial: account creation (for back-nav reload). | |
| 164 | + | /// Step 1 partial: account creation (back-nav reload, and the HTMX validation | |
| 165 | + | /// re-render). Carries preserved input + error so a failed HTMX submit swaps the | |
| 166 | + | /// form back with the typed username/email intact and the bad field flagged, | |
| 167 | + | /// instead of replacing the whole step with a bare error line (UX-S1, Run #23). | |
| 165 | 168 | #[derive(Template)] | |
| 166 | 169 | #[template(path = "wizards/steps/join/account.html")] | |
| 167 | 170 | pub struct WizardJoinAccountTemplate { | |
| 168 | 171 | pub nav: Vec<super::StepNavItem>, | |
| 169 | 172 | pub csrf_token: CsrfTokenOption, | |
| 170 | 173 | pub invite_code: Option<String>, | |
| 174 | + | pub username: String, | |
| 175 | + | pub email: String, | |
| 176 | + | pub error: Option<String>, | |
| 177 | + | pub error_field: Option<String>, | |
| 171 | 178 | } | |
| 172 | 179 | ||
| 173 | 180 | /// Step 2 partial: profile (display name + bio). |
| @@ -4,12 +4,17 @@ | |||
| 4 | 4 | <h2 class="subtitle-h2">Create account</h2> | |
| 5 | 5 | <p class="step-description">Choose a username, enter your email, and set a password.</p> | |
| 6 | 6 | ||
| 7 | + | {% if let Some(err) = error %} | |
| 8 | + | <div class="info-box info-box--error mb-4" role="alert">{{ err }}</div> | |
| 9 | + | {% endif %} | |
| 10 | + | ||
| 7 | 11 | <form hx-post="/join/step/account" | |
| 8 | 12 | hx-target="#wizard-step" hx-swap="innerHTML"> | |
| 9 | 13 | <div class="form-group"> | |
| 10 | 14 | <label for="wiz-username">Username</label> | |
| 11 | 15 | <input type="text" id="wiz-username" name="username" required | |
| 12 | - | placeholder="username" autocomplete="off" | |
| 16 | + | placeholder="username" autocomplete="off" value="{{ username }}" | |
| 17 | + | {% if error_field.as_deref() == Some("username") %}aria-invalid="true"{% endif %} | |
| 13 | 18 | hx-post="/api/validate/username" | |
| 14 | 19 | hx-trigger="keyup changed delay:500ms" | |
| 15 | 20 | hx-target="#username-status" | |
| @@ -22,7 +27,8 @@ | |||
| 22 | 27 | <div class="form-group"> | |
| 23 | 28 | <label for="wiz-email">Email</label> | |
| 24 | 29 | <input type="email" id="wiz-email" name="email" required | |
| 25 | - | placeholder="you@example.com" autocomplete="email"> | |
| 30 | + | placeholder="you@example.com" autocomplete="email" value="{{ email }}" | |
| 31 | + | {% if error_field.as_deref() == Some("email") %}aria-invalid="true"{% endif %}> | |
| 26 | 32 | <div class="hint">Used for account recovery and notifications</div> | |
| 27 | 33 | </div> | |
| 28 | 34 |
| @@ -2,6 +2,31 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use crate::harness::TestHarness; | |
| 4 | 4 | ||
| 5 | + | #[tokio::test] | |
| 6 | + | async fn join_wizard_htmx_validation_preserves_input() { | |
| 7 | + | // UX-S1 (Run #23): a failed HTMX account-step submit must re-render the step | |
| 8 | + | // with the typed username/email intact and the bad field flagged — not wipe | |
| 9 | + | // the whole form with a bare error line. | |
| 10 | + | let mut h = TestHarness::new().await; | |
| 11 | + | ||
| 12 | + | // Seed a taken username so the second signup's username step fails validation. | |
| 13 | + | h.signup("taken_name", "first@test.com", "password123").await; | |
| 14 | + | h.client.post_form("/logout", "").await; | |
| 15 | + | h.client.fetch_csrf_token().await; | |
| 16 | + | ||
| 17 | + | let body = "username=taken_name&email=kept@example.com&password=password123"; | |
| 18 | + | let resp = h.client.htmx_post_form("/join/step/account", body).await; | |
| 19 | + | ||
| 20 | + | assert!(resp.status.is_success(), "expected a 200 re-render, got {}: {}", resp.status, resp.text); | |
| 21 | + | // The re-rendered step keeps the typed values and is the account form, not a | |
| 22 | + | // bare error fragment. | |
| 23 | + | assert!(resp.text.contains("already taken"), "shows the validation error: {}", resp.text); | |
| 24 | + | assert!(resp.text.contains("value=\"taken_name\""), "preserves the typed username: {}", resp.text); | |
| 25 | + | assert!(resp.text.contains("value=\"kept@example.com\""), "preserves the typed email: {}", resp.text); | |
| 26 | + | assert!(resp.text.contains("aria-invalid=\"true\""), "flags the offending field: {}", resp.text); | |
| 27 | + | assert!(resp.text.contains("name=\"password\""), "the full form is re-rendered, not just an error line"); | |
| 28 | + | } | |
| 29 | + | ||
| 5 | 30 | // ============================================================================= | |
| 6 | 31 | // Project Wizard | |
| 7 | 32 | // ============================================================================= |