autumn_web/nested_form.rs
1//! Nested (`has_many`) form binding — a parent struct plus one child
2//! collection, decoded and validated in a single extractor.
3//!
4//! # Overview
5//!
6//! [`NestedChangesetForm<P, C>`] is the nested-form counterpart of
7//! [`ChangesetForm<T>`](crate::form::ChangesetForm): it decodes a form body
8//! carrying a parent struct `P` **and** a repeated child collection `C`
9//! (rendered with input names like `items[0][sku]`, `items[1][sku]`, …),
10//! runs [`validator::Validate`] on the parent and on every non-destroyed
11//! child row, and captures per-field errors so the whole form can be
12//! re-rendered inline after a failed submission.
13//!
14//! The child collection is identified by the [`NestedChild::COLLECTION`]
15//! constant, which names the field group in input names
16//! (`items[i][field]`) and in combined error keys (`items[1].quantity`).
17//!
18//! # Wire format
19//!
20//! ```text
21//! name=Order+1 // parent field
22//! items[0][sku]=A-1 // child row 0, subfield `sku`
23//! items[0][quantity]=2 // child row 0, subfield `quantity`
24//! items[1][sku]=B-2 // child row 1
25//! items[1][quantity]=3
26//! items[1][_destroy]=1 // optional: mark row 1 for removal
27//! ```
28//!
29//! Row indices need not be contiguous — client-side removal can leave gaps
30//! (`items[0]`, `items[2]`) which are compacted in ascending order to
31//! preserve row order. The compacted 0-based position is what appears in
32//! combined error keys and is what a renderer iterates.
33//!
34//! # Binding and validating
35//!
36//! ```rust
37//! use autumn_web::nested_form::{decode_nested_urlencoded, NestedChild};
38//!
39//! #[derive(serde::Deserialize, validator::Validate)]
40//! struct NewOrder {
41//! #[validate(length(min = 1))]
42//! name: String,
43//! }
44//!
45//! #[derive(serde::Deserialize, validator::Validate)]
46//! struct NewLineItem {
47//! #[validate(length(min = 1))]
48//! sku: String,
49//! #[validate(range(min = 1))]
50//! quantity: i32,
51//! }
52//!
53//! impl NestedChild for NewLineItem {
54//! const COLLECTION: &'static str = "items";
55//! }
56//!
57//! let pairs = vec![
58//! ("name".to_string(), "Order 1".to_string()),
59//! ("items[0][sku]".to_string(), "A-1".to_string()),
60//! ("items[0][quantity]".to_string(), "2".to_string()),
61//! // second row is invalid: quantity is below the range minimum
62//! ("items[1][sku]".to_string(), "B-2".to_string()),
63//! ("items[1][quantity]".to_string(), "0".to_string()),
64//! ];
65//!
66//! let changeset = decode_nested_urlencoded::<NewOrder, NewLineItem>(&pairs)
67//! .expect("parent decodes");
68//!
69//! // The invalid child surfaces under its per-row combined key, and the whole
70//! // changeset refuses to yield a valid `(parent, children)` pair.
71//! assert!(!changeset.errors_for("items[1].quantity").is_empty());
72//! assert!(changeset.errors_for("items[0].quantity").is_empty());
73//! assert!(!changeset.is_valid());
74//! assert!(changeset.into_valid().is_err());
75//! ```
76//!
77//! # Per-row error keys
78//!
79//! Errors are addressable with `#[validate(nested)]`-style combined keys of
80//! the shape `"{COLLECTION}[{i}].{sub}"`. A child whose `quantity` fails
81//! validation on the (compacted) second row surfaces under
82//! [`errors_for("items[1].quantity")`](NestedChangeset::errors_for); a bare
83//! `"items[1]"` returns that row's row-level (parse) error. Parent field
84//! errors keep their plain field-name keys and are delegated to
85//! [`Changeset::errors_for`]. This is exactly the key shape a Maud renderer
86//! reads back per row via [`RowScope::errors_for`], so a failed submission
87//! re-renders each field's message inline next to the offending input.
88//!
89//! # `_destroy` marker
90//!
91//! A child subfield named `_destroy` with a truthy value (`"1"`, `"true"`,
92//! `"on"`) marks its row for removal. Destroyed rows are **retained** for
93//! re-rendering (so the checkbox state survives a round-trip) but never
94//! contribute to `valid_children`, so [`into_valid`](NestedChangeset::into_valid)
95//! drops them from the returned child vector. [`RowScope::destroy_checkbox`]
96//! renders the durable no-JS control that drives this.
97//!
98//! # Rendering: `inputs_for` + htmx / no-JS
99//!
100//! With the `maud` feature, [`inputs_for`] renders the repeating child block:
101//! it re-emits every submitted row (values + inline errors) and then appends
102//! at least one blank template row so a user can add a child **without any
103//! JavaScript** — the pre-rendered blank row's `items[n][…]` inputs post like
104//! any other. Because the browser submits that blank row's empty inputs, the
105//! decoder applies Rails-style `reject_if: :all_blank`: a child row whose every
106//! non-`_destroy` subfield is blank (empty or whitespace-only) is **ignored**
107//! entirely — never decoded, validated, or persisted — so the auto-rendered
108//! blank template row is safe with no JS and never becomes a phantom child that
109//! blocks submission (on a required-field child) or saves an empty row (on an
110//! all-optional child). Supplying [`InputsForOptions::add_row_url`] additionally emits an
111//! htmx "Add row" button (`hx-get` + `hx-swap="beforeend"`, with
112//! `hx-params="index"` so only the `hx-vals` `index` is sent — no form fields
113//! and no CSRF/submit token — are serialized into the fragment request);
114//! [`nested_row_fragment`] renders the server response
115//! for that endpoint. htmx is a progressive enhancement layered over the no-JS
116//! path — it is never required.
117//!
118//! # Handler + atomic save
119//!
120//! ```rust,ignore
121//! #[post("/orders")]
122//! async fn create(
123//! mut db: Db,
124//! form: NestedChangesetForm<NewOrder, NewLineItem>,
125//! ) -> impl IntoResponse {
126//! match form.into_valid() {
127//! Ok((order, items)) => save_order_with_items(&mut db, order, items).await,
128//! Err(form) => (StatusCode::UNPROCESSABLE_ENTITY, render(&form)).into_response(),
129//! }
130//! }
131//! ```
132//!
133//! A parent and its children must be persisted **atomically** — a half-saved
134//! order with only some of its line items is never acceptable. Do it inside a
135//! **single** [`Db::tx`](crate::db::Db::tx): insert the parent, read back its
136//! generated `id`, stamp each child's foreign key with that `id`, and insert
137//! the children — all on the one `conn` the closure is handed. Returning `Err`
138//! from anywhere in the closure (a failing child, a DB constraint violation)
139//! rolls the **whole** transaction back, so neither the parent nor any child
140//! row is left behind.
141//!
142//! ```rust,ignore
143//! use scoped_futures::ScopedFutureExt;
144//!
145//! db.tx(|conn| async move {
146//! let order = diesel::insert_into(orders::table)
147//! .values(&new_order)
148//! .returning(Order::as_returning())
149//! .get_result(conn)
150//! .await?;
151//! for mut item in new_items {
152//! item.order_id = order.id; // stamp the FK from the freshly-read parent id
153//! diesel::insert_into(line_items::table)
154//! .values(&item)
155//! .execute(conn)
156//! .await?; // any Err here rolls back the parent insert too
157//! }
158//! Ok::<_, diesel::result::Error>(order.id)
159//! }.scope_boxed())
160//! .await
161//! ```
162//!
163//! Use **raw diesel inserts on `conn`**, not a generated
164//! [`Repository`](macro@crate::repository) `create`: that `create` opens its *own*
165//! `Db::tx`, and `Db::tx` cannot be re-entered on the same connection — the
166//! nested call trips the nested-transaction guard and returns a `400`. Keep the
167//! whole parent-plus-children unit of work in the one outer `tx`.
168//!
169//! See `tests/integration/nested_form_atomic_save.rs` for the rollback
170//! correctness gate (a failing child leaves **zero** rows in either table) and
171//! `tests/integration/nested_form_order_example.rs` for the full create flow.
172
173// autumn-panic-gate: request-path module — production code path must be panic-free.
174// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
175// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
176#![cfg_attr(
177 not(test),
178 deny(
179 clippy::unwrap_used,
180 clippy::expect_used,
181 clippy::panic,
182 clippy::unreachable,
183 clippy::todo,
184 clippy::unimplemented,
185 clippy::indexing_slicing,
186 clippy::string_slice,
187 clippy::arithmetic_side_effects,
188 )
189)]
190
191use std::collections::{BTreeMap, BTreeSet, HashMap};
192
193use axum::extract::{FromRequest, Request};
194use axum::response::IntoResponse;
195
196use crate::form::{
197 Changeset, IntoChangeset, decode_urlencoded_dropping_blank_optional_fields,
198 validation_errors_to_map,
199};
200#[cfg(feature = "maud")]
201use crate::form::{FieldKind, FieldOptions, FieldSpec, render_field};
202
203// ── NestedChild ────────────────────────────────────────────────────
204
205/// A child row type bound as part of a nested (`has_many`) form.
206///
207/// The [`COLLECTION`](NestedChild::COLLECTION) constant names the field
208/// group used in input names (`items[i][field]`) and in combined error
209/// keys (`items[1].quantity`).
210pub trait NestedChild: serde::de::DeserializeOwned + validator::Validate + Send {
211 /// Field-group name used in input names (`items[i][field]`) and error
212 /// keys (`items[1].quantity`). e.g. `"items"`.
213 const COLLECTION: &'static str;
214}
215
216// ── NestedRow ──────────────────────────────────────────────────────
217
218/// One submitted child row, retained for re-rendering regardless of whether
219/// it parsed or validated.
220#[derive(Debug, Clone)]
221pub struct NestedRow {
222 /// Raw submitted subfield values (`sku` → `["A-1"]`), used to pre-fill
223 /// inputs when re-rendering after a failed submission. A repeated subfield
224 /// (`items[0][tags]=a&items[0][tags]=b`) keeps every value in order
225 /// (issue #3394).
226 values: HashMap<String, Vec<String>>,
227 /// Per-subfield validation (or parse) errors. A row that failed to parse
228 /// records its error under the offending subfield's name (via
229 /// `serde_path_to_error`), falling back to the empty-string (row-level) key
230 /// only when no field can be identified.
231 errors: HashMap<String, Vec<String>>,
232 /// `true` when the row carried a truthy `_destroy` marker.
233 destroyed: bool,
234}
235
236impl NestedRow {
237 /// The raw submitted value for subfield `sub`, if present. For a repeated
238 /// subfield this is the last value; [`values`](Self::values) returns all
239 /// of them.
240 #[must_use]
241 pub fn value(&self, sub: &str) -> Option<&str> {
242 self.values
243 .get(sub)
244 .and_then(|v| v.last())
245 .map(String::as_str)
246 }
247
248 /// Every raw submitted value for subfield `sub`, in submission order, or an
249 /// empty slice. Use it to re-render a `Vec` subfield (a checkbox group or a
250 /// `<select multiple>`) after a failed submission (issue #3394).
251 #[must_use]
252 pub fn values(&self, sub: &str) -> &[String] {
253 self.values.get(sub).map_or(&[], Vec::as_slice)
254 }
255
256 /// Validation messages for subfield `sub`, or an empty slice.
257 ///
258 /// A row-level parse failure is stored under the empty-string key, so
259 /// `row.errors_for("")` returns any whole-row decode error.
260 #[must_use]
261 pub fn errors_for(&self, sub: &str) -> &[String] {
262 self.errors.get(sub).map_or(&[], Vec::as_slice)
263 }
264
265 /// `true` when the row was marked for removal via a truthy `_destroy`.
266 #[must_use]
267 pub const fn is_destroyed(&self) -> bool {
268 self.destroyed
269 }
270
271 /// Every error keyed by subfield name (empty key = row-level parse error).
272 #[must_use]
273 pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
274 &self.errors
275 }
276}
277
278// ── NestedChangeset ────────────────────────────────────────────────
279
280/// A parent [`Changeset<P>`] plus its bound child collection.
281///
282/// Obtain one from [`decode_nested_urlencoded`] or (preferred) the
283/// [`NestedChangesetForm`] axum extractor.
284#[derive(Debug)]
285pub struct NestedChangeset<P, C> {
286 /// The parent changeset (values + per-field errors), reusing the existing
287 /// [`Changeset`] pipeline.
288 pub parent: Changeset<P>,
289 /// The submitted child rows in compacted order, retained for re-render.
290 rows: Vec<NestedRow>,
291 /// `Some(children)` iff the parent is valid **and** every non-destroyed
292 /// row both parsed and validated; `None` otherwise.
293 valid_children: Option<Vec<C>>,
294}
295
296impl<P, C: NestedChild> NestedChangeset<P, C> {
297 /// Build a blank, **non-validating** nested changeset for the initial
298 /// `new` (create) render, before the user has submitted anything.
299 ///
300 /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
301 /// wraps `parent` in a valid [`Changeset`] (via [`Changeset::new`], which
302 /// records **no** errors — it does **not** run [`validator::Validate`])
303 /// with **zero** child rows. So `is_valid()` returns `true`,
304 /// `errors_for(..)` is empty, `rows()` is empty, and `into_valid()` returns
305 /// `Ok((parent, vec![]))` even when a required parent field is still empty.
306 ///
307 /// Use this for the initial GET render of an **empty** (create) form so the
308 /// blank page does not show a premature "field is required" error or
309 /// `aria-invalid="true"` before the user has typed. For an **edit** render
310 /// that must display existing child rows (with their persisted ids), use
311 /// [`seeded`](NestedChangeset::seeded) instead — `blank` produces zero child
312 /// rows and cannot pre-fill or preserve existing line items. Use
313 /// [`decode_nested_urlencoded`] (or the [`NestedChangesetForm`] extractor)
314 /// for the POST decode that validates a real submission and re-renders with
315 /// inline errors.
316 #[must_use]
317 pub fn blank(parent: P) -> Self {
318 Self {
319 parent: Changeset::new(parent),
320 rows: Vec::new(),
321 valid_children: Some(Vec::new()),
322 }
323 }
324
325 /// `true` when the parent is valid, every non-destroyed row has no
326 /// errors, and the children all parsed and validated.
327 #[must_use]
328 pub fn is_valid(&self) -> bool {
329 self.parent.is_valid()
330 && self.valid_children.is_some()
331 && self.rows.iter().all(|r| r.destroyed || r.errors.is_empty())
332 }
333
334 /// Consume the changeset, returning `Ok((parent, children))` when valid or
335 /// `Err(self)` (with all rows retained for re-render) when not.
336 ///
337 /// The returned child vector contains only non-destroyed rows, in order.
338 ///
339 /// # Errors
340 ///
341 /// Returns `Err(self)` when the parent or any non-destroyed child row has
342 /// validation errors, or any child failed to parse.
343 #[allow(
344 clippy::result_large_err,
345 reason = "the Err variant intentionally returns the whole changeset (rows + raw \
346 values) so the handler can re-render the form inline with errors"
347 )]
348 pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
349 if !self.is_valid() {
350 return Err(self);
351 }
352 let Self {
353 parent,
354 rows,
355 valid_children,
356 } = self;
357 // `is_valid()` guarantees both arms below take the happy path; the
358 // reconstruction branches keep the code panic-free without relying on
359 // that invariant.
360 match valid_children {
361 Some(children) => match parent.into_valid() {
362 Ok(p) => Ok((p, children)),
363 Err(parent) => Err(Self {
364 parent,
365 rows,
366 valid_children: None,
367 }),
368 },
369 None => Err(Self {
370 parent,
371 rows,
372 valid_children: None,
373 }),
374 }
375 }
376
377 /// Validation messages for `key`, or an empty slice.
378 ///
379 /// Supports both parent field keys (delegated to
380 /// [`Changeset::errors_for`]) and combined child keys of the form
381 /// `"{COLLECTION}[{i}].{sub}"` (e.g. `"items[1].quantity"`). A bare
382 /// `"{COLLECTION}[{i}]"` returns that row's row-level (parse) errors.
383 #[must_use]
384 pub fn errors_for(&self, key: &str) -> &[String] {
385 if let Some((idx, sub)) = parse_combined_child_key(key, C::COLLECTION) {
386 return self.rows.get(idx).map_or(&[], |r| r.errors_for(sub));
387 }
388 self.parent.errors_for(key)
389 }
390
391 /// The submitted child rows in compacted (ascending-index) order.
392 #[must_use]
393 pub fn rows(&self) -> &[NestedRow] {
394 &self.rows
395 }
396
397 /// The child collection name, i.e. [`NestedChild::COLLECTION`].
398 #[must_use]
399 pub const fn collection_name(&self) -> &'static str {
400 C::COLLECTION
401 }
402}
403
404/// Edit-render seeding — pre-populate a non-validating changeset with existing
405/// persisted children.
406///
407/// The extra [`serde::Serialize`] bound lives on this `impl` block alone, so the
408/// core [`NestedChild`] trait stays serialize-free; only callers that seed an
409/// edit form need their child type to be serializable.
410impl<P, C: NestedChild + serde::Serialize> NestedChangeset<P, C> {
411 /// Build a **non-validating**, valid nested changeset that pre-renders one
412 /// row per existing `child`, for the initial `edit` render of a form that
413 /// already has persisted children.
414 ///
415 /// Where [`blank`](NestedChangeset::blank) produces **zero** child rows (for
416 /// the create/new page), `seeded` produces exactly one [`NestedRow`] per
417 /// element of `children`, so an edit form can display and preserve existing
418 /// line items — including each child's `id` as a hidden input (via
419 /// [`RowScope::hidden_input`]) — before the first submit. That hidden `id`
420 /// also makes the in-scope `_destroy`-on-existing-children flow usable: the
421 /// row round-trips its identity so ticking Remove on a persisted child
422 /// submits a real, identifiable row for the handler to delete.
423 ///
424 /// Each row's raw `values` are produced by serializing the child with
425 /// [`serde_html_form::to_string`] (a `Vec` field becomes repeated keys) and
426 /// parsing the result back into the
427 /// per-subfield map — the **same** string representation
428 /// [`decode_nested_urlencoded`] populates from a real submission, so
429 /// [`RowScope::value`] pre-fills a seeded row's inputs identically to a
430 /// re-rendered submitted row.
431 ///
432 /// Like `blank`, this does **not** run [`validator::Validate`]: it wraps
433 /// `parent` in a valid [`Changeset`] (via [`Changeset::new`]) and keeps
434 /// `valid_children = Some(children)`, so `is_valid()` returns `true`,
435 /// `errors_for(..)` is empty, every seeded row reports no errors and is not
436 /// destroyed, and `into_valid()` returns `Ok((parent, children))`. Use the
437 /// POST decode path ([`decode_nested_urlencoded`] / the
438 /// [`NestedChangesetForm`] extractor) to validate the actual edit
439 /// submission.
440 ///
441 /// Deeper reconciliation of persisted children — reordering, or deep-diffing
442 /// submitted rows against the seeded set to compute per-row inserts /
443 /// updates / deletes — is intentionally **out of scope** here (see issue
444 /// #1346's out-of-scope list) and remains a follow-up; `seeded` only
445 /// pre-renders the existing rows so the no-JS edit + `_destroy` flow works.
446 #[must_use]
447 pub fn seeded(parent: P, children: Vec<C>) -> Self {
448 let rows = children
449 .iter()
450 .map(|child| {
451 let mut values: HashMap<String, Vec<String>> = HashMap::new();
452 // Serialize the child to the same `field=value` wire form the
453 // decoder reads, then parse it back so `RowScope::value` pre-fills
454 // seeded rows identically to a re-rendered submitted row. A
455 // serialization failure (never expected for a plain struct) simply
456 // yields an empty value map rather than panicking on the request path.
457 if let Ok(encoded) = serde_html_form::to_string(child) {
458 for (k, v) in url::form_urlencoded::parse(encoded.as_bytes()) {
459 values
460 .entry(k.into_owned())
461 .or_default()
462 .push(v.into_owned());
463 }
464 }
465 NestedRow {
466 values,
467 errors: HashMap::new(),
468 destroyed: false,
469 }
470 })
471 .collect();
472 Self {
473 parent: Changeset::new(parent),
474 rows,
475 valid_children: Some(children),
476 }
477 }
478}
479
480// ── Decoder ────────────────────────────────────────────────────────
481
482/// Decode URL-encoded `pairs` into a [`NestedChangeset<P, C>`].
483///
484/// Pairs whose key matches `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` are child
485/// subfields (captured index + subfield name); everything else is a parent
486/// pair. Child pairs are grouped by numeric index into ascending order and
487/// **compacted** so non-contiguous indices (gaps from client-side removal)
488/// still yield sequential rows preserving submission order. Subfield order
489/// within a row is preserved.
490///
491/// A `_destroy` subfield with a truthy value (`"1"`, `"true"`, `"on"`) marks
492/// its row destroyed; destroyed rows are retained (for re-render) but never
493/// contribute to `valid_children`.
494///
495/// Following Rails' `reject_if: :all_blank`, a child row whose every
496/// non-`_destroy` subfield value is blank (empty or whitespace-only after
497/// trimming) is **ignored**: it is not decoded, not validated, not retained in
498/// `rows`, and never counts toward `valid_children` — exactly as if that index
499/// had never been submitted. This is what makes the blank template row
500/// [`inputs_for`] auto-renders safe with no JavaScript. A row with at least one
501/// non-blank non-`_destroy` value is kept and validated as usual, so a
502/// partially filled row that omits a required field still surfaces per-field
503/// errors.
504///
505/// Both the parent and each non-destroyed child are decoded through the same
506/// blank-optional-dropping path
507/// [`ChangesetForm`](crate::form::ChangesetForm) uses, so string→typed
508/// coercion (`quantity=5` → `i32`) comes for free. A repeated key fills a
509/// `Vec<T>` field. On a single-value field it is an error (issue #3394): for
510/// the parent, the decode fails; for a row, the error is on that subfield.
511///
512/// # Unstorable bytes
513///
514/// Submitted text values are swept for NUL (`U+0000`) before anything decodes
515/// them (issue #2423), and a parent field or child subfield that carried one
516/// gets [`NUL_CHARACTER_FIELD_ERROR`](crate::form::NUL_CHARACTER_FIELD_ERROR)
517/// alongside whatever the validators said. `_destroy` is excluded from the
518/// sweep — cleaning a marker read only for truthiness would change it — and so
519/// are the *default* names of the framework's plumbing fields, since this
520/// function takes raw pairs and never sees the request extensions that carry
521/// their configured names. [`NestedChangesetForm`] additionally exempts those.
522///
523/// # Errors
524///
525/// Returns `Err(message)` when the **parent** fails to decode (a malformed,
526/// non-blank typed value) — mirroring the hard-400 contract of
527/// [`ChangesetForm`](crate::form::ChangesetForm). A **child** row that fails
528/// to parse is not a hard error: it is retained with a row-level error and
529/// marks the changeset invalid.
530pub fn decode_nested_urlencoded<P, C>(
531 pairs: &[(String, String)],
532) -> Result<NestedChangeset<P, C>, String>
533where
534 P: serde::de::DeserializeOwned + validator::Validate,
535 C: NestedChild,
536{
537 decode_nested_with_nul_message(pairs, crate::form::NUL_CHARACTER_FIELD_ERROR)
538 .map_err(|e| e.to_string())
539}
540
541/// Like [`decode_nested_urlencoded`], but the caller sets the message for a NUL
542/// byte.
543/// The error keeps its status, so the extractor answers 422 for a repeated
544/// parent field and 400 for the rest.
545fn decode_nested_with_nul_message<P, C>(
546 pairs: &[(String, String)],
547 nul_message: &str,
548) -> Result<NestedChangeset<P, C>, crate::form::FormDecodeError>
549where
550 P: serde::de::DeserializeOwned + validator::Validate,
551 C: NestedChild,
552{
553 let collection = C::COLLECTION;
554
555 // Split parent pairs from grouped child subfields. `BTreeMap` keeps the
556 // rows in ascending index order; enumerating it later compacts gaps.
557 let mut parent_pairs: Vec<(String, String)> = Vec::new();
558 let mut child_groups: BTreeMap<usize, Vec<(String, String)>> = BTreeMap::new();
559 for (key, value) in pairs {
560 if let Some((idx, sub)) = parse_child_key(key, collection) {
561 child_groups
562 .entry(idx)
563 .or_default()
564 .push((sub.to_string(), value.clone()));
565 } else {
566 parent_pairs.push((key.clone(), value.clone()));
567 }
568 }
569
570 // #2423: sweep NUL bytes out of the submitted values before anything
571 // decodes them, remembering which names carried one so the byte lands as
572 // an ordinary error on the field or subfield that held it. Postgres cannot
573 // store `0x00` in a TEXT column, so without this the row parses, validates,
574 // and fails only at the database with an unhandled 500. Parent fields and
575 // each row's subfields are swept separately because they are keyed
576 // separately in the rendered form.
577 // `_destroy` is excluded from the sweep, not merely from the reporting.
578 // Cleaning it would rewrite the marker itself: `is_truthy` trims only
579 // whitespace, so `"1\u{0}"` reads as false today and would read as *true*
580 // after stripping — silently destroying a row the author submitted to keep.
581 // It is never written to a column, so leaving the byte in it costs nothing.
582 let parent_nul_fields: Vec<String> = crate::form::strip_nul_from_pairs(&mut parent_pairs)
583 .into_iter()
584 // A direct caller of this public function passes the raw pairs, tokens
585 // and all, and never sees the request extensions that carry the
586 // configurable field names — so exempt the defaults here. The
587 // extractor additionally exempts the configured names.
588 .filter(|field| !crate::form::is_plumbing_field(field))
589 .collect();
590 let child_nul_fields: BTreeMap<usize, Vec<String>> = child_groups
591 .iter_mut()
592 .map(|(idx, subfields)| {
593 // Deduplicated for the same reason `strip_nul_from_pairs` is: a
594 // subfield may be submitted any number of times, and one message
595 // per *pair* would let a crafted body inflate a single row's error
596 // list without bound. One message per offending subfield.
597 let mut swept: BTreeSet<String> = BTreeSet::new();
598 for (sub, value) in subfields.iter_mut() {
599 if sub != DESTROY_SUBFIELD && value.as_bytes().contains(&0) {
600 *value = crate::normalize::strip_nul(value);
601 if !swept.contains(sub.as_str()) {
602 swept.insert(sub.clone());
603 }
604 }
605 }
606 (*idx, swept.into_iter().collect::<Vec<String>>())
607 })
608 .collect();
609
610 // Decode the parent through the shared blank-optional-dropping path.
611 let parent_encoded = encode_pairs(&parent_pairs);
612 let parent_data: P =
613 decode_urlencoded_dropping_blank_optional_fields::<P>(parent_encoded.as_bytes())?;
614 let mut parent = parent_data.into_changeset();
615 for field in parent_nul_fields {
616 parent.add_error(field, nul_message);
617 }
618
619 let mut rows: Vec<NestedRow> = Vec::new();
620 let mut children: Vec<C> = Vec::new();
621 let mut all_children_ok = true;
622
623 for (idx, subfields) in child_groups {
624 match decode_child_row::<C>(
625 &subfields,
626 child_nul_fields.get(&idx).map_or(&[], Vec::as_slice),
627 nul_message,
628 ) {
629 ChildRow::Dropped => {}
630 ChildRow::Bound { row, child } => {
631 if let Some(child) = child {
632 children.push(child);
633 }
634 rows.push(row);
635 }
636 ChildRow::Rejected(row) => {
637 all_children_ok = false;
638 rows.push(row);
639 }
640 }
641 }
642
643 let valid_children = if parent.is_valid() && all_children_ok {
644 Some(children)
645 } else {
646 None
647 };
648
649 Ok(NestedChangeset {
650 parent,
651 rows,
652 valid_children,
653 })
654}
655
656/// The outcome of decoding one submitted child row.
657enum ChildRow<C> {
658 /// A blank template row (Rails `reject_if: :all_blank`) — dropped
659 /// entirely, exactly as if that index had never been submitted.
660 Dropped,
661 /// Retained for re-render and contributing `child` to the bound children:
662 /// `Some` when the row parsed and validated, `None` when it is marked
663 /// destroyed and so has nothing to write. Neither blocks `into_valid`.
664 Bound {
665 /// The row as re-rendered.
666 row: NestedRow,
667 /// The bound child, absent for a destroyed row.
668 child: Option<C>,
669 },
670 /// Retained for re-render carrying per-subfield errors; blocks
671 /// `into_valid`.
672 Rejected(NestedRow),
673}
674
675/// Decode one submitted child row from its grouped subfields.
676///
677/// `nul_fields` names the subfields of this row whose submitted value carried
678/// a NUL byte (#2423); the values in `subfields` have already been cleaned, so
679/// this function only sets the message for them, from `nul_message`.
680fn decode_child_row<C: NestedChild>(
681 subfields: &[(String, String)],
682 nul_fields: &[String],
683 nul_message: &str,
684) -> ChildRow<C> {
685 let mut values: HashMap<String, Vec<String>> = HashMap::new();
686 let mut destroyed = false;
687 // Subfields decoded into `C`, excluding the `_destroy` marker (not a
688 // field of `C`).
689 let mut decode_pairs: Vec<(String, String)> = Vec::new();
690 for (sub, val) in subfields {
691 values.entry(sub.clone()).or_default().push(val.clone());
692 if sub == DESTROY_SUBFIELD {
693 if is_truthy(val) {
694 destroyed = true;
695 }
696 } else {
697 decode_pairs.push((sub.clone(), val.clone()));
698 }
699 }
700
701 // Rails `reject_if: :all_blank`: when every non-`_destroy` subfield value is blank —
702 // empty or whitespace-only after trimming — drop the row entirely. This is the
703 // auto-rendered blank template row `inputs_for` emits for the no-JS "add a child"
704 // path, not a real submitted child, so treat it as if the index was never submitted:
705 // do not decode, validate, retain, or count it toward the children. A row with at
706 // least one non-blank, non-`_destroy` value is kept and validated as usual, so a
707 // partially filled row still surfaces its per-field errors. `decode_pairs` already
708 // excludes `_destroy`, so an all-blank row that also carries `_destroy` is dropped
709 // here too, the same outcome as the destroy path.
710 let all_blank = decode_pairs.iter().all(|(_, val)| val.trim().is_empty());
711
712 // A genuinely blank template row drops, destroyed or not — unchanged.
713 if all_blank && nul_fields.is_empty() {
714 return ChildRow::Dropped;
715 }
716
717 // #2423: a destroyed row is never written, so there is nothing to reject
718 // there — the cleaned values are retained only so the row re-renders. This
719 // has to be decided *before* the NUL rejection below: a row whose only
720 // remaining content was an unstorable byte is still a row the author asked
721 // to delete, and rejecting it would block `into_valid` and leave them
722 // unable to remove it.
723 if destroyed {
724 return ChildRow::Bound {
725 row: NestedRow {
726 values,
727 errors: HashMap::new(),
728 destroyed,
729 },
730 child: None,
731 };
732 }
733
734 // #2423: values were cleaned before the blank check, so a subfield whose
735 // only content was a NUL now reads as blank. That must not make the row
736 // *vanish* — the author did submit something, it just could not be stored
737 // — so a row that carried a NUL is reported even when nothing legible is
738 // left of it.
739 if all_blank {
740 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
741 for field in nul_fields {
742 errors
743 .entry(field.clone())
744 .or_default()
745 .push(nul_message.to_owned());
746 }
747 return ChildRow::Rejected(NestedRow {
748 values,
749 errors,
750 destroyed,
751 });
752 }
753
754 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
755 let mut child: Option<C> = None;
756
757 let encoded = encode_pairs(&decode_pairs);
758 match decode_urlencoded_dropping_blank_optional_fields::<C>(encoded.as_bytes()) {
759 Ok(decoded) => match validator::Validate::validate(&decoded) {
760 Ok(()) => child = Some(decoded),
761 Err(ve) => errors = validation_errors_to_map(&ve),
762 },
763 Err(e) => {
764 // Row parse failure — deserialization failed before validation,
765 // as when `sku` is filled but the required numeric `quantity` is
766 // malformed or present-but-blank. Key the message under the
767 // offending subfield so it surfaces as `items[i].{field}`,
768 // rendered by `errors_for("{field}")` next to the offending input
769 // rather than only under the row-level "" key.
770 //
771 // The blank-optional retry inside
772 // `decode_urlencoded_dropping_blank_optional_fields` can drop a
773 // present-but-blank typed field (`quantity=`) before serde sees it,
774 // so the primary error is then a `missing field` report at the row
775 // root with an empty path, losing the field name the row helpers
776 // need. `recover_child_error_field` recovers it by preferring the
777 // primary error's `missing field` message, with a raw non-dropping
778 // re-decode only as a last resort. Only if every layer fails does it
779 // fall back to the row-level "" key, so the error is never silently
780 // dropped.
781 let field = recover_child_error_field::<C>(&e, &decode_pairs);
782 errors.entry(field).or_default().push(e.to_string());
783 }
784 }
785
786 // #2423: a NUL byte in this row's text is a rejection in its own right,
787 // recorded alongside whatever the validators said.
788 for field in nul_fields {
789 errors
790 .entry(field.clone())
791 .or_default()
792 .push(nul_message.to_owned());
793 child = None;
794 }
795
796 let row = NestedRow {
797 values,
798 errors,
799 destroyed,
800 };
801 match child {
802 Some(child) => ChildRow::Bound {
803 row,
804 child: Some(child),
805 },
806 None => ChildRow::Rejected(row),
807 }
808}
809
810/// Parse `key` as a child subfield reference `COLLECTION[<idx>][<sub>]`,
811/// returning `(idx, sub)`. Hand-parses the bracket pattern (no `regex`
812/// dependency), matching `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` with
813/// `COLLECTION` treated literally.
814fn parse_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
815 let rest = key.strip_prefix(collection)?;
816 let rest = rest.strip_prefix('[')?;
817 let close = rest.find(']')?;
818 let (idx_str, after) = rest.split_at(close);
819 let idx: usize = idx_str.parse().ok()?;
820 // `after` begins with the `]` that `close` pointed at.
821 let after = after.strip_prefix(']')?;
822 let after = after.strip_prefix('[')?;
823 let close2 = after.find(']')?;
824 let (sub, tail) = after.split_at(close2);
825 // The subfield must be the final segment: exactly a trailing `]`.
826 if tail != "]" || sub.is_empty() {
827 return None;
828 }
829 Some((idx, sub))
830}
831
832/// Parse a combined error key `COLLECTION[<idx>]` optionally followed by
833/// `.<sub>`, returning `(idx, sub)` where `sub` is `""` for the bare form.
834/// Returns `None` when the key is not a child key (so it falls through to the
835/// parent).
836fn parse_combined_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
837 let rest = key.strip_prefix(collection)?;
838 let rest = rest.strip_prefix('[')?;
839 let close = rest.find(']')?;
840 let (idx_str, after) = rest.split_at(close);
841 let idx: usize = idx_str.parse().ok()?;
842 let after = after.strip_prefix(']')?;
843 if after.is_empty() {
844 return Some((idx, ""));
845 }
846 let sub = after.strip_prefix('.')?;
847 if sub.is_empty() {
848 return None;
849 }
850 Some((idx, sub))
851}
852
853/// Re-encode `pairs` as an `application/x-www-form-urlencoded` string so the
854/// shared form decode path can re-parse them.
855fn encode_pairs(pairs: &[(String, String)]) -> String {
856 url::form_urlencoded::Serializer::new(String::new())
857 .extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
858 .finish()
859}
860
861/// Last `Map`/`Enum` key in a `serde_path_to_error` path, if any is non-empty.
862///
863/// This is the subfield the decode failure is attributed to (e.g. `quantity`
864/// for `items[i][quantity]`). `Seq`/`Unknown` segments carry no field name.
865fn last_path_field(path: &serde_path_to_error::Path) -> Option<String> {
866 path.iter()
867 .filter_map(|seg| match seg {
868 serde_path_to_error::Segment::Map { key }
869 | serde_path_to_error::Segment::Enum { variant: key } => Some(key.clone()),
870 serde_path_to_error::Segment::Seq { .. } | serde_path_to_error::Segment::Unknown => {
871 None
872 }
873 })
874 .next_back()
875 .filter(|f| !f.is_empty())
876}
877
878/// Extract the field name from serde's stable "missing field" message — the
879/// backtick-quoted identifier in [`serde::de::Error::missing_field`]'s
880/// `Display` format — if present.
881fn missing_field_name(msg: &str) -> Option<String> {
882 let after = msg.split_once("missing field `")?.1;
883 let name = after.split_once('`')?.0;
884 (!name.is_empty()).then(|| name.to_owned())
885}
886
887/// Recover the offending subfield name for a child-row parse failure so the
888/// error can be keyed to `items[i].{field}` (rendered next to the input) rather
889/// than only under the row-level "" key.
890///
891/// Layered because the blank-optional retry inside
892/// [`decode_urlencoded_dropping_blank_optional_fields`] can DROP a
893/// present-but-blank typed field (`quantity=`) before serde ever sees it, so
894/// the primary decode reports a root-level missing-field error for `quantity`
895/// with an empty path — losing the field name the row helpers need:
896///
897/// 1. The primary decode's `serde_path_to_error` path (last `Map`/`Enum` key).
898/// For a malformed non-blank value (`quantity=abc`) this already yields the
899/// field, since that pair is never dropped.
900/// 2. If empty, the backtick-quoted identifier parsed from the PRIMARY error's
901/// missing-field message. The blank-dropping decode stops on the first typed
902/// field it can't satisfy, so this names the truly-required field even when
903/// an earlier OPTIONAL blank precedes it — e.g. a child with an optional
904/// `weight` before a required `quantity`, both submitted blank, reports the
905/// required `quantity` as missing, not `weight`. It also covers the
906/// present-but-blank required field (dropped → missing) and the field truly
907/// never submitted. This is preferred over the raw re-decode below precisely
908/// so the error lands on the required field, not a harmless optional blank a
909/// non-dropping pass would trip on first.
910/// 3. Only if BOTH are empty (a rare root-level error carrying neither a path
911/// nor a missing-field message), a raw re-decode over the ORIGINAL row pairs
912/// with blanks RETAINED (no dropping), mirroring the shared helper's
913/// deserializer minus the drop loop, and its last `Map`/`Enum` key.
914/// 4. Otherwise `""` (row-level key) so the error is never silently dropped.
915///
916/// This runs ONLY on the already-failed path to LOCATE the field; it does not
917/// affect the success path. Legitimately-blank optional fields still decode to
918/// `None` via the primary blank-dropping decode, which succeeds for them and
919/// never reaches here.
920fn recover_child_error_field<C: serde::de::DeserializeOwned>(
921 primary: &crate::form::FormDecodeError,
922 decode_pairs: &[(String, String)],
923) -> String {
924 // 0. A repeated single-value subfield (issue #3394) names itself.
925 let Some(primary) = primary.invalid_error() else {
926 return primary.field().unwrap_or_default().to_owned();
927 };
928
929 // 1. Primary decode path (present-but-malformed value at a real path).
930 if let Some(field) = last_path_field(primary.path()) {
931 return field;
932 }
933
934 // 2. The `missing field \`X\`` identifier from the PRIMARY error — names
935 // the required field the blank-dropping pass actually stopped on.
936 if let Some(field) = missing_field_name(&primary.inner().to_string()) {
937 return field;
938 }
939
940 // 3. Fallback: raw (non-dropping) re-decode over the original row pairs,
941 // for the rare root-level error with neither a path nor a missing-field
942 // message.
943 let encoded = encode_pairs(decode_pairs);
944 let deserializer = serde_html_form::Deserializer::from_bytes(encoded.as_bytes());
945 if let Err(raw_err) = serde_path_to_error::deserialize::<_, C>(deserializer)
946 && let Some(field) = last_path_field(raw_err.path())
947 {
948 return field;
949 }
950
951 // 4. Row-level key.
952 String::new()
953}
954
955/// Whether a `_destroy` marker value counts as "destroy this row".
956/// The subfield name that marks a submitted child row for destruction.
957///
958/// Not a column: its value is only ever read for truthiness, so it is left out
959/// of the #2423 NUL sweep entirely (see `decode_nested_urlencoded`).
960const DESTROY_SUBFIELD: &str = "_destroy";
961
962fn is_truthy(value: &str) -> bool {
963 let trimmed = value.trim();
964 trimmed == "1" || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("on")
965}
966
967// ── NestedChangesetForm extractor ──────────────────────────────────
968
969/// Axum extractor that decodes a nested (`has_many`) form body, validates the
970/// parent and every non-destroyed child row, and captures the CSRF and
971/// submit-token context for re-rendering.
972///
973/// Mirrors [`ChangesetForm`](crate::form::ChangesetForm): errors live in the
974/// [`NestedChangeset`] rather than rejecting with 422; the handler decides how
975/// to respond. Only `application/x-www-form-urlencoded` bodies are accepted
976/// (multipart is a follow-up).
977pub struct NestedChangesetForm<P, C> {
978 /// The validated (or invalid) nested changeset.
979 pub changeset: NestedChangeset<P, C>,
980 csrf_token: Option<String>,
981 csrf_field: String,
982 submit_token: Option<String>,
983 submit_field: String,
984}
985
986impl<P, C: NestedChild> NestedChangesetForm<P, C> {
987 /// Build a blank nested-form context for the initial `new` (create) GET
988 /// render, before any submission.
989 ///
990 /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
991 /// wraps `parent` in a **non-validating** [`NestedChangeset::blank`] (no
992 /// child rows, no errors) so the initial page renders clean — no premature
993 /// "field is required" message or `aria-invalid="true"` before the user has
994 /// typed. For an **edit** render that must show existing children, use
995 /// [`seeded`](Self::seeded) instead (`blank` renders zero child rows).
996 /// Contrast the POST path (the [`NestedChangesetForm`] extractor /
997 /// [`decode_nested_urlencoded`]), which validates the submission and
998 /// re-renders inline errors.
999 ///
1000 /// `csrf_token` is the token from a `CsrfToken` extractor, or `None` when
1001 /// CSRF middleware is not active. The CSRF and submit-token field names
1002 /// default to `_csrf` / `_submit_token` (exactly what the extractor falls
1003 /// back to when the corresponding config extensions are absent); when the
1004 /// app customizes `security.csrf.form_field`, set it with
1005 /// [`with_csrf_field`](Self::with_csrf_field) so
1006 /// [`form_tag`](Self::form_tag) emits the right hidden field name.
1007 ///
1008 /// The submit token starts `None`, so a bare `blank(..).form_tag(..)` emits
1009 /// **no** submit-token hidden input and the first submission is not protected
1010 /// against double-submit ([`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1011 /// passes tokenless mutating requests through). Supply the minted token on the
1012 /// initial GET with [`with_submit_token`](Self::with_submit_token) (and
1013 /// [`with_submit_field`](Self::with_submit_field) if the field name is
1014 /// customized) so the **first** submit carries a token too:
1015 ///
1016 /// ```rust,ignore
1017 /// #[get("/orders/new")]
1018 /// async fn new_order(csrf: CsrfToken, submit: SubmitToken) -> Markup {
1019 /// let form = NestedChangesetForm::<NewOrder, NewLineItem>::blank(
1020 /// NewOrder::default(),
1021 /// Some(csrf.token().to_owned()),
1022 /// )
1023 /// .with_submit_token(Some(submit.token().to_owned()));
1024 /// form.form_tag("/orders", "post", /* … */)
1025 /// }
1026 /// ```
1027 #[must_use]
1028 pub fn blank(parent: P, csrf_token: Option<String>) -> Self {
1029 Self {
1030 changeset: NestedChangeset::blank(parent),
1031 csrf_token,
1032 csrf_field: "_csrf".to_owned(),
1033 submit_token: None,
1034 submit_field: "_submit_token".to_owned(),
1035 }
1036 }
1037
1038 /// Wrap a pre-built [`NestedChangeset`] (which may already carry validation
1039 /// errors) in a form for rendering, with no CSRF/submit token.
1040 ///
1041 /// Mirrors [`ChangesetForm::from_changeset`](crate::form::ChangesetForm::from_changeset):
1042 /// useful in tests and cases where a `NestedChangeset` was produced
1043 /// externally (e.g. via [`decode_nested_urlencoded`]) before constructing a
1044 /// form for re-render. The CSRF/submit-token field names default to
1045 /// `_csrf` / `_submit_token`; add a token with
1046 /// [`with_csrf_field`](Self::with_csrf_field) as needed.
1047 #[must_use]
1048 pub fn from_changeset(changeset: NestedChangeset<P, C>) -> Self {
1049 Self {
1050 changeset,
1051 csrf_token: None,
1052 csrf_field: "_csrf".to_owned(),
1053 submit_token: None,
1054 submit_field: "_submit_token".to_owned(),
1055 }
1056 }
1057
1058 /// Override the CSRF form-field name used by [`form_tag`](Self::form_tag).
1059 ///
1060 /// Mirrors
1061 /// [`ChangesetForm::with_csrf_field`](crate::form::ChangesetForm::with_csrf_field):
1062 /// call this on a [`blank`](Self::blank) GET-handler form when
1063 /// `security.csrf.form_field` is customized (e.g. `"authenticity_token"`).
1064 /// The extractor captures the configured name automatically on the POST
1065 /// path.
1066 #[must_use]
1067 pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
1068 self.csrf_field = field.into();
1069 self
1070 }
1071
1072 /// Supply the one-time submit token to a [`blank`](Self::blank) (or
1073 /// [`from_changeset`](Self::from_changeset)) GET-handler form so
1074 /// [`form_tag`](Self::form_tag) emits the hidden submit-token input on the
1075 /// **initial** render — protecting the very first submission against
1076 /// double-submit, not just later 422 re-renders.
1077 ///
1078 /// [`blank`](Self::blank) leaves this `None` (the initial page renders no
1079 /// submit-token field otherwise), and [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1080 /// passes tokenless mutating requests through unchanged — so without calling
1081 /// this the first create-form submit is unprotected. Source the token from a
1082 /// [`SubmitToken`](crate::security::SubmitToken) extractor on the GET handler.
1083 /// The extractor captures it automatically on the POST re-render path.
1084 ///
1085 /// When the app customizes `security.submit_token.field_name`, pair this with
1086 /// [`with_submit_field`](Self::with_submit_field) so the hidden input carries
1087 /// the right name.
1088 #[must_use]
1089 pub fn with_submit_token(mut self, token: Option<String>) -> Self {
1090 self.submit_token = token;
1091 self
1092 }
1093
1094 /// Override the submit-token form-field name used by
1095 /// [`form_tag`](Self::form_tag).
1096 ///
1097 /// Mirrors [`with_csrf_field`](Self::with_csrf_field): call this on a
1098 /// [`blank`](Self::blank) GET-handler form when
1099 /// `security.submit_token.field_name` is customized (the default is
1100 /// `_submit_token`). The extractor captures the configured name automatically
1101 /// on the POST path.
1102 #[must_use]
1103 pub fn with_submit_field(mut self, field: impl Into<String>) -> Self {
1104 self.submit_field = field.into();
1105 self
1106 }
1107
1108 /// The CSRF token captured from the request, if the CSRF middleware is active.
1109 #[must_use]
1110 pub fn csrf_token(&self) -> Option<&str> {
1111 self.csrf_token.as_deref()
1112 }
1113
1114 /// The CSRF form-field name (honours `security.csrf.form_field`).
1115 #[must_use]
1116 pub fn csrf_field(&self) -> &str {
1117 &self.csrf_field
1118 }
1119
1120 /// The one-time submit token captured from the request, if the
1121 /// submit-token middleware is active.
1122 #[must_use]
1123 pub fn submit_token(&self) -> Option<&str> {
1124 self.submit_token.as_deref()
1125 }
1126
1127 /// The submit-token form-field name (honours
1128 /// `security.submit_token.field_name`).
1129 #[must_use]
1130 pub fn submit_field(&self) -> &str {
1131 &self.submit_field
1132 }
1133
1134 /// Consume and return only the inner [`NestedChangeset`].
1135 pub fn into_changeset(self) -> NestedChangeset<P, C> {
1136 self.changeset
1137 }
1138
1139 /// Return `Ok((parent, children))` when valid, `Err(self)` when not.
1140 ///
1141 /// The `Err` branch retains the CSRF/submit context so the handler can
1142 /// immediately re-render the form with inline errors.
1143 ///
1144 /// # Errors
1145 ///
1146 /// Returns `Err(self)` when the inner changeset has validation errors.
1147 #[allow(
1148 clippy::result_large_err,
1149 reason = "the Err variant intentionally returns the whole form (changeset + CSRF/submit \
1150 context) so the handler can re-render inline with errors"
1151 )]
1152 pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
1153 let Self {
1154 changeset,
1155 csrf_token,
1156 csrf_field,
1157 submit_token,
1158 submit_field,
1159 } = self;
1160 match changeset.into_valid() {
1161 Ok(pair) => Ok(pair),
1162 Err(changeset) => Err(Self {
1163 changeset,
1164 csrf_token,
1165 csrf_field,
1166 submit_token,
1167 submit_field,
1168 }),
1169 }
1170 }
1171}
1172
1173/// Edit-render seeding for the form context — mirrors
1174/// [`NestedChangeset::seeded`], carrying the extra [`serde::Serialize`] bound on
1175/// this `impl` block alone.
1176impl<P, C: NestedChild + serde::Serialize> NestedChangesetForm<P, C> {
1177 /// Build a nested-form context for the initial `edit` GET render,
1178 /// pre-populated with the existing persisted `children`.
1179 ///
1180 /// Mirrors [`blank`](Self::blank)'s CSRF handling (the CSRF/submit-token
1181 /// field names default to `_csrf` / `_submit_token`, and the submit token
1182 /// starts `None`), but wraps [`NestedChangeset::seeded`] instead of
1183 /// [`NestedChangeset::blank`]: the changeset pre-renders one row per existing
1184 /// child so the edit page shows and preserves current line items — with their
1185 /// `id`s carried as hidden inputs (via [`RowScope::hidden_input`]) — and the
1186 /// no-JS `_destroy` removal of an existing child works before the first
1187 /// submit.
1188 ///
1189 /// The same builder methods used with [`blank`](Self::blank) apply here:
1190 /// [`with_csrf_field`](Self::with_csrf_field) for a customized
1191 /// `security.csrf.form_field`, and [`with_submit_token`](Self::with_submit_token)
1192 /// / [`with_submit_field`](Self::with_submit_field) to emit the one-time
1193 /// submit-token hidden input on the initial edit render. `csrf_token` is the
1194 /// token from a `CsrfToken` extractor, or `None` when CSRF middleware is not
1195 /// active.
1196 #[must_use]
1197 pub fn seeded(parent: P, children: Vec<C>, csrf_token: Option<String>) -> Self {
1198 Self {
1199 changeset: NestedChangeset::seeded(parent, children),
1200 csrf_token,
1201 csrf_field: "_csrf".to_owned(),
1202 submit_token: None,
1203 submit_field: "_submit_token".to_owned(),
1204 }
1205 }
1206}
1207
1208/// Maud rendering — emit the `<form>` open tag with the **captured** CSRF (and
1209/// submit-token) hidden fields injected.
1210#[cfg(feature = "maud")]
1211impl<P, C: NestedChild> NestedChangesetForm<P, C> {
1212 /// Render a `<form>` element wrapping `content`, injecting the CSRF hidden
1213 /// input under the **captured** field name (honouring
1214 /// `security.csrf.form_field`) and — when present — the one-time
1215 /// submit-token hidden input under its captured field name.
1216 ///
1217 /// Mirrors [`ChangesetForm::form_tag`](crate::form::ChangesetForm::form_tag),
1218 /// including the `PUT`/`PATCH`/`DELETE` → hidden `_method` override. **Prefer
1219 /// this over the standalone [`crate::form::form_tag`]** for a nested-form
1220 /// re-render: the standalone helper hardcodes the default `_csrf` field name
1221 /// and would emit the wrong hidden field for an app that customized
1222 /// `security.csrf.form_field`, so the next submit's CSRF check would reject
1223 /// the form. This method uses the field name the extractor captured (or the
1224 /// one set via [`with_csrf_field`](Self::with_csrf_field) on a
1225 /// [`blank`](Self::blank) form), keeping CSRF parity across the re-render.
1226 ///
1227 /// The submit-token hidden input is emitted only when a submit token is
1228 /// present. The POST re-render path captures it automatically; on the initial
1229 /// GET render from [`blank`](Self::blank), supply it with
1230 /// [`with_submit_token`](Self::with_submit_token) so the **first** submit is
1231 /// protected against double-submit too — otherwise a bare
1232 /// `blank(..).form_tag(..)` create form carries no submit token and its first
1233 /// submission passes through [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1234 /// unprotected.
1235 #[must_use]
1236 #[allow(clippy::needless_pass_by_value)]
1237 pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
1238 crate::form::form_tag_inner(
1239 action,
1240 method,
1241 &self.csrf_field,
1242 self.csrf_token.as_deref(),
1243 None,
1244 maud::html! {
1245 @if let Some(token) = self.submit_token.as_deref() {
1246 input type="hidden" name=(self.submit_field) value=(token);
1247 }
1248 (content)
1249 },
1250 )
1251 }
1252}
1253
1254/// Dereferences to [`NestedChangeset<P, C>`] so all changeset methods are
1255/// available directly on the form (`form.is_valid()`, `form.errors_for(…)`,
1256/// `form.rows()`, …).
1257impl<P, C> std::ops::Deref for NestedChangesetForm<P, C> {
1258 type Target = NestedChangeset<P, C>;
1259 fn deref(&self) -> &Self::Target {
1260 &self.changeset
1261 }
1262}
1263
1264impl<S, P, C> FromRequest<S> for NestedChangesetForm<P, C>
1265where
1266 S: Send + Sync,
1267 P: serde::de::DeserializeOwned + validator::Validate + Send,
1268 C: NestedChild,
1269{
1270 type Rejection = axum::response::Response;
1271
1272 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
1273 // Capture CSRF + submit-token context from request extensions exactly
1274 // as `ChangesetForm` does, before the body is consumed.
1275 let csrf_token = req
1276 .extensions()
1277 .get::<crate::security::CsrfToken>()
1278 .map(|t| t.token().to_string());
1279 let csrf_field = req
1280 .extensions()
1281 .get::<crate::security::csrf::CsrfFormField>()
1282 .map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
1283 let submit_token = req
1284 .extensions()
1285 .get::<crate::security::SubmitToken>()
1286 .map(|t| t.token().to_string());
1287 let submit_field = req
1288 .extensions()
1289 .get::<crate::security::SubmitFormField>()
1290 .map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
1291
1292 // Same content-type gate axum's own form extractors apply. Multipart
1293 // is out of scope for now (follow-up).
1294 let (req, nul_message) = crate::form::nul_field_message(req, state).await;
1295 let content_type = req
1296 .headers()
1297 .get(http::header::CONTENT_TYPE)
1298 .and_then(|v| v.to_str().ok())
1299 .unwrap_or_default()
1300 .to_string();
1301 if !crate::media_type::is_media_type(&content_type, "application/x-www-form-urlencoded") {
1302 return Err((
1303 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1304 "Nested form requests must have `Content-Type: application/x-www-form-urlencoded`",
1305 )
1306 .into_response());
1307 }
1308
1309 // Buffer through axum's `Bytes` extractor so `DefaultBodyLimit` is
1310 // enforced (a bare `to_bytes(.., usize::MAX)` would defeat it).
1311 let (parts, body) = req.into_parts();
1312 let bytes_req = Request::from_parts(parts, body);
1313 let bytes = axum::body::Bytes::from_request(bytes_req, state)
1314 .await
1315 .map_err(IntoResponse::into_response)?;
1316
1317 let mut pairs: Vec<(String, String)> = url::form_urlencoded::parse(&bytes)
1318 .map(|(k, v)| (k.into_owned(), v.into_owned()))
1319 .collect();
1320
1321 // #2423: the CSRF token, the submit token and the method override are
1322 // transport plumbing, not form fields — no template renders them, so an
1323 // error keyed under one would make the form permanently invalid with
1324 // nothing on screen to explain why. Clean them here so
1325 // `decode_nested_urlencoded`'s sweep, which knows nothing about the two
1326 // configurable names, never sees a NUL in one. Each is still rejected on
1327 // its own terms when malformed.
1328 for (key, value) in &mut pairs {
1329 if *key == csrf_field || *key == submit_field || crate::form::is_plumbing_field(key) {
1330 *value = crate::normalize::strip_nul(value);
1331 }
1332 }
1333
1334 let changeset = decode_nested_with_nul_message::<P, C>(&pairs, &nul_message)
1335 .map_err(|e| (e.status(), e.to_string()).into_response())?;
1336
1337 Ok(Self {
1338 changeset,
1339 csrf_token,
1340 csrf_field,
1341 submit_token,
1342 submit_field,
1343 })
1344 }
1345}
1346
1347// ── Maud view helpers ──────────────────────────────────────────────
1348
1349/// A single row's rendering scope inside [`inputs_for`].
1350///
1351/// Each scope binds the child collection name, the row's 0-based `index`, and
1352/// (for existing/submitted rows) the underlying [`NestedRow`] so its raw values
1353/// pre-fill inputs and its per-subfield errors render inline on re-render after
1354/// a failed submission. A blank template row carries `row: None` — its inputs
1355/// render empty with no error blocks.
1356///
1357/// The row-scoped input builders mirror the standalone field helpers in
1358/// [`crate::form`] (`text_input`, `number_input`, …) but emit **nested** input
1359/// names (`items[{index}][{sub}]`) and per-row-unique element ids
1360/// (`items-{index}-{sub}`) so ids and `aria-describedby` links stay unique
1361/// across repeated rows.
1362#[cfg(feature = "maud")]
1363pub struct RowScope<'a> {
1364 collection: &'a str,
1365 index: usize,
1366 row: Option<&'a NestedRow>,
1367}
1368
1369#[cfg(feature = "maud")]
1370impl RowScope<'_> {
1371 /// This row's 0-based position in the collection.
1372 #[must_use]
1373 pub const fn index(&self) -> usize {
1374 self.index
1375 }
1376
1377 /// The nested input `name` for subfield `sub`, i.e.
1378 /// `"{collection}[{index}][{sub}]"`.
1379 #[must_use]
1380 pub fn field_name(&self, sub: &str) -> String {
1381 format!("{}[{}][{}]", self.collection, self.index, sub)
1382 }
1383
1384 /// The raw submitted value for subfield `sub`, or `None` for a blank row.
1385 #[must_use]
1386 pub fn value(&self, sub: &str) -> Option<&str> {
1387 self.row.and_then(|r| r.value(sub))
1388 }
1389
1390 /// Every raw submitted value for subfield `sub`, or an empty slice (always
1391 /// empty for a blank row). See [`NestedRow::values`].
1392 #[must_use]
1393 pub fn values(&self, sub: &str) -> &[String] {
1394 self.row.map_or(&[], |r| r.values(sub))
1395 }
1396
1397 /// Validation messages for subfield `sub`, or an empty slice (always empty
1398 /// for a blank row).
1399 #[must_use]
1400 pub fn errors_for(&self, sub: &str) -> &[String] {
1401 self.row.map_or(&[], |r| r.errors_for(sub))
1402 }
1403
1404 /// `true` when this (existing) row carried a truthy `_destroy` marker.
1405 #[must_use]
1406 pub fn is_destroyed(&self) -> bool {
1407 self.row.is_some_and(NestedRow::is_destroyed)
1408 }
1409
1410 /// Per-row-unique element id base for subfield `sub`
1411 /// (`"{collection}-{index}-{sub}"`), used for `id` / `aria-describedby`
1412 /// linkage so repeated rows never collide.
1413 fn element_id(&self, sub: &str) -> String {
1414 format!("{}-{}-{}", self.collection, self.index, sub)
1415 }
1416
1417 /// Render a labeled row-scoped `<input type="text">` for subfield `sub`.
1418 ///
1419 /// Mirrors [`crate::form::text_input`]: `autumn-field` wrapper, per-row
1420 /// pre-fill, `aria-invalid` / `aria-describedby`, and a `role="alert"`
1421 /// error block — but with the nested `name` and a per-row-unique `id`.
1422 #[must_use]
1423 pub fn text_input(&self, sub: &str, label: &str) -> maud::Markup {
1424 self.text_input_with(sub, label, &FieldOptions::new())
1425 }
1426
1427 /// Like [`RowScope::text_input`] but adds `required` + `aria-required="true"`
1428 /// — **only for real/submitted rows that are not marked for destruction**
1429 /// (`self.row.is_some() && !self.is_destroyed()`).
1430 ///
1431 /// A blank template row (`row: None`, the one [`inputs_for`] auto-appends
1432 /// for the no-JS "add a child" path) deliberately renders the *same* input
1433 /// **without** the client-side `required`/`aria-required` attributes so it
1434 /// stays leaveable-empty. Otherwise the browser's native constraint
1435 /// validation would block form submit on the empty trailing template row
1436 /// *before* the server's all-blank rejection can run — a user who filled one
1437 /// child row could not submit while a blank row sat beneath it. A submitted
1438 /// row the user ticked `_destroy` on is skipped for the same reason: the
1439 /// decoder drops destroyed rows before validation (and from `into_valid()`),
1440 /// so re-emitting client-side `required` on an empty destroyed field would
1441 /// let the browser block the next submit before the server can honor the
1442 /// removal. Required-ness is still enforced server-side for any row the user
1443 /// actually engages: a partially-filled, non-destroyed row is retained as a
1444 /// `Some` row on re-render and re-acquires `required`, and the decoder's
1445 /// all-blank rejection drops the untouched template row. This composes with
1446 /// that all-blank rejection so
1447 /// neither the client nor the server treats the template row as a real child.
1448 #[must_use]
1449 pub fn required_text_input(&self, sub: &str, label: &str) -> maud::Markup {
1450 self.text_input_with(sub, label, &FieldOptions::new().required())
1451 }
1452
1453 /// [`RowScope::text_input`] with a hint and typed attributes. See
1454 /// [`FieldOptions`]. A required option follows the template-row rule of
1455 /// [`RowScope::required_text_input`].
1456 #[must_use]
1457 pub fn text_input_with(
1458 &self,
1459 sub: &str,
1460 label: &str,
1461 options: &FieldOptions<'_>,
1462 ) -> maud::Markup {
1463 self.row_field(FieldKind::Text, sub, label, options)
1464 }
1465
1466 /// Render a labeled row-scoped `<input type="number">` for subfield `sub`.
1467 ///
1468 /// Mirrors [`crate::form::number_input`] (leaving the browser-default
1469 /// `step`), with the nested `name` and a per-row-unique `id`.
1470 #[must_use]
1471 pub fn number_input(&self, sub: &str, label: &str) -> maud::Markup {
1472 self.number_input_with(sub, label, &FieldOptions::new())
1473 }
1474
1475 /// [`RowScope::number_input`] for a required subfield. See
1476 /// [`RowScope::required_text_input`] for the template-row rule.
1477 #[must_use]
1478 pub fn required_number_input(&self, sub: &str, label: &str) -> maud::Markup {
1479 self.number_input_with(sub, label, &FieldOptions::new().required())
1480 }
1481
1482 /// [`RowScope::number_input`] with a hint and typed attributes. See
1483 /// [`FieldOptions`].
1484 #[must_use]
1485 pub fn number_input_with(
1486 &self,
1487 sub: &str,
1488 label: &str,
1489 options: &FieldOptions<'_>,
1490 ) -> maud::Markup {
1491 self.row_field(FieldKind::Number, sub, label, options)
1492 }
1493
1494 /// Render a labeled row-scoped `<textarea>` for subfield `sub`.
1495 ///
1496 /// Mirrors [`crate::form::textarea_input`]: the value is emitted as the
1497 /// element body, with the nested `name` and a per-row-unique `id`.
1498 #[must_use]
1499 pub fn textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
1500 self.textarea_input_with(sub, label, &FieldOptions::new())
1501 }
1502
1503 /// [`RowScope::textarea_input`] for a required subfield. See
1504 /// [`RowScope::required_text_input`] for the template-row rule.
1505 #[must_use]
1506 pub fn required_textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
1507 self.textarea_input_with(sub, label, &FieldOptions::new().required())
1508 }
1509
1510 /// [`RowScope::textarea_input`] with a hint and typed attributes. See
1511 /// [`FieldOptions`].
1512 #[must_use]
1513 pub fn textarea_input_with(
1514 &self,
1515 sub: &str,
1516 label: &str,
1517 options: &FieldOptions<'_>,
1518 ) -> maud::Markup {
1519 self.row_field(FieldKind::Textarea, sub, label, options)
1520 }
1521
1522 /// Shared body of the row-scoped field helpers.
1523 fn row_field(
1524 &self,
1525 kind: FieldKind,
1526 sub: &str,
1527 label: &str,
1528 options: &FieldOptions<'_>,
1529 ) -> maud::Markup {
1530 // Emit the client-side `required`/`aria-required` constraint only for a
1531 // real/submitted row that is *not* marked for destruction. A blank
1532 // template row (`row: None`) must stay leaveable-empty so the browser
1533 // does not block submit on the trailing auto-appended blank row before
1534 // the server's all-blank rejection runs; server-side validation still
1535 // enforces required-ness for any row the user actually fills (it is
1536 // retained as a `Some` row on re-render). A submitted row the user
1537 // ticked `_destroy` on is likewise skipped: the decoder drops destroyed
1538 // rows before validation, so re-emitting client-side `required` on an
1539 // empty destroyed field would let the browser's constraint validation
1540 // block the next submit before the server can honor `_destroy`.
1541 let unmasked = self.row.is_some() && !self.is_destroyed();
1542 let masked;
1543 let options = if options.is_required() && !unmasked {
1544 masked = options.clone().optional();
1545 &masked
1546 } else {
1547 options
1548 };
1549 let name = self.field_name(sub);
1550 let id = self.element_id(sub);
1551 let spec = FieldSpec {
1552 name: Some(&name),
1553 value: Some(self.value(sub).unwrap_or_default()),
1554 ..FieldSpec::new(kind, &id, label)
1555 };
1556 render_field(&spec, self.errors_for(sub), options)
1557 }
1558
1559 /// Render a row-scoped `<input type="hidden">` for subfield `sub`.
1560 ///
1561 /// Use this to carry an existing child's primary key (e.g. `id`) on an edit
1562 /// form so the decoder can match the submitted row back to a persisted record.
1563 #[must_use]
1564 pub fn hidden_input(&self, sub: &str, value: &str) -> maud::Markup {
1565 let name = self.field_name(sub);
1566 maud::html! {
1567 input type="hidden" name=(name) value=(value);
1568 }
1569 }
1570
1571 /// Render the durable no-JS removal control: a `_destroy` checkbox whose
1572 /// checked state is preserved on re-render (`checked` iff
1573 /// [`RowScope::is_destroyed`]).
1574 ///
1575 /// The decoder honours a truthy `_destroy` marker
1576 /// ([`decode_nested_urlencoded`]), so ticking this box and submitting the
1577 /// surrounding form removes the row with no JavaScript. htmx/JS row removal
1578 /// (swapping the `.nested-fields__row` node's `outerHTML`) is an optional
1579 /// progressive enhancement layered on top; this checkbox is the required
1580 /// mechanism.
1581 #[must_use]
1582 pub fn destroy_checkbox(&self, label: &str) -> maud::Markup {
1583 let checked = self.is_destroyed();
1584 let name = self.field_name("_destroy");
1585 let id = self.element_id("_destroy");
1586 maud::html! {
1587 div class="autumn-field autumn-field--destroy" {
1588 input
1589 type="checkbox"
1590 id=(id)
1591 name=(name)
1592 value="1"
1593 checked[checked]
1594 class="autumn-field__checkbox";
1595 label for=(id) class="autumn-field__label" { (label) }
1596 }
1597 }
1598 }
1599}
1600
1601/// Options for [`inputs_for`].
1602#[cfg(feature = "maud")]
1603pub struct InputsForOptions {
1604 /// Number of extra blank rows to pre-render after the existing rows. The
1605 /// no-JS fallback lets users fill and submit these without any JavaScript.
1606 /// Defaults to `1`; [`inputs_for`] always emits **at least one** blank
1607 /// template row even when this is `0`.
1608 pub blank_rows: usize,
1609 /// Optional htmx URL for the server "Add row" fragment endpoint (see
1610 /// [`nested_row_fragment`]). When `None`, no Add button is rendered and the
1611 /// no-JS path still works via the pre-rendered blank rows.
1612 pub add_row_url: Option<String>,
1613 /// Container element id. Defaults to `"{collection}-rows"`.
1614 ///
1615 /// This id is load-bearing when [`add_row_url`](Self::add_row_url) is set:
1616 /// the Add-row button's `hx-target` and its `hx-vals` index scan both select
1617 /// `#{container_id}`. HTML ids must be unique in a document, so **when two or
1618 /// more nested forms for the same child collection appear on one page, each
1619 /// MUST pass a distinct `container_id`.** The `{collection}-rows` default is
1620 /// correct for a single such form per page; leaving two same-collection forms
1621 /// on the default id produces duplicate ids (invalid HTML) and makes each
1622 /// Add-row button resolve against — and append rows / compute indices from —
1623 /// whichever container the shared id happens to match first, i.e. the wrong
1624 /// form. Distinct ids are the only requirement; any stable, unique string
1625 /// (e.g. `"shipping-items-rows"` vs `"billing-items-rows"`) works.
1626 pub container_id: Option<String>,
1627}
1628
1629#[cfg(feature = "maud")]
1630impl Default for InputsForOptions {
1631 fn default() -> Self {
1632 Self {
1633 blank_rows: 1,
1634 add_row_url: None,
1635 container_id: None,
1636 }
1637 }
1638}
1639
1640/// Render the repeating child field-group block for a nested (`has_many`) form.
1641///
1642/// Wraps the rows in `<div id="{container_id}" class="nested-fields">`. For each
1643/// existing/submitted row (from [`NestedChangeset::rows`], in order — **including**
1644/// rows re-submitted after a validation failure) it invokes `render_row` with a
1645/// [`RowScope`] carrying that row, so values and per-field errors pre-fill on
1646/// re-render. It then appends [`InputsForOptions::blank_rows`] blank rows (always
1647/// at least one) whose scopes carry `row: None`. Every row is wrapped in
1648/// `<div class="nested-fields__row" data-index="{i}">` so htmx/JS removal can
1649/// target the node's `outerHTML`.
1650///
1651/// The appended blank template row makes adding a child work with **no
1652/// JavaScript**, and it is safe even though the browser submits its empty
1653/// inputs: [`decode_nested_urlencoded`] applies Rails-style
1654/// `reject_if: :all_blank`, so a child row whose every non-`_destroy` subfield
1655/// is blank is ignored rather than decoded as a phantom child. An untouched
1656/// blank row therefore never blocks submission or persists an empty child.
1657///
1658/// So the no-JS path stays submittable, the appended blank template row's inputs
1659/// omit the client-side `required`/`aria-required` constraint by design (see
1660/// [`RowScope::required_text_input`]): a required-variant input renders empty and
1661/// *leaveable* on a `row: None` template row, so the browser's native validation
1662/// does not block submitting a form that still carries a trailing blank row.
1663/// This composes with the decoder's all-blank rejection — the untouched template
1664/// row is dropped server-side — while server-side validation still enforces
1665/// required-ness for any row the user actually fills.
1666///
1667/// When [`InputsForOptions::add_row_url`] is `Some`, an "Add row"
1668/// `<button type="button">` is emitted with `hx-get`, `hx-target="#{container_id}"`,
1669/// `hx-swap="beforeend"`, and — critically — `hx-params="index"` so **only**
1670/// the `index` param (supplied by the `hx-vals` below) is serialized into the
1671/// Add-row GET; every parent + line-item field, and `_csrf`/submit token, is
1672/// dropped, avoiding leaks into the request query string / proxy logs. `"none"`
1673/// can NOT be used: the vendored htmx 2.0.4 merges `hx-vals` into the request
1674/// `FormData` *before* the `hx-params` filter runs, so `"none"` (an empty
1675/// `FormData`) would strip the computed `index` too — naming `index` keeps it.
1676///
1677/// The button also carries an `hx-vals` (`js:` form) that computes a fresh
1678/// `index` for each request — `max(existing data-index) + 1` scanning only
1679/// `#{container_id} .nested-fields__row[data-index]` (scoped by the container
1680/// id so a form reads its own rows). The endpoint should read that `index`
1681/// param and pass it to [`nested_row_fragment`] so every appended row gets a
1682/// name unique within the form; a static request would reuse the same index and
1683/// emit duplicate control names. This is the JS-present enhancement path only —
1684/// the no-JS fallback (pre-rendered blank rows) needs no such counter and is
1685/// unaffected.
1686///
1687/// # Multiple same-collection forms on one page
1688///
1689/// Both the Add-row `hx-target` and its `hx-vals` index scan address the
1690/// container by its `#{container_id}`. That scoping is only distinct if the ids
1691/// are distinct, so **every same-collection nested form that also sets
1692/// `add_row_url` must be given its own [`InputsForOptions::container_id`]** when
1693/// more than one appears on a page. The default `{collection}-rows` id is right
1694/// for a single such form; two forms sharing it emit duplicate HTML ids and each
1695/// Add-row button then targets / scans whichever container the shared id resolves
1696/// to first (the wrong form). A relative, id-free `hx-vals` scan was evaluated
1697/// and rejected: the vendored htmx 2.0.4 evaluates a `js:` `hx-vals` expression
1698/// with `Function("return (" + expr + ")")()` — no receiver — so `this` is the
1699/// global object, not the triggering button, leaving no reliable handle to walk
1700/// to the button's own container. The unique-id contract above is therefore the
1701/// supported way to keep several same-collection forms independent.
1702///
1703/// # CSRF
1704///
1705/// This renders only the child block. The surrounding `<form>` — via
1706/// [`crate::form::form_tag`] / `ChangesetForm` — carries the CSRF and submit-token
1707/// fields exactly as today; do **not** duplicate them here.
1708///
1709/// # Example
1710///
1711/// ```rust,ignore
1712/// use autumn_web::form::{required_text_input, submit_button};
1713/// use autumn_web::nested_form::{inputs_for, InputsForOptions};
1714///
1715/// // `form` is a `NestedChangesetForm<NewOrder, NewLineItem>` re-rendered after
1716/// // a failed submit; it derefs to its inner `NestedChangeset`.
1717/// let opts = InputsForOptions {
1718/// add_row_url: Some("/orders/line-item-row".into()),
1719/// ..InputsForOptions::default()
1720/// };
1721/// // Prefer `form.form_tag` over the standalone `form_tag`: it emits the CSRF
1722/// // hidden field under the app-configured field name (and the submit token),
1723/// // so a customized `security.csrf.form_field` survives the re-render.
1724/// form.form_tag("/orders", "POST", maud::html! {
1725/// // Parent fields (CSRF + submit token are emitted by `form.form_tag`).
1726/// (required_text_input(&form.parent, "name", "Order name"))
1727/// // Repeating child rows.
1728/// (inputs_for(&form, &opts, |row| maud::html! {
1729/// (row.required_text_input("sku", "SKU"))
1730/// (row.number_input("quantity", "Quantity"))
1731/// (row.destroy_checkbox("Remove"))
1732/// }))
1733/// (submit_button("Create order"))
1734/// });
1735/// ```
1736#[cfg(feature = "maud")]
1737#[must_use]
1738pub fn inputs_for<P, C: NestedChild>(
1739 nested: &NestedChangeset<P, C>,
1740 opts: &InputsForOptions,
1741 render_row: impl Fn(&RowScope) -> maud::Markup,
1742) -> maud::Markup {
1743 let collection = C::COLLECTION;
1744 let container_id = opts
1745 .container_id
1746 .clone()
1747 .unwrap_or_else(|| format!("{collection}-rows"));
1748 let rows = nested.rows();
1749 // Always emit at least one blank template row so the no-JS path can add a
1750 // child even when the caller asked for zero.
1751 let blank_count = opts.blank_rows.max(1);
1752
1753 maud::html! {
1754 div id=(container_id) class="nested-fields" {
1755 @for (i, row) in rows.iter().enumerate() {
1756 @let scope = RowScope { collection, index: i, row: Some(row) };
1757 div class="nested-fields__row" data-index=(i) {
1758 (render_row(&scope))
1759 }
1760 }
1761 @for k in 0..blank_count {
1762 @let index = rows.len().saturating_add(k);
1763 @let scope = RowScope { collection, index, row: None };
1764 div class="nested-fields__row" data-index=(index) {
1765 (render_row(&scope))
1766 }
1767 }
1768 @if let Some(url) = &opts.add_row_url {
1769 // Send a *fresh* unique row index with every Add-row request.
1770 // `nested_row_fragment` requires the returned row's index to be
1771 // unique within this form; a static request would keep reusing
1772 // the same index and produce duplicate control names. Compute
1773 // `max(existing data-index) + 1` from this container's rows,
1774 // scoped by `#{container_id}`. That scoping is only distinct when
1775 // the id is distinct: callers rendering more than one
1776 // same-collection nested form on a page MUST give each a unique
1777 // `container_id` (see `InputsForOptions::container_id`), because
1778 // the vendored htmx 2.0.4 evaluates this `js:` `hx-vals` with no
1779 // `this`/`event` bound to the button, so an id-free relative walk
1780 // to the button's own container is not available. The decoder
1781 // tolerates gaps, so this stays collision-free even after
1782 // client-side row removals.
1783 //
1784 // `hx-params="index"` serializes ONLY the `index` param with
1785 // the Add-row GET: per htmx semantics a bare comma-list keeps
1786 // just the named params, so every parent + line-item field
1787 // (and `_csrf`/submit token) is dropped from the request query
1788 // string / proxy logs, avoiding leaks and URL-length limits.
1789 // We can NOT use `"none"`: in the vendored htmx 2.0.4 the
1790 // `hx-vals` values are merged into the request FormData BEFORE
1791 // the `hx-params` filter runs (see `htmx.min.js`: the request
1792 // builder does `const v=ln(j,V);let w=hn(v,r)` where `V` is the
1793 // resolved `hx-vals` and `hn` is `filterValues`), so `"none"`
1794 // (which returns an empty FormData) would strip the `index` we
1795 // compute here too. Naming `index` keeps exactly that one value.
1796 @let hx_vals = format!(
1797 "js:{{\"index\": Math.max(-1, ...Array.from(document.querySelectorAll(\"#{container_id} .nested-fields__row[data-index]\")).map(function(e){{return parseInt(e.dataset.index,10);}})) + 1}}"
1798 );
1799 button
1800 type="button"
1801 class="nested-fields__add"
1802 hx-get=(url)
1803 hx-target=(format!("#{container_id}"))
1804 hx-swap="beforeend"
1805 hx-params="index"
1806 hx-vals=(hx_vals)
1807 { "Add row" }
1808 }
1809 }
1810 }
1811}
1812
1813/// Render a single blank child row for the htmx "Add row" fragment endpoint.
1814///
1815/// Returns one `<div class="nested-fields__row" data-index="{index}">` produced
1816/// by `render_row` with a blank [`RowScope`]. Because the decoder tolerates
1817/// non-contiguous indices, `index` only needs to be **unique** within the form
1818/// (e.g. a monotonically increasing counter the client tracks), not contiguous.
1819///
1820/// The [`inputs_for`] Add-row button sends this unique value as an `index`
1821/// param (via `hx-vals`, computed as `max(existing data-index) + 1`). Because
1822/// the button sets `hx-params="index"`, the Add-row request carries ONLY the
1823/// `hx-vals` `index` — no parent/line-item form fields and no CSRF/submit
1824/// token are serialized — and `index` is the only value this endpoint needs.
1825/// The endpoint should read that param from the query/form and pass it straight
1826/// to `nested_row_fragment(index, ..)`. Uniqueness — not contiguity — is the
1827/// contract: the decoder tolerates gaps, so indices left behind by client-side
1828/// row removals never cause collisions.
1829#[cfg(feature = "maud")]
1830#[must_use]
1831pub fn nested_row_fragment<C: NestedChild>(
1832 index: usize,
1833 render_row: impl Fn(&RowScope) -> maud::Markup,
1834) -> maud::Markup {
1835 let scope = RowScope {
1836 collection: C::COLLECTION,
1837 index,
1838 row: None,
1839 };
1840 maud::html! {
1841 div class="nested-fields__row" data-index=(index) {
1842 (render_row(&scope))
1843 }
1844 }
1845}
1846
1847// ── Tests ──────────────────────────────────────────────────────────
1848
1849#[cfg(test)]
1850mod tests {
1851 use super::*;
1852 use crate::form::NUL_CHARACTER_FIELD_ERROR;
1853
1854 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1855 struct Order {
1856 #[validate(length(min = 1, message = "name required"))]
1857 name: String,
1858 }
1859
1860 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1861 struct LineItem {
1862 #[validate(length(min = 1, message = "sku required"))]
1863 sku: String,
1864 #[validate(range(min = 1, message = "quantity must be >= 1"))]
1865 quantity: i32,
1866 }
1867
1868 impl NestedChild for LineItem {
1869 const COLLECTION: &'static str = "items";
1870 }
1871
1872 /// A child with one **required** field (`name`) and one **optional** field
1873 /// (`nickname`), used to exercise the reject-all-blank rules.
1874 #[derive(serde::Deserialize, validator::Validate)]
1875 struct Contact {
1876 #[validate(length(min = 1, message = "name required"))]
1877 name: String,
1878 #[validate(length(min = 1, message = "nickname too short"))]
1879 nickname: Option<String>,
1880 }
1881
1882 impl NestedChild for Contact {
1883 const COLLECTION: &'static str = "contacts";
1884 }
1885
1886 async fn nested_ok(_f: NestedChangesetForm<Order, LineItem>) -> &'static str {
1887 "ok"
1888 }
1889
1890 #[tokio::test]
1891 async fn mixed_case_urlencoded_content_type_is_accepted() {
1892 use tower::ServiceExt;
1893 let req = Request::builder()
1894 .method("POST")
1895 .uri("/t")
1896 .header("Content-Type", "Application/X-WWW-Form-Urlencoded")
1897 .body(axum::body::Body::from("name=Order"))
1898 .unwrap();
1899 let resp = axum::Router::new()
1900 .route("/t", axum::routing::post(nested_ok))
1901 .oneshot(req)
1902 .await
1903 .unwrap();
1904 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1905 }
1906
1907 #[tokio::test]
1908 async fn wrong_content_type_is_rejected_with_415() {
1909 use tower::ServiceExt;
1910 let req = Request::builder()
1911 .method("POST")
1912 .uri("/t")
1913 .header("Content-Type", "text/plain")
1914 .body(axum::body::Body::from("name=Order"))
1915 .unwrap();
1916 let resp = axum::Router::new()
1917 .route("/t", axum::routing::post(nested_ok))
1918 .oneshot(req)
1919 .await
1920 .unwrap();
1921 assert_eq!(
1922 resp.status(),
1923 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE
1924 );
1925 }
1926
1927 fn p(k: &str, v: &str) -> (String, String) {
1928 (k.to_owned(), v.to_owned())
1929 }
1930
1931 // ── #3394: repeated keys on the nested path ───────────────────────
1932
1933 #[derive(serde::Deserialize, validator::Validate)]
1934 struct TaggedOrder {
1935 #[serde(default)]
1936 tags: Vec<String>,
1937 }
1938
1939 #[test]
1940 fn repeated_parent_key_decodes_into_a_vec_field() {
1941 let pairs = vec![
1942 p("tags", "a"),
1943 p("tags", "b"),
1944 p("items[0][sku]", "A-1"),
1945 p("items[0][quantity]", "2"),
1946 ];
1947 let cs = decode_nested_urlencoded::<TaggedOrder, LineItem>(&pairs).expect("parent decodes");
1948 assert_eq!(cs.parent.data().tags, ["a", "b"]);
1949 assert!(cs.is_valid());
1950 }
1951
1952 #[test]
1953 fn repeated_child_scalar_is_a_row_error_on_that_subfield() {
1954 let pairs = vec![
1955 p("name", "Order 1"),
1956 p("items[0][sku]", "A-1"),
1957 p("items[0][sku]", "B-2"),
1958 p("items[0][quantity]", "2"),
1959 ];
1960 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1961 assert!(!cs.is_valid());
1962 let errors = cs.rows()[0].errors_for("sku");
1963 assert_eq!(errors.len(), 1, "{errors:?}");
1964 assert!(errors[0].contains("`sku`"), "{errors:?}");
1965 }
1966
1967 #[tokio::test]
1968 async fn repeated_parent_scalar_is_a_422_through_the_extractor() {
1969 use tower::ServiceExt;
1970 let req = Request::builder()
1971 .method("POST")
1972 .uri("/t")
1973 .header("Content-Type", "application/x-www-form-urlencoded")
1974 .body(axum::body::Body::from("name=a&name=b"))
1975 .unwrap();
1976 let resp = axum::Router::new()
1977 .route("/t", axum::routing::post(nested_ok))
1978 .oneshot(req)
1979 .await
1980 .unwrap();
1981 assert_eq!(resp.status(), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
1982 }
1983
1984 #[test]
1985 fn repeated_parent_scalar_fails_the_decode() {
1986 let pairs = vec![p("name", "a"), p("name", "b")];
1987 let err = decode_nested_urlencoded::<Order, LineItem>(&pairs)
1988 .err()
1989 .expect("a repeated parent scalar must not decode");
1990 assert!(err.contains("`name`"), "{err}");
1991 }
1992
1993 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1994 struct TaggedItem {
1995 #[validate(length(min = 1, message = "sku required"))]
1996 sku: String,
1997 #[serde(default)]
1998 tags: Vec<String>,
1999 }
2000
2001 impl NestedChild for TaggedItem {
2002 const COLLECTION: &'static str = "items";
2003 }
2004
2005 #[test]
2006 fn repeated_child_values_survive_a_validation_error_re_render() {
2007 // `sku` fails validation; the re-render must keep both tags, not only
2008 // the last one.
2009 let pairs = vec![
2010 p("name", "Order 1"),
2011 p("items[0][sku]", ""),
2012 p("items[0][tags]", "a"),
2013 p("items[0][tags]", "b"),
2014 ];
2015 let cs = decode_nested_urlencoded::<Order, TaggedItem>(&pairs).expect("parent decodes");
2016 assert!(!cs.is_valid());
2017 let row = &cs.rows()[0];
2018 assert!(!row.errors_for("sku").is_empty());
2019 assert_eq!(row.values("tags"), ["a", "b"]);
2020 assert_eq!(row.value("tags"), Some("b"));
2021 assert_eq!(row.values("sku"), [""]);
2022 assert!(row.values("missing").is_empty());
2023 }
2024
2025 #[test]
2026 fn seeded_child_vec_field_pre_fills_every_element() {
2027 let cs = NestedChangeset::seeded(
2028 Order {
2029 name: "Existing order".to_owned(),
2030 },
2031 vec![TaggedItem {
2032 sku: "A-1".to_owned(),
2033 tags: vec!["a".to_owned(), "b".to_owned()],
2034 }],
2035 );
2036 let row = &cs.rows()[0];
2037 assert_eq!(row.value("sku"), Some("A-1"));
2038 assert_eq!(row.values("tags"), ["a", "b"]);
2039 }
2040
2041 // ── #2423: NUL bytes on the nested path ───────────────────────────
2042
2043 /// The parent behaves exactly like a flat [`ChangesetForm`] field: the
2044 /// byte is stripped from the retained value and recorded as one error on
2045 /// the field that carried it.
2046 #[test]
2047 fn nul_byte_in_a_parent_field_is_a_parent_error() {
2048 let pairs = vec![
2049 p("name", "Order\u{0}1"),
2050 p("items[0][sku]", "A-1"),
2051 p("items[0][quantity]", "2"),
2052 ];
2053 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2054 assert!(!cs.is_valid());
2055 assert_eq!(cs.errors_for("name"), [NUL_CHARACTER_FIELD_ERROR]);
2056 assert_eq!(cs.parent.data().name, "Order1");
2057 }
2058
2059 /// A child subfield is keyed under that subfield on that row, so it
2060 /// renders next to the offending input rather than at the row root.
2061 #[test]
2062 fn nul_byte_in_a_child_subfield_is_a_row_error() {
2063 let pairs = vec![
2064 p("name", "Order 1"),
2065 p("items[0][sku]", "A\u{0}-1"),
2066 p("items[0][quantity]", "2"),
2067 ];
2068 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2069 assert!(!cs.is_valid());
2070 assert_eq!(cs.rows().len(), 1);
2071 assert_eq!(cs.rows()[0].errors_for("sku"), [NUL_CHARACTER_FIELD_ERROR]);
2072 // The retained value the row re-renders is the cleaned text.
2073 assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2074 assert!(
2075 cs.rows()[0].errors_for("quantity").is_empty(),
2076 "only the offending subfield is flagged"
2077 );
2078 }
2079
2080 /// A NUL anywhere in the submission blocks the whole write, so
2081 /// `into_valid` cannot hand back children built from unstorable text.
2082 #[test]
2083 fn nul_byte_in_a_child_subfield_blocks_into_valid() {
2084 let pairs = vec![
2085 p("name", "Order 1"),
2086 p("items[0][sku]", "A\u{0}-1"),
2087 p("items[0][quantity]", "2"),
2088 ];
2089 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2090 assert!(cs.into_valid().is_err());
2091 }
2092
2093 /// `_destroy` is a marker read only for truthiness, never a stored column,
2094 /// and `is_truthy` trims whitespace but not NUL. Cleaning it would rewrite
2095 /// the marker: `"1\u{0}"` reads as *false* today and would read as true
2096 /// after stripping, silently destroying a row the author submitted to keep.
2097 /// So the sweep leaves it alone entirely.
2098 #[test]
2099 fn a_nul_in_the_destroy_marker_does_not_flip_it_truthy() {
2100 let pairs = vec![
2101 p("name", "Order 1"),
2102 p("items[0][sku]", "A-1"),
2103 p("items[0][quantity]", "2"),
2104 p("items[0][_destroy]", "1\u{0}"),
2105 ];
2106 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2107 assert_eq!(cs.rows().len(), 1);
2108 assert!(
2109 !cs.rows()[0].is_destroyed(),
2110 "a NUL-mangled marker must stay falsy, not become a destroy"
2111 );
2112 assert!(cs.is_valid(), "and it is not an error either");
2113 }
2114
2115 /// A row whose every legible subfield was nothing but NUL bytes must not
2116 /// silently vanish the way a genuinely blank template row does — the author
2117 /// submitted something, it just could not be stored.
2118 #[test]
2119 fn a_row_of_only_nul_bytes_is_reported_rather_than_dropped() {
2120 let pairs = vec![
2121 p("name", "Order 1"),
2122 p("items[0][sku]", "\u{0}"),
2123 p("items[0][quantity]", ""),
2124 ];
2125 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2126 assert_eq!(cs.rows().len(), 1, "the row must survive for re-render");
2127 assert_eq!(cs.rows()[0].errors_for("sku"), [NUL_CHARACTER_FIELD_ERROR]);
2128 assert!(!cs.is_valid());
2129 }
2130
2131 /// A genuinely blank template row still drops, unchanged.
2132 #[test]
2133 fn a_blank_template_row_still_drops() {
2134 let pairs = vec![
2135 p("name", "Order 1"),
2136 p("items[0][sku]", ""),
2137 p("items[0][quantity]", ""),
2138 ];
2139 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2140 assert!(cs.rows().is_empty());
2141 assert!(cs.is_valid());
2142 }
2143
2144 /// `decode_nested_urlencoded` is public and takes the raw pairs — tokens
2145 /// and all — so it applies the default plumbing exemption itself. A direct
2146 /// caller must not get a parent error keyed `_csrf`, which no template
2147 /// renders.
2148 #[test]
2149 fn default_plumbing_fields_are_exempt_for_direct_callers() {
2150 for field in ["_csrf", "_submit_token", "_method"] {
2151 let pairs = vec![
2152 p("name", "Order 1"),
2153 p(field, "to%00ken".replace("%00", "\u{0}").as_str()),
2154 p("items[0][sku]", "A-1"),
2155 p("items[0][quantity]", "2"),
2156 ];
2157 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2158 assert!(
2159 cs.is_valid(),
2160 "`{field}` must not produce an unrenderable error: {:?}",
2161 cs.errors_for(field)
2162 );
2163 }
2164 }
2165
2166 /// The extractor is where the *configured* token field names are known, so
2167 /// that is where they have to be exempted. `decode_nested_urlencoded`
2168 /// covers only the defaults.
2169 #[tokio::test]
2170 async fn nested_extractor_exempts_the_configured_token_field_names() {
2171 use axum::body::Body;
2172 use axum::extract::FromRequest as _;
2173
2174 let mut req = axum::http::Request::builder()
2175 .method("POST")
2176 .uri("/orders")
2177 .header("Content-Type", "application/x-www-form-urlencoded")
2178 .body(Body::from(
2179 "authenticity=to%00ken&stamp=st%00amp&name=Order+1\
2180 &items%5B0%5D%5Bsku%5D=A-1&items%5B0%5D%5Bquantity%5D=2",
2181 ))
2182 .expect("build request");
2183 req.extensions_mut()
2184 .insert(crate::security::csrf::CsrfFormField(
2185 "authenticity".to_owned(),
2186 ));
2187 req.extensions_mut()
2188 .insert(crate::security::SubmitFormField("stamp".to_owned()));
2189
2190 let form = NestedChangesetForm::<Order, LineItem>::from_request(req, &())
2191 .await
2192 .expect("body decodes");
2193
2194 assert!(
2195 form.changeset.is_valid(),
2196 "renamed token fields must not produce unrenderable errors: {:?}",
2197 form.changeset.parent.errors()
2198 );
2199 }
2200
2201 /// A destroyed row is never written, so it must never be *rejected* —
2202 /// otherwise the author cannot delete a row whose only remaining content
2203 /// was an unstorable byte. The blank-and-NUL rejection must not run ahead
2204 /// of the destroy check.
2205 #[test]
2206 fn a_destroyed_row_of_only_nul_bytes_is_not_rejected() {
2207 let pairs = vec![
2208 p("name", "Order 1"),
2209 p("items[0][sku]", "\u{0}"),
2210 p("items[0][quantity]", ""),
2211 p("items[0][_destroy]", "1"),
2212 ];
2213 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2214 assert!(
2215 cs.is_valid(),
2216 "a row the author asked to destroy must not block the submission"
2217 );
2218 assert!(
2219 cs.into_valid().is_ok(),
2220 "the parent must still be bindable so the destroy can be applied"
2221 );
2222 }
2223
2224 /// A repeated subfield carrying NULs contributes one message, not one per
2225 /// occurrence — the same guarantee the flat sweep already makes. Without
2226 /// it a crafted body inflates a single row's error list without bound.
2227 #[test]
2228 fn a_repeated_nul_subfield_in_a_row_reports_once() {
2229 let pairs = vec![
2230 p("name", "Order 1"),
2231 p("items[0][sku]", "A-1"),
2232 p("items[0][quantity]", "2"),
2233 p("items[0][unknown]", "a\u{0}a"),
2234 p("items[0][unknown]", "b\u{0}b"),
2235 p("items[0][unknown]", "c\u{0}c"),
2236 ];
2237 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2238 assert_eq!(cs.rows().len(), 1);
2239 assert_eq!(
2240 cs.rows()[0].errors_for("unknown").len(),
2241 1,
2242 "one message per offending subfield, not one per submitted pair"
2243 );
2244 }
2245
2246 /// No false positives on the nested path either.
2247 #[test]
2248 fn clean_nested_submission_is_unaffected() {
2249 let pairs = vec![
2250 p("name", "Order 1"),
2251 p("items[0][sku]", "A-1"),
2252 p("items[0][quantity]", "2"),
2253 ];
2254 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2255 assert!(cs.is_valid());
2256 }
2257
2258 #[test]
2259 fn binds_parent_and_two_children_in_order() {
2260 let pairs = vec![
2261 p("name", "Order 1"),
2262 p("items[0][sku]", "A-1"),
2263 p("items[0][quantity]", "2"),
2264 p("items[1][sku]", "B-2"),
2265 p("items[1][quantity]", "3"),
2266 ];
2267 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2268 assert!(cs.is_valid());
2269 assert_eq!(cs.rows().len(), 2);
2270 assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2271 assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
2272 assert_eq!(cs.collection_name(), "items");
2273
2274 let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2275 assert_eq!(order.name, "Order 1");
2276 assert_eq!(items.len(), 2);
2277 assert_eq!(items[0].sku, "A-1");
2278 assert_eq!(items[0].quantity, 2);
2279 assert_eq!(items[1].quantity, 3);
2280 }
2281
2282 #[test]
2283 fn non_contiguous_indices_compact_preserving_order() {
2284 let pairs = vec![
2285 p("name", "Order"),
2286 p("items[0][sku]", "A"),
2287 p("items[0][quantity]", "1"),
2288 p("items[2][sku]", "C"),
2289 p("items[2][quantity]", "5"),
2290 ];
2291 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2292 // Gap at index 1 compacts: two rows in ascending order.
2293 assert_eq!(cs.rows().len(), 2);
2294 assert_eq!(cs.rows()[0].value("sku"), Some("A"));
2295 assert_eq!(cs.rows()[1].value("sku"), Some("C"));
2296 assert!(cs.is_valid());
2297
2298 let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2299 assert_eq!(items.len(), 2);
2300 assert_eq!(items[0].sku, "A");
2301 assert_eq!(items[1].sku, "C");
2302 }
2303
2304 #[test]
2305 fn destroy_marker_drops_row_from_children_but_retains_it() {
2306 let pairs = vec![
2307 p("name", "Order"),
2308 p("items[0][sku]", "A"),
2309 p("items[0][quantity]", "1"),
2310 p("items[1][sku]", "X"),
2311 p("items[1][quantity]", "9"),
2312 p("items[1][_destroy]", "1"),
2313 ];
2314 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2315 assert_eq!(cs.rows().len(), 2);
2316 assert!(!cs.rows()[0].is_destroyed());
2317 assert!(cs.rows()[1].is_destroyed());
2318 // Destroyed row is still retained with its raw values for re-render.
2319 assert_eq!(cs.rows()[1].value("sku"), Some("X"));
2320 assert!(cs.is_valid());
2321
2322 let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2323 // Only the non-destroyed row contributes.
2324 assert_eq!(items.len(), 1);
2325 assert_eq!(items[0].sku, "A");
2326 }
2327
2328 #[test]
2329 fn child_validation_failure_surfaces_combined_key_and_blocks_valid() {
2330 let pairs = vec![
2331 p("name", "Order"),
2332 p("items[0][sku]", "A"),
2333 p("items[0][quantity]", "2"),
2334 // quantity = 0 violates range(min = 1)
2335 p("items[1][sku]", "B"),
2336 p("items[1][quantity]", "0"),
2337 ];
2338 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2339 assert!(!cs.is_valid());
2340 assert!(!cs.errors_for("items[1].quantity").is_empty());
2341 // The valid sibling row has no error for that key.
2342 assert!(cs.errors_for("items[0].quantity").is_empty());
2343 assert!(cs.into_valid().is_err());
2344 }
2345
2346 #[test]
2347 fn all_valid_yields_ok_with_coerced_numeric_fields() {
2348 let pairs = vec![
2349 p("name", "Order"),
2350 p("items[0][sku]", "A"),
2351 p("items[0][quantity]", "5"),
2352 ];
2353 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2354 assert!(cs.is_valid());
2355 let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2356 assert_eq!(order.name, "Order");
2357 assert_eq!(items.len(), 1);
2358 // "5" coerced to i32.
2359 let q: i32 = items[0].quantity;
2360 assert_eq!(q, 5);
2361 }
2362
2363 #[test]
2364 fn parent_invalid_blocks_valid_children() {
2365 let pairs = vec![
2366 // Empty parent name violates length(min = 1).
2367 p("name", ""),
2368 p("items[0][sku]", "A"),
2369 p("items[0][quantity]", "1"),
2370 ];
2371 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2372 assert!(!cs.is_valid());
2373 assert!(!cs.errors_for("name").is_empty());
2374 assert!(cs.into_valid().is_err());
2375 }
2376
2377 #[test]
2378 fn child_parse_failure_keys_error_to_offending_field() {
2379 let pairs = vec![
2380 p("name", "Order"),
2381 // `sku` is filled, so the row is NOT all-blank (it is kept, not
2382 // dropped) — contrast `all_blank_template_row_is_ignored`, where an
2383 // empty row is dropped entirely with no error.
2384 p("items[0][sku]", "A"),
2385 // Non-numeric quantity: hard parse failure for i32, before
2386 // validation. `serde_path_to_error` attributes it to `quantity`.
2387 p("items[0][quantity]", "not-a-number"),
2388 ];
2389 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2390 assert!(!cs.is_valid());
2391 assert_eq!(cs.rows().len(), 1);
2392 // Raw values retained for re-render.
2393 assert_eq!(cs.rows()[0].value("sku"), Some("A"));
2394 assert_eq!(cs.rows()[0].value("quantity"), Some("not-a-number"));
2395 // The parse error is keyed to the offending subfield, so it renders next
2396 // to the bad input via `errors_for("quantity")` on that row.
2397 assert!(!cs.errors_for("items[0].quantity").is_empty());
2398 assert!(!cs.rows()[0].errors_for("quantity").is_empty());
2399 // It is NOT stored under the row-level "" key anymore.
2400 assert!(cs.rows()[0].errors_for("").is_empty());
2401 assert!(cs.errors_for("items[0]").is_empty());
2402 assert!(cs.into_valid().is_err());
2403 }
2404
2405 #[test]
2406 fn child_parse_failure_on_blank_typed_field_keys_error_to_field() {
2407 // The Codex case: a row with `sku` filled but the required TYPED
2408 // `quantity` present-but-blank. The blank-optional retry inside
2409 // `decode_urlencoded_dropping_blank_optional_fields` DROPS the blank
2410 // `quantity=` pair, so serde reports `missing field \`quantity\`` at the
2411 // ROW ROOT (empty path). The field-recovery re-decode over the original
2412 // (blank-retained) pairs must still key the error to `quantity` so it
2413 // renders next to the blank input rather than the row-level "" key.
2414 let pairs = vec![
2415 p("name", "Order"),
2416 p("items[0][sku]", "A"),
2417 p("items[0][quantity]", ""),
2418 ];
2419 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2420 assert!(!cs.is_valid());
2421 assert_eq!(cs.rows().len(), 1);
2422 // Keyed to the offending subfield, so it renders next to the blank input.
2423 assert!(!cs.errors_for("items[0].quantity").is_empty());
2424 assert!(!cs.rows()[0].errors_for("quantity").is_empty());
2425 // NOT stored under the row-level "" / `items[0]` key.
2426 assert!(cs.rows()[0].errors_for("").is_empty());
2427 assert!(cs.errors_for("items[0]").is_empty());
2428 assert!(cs.into_valid().is_err());
2429 }
2430
2431 #[test]
2432 fn child_row_with_blank_optional_typed_field_still_decodes_valid() {
2433 // Regression guard for the fix: a row with the required field filled and
2434 // an OPTIONAL TYPED field present-but-blank (`weight=`) must still decode
2435 // to a VALID row. The primary blank-dropping decode drops the blank
2436 // `weight=` pair so it resolves to `None` and the decode SUCCEEDS — the
2437 // field-recovery re-decode (which runs only on the failed path) is never
2438 // reached, and the row must stay valid.
2439 #[derive(serde::Deserialize, validator::Validate)]
2440 struct Widget {
2441 #[validate(length(min = 1, message = "label required"))]
2442 label: String,
2443 // Typed optional: a blank submission is dropped to `None` by the
2444 // blank-optional decode (an empty string is not a valid `i32`).
2445 weight: Option<i32>,
2446 }
2447 impl NestedChild for Widget {
2448 const COLLECTION: &'static str = "widgets";
2449 }
2450
2451 let pairs = vec![
2452 p("name", "Order"),
2453 p("widgets[0][label]", "Bolt"),
2454 p("widgets[0][weight]", ""),
2455 ];
2456 let cs = decode_nested_urlencoded::<Order, Widget>(&pairs).expect("parent decodes");
2457 assert!(cs.is_valid());
2458 assert_eq!(cs.rows().len(), 1);
2459 assert!(cs.errors_for("widgets[0].weight").is_empty());
2460 assert!(cs.rows()[0].errors_for("weight").is_empty());
2461 let (_order, widgets) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2462 assert_eq!(widgets.len(), 1);
2463 assert_eq!(widgets[0].label, "Bolt");
2464 assert_eq!(widgets[0].weight, None);
2465 }
2466
2467 #[test]
2468 fn child_optional_blank_before_required_blank_keys_error_to_required_field() {
2469 // Codex P2 follow-on: a child whose OPTIONAL typed field (`weight:
2470 // Option<i32>`) is declared BEFORE a REQUIRED typed field (`quantity:
2471 // i32`), with BOTH submitted blank. The blank-dropping decode drops both
2472 // blank pairs and ends at `missing field \`quantity\`` — the required
2473 // one. A raw non-dropping re-decode, by contrast, trips FIRST on the
2474 // harmless optional `weight=` (parsing `""` as an `i32` at `.weight`), so
2475 // it would mis-key the error to `weight`. The field-recovery therefore
2476 // prefers the PRIMARY error's missing-field message over the raw
2477 // re-decode, so the inline error lands on `quantity`, not `weight`.
2478 #[derive(serde::Deserialize, validator::Validate)]
2479 struct Part {
2480 #[validate(length(min = 1, message = "sku required"))]
2481 sku: String,
2482 // Optional, declared FIRST — a blank submission is harmless (→ None).
2483 // Never read on this invalid-row path (the row fails to fully decode),
2484 // so silence the dead-field lint.
2485 #[allow(dead_code)]
2486 weight: Option<i32>,
2487 // Required, declared AFTER — a blank submission is the real error.
2488 #[validate(range(min = 1, message = "quantity must be >= 1"))]
2489 quantity: i32,
2490 }
2491 impl NestedChild for Part {
2492 const COLLECTION: &'static str = "items";
2493 }
2494
2495 let pairs = vec![
2496 p("name", "Order"),
2497 // `sku` filled so the row is retained (not all-blank).
2498 p("items[0][sku]", "Widget"),
2499 p("items[0][weight]", ""),
2500 p("items[0][quantity]", ""),
2501 ];
2502 let cs = decode_nested_urlencoded::<Order, Part>(&pairs).expect("parent decodes");
2503 assert!(!cs.is_valid());
2504 assert_eq!(cs.rows().len(), 1);
2505 // The error is keyed to the truly-missing REQUIRED field…
2506 assert!(!cs.errors_for("items[0].quantity").is_empty());
2507 assert!(!cs.rows()[0].errors_for("quantity").is_empty());
2508 // …and NOT to the harmless OPTIONAL blank the raw re-decode trips on.
2509 assert!(cs.errors_for("items[0].weight").is_empty());
2510 assert!(cs.rows()[0].errors_for("weight").is_empty());
2511 // Not stored under the row-level "" / `items[0]` key either.
2512 assert!(cs.rows()[0].errors_for("").is_empty());
2513 assert!(cs.errors_for("items[0]").is_empty());
2514 assert!(cs.into_valid().is_err());
2515 }
2516
2517 #[test]
2518 fn parent_hard_parse_failure_is_err() {
2519 #[derive(serde::Deserialize, validator::Validate)]
2520 struct NumericParent {
2521 #[validate(range(min = 0))]
2522 count: i32,
2523 }
2524 #[derive(serde::Deserialize, validator::Validate)]
2525 struct Child {
2526 #[validate(length(min = 1))]
2527 name: String,
2528 }
2529 impl NestedChild for Child {
2530 const COLLECTION: &'static str = "kids";
2531 }
2532
2533 let pairs = vec![p("count", "not-a-number")];
2534 let result = decode_nested_urlencoded::<NumericParent, Child>(&pairs);
2535 assert!(result.is_err());
2536 }
2537
2538 // ── reject_if: :all_blank ──────────────────────────────────────
2539
2540 /// The core regression test for the phantom-blank-row P1: `inputs_for`
2541 /// always emits a trailing all-blank template row, and with no JS the
2542 /// browser submits its empty inputs. That row must be ignored, not decoded
2543 /// as a real (empty) child.
2544 #[test]
2545 fn all_blank_template_row_is_ignored() {
2546 let pairs = vec![
2547 p("name", "Order"),
2548 // One filled, valid child row.
2549 p("contacts[0][name]", "Alice"),
2550 p("contacts[0][nickname]", "Al"),
2551 // The auto-rendered blank template row: every subfield empty.
2552 p("contacts[1][name]", ""),
2553 p("contacts[1][nickname]", ""),
2554 ];
2555 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2556 assert!(cs.is_valid());
2557 // The blank template row was dropped entirely — only the real row remains.
2558 assert_eq!(cs.rows().len(), 1);
2559 assert_eq!(cs.rows()[0].value("name"), Some("Alice"));
2560
2561 let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2562 assert_eq!(contacts.len(), 1);
2563 assert_eq!(contacts[0].name, "Alice");
2564 }
2565
2566 /// Blank detection trims: a row whose fields are whitespace-only (or empty)
2567 /// is treated as blank and dropped.
2568 #[test]
2569 fn whitespace_only_row_is_ignored() {
2570 let pairs = vec![
2571 p("name", "Order"),
2572 p("contacts[0][name]", "Bob"),
2573 // Whitespace-only + empty: still all-blank after trimming.
2574 p("contacts[1][name]", " "),
2575 p("contacts[1][nickname]", ""),
2576 ];
2577 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2578 assert!(cs.is_valid());
2579 assert_eq!(cs.rows().len(), 1);
2580 assert_eq!(cs.rows()[0].value("name"), Some("Bob"));
2581
2582 let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2583 assert_eq!(contacts.len(), 1);
2584 }
2585
2586 /// A row with at least one non-blank value is a real child the user is
2587 /// adding: it is kept and validated, so a missing **required** field still
2588 /// surfaces a per-row error. We must not over-aggressively drop it.
2589 #[test]
2590 fn partially_filled_row_with_missing_required_still_errors() {
2591 let pairs = vec![
2592 p("name", "Order"),
2593 // Required `name` left blank, but the optional `nickname` is filled,
2594 // so the row is NOT all-blank: it is kept and must error.
2595 p("contacts[0][name]", ""),
2596 p("contacts[0][nickname]", "Ally"),
2597 ];
2598 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2599 assert!(!cs.is_valid());
2600 assert_eq!(cs.rows().len(), 1);
2601 // The kept row surfaces the required-field error under its combined key.
2602 assert!(!cs.errors_for("contacts[0].name").is_empty());
2603 // Raw values retained for re-render.
2604 assert_eq!(cs.rows()[0].value("nickname"), Some("Ally"));
2605 assert!(cs.into_valid().is_err());
2606 }
2607
2608 /// A fully filled row is still bound — reject-all-blank does not regress the
2609 /// happy path when every submitted row carries real values.
2610 #[test]
2611 fn fully_filled_row_is_still_bound() {
2612 let pairs = vec![
2613 p("name", "Order"),
2614 p("contacts[0][name]", "Carol"),
2615 p("contacts[0][nickname]", "Caz"),
2616 ];
2617 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2618 assert!(cs.is_valid());
2619 assert_eq!(cs.rows().len(), 1);
2620
2621 let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2622 assert_eq!(contacts.len(), 1);
2623 assert_eq!(contacts[0].name, "Carol");
2624 assert_eq!(contacts[0].nickname.as_deref(), Some("Caz"));
2625 }
2626
2627 #[test]
2628 fn parse_child_key_matches_and_rejects() {
2629 assert_eq!(parse_child_key("items[0][sku]", "items"), Some((0, "sku")));
2630 assert_eq!(
2631 parse_child_key("items[12][quantity]", "items"),
2632 Some((12, "quantity"))
2633 );
2634 // Wrong collection.
2635 assert_eq!(parse_child_key("other[0][sku]", "items"), None);
2636 // Not a child subfield (parent key).
2637 assert_eq!(parse_child_key("name", "items"), None);
2638 // Trailing junk after the closing bracket.
2639 assert_eq!(parse_child_key("items[0][sku]x", "items"), None);
2640 // Non-numeric index.
2641 assert_eq!(parse_child_key("items[a][sku]", "items"), None);
2642 }
2643
2644 // ── blank (non-validating) constructor ─────────────────────────
2645
2646 #[test]
2647 fn blank_changeset_is_valid_with_no_rows_or_errors() {
2648 // The initial `new`-page path: the required parent `name` is still
2649 // empty, but `blank` does NOT validate, so nothing errors before the
2650 // user types (unlike `decode_nested_urlencoded`).
2651 let cs = NestedChangeset::<Order, LineItem>::blank(Order {
2652 name: String::new(),
2653 });
2654 assert!(cs.is_valid());
2655 assert!(cs.errors_for("name").is_empty());
2656 assert!(cs.rows().is_empty());
2657
2658 let (order, items) = cs
2659 .into_valid()
2660 .unwrap_or_else(|_| panic!("blank changeset is valid"));
2661 assert_eq!(order.name, "");
2662 assert!(items.is_empty());
2663 }
2664
2665 #[test]
2666 fn decoded_empty_parent_errors_unlike_blank() {
2667 // Contrast the POST decode path: it DOES validate, so the same empty
2668 // required parent surfaces an error — exactly what `blank` avoids on the
2669 // initial GET render.
2670 let cs =
2671 decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
2672 assert!(!cs.is_valid());
2673 assert!(!cs.errors_for("name").is_empty());
2674 }
2675
2676 // ── seeded (edit-render) constructor ───────────────────────────
2677
2678 #[test]
2679 fn seeded_changeset_pre_populates_one_row_per_child() {
2680 // The initial `edit`-page path: seed the changeset with the two existing
2681 // children so an edit form can display/preserve them before first submit.
2682 let cs = NestedChangeset::seeded(
2683 Order {
2684 name: "Existing order".to_owned(),
2685 },
2686 vec![
2687 LineItem {
2688 sku: "A-1".to_owned(),
2689 quantity: 2,
2690 },
2691 LineItem {
2692 sku: "B-2".to_owned(),
2693 quantity: 3,
2694 },
2695 ],
2696 );
2697
2698 // One row per seeded child, each pre-filled with its serialized values —
2699 // the same string representation the decoder populates.
2700 assert_eq!(cs.rows().len(), 2);
2701 assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2702 assert_eq!(cs.rows()[0].value("quantity"), Some("2"));
2703 assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
2704 assert_eq!(cs.rows()[1].value("quantity"), Some("3"));
2705
2706 // Non-validating and clean: no errors, no destroyed rows.
2707 assert!(cs.is_valid());
2708 assert!(cs.errors_for("name").is_empty());
2709 assert!(cs.errors_for("items[0].sku").is_empty());
2710 assert!(cs.errors_for("items[1].quantity").is_empty());
2711 assert!(!cs.rows()[0].is_destroyed());
2712 assert!(!cs.rows()[1].is_destroyed());
2713
2714 // `into_valid` returns the seeded parent + children unchanged.
2715 let (order, items) = cs
2716 .into_valid()
2717 .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2718 assert_eq!(order.name, "Existing order");
2719 assert_eq!(items.len(), 2);
2720 assert_eq!(items[0].sku, "A-1");
2721 assert_eq!(items[0].quantity, 2);
2722 assert_eq!(items[1].sku, "B-2");
2723 assert_eq!(items[1].quantity, 3);
2724 }
2725
2726 #[test]
2727 fn seeded_changeset_with_no_children_is_valid_and_empty() {
2728 // Seeding zero children behaves like `blank` — a valid changeset with no
2729 // rows — so an edit form for a parent that currently has no children is
2730 // handled uniformly.
2731 let cs = NestedChangeset::<Order, LineItem>::seeded(
2732 Order {
2733 name: "Empty".to_owned(),
2734 },
2735 Vec::new(),
2736 );
2737 assert!(cs.is_valid());
2738 assert!(cs.rows().is_empty());
2739 let (_order, items) = cs
2740 .into_valid()
2741 .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2742 assert!(items.is_empty());
2743 }
2744}
2745
2746#[cfg(all(test, feature = "maud"))]
2747mod maud_tests {
2748 use super::*;
2749
2750 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2751 struct Order {
2752 #[validate(length(min = 1, message = "name required"))]
2753 name: String,
2754 }
2755
2756 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2757 struct LineItem {
2758 #[validate(length(min = 1, message = "sku required"))]
2759 sku: String,
2760 #[validate(range(min = 1, message = "quantity must be >= 1"))]
2761 quantity: i32,
2762 }
2763
2764 impl NestedChild for LineItem {
2765 const COLLECTION: &'static str = "items";
2766 }
2767
2768 fn p(k: &str, v: &str) -> (String, String) {
2769 (k.to_owned(), v.to_owned())
2770 }
2771
2772 /// A changeset with two submitted rows, the second failing child validation
2773 /// (`quantity = 0`), so both rows are retained for re-render.
2774 fn two_row_changeset() -> NestedChangeset<Order, LineItem> {
2775 let pairs = vec![
2776 p("name", "Order 1"),
2777 p("items[0][sku]", "A-1"),
2778 p("items[0][quantity]", "2"),
2779 p("items[1][sku]", "B-2"),
2780 // quantity = 0 violates range(min = 1): row 1 is retained with an error.
2781 p("items[1][quantity]", "0"),
2782 ];
2783 decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes")
2784 }
2785
2786 fn render_row(row: &RowScope) -> maud::Markup {
2787 maud::html! {
2788 (row.required_text_input("sku", "SKU"))
2789 (row.number_input("quantity", "Quantity"))
2790 (row.destroy_checkbox("Remove"))
2791 }
2792 }
2793
2794 #[test]
2795 fn existing_rows_render_indexed_names_and_prefilled_values() {
2796 let cs = two_row_changeset();
2797 let opts = InputsForOptions::default();
2798 let html = inputs_for(&cs, &opts, render_row).into_string();
2799
2800 assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
2801 assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
2802 // Pre-filled values from the re-rendered changeset.
2803 assert!(html.contains(r#"value="A-1""#), "{html}");
2804 assert!(html.contains(r#"value="B-2""#), "{html}");
2805 // Container defaults to "{collection}-rows".
2806 assert!(html.contains(r#"id="items-rows""#), "{html}");
2807 }
2808
2809 #[test]
2810 fn per_row_error_renders_scoped_alert_block() {
2811 let cs = two_row_changeset();
2812 let opts = InputsForOptions::default();
2813 let html = inputs_for(&cs, &opts, render_row).into_string();
2814
2815 // The failing row's quantity error is scoped to that row's unique id.
2816 assert!(html.contains(r#"id="items-1-quantity-error""#), "{html}");
2817 assert!(html.contains(r#"role="alert""#), "{html}");
2818 assert!(html.contains("quantity must be >= 1"), "{html}");
2819 // The valid sibling row's quantity has no error block.
2820 assert!(!html.contains(r#"id="items-0-quantity-error""#), "{html}");
2821 }
2822
2823 #[test]
2824 fn appends_blank_template_row_with_next_index() {
2825 let cs = two_row_changeset();
2826 let opts = InputsForOptions::default();
2827 let html = inputs_for(&cs, &opts, render_row).into_string();
2828
2829 // Two existing rows (indices 0,1) plus one blank row at index 2.
2830 assert!(html.contains(r#"data-index="0""#), "{html}");
2831 assert!(html.contains(r#"data-index="1""#), "{html}");
2832 assert!(html.contains(r#"data-index="2""#), "{html}");
2833 assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
2834 }
2835
2836 /// Extract the full `<...>` tag containing the given `name="…"` attribute,
2837 /// so a per-input attribute assertion isn't fooled by a sibling row's tag.
2838 fn tag_with_name(html: &str, name: &str) -> String {
2839 let needle = format!(r#"name="{name}""#);
2840 let at = html
2841 .find(&needle)
2842 .unwrap_or_else(|| panic!("missing {name} in {html}"));
2843 let open = html[..at].rfind('<').expect("tag has an opening '<'");
2844 let close = html[at..].find('>').expect("tag has a closing '>'") + at;
2845 html[open..=close].to_string()
2846 }
2847
2848 /// Fix 1: a required-variant input on a submitted row carries the client-side
2849 /// `required`/`aria-required` constraint, but the auto-appended blank template
2850 /// row (`row: None`) omits it so the form stays submittable with a trailing
2851 /// blank row present (server-side validation still enforces required-ness).
2852 #[test]
2853 fn blank_template_row_omits_client_required_but_submitted_row_keeps_it() {
2854 let cs = two_row_changeset();
2855 let opts = InputsForOptions::default();
2856 let html = inputs_for(&cs, &opts, render_row).into_string();
2857
2858 // Submitted rows (indices 0, 1) keep the client-side constraint.
2859 let submitted = tag_with_name(&html, "items[0][sku]");
2860 assert!(submitted.contains(" required"), "{submitted}");
2861 assert!(submitted.contains(r#"aria-required="true""#), "{submitted}");
2862
2863 // The appended blank template row (index 2) omits both, so it is
2864 // leaveable-empty and does not block submitting the form.
2865 let blank = tag_with_name(&html, "items[2][sku]");
2866 assert!(!blank.contains("required"), "{blank}");
2867 assert!(!blank.contains("aria-required"), "{blank}");
2868 }
2869
2870 /// Fix (Codex P2, near the `required` gate): a submitted row the user ticked
2871 /// `_destroy` on must ALSO omit the client-side `required`/`aria-required`,
2872 /// exactly like a blank template row. Otherwise, in a 422 re-render where the
2873 /// user checked Remove on a row with an empty required field, the browser's
2874 /// constraint validation would block the next submit before the server can
2875 /// honor `_destroy`. A non-destroyed submitted row in the same render keeps
2876 /// the constraint.
2877 #[test]
2878 fn destroyed_row_omits_client_required_while_submitted_row_keeps_it() {
2879 // Row 0: an ordinary submitted row. Row 1: the user ticked Remove on a
2880 // row whose required `sku` is empty (it is retained because `quantity`
2881 // is non-blank, so the all-blank rejection does not drop it).
2882 let pairs = vec![
2883 p("name", "Order 1"),
2884 p("items[0][sku]", "A-1"),
2885 p("items[0][quantity]", "2"),
2886 p("items[1][sku]", ""),
2887 p("items[1][quantity]", "9"),
2888 p("items[1][_destroy]", "1"),
2889 ];
2890 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2891 assert!(cs.rows()[1].is_destroyed());
2892 let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2893
2894 // The non-destroyed submitted row keeps the client-side constraint.
2895 let kept = tag_with_name(&html, "items[0][sku]");
2896 assert!(kept.contains(" required"), "{kept}");
2897 assert!(kept.contains(r#"aria-required="true""#), "{kept}");
2898
2899 // The destroyed row omits both, exactly like a blank template row, so a
2900 // form carrying an empty required field on a to-be-removed row still
2901 // submits and the server can honor `_destroy`.
2902 let destroyed = tag_with_name(&html, "items[1][sku]");
2903 assert!(!destroyed.contains("required"), "{destroyed}");
2904 assert!(!destroyed.contains("aria-required"), "{destroyed}");
2905 }
2906
2907 #[test]
2908 fn always_emits_a_blank_row_even_when_blank_rows_zero() {
2909 let cs = two_row_changeset();
2910 let opts = InputsForOptions {
2911 blank_rows: 0,
2912 ..InputsForOptions::default()
2913 };
2914 let html = inputs_for(&cs, &opts, render_row).into_string();
2915 // Still emits the blank template row at index 2.
2916 assert!(html.contains(r#"data-index="2""#), "{html}");
2917 }
2918
2919 #[test]
2920 fn destroy_checkbox_emits_indexed_marker() {
2921 let cs = two_row_changeset();
2922 let opts = InputsForOptions::default();
2923 let html = inputs_for(&cs, &opts, render_row).into_string();
2924
2925 assert!(html.contains(r#"name="items[0][_destroy]""#), "{html}");
2926 assert!(html.contains(r#"name="items[1][_destroy]""#), "{html}");
2927 assert!(html.contains(r#"type="checkbox""#), "{html}");
2928 }
2929
2930 #[test]
2931 fn add_button_renders_only_with_url_and_carries_htmx_attrs() {
2932 let cs = two_row_changeset();
2933
2934 // No URL: no Add button.
2935 let none = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2936 assert!(!none.contains("Add row"), "{none}");
2937
2938 // With URL: button serializes only the `index` param (`hx-params="index"`)
2939 // and uses a beforeend swap.
2940 let opts = InputsForOptions {
2941 add_row_url: Some("/orders/line-item-row".into()),
2942 ..InputsForOptions::default()
2943 };
2944 let html = inputs_for(&cs, &opts, render_row).into_string();
2945 assert!(html.contains("Add row"), "{html}");
2946 assert!(html.contains(r#"hx-params="index""#), "{html}");
2947 assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2948 assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2949 assert!(html.contains(r##"hx-target="#items-rows""##), "{html}");
2950 }
2951
2952 /// Fix (Codex P2, near the Add-row button): the Add-row button sends a fresh
2953 /// unique `index` with each htmx request via `hx-vals` (`js:` form) computed
2954 /// from THIS container's rows (`max(existing data-index) + 1`), so repeated
2955 /// clicks never reuse an index and emit duplicate control names. The selector
2956 /// is scoped to the form's own container id; keeping several same-collection
2957 /// forms independent requires each to pass a distinct `container_id` (see
2958 /// `add_button_targets_and_scans_custom_container_id`). The pre-existing htmx
2959 /// attrs stay intact.
2960 #[test]
2961 fn add_button_hx_vals_sends_fresh_container_scoped_index() {
2962 let cs = two_row_changeset();
2963 let opts = InputsForOptions {
2964 add_row_url: Some("/orders/line-item-row".into()),
2965 ..InputsForOptions::default()
2966 };
2967 let html = inputs_for(&cs, &opts, render_row).into_string();
2968
2969 // Pre-existing htmx wiring is preserved.
2970 assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2971 assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2972 assert!(html.contains(r#"hx-params="index""#), "{html}");
2973
2974 // A JS-computed `hx-vals` that sends a fresh `index`…
2975 assert!(html.contains("hx-vals="), "{html}");
2976 assert!(html.contains("Math.max(-1"), "{html}");
2977 // …referencing the `index` param and reading each row's `data-index`…
2978 assert!(html.contains("parseInt(e.dataset.index,10)"), "{html}");
2979 // …via a selector scoped to THIS form's container id.
2980 assert!(
2981 html.contains("#items-rows .nested-fields__row[data-index]"),
2982 "{html}"
2983 );
2984 }
2985
2986 /// Fix (Codex P2, container-id collisions): a caller rendering two nested
2987 /// forms for the same child collection on one page must give each a distinct
2988 /// `container_id` so their Add-row buttons stay independent. Assert that an
2989 /// explicit `container_id` flows through to BOTH the container `id` and the
2990 /// Add-row button's `hx-target` + `hx-vals` index scan — proving per-form
2991 /// scoping works once ids are distinct — and that the default id is absent.
2992 #[test]
2993 fn add_button_targets_and_scans_custom_container_id() {
2994 let cs = two_row_changeset();
2995 let opts = InputsForOptions {
2996 add_row_url: Some("/orders/line-item-row".into()),
2997 // A second same-collection form on the page would pass its own id;
2998 // this one deliberately avoids containing the `items-rows` default so
2999 // the negative assertions below are exact substring checks.
3000 container_id: Some("shipping-lines".into()),
3001 ..InputsForOptions::default()
3002 };
3003 let html = inputs_for(&cs, &opts, render_row).into_string();
3004
3005 // The container carries the custom id, not the `{collection}-rows` default.
3006 assert!(
3007 html.contains(r#"<div id="shipping-lines" class="nested-fields">"#),
3008 "{html}"
3009 );
3010 // The Add-row button targets the custom container…
3011 assert!(html.contains(r##"hx-target="#shipping-lines""##), "{html}");
3012 // …and its index scan reads that same custom container's rows…
3013 assert!(
3014 html.contains("#shipping-lines .nested-fields__row[data-index]"),
3015 "{html}"
3016 );
3017 // …with no leftover reference to the default id.
3018 assert!(!html.contains("items-rows .nested-fields__row"), "{html}");
3019 assert!(!html.contains(r##"hx-target="#items-rows""##), "{html}");
3020 }
3021
3022 #[test]
3023 fn destroy_checkbox_checked_reflects_destroyed_row() {
3024 let pairs = vec![
3025 p("name", "Order"),
3026 p("items[0][sku]", "A"),
3027 p("items[0][quantity]", "1"),
3028 p("items[0][_destroy]", "1"),
3029 ];
3030 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
3031 let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
3032 // The destroyed row's checkbox is checked.
3033 assert!(
3034 html.contains(r#"name="items[0][_destroy]" value="1" checked"#),
3035 "{html}"
3036 );
3037 }
3038
3039 #[test]
3040 fn nested_row_fragment_renders_single_row_at_index() {
3041 let html = nested_row_fragment::<LineItem>(7, render_row).into_string();
3042 assert!(html.contains(r#"data-index="7""#), "{html}");
3043 assert!(html.contains(r#"name="items[7][sku]""#), "{html}");
3044 // Exactly one row wrapper.
3045 assert_eq!(html.matches("nested-fields__row").count(), 1, "{html}");
3046 }
3047
3048 // ── Fix 1: form_tag preserves the captured CSRF/submit-token fields ─
3049
3050 /// `NestedChangesetForm::form_tag` must emit the CSRF hidden input under the
3051 /// **captured/configured** field name — NOT the standalone `form_tag`'s
3052 /// hardcoded `_csrf` — so an app with a custom `security.csrf.form_field`
3053 /// keeps CSRF parity across a nested re-render.
3054 #[test]
3055 fn form_tag_emits_custom_csrf_field_name_not_default() {
3056 let form = NestedChangesetForm::<Order, LineItem>::blank(
3057 Order {
3058 name: String::new(),
3059 },
3060 Some("tok-123".to_owned()),
3061 )
3062 .with_csrf_field("authenticity_token");
3063
3064 let html = form
3065 .form_tag("/orders", "post", maud::html! {})
3066 .into_string();
3067
3068 // The captured custom field name carries the token…
3069 assert!(
3070 html.contains(r#"name="authenticity_token" value="tok-123""#),
3071 "{html}"
3072 );
3073 // …and the hardcoded default the standalone `form_tag` would emit does not.
3074 assert!(!html.contains(r#"name="_csrf""#), "{html}");
3075 assert!(html.contains(r#"action="/orders""#), "{html}");
3076 assert!(html.contains(r#"method="post""#), "{html}");
3077 }
3078
3079 /// When a submit token is captured, `form_tag` also emits it under its
3080 /// captured field name (alongside the custom CSRF field).
3081 #[test]
3082 fn form_tag_emits_captured_submit_token_field() {
3083 let form = NestedChangesetForm::<Order, LineItem> {
3084 changeset: NestedChangeset::blank(Order {
3085 name: String::new(),
3086 }),
3087 csrf_token: Some("tok".to_owned()),
3088 csrf_field: "authenticity_token".to_owned(),
3089 submit_token: Some("stok-9".to_owned()),
3090 submit_field: "_submit_token".to_owned(),
3091 };
3092
3093 let html = form
3094 .form_tag("/orders", "post", maud::html! {})
3095 .into_string();
3096
3097 assert!(
3098 html.contains(r#"name="authenticity_token" value="tok""#),
3099 "{html}"
3100 );
3101 assert!(
3102 html.contains(r#"name="_submit_token" value="stok-9""#),
3103 "{html}"
3104 );
3105 assert!(!html.contains(r#"name="_csrf""#), "{html}");
3106 }
3107
3108 // ── Fix 2: blank render shows no premature parent errors ────────────
3109
3110 /// The initial `new`-page render from `blank`: the required parent field is
3111 /// empty but un-validated, so no error message or `aria-invalid="true"`
3112 /// appears before the user types.
3113 #[test]
3114 fn blank_form_render_shows_no_parent_validation_error() {
3115 let form = NestedChangesetForm::<Order, LineItem>::blank(
3116 Order {
3117 name: String::new(),
3118 },
3119 Some("tok".to_owned()),
3120 );
3121
3122 let html = form
3123 .form_tag(
3124 "/orders",
3125 "post",
3126 maud::html! {
3127 (crate::form::required_text_input(&form.parent, "name", "Order name"))
3128 (inputs_for(&*form, &InputsForOptions::default(), render_row))
3129 },
3130 )
3131 .into_string();
3132
3133 assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
3134 assert!(!html.contains("name required"), "{html}");
3135 }
3136
3137 /// Contrast: a re-render from a failed decode DOES surface the parent error
3138 /// and marks the field invalid — the exact behaviour `blank` suppresses on
3139 /// the initial GET render.
3140 #[test]
3141 fn decoded_invalid_parent_render_shows_error_unlike_blank() {
3142 let cs =
3143 decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
3144 let form = NestedChangesetForm::from_changeset(cs);
3145
3146 let html = form
3147 .form_tag(
3148 "/orders",
3149 "post",
3150 maud::html! {
3151 (crate::form::required_text_input(&form.parent, "name", "Order name"))
3152 },
3153 )
3154 .into_string();
3155
3156 assert!(html.contains(r#"aria-invalid="true""#), "{html}");
3157 assert!(html.contains("name required"), "{html}");
3158 }
3159
3160 // ── Fix 3: blank forms can carry the current submit token ───────────
3161
3162 /// A `blank` form given a submit token (and a custom submit field name) emits
3163 /// the hidden submit-token input on the initial GET render, so the FIRST
3164 /// submission is protected against double-submit — not just later 422
3165 /// re-renders.
3166 #[test]
3167 fn blank_form_with_submit_token_emits_hidden_submit_field() {
3168 let form = NestedChangesetForm::<Order, LineItem>::blank(
3169 Order {
3170 name: String::new(),
3171 },
3172 Some("csrf".to_owned()),
3173 )
3174 .with_submit_field("authenticity_submit")
3175 .with_submit_token(Some("tok".to_owned()));
3176
3177 let html = form
3178 .form_tag("/orders", "post", maud::html! {})
3179 .into_string();
3180
3181 assert!(
3182 html.contains(r#"name="authenticity_submit" value="tok""#),
3183 "{html}"
3184 );
3185 }
3186
3187 /// Without supplying a submit token, a `blank` form emits NO submit-token
3188 /// hidden input — documenting the default and that the caller must supply the
3189 /// minted token on the initial GET to protect the first submit.
3190 #[test]
3191 fn blank_form_without_submit_token_omits_submit_field() {
3192 let form = NestedChangesetForm::<Order, LineItem>::blank(
3193 Order {
3194 name: String::new(),
3195 },
3196 Some("csrf".to_owned()),
3197 );
3198
3199 let html = form
3200 .form_tag("/orders", "post", maud::html! {})
3201 .into_string();
3202
3203 assert!(!html.contains("_submit_token"), "{html}");
3204 assert!(!html.contains(r#"name="_submit_token""#), "{html}");
3205 }
3206
3207 // ── seeded (edit-render) rendering ──────────────────────────────
3208
3209 /// A `seeded` changeset pre-renders its existing children through
3210 /// `inputs_for`: each seeded row's inputs carry the child's serialized values
3211 /// so an edit form shows the current line items, and a trailing blank template
3212 /// row is still appended for adding another child with no JS.
3213 #[test]
3214 fn seeded_rows_render_with_prefilled_values() {
3215 let cs = NestedChangeset::seeded(
3216 Order {
3217 name: "Existing order".to_owned(),
3218 },
3219 vec![
3220 LineItem {
3221 sku: "A-1".to_owned(),
3222 quantity: 2,
3223 },
3224 LineItem {
3225 sku: "B-2".to_owned(),
3226 quantity: 3,
3227 },
3228 ],
3229 );
3230 let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
3231
3232 // Both seeded rows render their indexed inputs with pre-filled values.
3233 assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
3234 assert!(html.contains(r#"value="A-1""#), "{html}");
3235 assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
3236 assert!(html.contains(r#"value="B-2""#), "{html}");
3237 // The trailing blank template row is still appended (index 2).
3238 assert!(html.contains(r#"data-index="2""#), "{html}");
3239 assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
3240 }
3241}