Skip to main content

max / makenotwork

server: fix join-wizard HTMX validation input loss; clamp discover paging - UX-S1: a failed HTMX account-step submit returned a bare LoginErrorTemplate into #wizard-step (the div wrapping the whole form), destroying all typed input with no field highlight — strictly worse than the non-HTMX fallback beside it. Re-render the account step partial instead, with the typed username/email preserved and the offending field flagged (the partial now carries the same error/value plumbing the full page already had). - discover paging clamped its lower bound only; add the upper clamp its git/admin siblings use so a huge ?page can't force a deep-OFFSET scan. Ultra-fuzz Run #23, UX axis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 22:35 UTC
Commit: 4674725ef442a86bab644750971135718df4f06f
Parent: 349dc7f
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 // =============================================================================