Skip to main content

max / quasi

A refused form offers back what was typed
Author: Max Johnson <me@maxj.phd> · 2026-08-09 14:54 UTC
Signed with PGP, not checked
Commit: 4207aab8856042fa72e4a25ba973c3d09a6600b9
Parent: 9456cdd
6 files changed, +157 insertions, -17 deletions
M Cargo.lock +4 -4
@@ -4530,10 +4530,6 @@
4530 4530 source = "registry+https://github.com/rust-lang/crates.io-index"
4531 4531 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4532 4532
4533 - [[patch.unused]]
4534 - name = "docengine"
4535 - version = "0.4.0"
4536 -
4537 4533 [[patch.unused]]
4538 4534 name = "kberg"
4539 4535 version = "0.1.0"
@@ -4546,6 +4542,10 @@
4546 4542 name = "tagtree"
4547 4543 version = "0.4.0"
4548 4544
4545 + [[patch.unused]]
4546 + name = "docengine"
4547 + version = "0.4.0"
4548 +
4549 4549 [[patch.unused]]
4550 4550 name = "synckit-client"
4551 4551 version = "0.8.0"
@@ -150,9 +150,10 @@
150 150 Ok(answer) => {
151 151 // The notice is orthogonal to the outcome and is applied to all
152 152 // three, including a redirect, which has no body to carry one.
153 - let trigger = answer.notice.as_ref().map(|notice| {
154 - htmx::notice_trigger(notice.kind, notice.tone, &notice.text)
155 - });
153 + let trigger = answer
154 + .notice
155 + .as_ref()
156 + .map(|notice| htmx::notice_trigger(notice.kind, notice.tone, &notice.text));
156 157 let mut response = match answer.outcome {
157 158 Outcome::Screen(screen) => body(render, 200, render.screen(&screen), None),
158 159 Outcome::Fragment { region, node } => {
@@ -234,7 +234,10 @@
234 234 let response = respond(&Spy, Ok(answer));
235 235 assert_eq!(response.status(), 200);
236 236 assert!(response.body().is_empty());
237 - assert_eq!(response.headers().get(super::htmx::LOCATION).unwrap(), "/tasks");
237 + assert_eq!(
238 + response.headers().get(super::htmx::LOCATION).unwrap(),
239 + "/tasks"
240 + );
238 241 // Not a fragment, so nothing is being replaced in place.
239 242 assert!(response.headers().get(super::htmx::RETARGET).is_none());
240 243 }
@@ -295,11 +298,14 @@
295 298 fn a_notice_survives_a_redirect_which_has_no_body_to_put_one_in() {
296 299 // The composition the two findings were filed apart from each other and
297 300 // could not express: a delete both goes elsewhere and says it is gone.
298 - let answer = Response::goto(Action::get("/tasks"))
299 - .toast(quasi_router::layout::Tone::Success, "Deleted");
301 + let answer =
302 + Response::goto(Action::get("/tasks")).toast(quasi_router::layout::Tone::Success, "Deleted");
300 303 let response = respond(&Spy, Ok(answer));
301 304 assert!(response.body().is_empty());
302 - assert_eq!(response.headers().get(super::htmx::LOCATION).unwrap(), "/tasks");
305 + assert_eq!(
306 + response.headers().get(super::htmx::LOCATION).unwrap(),
307 + "/tasks"
308 + );
303 309 assert!(
304 310 response
305 311 .headers()
@@ -293,10 +293,28 @@
293 293 /// One field of a form, owned.
294 294 ///
295 295 /// The borrowed original is [`layout::Field`], and everything it says about
296 - /// what a field carries applies unchanged. In particular this does not carry
297 - /// the current value, and it is not going to: that is genuinely renderer state,
298 - /// and a description that carried it would need a way to write it back, at
299 - /// which point it is a form model.
296 + /// what a field carries applies unchanged, with one addition that does not
297 + /// travel down to it: [`value`](Self::value).
298 + ///
299 + /// # Why the value lives here and not in `makeover-layout`
300 + ///
301 + /// `1c4a66a4`, decided 2026-08-09. [`layout::Field`] refuses to carry the
302 + /// current value, and that refusal is right: an immediate-mode renderer writes
303 + /// through a `&mut String` the app owns, and a terminal keeps an edit buffer,
304 + /// so a description carrying a live value would need a way to write it back and
305 + /// would then be a form model.
306 + ///
307 + /// What is carried here is not a live value. It is what to re-offer after a
308 + /// submission was refused, and it has [`error`](Self::error)'s lifecycle rather
309 + /// than a live value's: per-submission, one way, supplied by whoever validated,
310 + /// gone on the next request. `error` already sits in this struct on exactly
311 + /// those terms.
312 + ///
313 + /// The reason it is this crate's field and not the vocabulary's is that only a
314 + /// stateless request and response destroys the value. In egui and in a terminal
315 + /// the buffer never went anywhere, so nothing is lost and there is nothing to
316 + /// re-offer. This is the layer where the loss happens, so this is the layer that
317 + /// repairs it.
300 318 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
301 319 pub struct Field {
302 320 /// What kind of value it takes.
@@ -319,6 +337,17 @@
319 337 pub required: bool,
320 338 /// Whether the field lives behind a "more options" disclosure.
321 339 pub extended: bool,
340 + /// What to put back in the box: what was submitted, when a submission was
341 + /// refused and the form is being offered again.
342 + ///
343 + /// `None` on a first showing, which is every form that is not answering a
344 + /// refusal. A checkbox is here by presence, the way HTML submits one: a
345 + /// value means ticked and `None` means not.
346 + ///
347 + /// A [`layout::FieldKind::Secret`] never gets one. [`Field::value`] refuses
348 + /// to set it and every renderer refuses to emit it, so the guarantee does
349 + /// not rest on either alone.
350 + pub value: Option<String>,
322 351 }
323 352
324 353 impl Field {
@@ -334,6 +363,7 @@
334 363 options: Vec::new(),
335 364 required: false,
336 365 extended: false,
366 + value: None,
337 367 }
338 368 }
339 369
@@ -380,6 +410,37 @@
380 410 self.error.is_some()
381 411 }
382 412
413 + /// Put this back in the box when the form is offered again.
414 + ///
415 + /// A [`layout::FieldKind::Secret`] keeps `None` whatever it is handed. A
416 + /// password that comes back down the wire is a password in a page, in a
417 + /// proxy log and in a browser cache, and the field kind exists to say so.
418 + /// Silently rather than by a `Result`, because there is no answer a caller
419 + /// could give that would make echoing it right.
420 + #[must_use]
421 + pub fn value(mut self, value: impl Into<String>) -> Self {
422 + if self.kind != layout::FieldKind::Secret {
423 + self.value = Some(value.into());
424 + }
425 + self
426 + }
427 +
428 + /// Re-offer whatever was submitted under this field's name.
429 + ///
430 + /// What a refused write calls, with the [`Params`](crate::Params) it was
431 + /// refusing. A name with nothing under it stays empty, which is what an
432 + /// unticked checkbox and an untouched box both are.
433 + #[must_use]
434 + pub fn refilled(self, params: &crate::Params) -> Self {
435 + match params.get(&self.name) {
436 + Some(value) => {
437 + let value = value.to_owned();
438 + self.value(value)
439 + }
440 + None => self,
441 + }
442 + }
443 +
383 444 /// Read this field as the description layer's own type.
384 445 ///
385 446 /// A callback rather than a return, because [`layout::Field`] holds its
@@ -26,7 +26,7 @@
26 26 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
27 27 use makeover_webview::list::{Cell, cells_html};
28 28 use makeover_webview::meter::meter_html;
29 - use quasi_router::screen::{Act, Cells, Destination, Node, Row, Slot, Tag};
29 + use quasi_router::screen::{Act, Cells, Destination, Field, Node, Row, Slot, Tag};
30 30 use quasi_router::{Action, Method, Params};
31 31
32 32 /// The class-name prefix, applied through [`Emit::class_prefix`].
@@ -60,6 +60,29 @@
60 60 }
61 61 }
62 62
63 + /// What goes back in the box, for a form being offered again after a refusal.
64 + ///
65 + /// `1c4a66a4`. The description carries the value as a string, because that is
66 + /// what came off the wire; the kind is what says how to read it. A checkbox is
67 + /// carried by presence the way HTML submits one, so any value means ticked and
68 + /// nothing means not.
69 + ///
70 + /// A [`layout::FieldKind::Secret`] is emitted empty whatever it holds. That is
71 + /// the third refusal of the same thing and none of the three is redundant:
72 + /// `Field::value` will not store one, `makeover_webview::form` will not write
73 + /// one into an `<input type="password">`, and this one stands between them
74 + /// because `Field::value` is a public field that a struct literal reaches past.
75 + fn refill(field: &Field) -> Value<'_> {
76 + if field.kind == layout::FieldKind::Secret {
77 + return Value::Absent;
78 + }
79 + match field.value.as_deref() {
80 + None => Value::Absent,
81 + Some(_) if field.kind == layout::FieldKind::Checkbox => Value::On(true),
82 + Some(value) => Value::Text(value),
83 + }
84 + }
85 +
63 86 /// JSON-encode a string, for an `hx-vals` payload.
64 87 ///
65 88 /// Small enough to own. Pulling in a JSON crate to write object literals of
@@ -419,8 +442,9 @@
419 442 // second field emitter here is the divergence phase A existed
420 443 // to end, and it would be the same anatomy with a different
421 444 // escaping story.
445 + let filling = Filling::of(refill(field));
422 446 field.with_layout(|borrowed| {
423 - out.push_str(&field_html(&borrowed, &Filling::of(Value::Absent), opts));
447 + out.push_str(&field_html(&borrowed, &filling, opts));
424 448 });
425 449 }
426 450 out.push_str("<button type=\"submit\"");
@@ -455,6 +455,54 @@
455 455 assert!(html.contains("<label"));
456 456 }
457 457
458 + #[test]
459 + fn a_refused_form_comes_back_with_what_was_typed_in_it() {
460 + // `1c4a66a4`. The value the user lost is the whole point of the finding, so
461 + // the assertion is that it is in the markup rather than that a field exists.
462 + let params = quasi_router::Params::new()
463 + .with("title", "a name with <angles> in it")
464 + .with("done", "on");
465 + let html = fragment(&Node::Form {
466 + action: Action::post("/tasks"),
467 + submit: "Save".into(),
468 + fields: vec![
469 + Field::new(layout::FieldKind::Text, "title", "Title").refilled(&params),
470 + Field::new(layout::FieldKind::Checkbox, "done", "Done").refilled(&params),
471 + // Nothing was submitted under this name, so it stays empty rather
472 + // than coming back as the empty string.
473 + Field::new(layout::FieldKind::Text, "notes", "Notes").refilled(&params),
474 + ],
475 + });
476 +
477 + assert!(html.contains("value=\"a name with &lt;angles&gt; in it\""));
478 + assert!(html.contains("checked"));
479 + // The escaping guarantee is not weakened by carrying a value.
480 + assert!(!html.contains("<angles>"));
481 + // `notes` had nothing submitted under it, so it comes back empty rather
482 + // than carrying a neighbour's value.
483 + assert!(html.contains("name=\"notes\" value=\"\""));
484 + }
485 +
486 + #[test]
487 + fn a_secret_is_never_offered_back_however_it_was_set() {
488 + // Two halves of one guarantee. The builder refuses to store it, and the
489 + // renderer refuses to emit it, because `Field::value` is a public field and
490 + // a struct literal reaches past the builder.
491 + let params = quasi_router::Params::new().with("password", "hunter2");
492 +
493 + let refused = Field::new(layout::FieldKind::Secret, "password", "Password").refilled(&params);
494 + assert_eq!(refused.value, None);
495 +
496 + let mut forced = Field::new(layout::FieldKind::Secret, "password", "Password");
497 + forced.value = Some("hunter2".to_owned());
498 + let html = fragment(&Node::Form {
499 + action: Action::post("/login"),
500 + submit: "Sign in".into(),
501 + fields: vec![forced],
502 + });
503 + assert!(!html.contains("hunter2"));
504 + }
505 +
458 506 #[test]
459 507 fn a_notice_interrupts_only_when_its_tone_says_to() {
460 508 let danger = fragment(&Node::banner(layout::Tone::Danger, "Disk full"));