Skip to main content

max / goingson

The projects form re-offers a refused submission
Author: Max Johnson <me@maxj.phd> · 2026-08-09 14:54 UTC
Signed with PGP, not checked
Commit: c489e2b7f8bdfa9befd7c9c9a823e36831c84bc4
Parent: f44e64f
2 files changed, +45 insertions, -28 deletions
@@ -378,23 +378,23 @@
378 378 /// The questions the create form asks.
379 379 ///
380 380 /// The four `projects.js` asks, in its order. `errors` is what a rejected
381 - /// submission carries back, keyed by field name; on a first showing it is
382 - /// empty.
381 + /// submission carries back, keyed by field name, and `submitted` is what that
382 + /// submission held; on a first showing both are empty.
383 383 ///
384 - /// # The finding this route ran into
384 + /// # The finding this route ran into, and how it closed
385 385 ///
386 - /// **A form that refuses cannot re-offer what was typed.** [`Field`] carries no
387 - /// value and says in its own docs that it is not going to, on the grounds that a
388 - /// value is renderer state. That is right for a form being shown, and it leaves
389 - /// the rejected case with nowhere to put the name the user typed: this answer
390 - /// names what is wrong and hands back an empty box to fix it in. `projects.js`
391 - /// validates in the browser and never loses a keystroke.
386 + /// **A form that refuses cannot re-offer what was typed.** It named what was
387 + /// wrong and handed back an empty box to fix it in, so a 101-character name was
388 + /// reported as too long and then thrown away, and the user retyped it to
389 + /// shorten it. `projects.js` validates in the browser and never loses a
390 + /// keystroke, so the described screen was worse than the one it replaces on
391 + /// exactly the path the description was meant to make safer.
392 392 ///
393 - /// The workaround is asserted by a test rather than left to be noticed, and it
394 - /// is filed as makeover-layout `1c4a66a4`. Not patched here: the
395 - /// admission test says a description that needs a new fact asks the vocabulary
396 - /// for it.
397 - fn form_fields(errors: &[(&str, String)]) -> Vec<Field> {
393 + /// Filed as `1c4a66a4` and closed 2026-08-09 on quasi-router rather than on
394 + /// makeover-layout: an immediate-mode renderer and a terminal hold the value
395 + /// across a refusal by construction, so the loss belongs to the request and
396 + /// response cycle and is repaired there. [`Field::refilled`] is what this calls.
397 + fn form_fields(errors: &[(&str, String)], submitted: Option<&quasi_router::Params>) -> Vec<Field> {
398 398 let error_for = |name: &str| {
399 399 errors
400 400 .iter()
@@ -416,7 +416,7 @@
416 416 );
417 417 description.placeholder = Some("What's this project about?".to_owned());
418 418
419 - vec![
419 + let fields = vec![
420 420 apply(name, "name"),
421 421 apply(description, "description"),
422 422 apply(
@@ -441,18 +441,31 @@
441 441 ),
442 442 "status",
443 443 ),
444 - ]
444 + ];
445 +
446 + match submitted {
447 + Some(params) => fields
448 + .into_iter()
449 + .map(|field| field.refilled(params))
450 + .collect(),
451 + None => fields,
452 + }
445 453 }
446 454
447 455 /// The create form, in the pane the detail pane uses.
448 - fn form_pane(shared_only: bool, show_retired: bool, errors: &[(&str, String)]) -> Node {
456 + fn form_pane(
457 + shared_only: bool,
458 + show_retired: bool,
459 + errors: &[(&str, String)],
460 + submitted: Option<&quasi_router::Params>,
461 + ) -> Node {
449 462 Node::Region(
450 463 Slot::new("projects-detail", RegionKind::Pane)
451 464 .with(Node::section("New project"))
452 465 .with(Node::Form {
453 466 action: filtered(Action::post("/projects"), shared_only, show_retired),
454 467 submit: "Create project".to_owned(),
455 - fields: form_fields(errors),
468 + fields: form_fields(errors, submitted),
456 469 }),
457 470 )
458 471 }
@@ -461,7 +474,7 @@
461 474 fn new(_state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
462 475 Ok(Response::fragment(
463 476 "projects-detail",
464 - form_pane(flag(&params, "shared"), flag(&params, "retired"), &[]),
477 + form_pane(flag(&params, "shared"), flag(&params, "retired"), &[], None),
465 478 ))
466 479 }
467 480
@@ -529,13 +542,13 @@
529 542 let (Some(project_type), Some(status)) = (project_type, status) else {
530 543 return Ok(Response::fragment(
531 544 "projects-detail",
532 - form_pane(shared_only, show_retired, &errors),
545 + form_pane(shared_only, show_retired, &errors, Some(&params)),
533 546 ));
534 547 };
535 548 if !errors.is_empty() {
536 549 return Ok(Response::fragment(
537 550 "projects-detail",
538 - form_pane(shared_only, show_retired, &errors),
551 + form_pane(shared_only, show_retired, &errors, Some(&params)),
539 552 ));
540 553 }
541 554
@@ -329,11 +329,11 @@
329 329 }
330 330
331 331 #[tokio::test]
332 - async fn a_rejected_form_names_what_is_wrong_and_loses_what_was_typed() {
333 - // The fourth finding, asserted rather than left to be noticed. `Field`
334 - // carries no value, so the form comes back empty: the too-long name is
335 - // reported and then thrown away, and it is retyped to be shortened.
336 - // makeover-layout `1c4a66a4`.
332 + async fn a_rejected_form_names_what_is_wrong_and_offers_back_what_was_typed() {
333 + // The fourth finding, closed. It was asserted here in its broken form until
334 + // 2026-08-09 — the too-long name was reported and then thrown away, and the
335 + // user retyped it to shorten it. `Field::refilled` is what changed, and this
336 + // test changing is what says the finding closed. `1c4a66a4`.
337 337 let state = state().await;
338 338 let long = "x".repeat(101);
339 339
@@ -344,8 +344,12 @@
344 344
345 345 assert!(html.contains("Maximum 100 characters"));
346 346 assert!(html.contains("aria-invalid=\"true\""));
347 - // The workaround. When this line has to change, the finding is closed.
348 - assert!(!html.contains(&long));
347 + // What the finding was about: the name comes back in the box it was typed
348 + // in, so shortening it is an edit rather than a retype.
349 + assert!(html.contains(&format!("value=\"{long}\"")));
350 + // The select keeps what was chosen too, which is the same fact in the other
351 + // control the form offers.
352 + assert!(html.contains("value=\"SideProject\" selected"));
349 353
350 354 // And nothing was written.
351 355 assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());