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 )
187)]
188
189use std::collections::{BTreeMap, HashMap};
190
191use axum::extract::{FromRequest, Request};
192use axum::response::IntoResponse;
193
194use crate::form::{
195 Changeset, IntoChangeset, decode_urlencoded_dropping_blank_optional_fields,
196 validation_errors_to_map,
197};
198
199// ── NestedChild ────────────────────────────────────────────────────
200
201/// A child row type bound as part of a nested (`has_many`) form.
202///
203/// The [`COLLECTION`](NestedChild::COLLECTION) constant names the field
204/// group used in input names (`items[i][field]`) and in combined error
205/// keys (`items[1].quantity`).
206pub trait NestedChild: serde::de::DeserializeOwned + validator::Validate + Send {
207 /// Field-group name used in input names (`items[i][field]`) and error
208 /// keys (`items[1].quantity`). e.g. `"items"`.
209 const COLLECTION: &'static str;
210}
211
212// ── NestedRow ──────────────────────────────────────────────────────
213
214/// One submitted child row, retained for re-rendering regardless of whether
215/// it parsed or validated.
216#[derive(Debug, Clone)]
217pub struct NestedRow {
218 /// Raw submitted subfield values (`sku` → `"A-1"`), used to pre-fill
219 /// inputs when re-rendering after a failed submission.
220 values: HashMap<String, String>,
221 /// Per-subfield validation (or parse) errors. A row that failed to parse
222 /// records its error under the offending subfield's name (via
223 /// `serde_path_to_error`), falling back to the empty-string (row-level) key
224 /// only when no field can be identified.
225 errors: HashMap<String, Vec<String>>,
226 /// `true` when the row carried a truthy `_destroy` marker.
227 destroyed: bool,
228}
229
230impl NestedRow {
231 /// The raw submitted value for subfield `sub`, if present.
232 #[must_use]
233 pub fn value(&self, sub: &str) -> Option<&str> {
234 self.values.get(sub).map(String::as_str)
235 }
236
237 /// Validation messages for subfield `sub`, or an empty slice.
238 ///
239 /// A row-level parse failure is stored under the empty-string key, so
240 /// `row.errors_for("")` returns any whole-row decode error.
241 #[must_use]
242 pub fn errors_for(&self, sub: &str) -> &[String] {
243 self.errors.get(sub).map_or(&[], Vec::as_slice)
244 }
245
246 /// `true` when the row was marked for removal via a truthy `_destroy`.
247 #[must_use]
248 pub const fn is_destroyed(&self) -> bool {
249 self.destroyed
250 }
251
252 /// Every error keyed by subfield name (empty key = row-level parse error).
253 #[must_use]
254 pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
255 &self.errors
256 }
257}
258
259// ── NestedChangeset ────────────────────────────────────────────────
260
261/// A parent [`Changeset<P>`] plus its bound child collection.
262///
263/// Obtain one from [`decode_nested_urlencoded`] or (preferred) the
264/// [`NestedChangesetForm`] axum extractor.
265#[derive(Debug)]
266pub struct NestedChangeset<P, C> {
267 /// The parent changeset (values + per-field errors), reusing the existing
268 /// [`Changeset`] pipeline.
269 pub parent: Changeset<P>,
270 /// The submitted child rows in compacted order, retained for re-render.
271 rows: Vec<NestedRow>,
272 /// `Some(children)` iff the parent is valid **and** every non-destroyed
273 /// row both parsed and validated; `None` otherwise.
274 valid_children: Option<Vec<C>>,
275}
276
277impl<P, C: NestedChild> NestedChangeset<P, C> {
278 /// Build a blank, **non-validating** nested changeset for the initial
279 /// `new` (create) render, before the user has submitted anything.
280 ///
281 /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
282 /// wraps `parent` in a valid [`Changeset`] (via [`Changeset::new`], which
283 /// records **no** errors — it does **not** run [`validator::Validate`])
284 /// with **zero** child rows. So `is_valid()` returns `true`,
285 /// `errors_for(..)` is empty, `rows()` is empty, and `into_valid()` returns
286 /// `Ok((parent, vec![]))` even when a required parent field is still empty.
287 ///
288 /// Use this for the initial GET render of an **empty** (create) form so the
289 /// blank page does not show a premature "field is required" error or
290 /// `aria-invalid="true"` before the user has typed. For an **edit** render
291 /// that must display existing child rows (with their persisted ids), use
292 /// [`seeded`](NestedChangeset::seeded) instead — `blank` produces zero child
293 /// rows and cannot pre-fill or preserve existing line items. Use
294 /// [`decode_nested_urlencoded`] (or the [`NestedChangesetForm`] extractor)
295 /// for the POST decode that validates a real submission and re-renders with
296 /// inline errors.
297 #[must_use]
298 pub fn blank(parent: P) -> Self {
299 Self {
300 parent: Changeset::new(parent),
301 rows: Vec::new(),
302 valid_children: Some(Vec::new()),
303 }
304 }
305
306 /// `true` when the parent is valid, every non-destroyed row has no
307 /// errors, and the children all parsed and validated.
308 #[must_use]
309 pub fn is_valid(&self) -> bool {
310 self.parent.is_valid()
311 && self.valid_children.is_some()
312 && self.rows.iter().all(|r| r.destroyed || r.errors.is_empty())
313 }
314
315 /// Consume the changeset, returning `Ok((parent, children))` when valid or
316 /// `Err(self)` (with all rows retained for re-render) when not.
317 ///
318 /// The returned child vector contains only non-destroyed rows, in order.
319 ///
320 /// # Errors
321 ///
322 /// Returns `Err(self)` when the parent or any non-destroyed child row has
323 /// validation errors, or any child failed to parse.
324 #[allow(
325 clippy::result_large_err,
326 reason = "the Err variant intentionally returns the whole changeset (rows + raw \
327 values) so the handler can re-render the form inline with errors"
328 )]
329 pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
330 if !self.is_valid() {
331 return Err(self);
332 }
333 let Self {
334 parent,
335 rows,
336 valid_children,
337 } = self;
338 // `is_valid()` guarantees both arms below take the happy path; the
339 // reconstruction branches keep the code panic-free without relying on
340 // that invariant.
341 match valid_children {
342 Some(children) => match parent.into_valid() {
343 Ok(p) => Ok((p, children)),
344 Err(parent) => Err(Self {
345 parent,
346 rows,
347 valid_children: None,
348 }),
349 },
350 None => Err(Self {
351 parent,
352 rows,
353 valid_children: None,
354 }),
355 }
356 }
357
358 /// Validation messages for `key`, or an empty slice.
359 ///
360 /// Supports both parent field keys (delegated to
361 /// [`Changeset::errors_for`]) and combined child keys of the form
362 /// `"{COLLECTION}[{i}].{sub}"` (e.g. `"items[1].quantity"`). A bare
363 /// `"{COLLECTION}[{i}]"` returns that row's row-level (parse) errors.
364 #[must_use]
365 pub fn errors_for(&self, key: &str) -> &[String] {
366 if let Some((idx, sub)) = parse_combined_child_key(key, C::COLLECTION) {
367 return self.rows.get(idx).map_or(&[], |r| r.errors_for(sub));
368 }
369 self.parent.errors_for(key)
370 }
371
372 /// The submitted child rows in compacted (ascending-index) order.
373 #[must_use]
374 pub fn rows(&self) -> &[NestedRow] {
375 &self.rows
376 }
377
378 /// The child collection name, i.e. [`NestedChild::COLLECTION`].
379 #[must_use]
380 pub const fn collection_name(&self) -> &'static str {
381 C::COLLECTION
382 }
383}
384
385/// Edit-render seeding — pre-populate a non-validating changeset with existing
386/// persisted children.
387///
388/// The extra [`serde::Serialize`] bound lives on this `impl` block alone, so the
389/// core [`NestedChild`] trait stays serialize-free; only callers that seed an
390/// edit form need their child type to be serializable.
391impl<P, C: NestedChild + serde::Serialize> NestedChangeset<P, C> {
392 /// Build a **non-validating**, valid nested changeset that pre-renders one
393 /// row per existing `child`, for the initial `edit` render of a form that
394 /// already has persisted children.
395 ///
396 /// Where [`blank`](NestedChangeset::blank) produces **zero** child rows (for
397 /// the create/new page), `seeded` produces exactly one [`NestedRow`] per
398 /// element of `children`, so an edit form can display and preserve existing
399 /// line items — including each child's `id` as a hidden input (via
400 /// [`RowScope::hidden_input`]) — before the first submit. That hidden `id`
401 /// also makes the in-scope `_destroy`-on-existing-children flow usable: the
402 /// row round-trips its identity so ticking Remove on a persisted child
403 /// submits a real, identifiable row for the handler to delete.
404 ///
405 /// Each row's raw `values` are produced by serializing the child with
406 /// [`serde_urlencoded::to_string`] and parsing the result back into the
407 /// per-subfield map — the **same** string representation
408 /// [`decode_nested_urlencoded`] populates from a real submission, so
409 /// [`RowScope::value`] pre-fills a seeded row's inputs identically to a
410 /// re-rendered submitted row.
411 ///
412 /// Like `blank`, this does **not** run [`validator::Validate`]: it wraps
413 /// `parent` in a valid [`Changeset`] (via [`Changeset::new`]) and keeps
414 /// `valid_children = Some(children)`, so `is_valid()` returns `true`,
415 /// `errors_for(..)` is empty, every seeded row reports no errors and is not
416 /// destroyed, and `into_valid()` returns `Ok((parent, children))`. Use the
417 /// POST decode path ([`decode_nested_urlencoded`] / the
418 /// [`NestedChangesetForm`] extractor) to validate the actual edit
419 /// submission.
420 ///
421 /// Deeper reconciliation of persisted children — reordering, or deep-diffing
422 /// submitted rows against the seeded set to compute per-row inserts /
423 /// updates / deletes — is intentionally **out of scope** here (see issue
424 /// #1346's out-of-scope list) and remains a follow-up; `seeded` only
425 /// pre-renders the existing rows so the no-JS edit + `_destroy` flow works.
426 #[must_use]
427 pub fn seeded(parent: P, children: Vec<C>) -> Self {
428 let rows = children
429 .iter()
430 .map(|child| {
431 let mut values: HashMap<String, String> = HashMap::new();
432 // Serialize the child to the same `field=value` wire form the
433 // decoder reads, then parse it back so `RowScope::value` pre-fills
434 // seeded rows identically to a re-rendered submitted row. A
435 // serialization failure (never expected for a plain struct) simply
436 // yields an empty value map rather than panicking on the request path.
437 if let Ok(encoded) = serde_urlencoded::to_string(child) {
438 for (k, v) in url::form_urlencoded::parse(encoded.as_bytes()) {
439 values.insert(k.into_owned(), v.into_owned());
440 }
441 }
442 NestedRow {
443 values,
444 errors: HashMap::new(),
445 destroyed: false,
446 }
447 })
448 .collect();
449 Self {
450 parent: Changeset::new(parent),
451 rows,
452 valid_children: Some(children),
453 }
454 }
455}
456
457// ── Decoder ────────────────────────────────────────────────────────
458
459/// Decode URL-encoded `pairs` into a [`NestedChangeset<P, C>`].
460///
461/// Pairs whose key matches `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` are child
462/// subfields (captured index + subfield name); everything else is a parent
463/// pair. Child pairs are grouped by numeric index into ascending order and
464/// **compacted** so non-contiguous indices (gaps from client-side removal)
465/// still yield sequential rows preserving submission order. Subfield order
466/// within a row is preserved.
467///
468/// A `_destroy` subfield with a truthy value (`"1"`, `"true"`, `"on"`) marks
469/// its row destroyed; destroyed rows are retained (for re-render) but never
470/// contribute to `valid_children`.
471///
472/// Following Rails' `reject_if: :all_blank`, a child row whose every
473/// non-`_destroy` subfield value is blank (empty or whitespace-only after
474/// trimming) is **ignored**: it is not decoded, not validated, not retained in
475/// `rows`, and never counts toward `valid_children` — exactly as if that index
476/// had never been submitted. This is what makes the blank template row
477/// [`inputs_for`] auto-renders safe with no JavaScript. A row with at least one
478/// non-blank non-`_destroy` value is kept and validated as usual, so a
479/// partially filled row that omits a required field still surfaces per-field
480/// errors.
481///
482/// Both the parent and each non-destroyed child are decoded through the same
483/// blank-optional-dropping `serde_urlencoded` path
484/// [`ChangesetForm`](crate::form::ChangesetForm) uses, so string→typed
485/// coercion (`quantity=5` → `i32`) comes for free.
486///
487/// # Errors
488///
489/// Returns `Err(message)` when the **parent** fails to decode (a malformed,
490/// non-blank typed value) — mirroring the hard-400 contract of
491/// [`ChangesetForm`](crate::form::ChangesetForm). A **child** row that fails
492/// to parse is not a hard error: it is retained with a row-level error and
493/// marks the changeset invalid.
494pub fn decode_nested_urlencoded<P, C>(
495 pairs: &[(String, String)],
496) -> Result<NestedChangeset<P, C>, String>
497where
498 P: serde::de::DeserializeOwned + validator::Validate,
499 C: NestedChild,
500{
501 let collection = C::COLLECTION;
502
503 // Split parent pairs from grouped child subfields. `BTreeMap` keeps the
504 // rows in ascending index order; enumerating it later compacts gaps.
505 let mut parent_pairs: Vec<(String, String)> = Vec::new();
506 let mut child_groups: BTreeMap<usize, Vec<(String, String)>> = BTreeMap::new();
507 for (key, value) in pairs {
508 if let Some((idx, sub)) = parse_child_key(key, collection) {
509 child_groups
510 .entry(idx)
511 .or_default()
512 .push((sub.to_string(), value.clone()));
513 } else {
514 parent_pairs.push((key.clone(), value.clone()));
515 }
516 }
517
518 // Decode the parent through the shared blank-optional-dropping path.
519 let parent_encoded = encode_pairs(&parent_pairs);
520 let parent_data: P =
521 decode_urlencoded_dropping_blank_optional_fields::<P>(parent_encoded.as_bytes())
522 .map_err(|e| e.to_string())?;
523 let parent = parent_data.into_changeset();
524
525 let mut rows: Vec<NestedRow> = Vec::new();
526 let mut children: Vec<C> = Vec::new();
527 let mut all_children_ok = true;
528
529 for subfields in child_groups.into_values() {
530 let mut values: HashMap<String, String> = HashMap::new();
531 let mut destroyed = false;
532 // Subfields decoded into `C`, excluding the `_destroy` marker (not a
533 // field of `C`).
534 let mut decode_pairs: Vec<(String, String)> = Vec::new();
535 for (sub, val) in &subfields {
536 values.insert(sub.clone(), val.clone());
537 if sub == "_destroy" {
538 if is_truthy(val) {
539 destroyed = true;
540 }
541 } else {
542 decode_pairs.push((sub.clone(), val.clone()));
543 }
544 }
545
546 // Rails `reject_if: :all_blank`: if every non-`_destroy` subfield value
547 // is blank (empty or whitespace-only after trimming), drop the row
548 // entirely. This is the auto-rendered blank template row `inputs_for`
549 // emits for the no-JS "add a child" path, not a real submitted child —
550 // treat it as if the index was never submitted: do not decode, do not
551 // validate, do not retain it, and do not count it toward the children.
552 // A row with at least one non-blank non-`_destroy` value is kept and
553 // validated as usual, so a partially filled row still surfaces its
554 // per-field errors. (`decode_pairs` already excludes `_destroy`, so an
555 // all-blank row that also carries `_destroy` is dropped here too — the
556 // same outcome as the destroy path.)
557 let all_blank = decode_pairs.iter().all(|(_, val)| val.trim().is_empty());
558 if all_blank {
559 continue;
560 }
561
562 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
563
564 if destroyed {
565 rows.push(NestedRow {
566 values,
567 errors,
568 destroyed,
569 });
570 continue;
571 }
572
573 let encoded = encode_pairs(&decode_pairs);
574 match decode_urlencoded_dropping_blank_optional_fields::<C>(encoded.as_bytes()) {
575 Ok(child) => match validator::Validate::validate(&child) {
576 Ok(()) => children.push(child),
577 Err(ve) => {
578 errors = validation_errors_to_map(&ve);
579 all_children_ok = false;
580 }
581 },
582 Err(e) => {
583 // Row parse failure (deserialization failed before validation,
584 // e.g. `sku` filled but the required numeric `quantity` is
585 // malformed or present-but-blank). Key the message under the
586 // offending subfield so it surfaces as `items[i].{field}`,
587 // rendered by `errors_for("{field}")` next to the offending
588 // input rather than only under the row-level "" key.
589 //
590 // The blank-optional retry inside
591 // `decode_urlencoded_dropping_blank_optional_fields` can DROP a
592 // present-but-blank typed field (`quantity=`) before serde sees
593 // it, so the primary error is then `missing field \`quantity\``
594 // reported at the ROW ROOT with an empty path — losing the field
595 // name the row helpers need. `recover_child_error_field` recovers
596 // it by preferring the primary error's `missing field \`X\``
597 // message (with a raw non-dropping re-decode only as a last
598 // resort); see that helper. Only if all layers fail does it fall
599 // back to the row-level "" key, so the error is never silently
600 // dropped.
601 let field = recover_child_error_field::<C>(&e, &decode_pairs);
602 errors.entry(field).or_default().push(e.to_string());
603 all_children_ok = false;
604 }
605 }
606
607 rows.push(NestedRow {
608 values,
609 errors,
610 destroyed,
611 });
612 }
613
614 let valid_children = if parent.is_valid() && all_children_ok {
615 Some(children)
616 } else {
617 None
618 };
619
620 Ok(NestedChangeset {
621 parent,
622 rows,
623 valid_children,
624 })
625}
626
627/// Parse `key` as a child subfield reference `COLLECTION[<idx>][<sub>]`,
628/// returning `(idx, sub)`. Hand-parses the bracket pattern (no `regex`
629/// dependency), matching `^{COLLECTION}\[(\d+)\]\[([^\]]+)\]$` with
630/// `COLLECTION` treated literally.
631fn parse_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
632 let rest = key.strip_prefix(collection)?;
633 let rest = rest.strip_prefix('[')?;
634 let close = rest.find(']')?;
635 let (idx_str, after) = rest.split_at(close);
636 let idx: usize = idx_str.parse().ok()?;
637 // `after` begins with the `]` that `close` pointed at.
638 let after = after.strip_prefix(']')?;
639 let after = after.strip_prefix('[')?;
640 let close2 = after.find(']')?;
641 let (sub, tail) = after.split_at(close2);
642 // The subfield must be the final segment: exactly a trailing `]`.
643 if tail != "]" || sub.is_empty() {
644 return None;
645 }
646 Some((idx, sub))
647}
648
649/// Parse a combined error key `COLLECTION[<idx>]` optionally followed by
650/// `.<sub>`, returning `(idx, sub)` where `sub` is `""` for the bare form.
651/// Returns `None` when the key is not a child key (so it falls through to the
652/// parent).
653fn parse_combined_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
654 let rest = key.strip_prefix(collection)?;
655 let rest = rest.strip_prefix('[')?;
656 let close = rest.find(']')?;
657 let (idx_str, after) = rest.split_at(close);
658 let idx: usize = idx_str.parse().ok()?;
659 let after = after.strip_prefix(']')?;
660 if after.is_empty() {
661 return Some((idx, ""));
662 }
663 let sub = after.strip_prefix('.')?;
664 if sub.is_empty() {
665 return None;
666 }
667 Some((idx, sub))
668}
669
670/// Re-encode `pairs` as an `application/x-www-form-urlencoded` string so the
671/// shared `serde_urlencoded` decode path can re-parse them.
672fn encode_pairs(pairs: &[(String, String)]) -> String {
673 url::form_urlencoded::Serializer::new(String::new())
674 .extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
675 .finish()
676}
677
678/// Last `Map`/`Enum` key in a `serde_path_to_error` path, if any is non-empty.
679///
680/// This is the subfield the decode failure is attributed to (e.g. `quantity`
681/// for `items[i][quantity]`). `Seq`/`Unknown` segments carry no field name.
682fn last_path_field(path: &serde_path_to_error::Path) -> Option<String> {
683 path.iter()
684 .filter_map(|seg| match seg {
685 serde_path_to_error::Segment::Map { key }
686 | serde_path_to_error::Segment::Enum { variant: key } => Some(key.clone()),
687 serde_path_to_error::Segment::Seq { .. } | serde_path_to_error::Segment::Unknown => {
688 None
689 }
690 })
691 .next_back()
692 .filter(|f| !f.is_empty())
693}
694
695/// Extract the field name from serde's stable "missing field" message — the
696/// backtick-quoted identifier in [`serde::de::Error::missing_field`]'s
697/// `Display` format — if present.
698fn missing_field_name(msg: &str) -> Option<String> {
699 let after = msg.split_once("missing field `")?.1;
700 let name = after.split_once('`')?.0;
701 (!name.is_empty()).then(|| name.to_owned())
702}
703
704/// Recover the offending subfield name for a child-row parse failure so the
705/// error can be keyed to `items[i].{field}` (rendered next to the input) rather
706/// than only under the row-level "" key.
707///
708/// Layered because the blank-optional retry inside
709/// [`decode_urlencoded_dropping_blank_optional_fields`] can DROP a
710/// present-but-blank typed field (`quantity=`) before serde ever sees it, so
711/// the primary decode reports a root-level missing-field error for `quantity`
712/// with an empty path — losing the field name the row helpers need:
713///
714/// 1. The primary decode's `serde_path_to_error` path (last `Map`/`Enum` key).
715/// For a malformed non-blank value (`quantity=abc`) this already yields the
716/// field, since that pair is never dropped.
717/// 2. If empty, the backtick-quoted identifier parsed from the PRIMARY error's
718/// missing-field message. The blank-dropping decode stops on the first typed
719/// field it can't satisfy, so this names the truly-required field even when
720/// an earlier OPTIONAL blank precedes it — e.g. a child with an optional
721/// `weight` before a required `quantity`, both submitted blank, reports the
722/// required `quantity` as missing, not `weight`. It also covers the
723/// present-but-blank required field (dropped → missing) and the field truly
724/// never submitted. This is preferred over the raw re-decode below precisely
725/// so the error lands on the required field, not a harmless optional blank a
726/// non-dropping pass would trip on first.
727/// 3. Only if BOTH are empty (a rare root-level error carrying neither a path
728/// nor a missing-field message), a raw re-decode over the ORIGINAL row pairs
729/// with blanks RETAINED (no dropping), mirroring the shared helper's
730/// deserializer minus the drop loop, and its last `Map`/`Enum` key.
731/// 4. Otherwise `""` (row-level key) so the error is never silently dropped.
732///
733/// This runs ONLY on the already-failed path to LOCATE the field; it does not
734/// affect the success path. Legitimately-blank optional fields still decode to
735/// `None` via the primary blank-dropping decode, which succeeds for them and
736/// never reaches here.
737fn recover_child_error_field<C: serde::de::DeserializeOwned>(
738 primary: &serde_path_to_error::Error<serde_urlencoded::de::Error>,
739 decode_pairs: &[(String, String)],
740) -> String {
741 // 1. Primary decode path (present-but-malformed value at a real path).
742 if let Some(field) = last_path_field(primary.path()) {
743 return field;
744 }
745
746 // 2. The `missing field \`X\`` identifier from the PRIMARY error — names
747 // the required field the blank-dropping pass actually stopped on.
748 if let Some(field) = missing_field_name(&primary.inner().to_string()) {
749 return field;
750 }
751
752 // 3. Fallback: raw (non-dropping) re-decode over the original row pairs,
753 // for the rare root-level error with neither a path nor a missing-field
754 // message.
755 let encoded = encode_pairs(decode_pairs);
756 let deserializer =
757 serde_urlencoded::Deserializer::new(url::form_urlencoded::parse(encoded.as_bytes()));
758 if let Err(raw_err) = serde_path_to_error::deserialize::<_, C>(deserializer)
759 && let Some(field) = last_path_field(raw_err.path())
760 {
761 return field;
762 }
763
764 // 4. Row-level key.
765 String::new()
766}
767
768/// Whether a `_destroy` marker value counts as "destroy this row".
769fn is_truthy(value: &str) -> bool {
770 let trimmed = value.trim();
771 trimmed == "1" || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("on")
772}
773
774// ── NestedChangesetForm extractor ──────────────────────────────────
775
776/// Axum extractor that decodes a nested (`has_many`) form body, validates the
777/// parent and every non-destroyed child row, and captures the CSRF and
778/// submit-token context for re-rendering.
779///
780/// Mirrors [`ChangesetForm`](crate::form::ChangesetForm): errors live in the
781/// [`NestedChangeset`] rather than rejecting with 422; the handler decides how
782/// to respond. Only `application/x-www-form-urlencoded` bodies are accepted
783/// (multipart is a follow-up).
784pub struct NestedChangesetForm<P, C> {
785 /// The validated (or invalid) nested changeset.
786 pub changeset: NestedChangeset<P, C>,
787 csrf_token: Option<String>,
788 csrf_field: String,
789 submit_token: Option<String>,
790 submit_field: String,
791}
792
793impl<P, C: NestedChild> NestedChangesetForm<P, C> {
794 /// Build a blank nested-form context for the initial `new` (create) GET
795 /// render, before any submission.
796 ///
797 /// Mirrors [`ChangesetForm::blank`](crate::form::ChangesetForm::blank): it
798 /// wraps `parent` in a **non-validating** [`NestedChangeset::blank`] (no
799 /// child rows, no errors) so the initial page renders clean — no premature
800 /// "field is required" message or `aria-invalid="true"` before the user has
801 /// typed. For an **edit** render that must show existing children, use
802 /// [`seeded`](Self::seeded) instead (`blank` renders zero child rows).
803 /// Contrast the POST path (the [`NestedChangesetForm`] extractor /
804 /// [`decode_nested_urlencoded`]), which validates the submission and
805 /// re-renders inline errors.
806 ///
807 /// `csrf_token` is the token from a `CsrfToken` extractor, or `None` when
808 /// CSRF middleware is not active. The CSRF and submit-token field names
809 /// default to `_csrf` / `_submit_token` (exactly what the extractor falls
810 /// back to when the corresponding config extensions are absent); when the
811 /// app customizes `security.csrf.form_field`, set it with
812 /// [`with_csrf_field`](Self::with_csrf_field) so
813 /// [`form_tag`](Self::form_tag) emits the right hidden field name.
814 ///
815 /// The submit token starts `None`, so a bare `blank(..).form_tag(..)` emits
816 /// **no** submit-token hidden input and the first submission is not protected
817 /// against double-submit ([`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
818 /// passes tokenless mutating requests through). Supply the minted token on the
819 /// initial GET with [`with_submit_token`](Self::with_submit_token) (and
820 /// [`with_submit_field`](Self::with_submit_field) if the field name is
821 /// customized) so the **first** submit carries a token too:
822 ///
823 /// ```rust,ignore
824 /// #[get("/orders/new")]
825 /// async fn new_order(csrf: CsrfToken, submit: SubmitToken) -> Markup {
826 /// let form = NestedChangesetForm::<NewOrder, NewLineItem>::blank(
827 /// NewOrder::default(),
828 /// Some(csrf.token().to_owned()),
829 /// )
830 /// .with_submit_token(Some(submit.token().to_owned()));
831 /// form.form_tag("/orders", "post", /* … */)
832 /// }
833 /// ```
834 #[must_use]
835 pub fn blank(parent: P, csrf_token: Option<String>) -> Self {
836 Self {
837 changeset: NestedChangeset::blank(parent),
838 csrf_token,
839 csrf_field: "_csrf".to_owned(),
840 submit_token: None,
841 submit_field: "_submit_token".to_owned(),
842 }
843 }
844
845 /// Wrap a pre-built [`NestedChangeset`] (which may already carry validation
846 /// errors) in a form for rendering, with no CSRF/submit token.
847 ///
848 /// Mirrors [`ChangesetForm::from_changeset`](crate::form::ChangesetForm::from_changeset):
849 /// useful in tests and cases where a `NestedChangeset` was produced
850 /// externally (e.g. via [`decode_nested_urlencoded`]) before constructing a
851 /// form for re-render. The CSRF/submit-token field names default to
852 /// `_csrf` / `_submit_token`; add a token with
853 /// [`with_csrf_field`](Self::with_csrf_field) as needed.
854 #[must_use]
855 pub fn from_changeset(changeset: NestedChangeset<P, C>) -> Self {
856 Self {
857 changeset,
858 csrf_token: None,
859 csrf_field: "_csrf".to_owned(),
860 submit_token: None,
861 submit_field: "_submit_token".to_owned(),
862 }
863 }
864
865 /// Override the CSRF form-field name used by [`form_tag`](Self::form_tag).
866 ///
867 /// Mirrors
868 /// [`ChangesetForm::with_csrf_field`](crate::form::ChangesetForm::with_csrf_field):
869 /// call this on a [`blank`](Self::blank) GET-handler form when
870 /// `security.csrf.form_field` is customized (e.g. `"authenticity_token"`).
871 /// The extractor captures the configured name automatically on the POST
872 /// path.
873 #[must_use]
874 pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
875 self.csrf_field = field.into();
876 self
877 }
878
879 /// Supply the one-time submit token to a [`blank`](Self::blank) (or
880 /// [`from_changeset`](Self::from_changeset)) GET-handler form so
881 /// [`form_tag`](Self::form_tag) emits the hidden submit-token input on the
882 /// **initial** render — protecting the very first submission against
883 /// double-submit, not just later 422 re-renders.
884 ///
885 /// [`blank`](Self::blank) leaves this `None` (the initial page renders no
886 /// submit-token field otherwise), and [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
887 /// passes tokenless mutating requests through unchanged — so without calling
888 /// this the first create-form submit is unprotected. Source the token from a
889 /// [`SubmitToken`](crate::security::SubmitToken) extractor on the GET handler.
890 /// The extractor captures it automatically on the POST re-render path.
891 ///
892 /// When the app customizes `security.submit_token.field_name`, pair this with
893 /// [`with_submit_field`](Self::with_submit_field) so the hidden input carries
894 /// the right name.
895 #[must_use]
896 pub fn with_submit_token(mut self, token: Option<String>) -> Self {
897 self.submit_token = token;
898 self
899 }
900
901 /// Override the submit-token form-field name used by
902 /// [`form_tag`](Self::form_tag).
903 ///
904 /// Mirrors [`with_csrf_field`](Self::with_csrf_field): call this on a
905 /// [`blank`](Self::blank) GET-handler form when
906 /// `security.submit_token.field_name` is customized (the default is
907 /// `_submit_token`). The extractor captures the configured name automatically
908 /// on the POST path.
909 #[must_use]
910 pub fn with_submit_field(mut self, field: impl Into<String>) -> Self {
911 self.submit_field = field.into();
912 self
913 }
914
915 /// The CSRF token captured from the request, if the CSRF middleware is active.
916 #[must_use]
917 pub fn csrf_token(&self) -> Option<&str> {
918 self.csrf_token.as_deref()
919 }
920
921 /// The CSRF form-field name (honours `security.csrf.form_field`).
922 #[must_use]
923 pub fn csrf_field(&self) -> &str {
924 &self.csrf_field
925 }
926
927 /// The one-time submit token captured from the request, if the
928 /// submit-token middleware is active.
929 #[must_use]
930 pub fn submit_token(&self) -> Option<&str> {
931 self.submit_token.as_deref()
932 }
933
934 /// The submit-token form-field name (honours
935 /// `security.submit_token.field_name`).
936 #[must_use]
937 pub fn submit_field(&self) -> &str {
938 &self.submit_field
939 }
940
941 /// Consume and return only the inner [`NestedChangeset`].
942 pub fn into_changeset(self) -> NestedChangeset<P, C> {
943 self.changeset
944 }
945
946 /// Return `Ok((parent, children))` when valid, `Err(self)` when not.
947 ///
948 /// The `Err` branch retains the CSRF/submit context so the handler can
949 /// immediately re-render the form with inline errors.
950 ///
951 /// # Errors
952 ///
953 /// Returns `Err(self)` when the inner changeset has validation errors.
954 #[allow(
955 clippy::result_large_err,
956 reason = "the Err variant intentionally returns the whole form (changeset + CSRF/submit \
957 context) so the handler can re-render inline with errors"
958 )]
959 pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
960 let Self {
961 changeset,
962 csrf_token,
963 csrf_field,
964 submit_token,
965 submit_field,
966 } = self;
967 match changeset.into_valid() {
968 Ok(pair) => Ok(pair),
969 Err(changeset) => Err(Self {
970 changeset,
971 csrf_token,
972 csrf_field,
973 submit_token,
974 submit_field,
975 }),
976 }
977 }
978}
979
980/// Edit-render seeding for the form context — mirrors
981/// [`NestedChangeset::seeded`], carrying the extra [`serde::Serialize`] bound on
982/// this `impl` block alone.
983impl<P, C: NestedChild + serde::Serialize> NestedChangesetForm<P, C> {
984 /// Build a nested-form context for the initial `edit` GET render,
985 /// pre-populated with the existing persisted `children`.
986 ///
987 /// Mirrors [`blank`](Self::blank)'s CSRF handling (the CSRF/submit-token
988 /// field names default to `_csrf` / `_submit_token`, and the submit token
989 /// starts `None`), but wraps [`NestedChangeset::seeded`] instead of
990 /// [`NestedChangeset::blank`]: the changeset pre-renders one row per existing
991 /// child so the edit page shows and preserves current line items — with their
992 /// `id`s carried as hidden inputs (via [`RowScope::hidden_input`]) — and the
993 /// no-JS `_destroy` removal of an existing child works before the first
994 /// submit.
995 ///
996 /// The same builder methods used with [`blank`](Self::blank) apply here:
997 /// [`with_csrf_field`](Self::with_csrf_field) for a customized
998 /// `security.csrf.form_field`, and [`with_submit_token`](Self::with_submit_token)
999 /// / [`with_submit_field`](Self::with_submit_field) to emit the one-time
1000 /// submit-token hidden input on the initial edit render. `csrf_token` is the
1001 /// token from a `CsrfToken` extractor, or `None` when CSRF middleware is not
1002 /// active.
1003 #[must_use]
1004 pub fn seeded(parent: P, children: Vec<C>, csrf_token: Option<String>) -> Self {
1005 Self {
1006 changeset: NestedChangeset::seeded(parent, children),
1007 csrf_token,
1008 csrf_field: "_csrf".to_owned(),
1009 submit_token: None,
1010 submit_field: "_submit_token".to_owned(),
1011 }
1012 }
1013}
1014
1015/// Maud rendering — emit the `<form>` open tag with the **captured** CSRF (and
1016/// submit-token) hidden fields injected.
1017#[cfg(feature = "maud")]
1018impl<P, C: NestedChild> NestedChangesetForm<P, C> {
1019 /// Render a `<form>` element wrapping `content`, injecting the CSRF hidden
1020 /// input under the **captured** field name (honouring
1021 /// `security.csrf.form_field`) and — when present — the one-time
1022 /// submit-token hidden input under its captured field name.
1023 ///
1024 /// Mirrors [`ChangesetForm::form_tag`](crate::form::ChangesetForm::form_tag),
1025 /// including the `PUT`/`PATCH`/`DELETE` → hidden `_method` override. **Prefer
1026 /// this over the standalone [`crate::form::form_tag`]** for a nested-form
1027 /// re-render: the standalone helper hardcodes the default `_csrf` field name
1028 /// and would emit the wrong hidden field for an app that customized
1029 /// `security.csrf.form_field`, so the next submit's CSRF check would reject
1030 /// the form. This method uses the field name the extractor captured (or the
1031 /// one set via [`with_csrf_field`](Self::with_csrf_field) on a
1032 /// [`blank`](Self::blank) form), keeping CSRF parity across the re-render.
1033 ///
1034 /// The submit-token hidden input is emitted only when a submit token is
1035 /// present. The POST re-render path captures it automatically; on the initial
1036 /// GET render from [`blank`](Self::blank), supply it with
1037 /// [`with_submit_token`](Self::with_submit_token) so the **first** submit is
1038 /// protected against double-submit too — otherwise a bare
1039 /// `blank(..).form_tag(..)` create form carries no submit token and its first
1040 /// submission passes through [`SubmitTokenLayer`](crate::security::SubmitTokenLayer)
1041 /// unprotected.
1042 #[must_use]
1043 #[allow(clippy::needless_pass_by_value)]
1044 pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
1045 crate::form::form_tag_inner(
1046 action,
1047 method,
1048 &self.csrf_field,
1049 self.csrf_token.as_deref(),
1050 None,
1051 maud::html! {
1052 @if let Some(token) = self.submit_token.as_deref() {
1053 input type="hidden" name=(self.submit_field) value=(token);
1054 }
1055 (content)
1056 },
1057 )
1058 }
1059}
1060
1061/// Dereferences to [`NestedChangeset<P, C>`] so all changeset methods are
1062/// available directly on the form (`form.is_valid()`, `form.errors_for(…)`,
1063/// `form.rows()`, …).
1064impl<P, C> std::ops::Deref for NestedChangesetForm<P, C> {
1065 type Target = NestedChangeset<P, C>;
1066 fn deref(&self) -> &Self::Target {
1067 &self.changeset
1068 }
1069}
1070
1071impl<S, P, C> FromRequest<S> for NestedChangesetForm<P, C>
1072where
1073 S: Send + Sync,
1074 P: serde::de::DeserializeOwned + validator::Validate + Send,
1075 C: NestedChild,
1076{
1077 type Rejection = axum::response::Response;
1078
1079 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
1080 // Capture CSRF + submit-token context from request extensions exactly
1081 // as `ChangesetForm` does, before the body is consumed.
1082 let csrf_token = req
1083 .extensions()
1084 .get::<crate::security::CsrfToken>()
1085 .map(|t| t.token().to_string());
1086 let csrf_field = req
1087 .extensions()
1088 .get::<crate::security::csrf::CsrfFormField>()
1089 .map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
1090 let submit_token = req
1091 .extensions()
1092 .get::<crate::security::SubmitToken>()
1093 .map(|t| t.token().to_string());
1094 let submit_field = req
1095 .extensions()
1096 .get::<crate::security::SubmitFormField>()
1097 .map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
1098
1099 // Same content-type gate axum's own form extractors apply. Multipart
1100 // is out of scope for now (follow-up).
1101 let content_type = req
1102 .headers()
1103 .get(http::header::CONTENT_TYPE)
1104 .and_then(|v| v.to_str().ok())
1105 .unwrap_or_default()
1106 .to_string();
1107 if !content_type.starts_with("application/x-www-form-urlencoded") {
1108 return Err((
1109 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1110 "Nested form requests must have `Content-Type: application/x-www-form-urlencoded`",
1111 )
1112 .into_response());
1113 }
1114
1115 // Buffer through axum's `Bytes` extractor so `DefaultBodyLimit` is
1116 // enforced (a bare `to_bytes(.., usize::MAX)` would defeat it).
1117 let (parts, body) = req.into_parts();
1118 let bytes_req = Request::from_parts(parts, body);
1119 let bytes = axum::body::Bytes::from_request(bytes_req, state)
1120 .await
1121 .map_err(IntoResponse::into_response)?;
1122
1123 let pairs: Vec<(String, String)> = url::form_urlencoded::parse(&bytes)
1124 .map(|(k, v)| (k.into_owned(), v.into_owned()))
1125 .collect();
1126
1127 let changeset = decode_nested_urlencoded::<P, C>(&pairs)
1128 .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e).into_response())?;
1129
1130 Ok(Self {
1131 changeset,
1132 csrf_token,
1133 csrf_field,
1134 submit_token,
1135 submit_field,
1136 })
1137 }
1138}
1139
1140// ── Maud view helpers ──────────────────────────────────────────────
1141
1142/// A single row's rendering scope inside [`inputs_for`].
1143///
1144/// Each scope binds the child collection name, the row's 0-based `index`, and
1145/// (for existing/submitted rows) the underlying [`NestedRow`] so its raw values
1146/// pre-fill inputs and its per-subfield errors render inline on re-render after
1147/// a failed submission. A blank template row carries `row: None` — its inputs
1148/// render empty with no error blocks.
1149///
1150/// The row-scoped input builders mirror the standalone field helpers in
1151/// [`crate::form`] (`text_input`, `number_input`, …) but emit **nested** input
1152/// names (`items[{index}][{sub}]`) and per-row-unique element ids
1153/// (`items-{index}-{sub}`) so ids and `aria-describedby` links stay unique
1154/// across repeated rows.
1155#[cfg(feature = "maud")]
1156pub struct RowScope<'a> {
1157 collection: &'a str,
1158 index: usize,
1159 row: Option<&'a NestedRow>,
1160}
1161
1162#[cfg(feature = "maud")]
1163impl RowScope<'_> {
1164 /// This row's 0-based position in the collection.
1165 #[must_use]
1166 pub const fn index(&self) -> usize {
1167 self.index
1168 }
1169
1170 /// The nested input `name` for subfield `sub`, i.e.
1171 /// `"{collection}[{index}][{sub}]"`.
1172 #[must_use]
1173 pub fn field_name(&self, sub: &str) -> String {
1174 format!("{}[{}][{}]", self.collection, self.index, sub)
1175 }
1176
1177 /// The raw submitted value for subfield `sub`, or `None` for a blank row.
1178 #[must_use]
1179 pub fn value(&self, sub: &str) -> Option<&str> {
1180 self.row.and_then(|r| r.value(sub))
1181 }
1182
1183 /// Validation messages for subfield `sub`, or an empty slice (always empty
1184 /// for a blank row).
1185 #[must_use]
1186 pub fn errors_for(&self, sub: &str) -> &[String] {
1187 self.row.map_or(&[], |r| r.errors_for(sub))
1188 }
1189
1190 /// `true` when this (existing) row carried a truthy `_destroy` marker.
1191 #[must_use]
1192 pub fn is_destroyed(&self) -> bool {
1193 self.row.is_some_and(NestedRow::is_destroyed)
1194 }
1195
1196 /// Per-row-unique element id base for subfield `sub`
1197 /// (`"{collection}-{index}-{sub}"`), used for `id` / `aria-describedby`
1198 /// linkage so repeated rows never collide.
1199 fn element_id(&self, sub: &str) -> String {
1200 format!("{}-{}-{}", self.collection, self.index, sub)
1201 }
1202
1203 /// Render a labeled row-scoped `<input type="text">` for subfield `sub`.
1204 ///
1205 /// Mirrors [`crate::form::text_input`]: `autumn-field` wrapper, per-row
1206 /// pre-fill, `aria-invalid` / `aria-describedby`, and a `role="alert"`
1207 /// error block — but with the nested `name` and a per-row-unique `id`.
1208 #[must_use]
1209 pub fn text_input(&self, sub: &str, label: &str) -> maud::Markup {
1210 self.text_like_input(sub, label, false)
1211 }
1212
1213 /// Like [`RowScope::text_input`] but adds `required` + `aria-required="true"`
1214 /// — **only for real/submitted rows that are not marked for destruction**
1215 /// (`self.row.is_some() && !self.is_destroyed()`).
1216 ///
1217 /// A blank template row (`row: None`, the one [`inputs_for`] auto-appends
1218 /// for the no-JS "add a child" path) deliberately renders the *same* input
1219 /// **without** the client-side `required`/`aria-required` attributes so it
1220 /// stays leaveable-empty. Otherwise the browser's native constraint
1221 /// validation would block form submit on the empty trailing template row
1222 /// *before* the server's all-blank rejection can run — a user who filled one
1223 /// child row could not submit while a blank row sat beneath it. A submitted
1224 /// row the user ticked `_destroy` on is skipped for the same reason: the
1225 /// decoder drops destroyed rows before validation (and from `into_valid()`),
1226 /// so re-emitting client-side `required` on an empty destroyed field would
1227 /// let the browser block the next submit before the server can honor the
1228 /// removal. Required-ness is still enforced server-side for any row the user
1229 /// actually engages: a partially-filled, non-destroyed row is retained as a
1230 /// `Some` row on re-render and re-acquires `required`, and the decoder's
1231 /// all-blank rejection drops the untouched template row. This composes with
1232 /// that all-blank rejection so
1233 /// neither the client nor the server treats the template row as a real child.
1234 #[must_use]
1235 pub fn required_text_input(&self, sub: &str, label: &str) -> maud::Markup {
1236 self.text_like_input(sub, label, true)
1237 }
1238
1239 /// Shared body for the text-like inputs.
1240 fn text_like_input(&self, sub: &str, label: &str, required: bool) -> maud::Markup {
1241 // Emit the client-side `required`/`aria-required` constraint only for a
1242 // real/submitted row that is *not* marked for destruction. A blank
1243 // template row (`row: None`) must stay leaveable-empty so the browser
1244 // does not block submit on the trailing auto-appended blank row before
1245 // the server's all-blank rejection runs; server-side validation still
1246 // enforces required-ness for any row the user actually fills (it is
1247 // retained as a `Some` row on re-render). A submitted row the user
1248 // ticked `_destroy` on is likewise skipped: the decoder drops destroyed
1249 // rows before validation, so re-emitting client-side `required` on an
1250 // empty destroyed field would let the browser's constraint validation
1251 // block the next submit before the server can honor `_destroy`.
1252 let required = required && self.row.is_some() && !self.is_destroyed();
1253 let errors = self.errors_for(sub);
1254 let has_errors = !errors.is_empty();
1255 let value = self.value(sub).unwrap_or_default();
1256 let name = self.field_name(sub);
1257 let id = self.element_id(sub);
1258 let error_id = format!("{id}-error");
1259 let wrapper_id = format!("{id}-field");
1260
1261 maud::html! {
1262 div id=(wrapper_id) class="autumn-field" {
1263 label for=(id) class="autumn-field__label" { (label) }
1264 input
1265 type="text"
1266 id=(id)
1267 name=(name)
1268 value=(value)
1269 required[required]
1270 aria-required=[required.then_some("true")]
1271 class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
1272 aria-invalid=(if has_errors { "true" } else { "false" })
1273 aria-describedby=(if has_errors { error_id.as_str() } else { "" });
1274 @if has_errors {
1275 div id=(error_id) role="alert" class="autumn-field__errors" {
1276 @for error in errors {
1277 p class="autumn-field__error" { (error) }
1278 }
1279 }
1280 }
1281 }
1282 }
1283 }
1284
1285 /// Render a labeled row-scoped `<input type="number">` for subfield `sub`.
1286 ///
1287 /// Mirrors [`crate::form::number_input`] (leaving the browser-default
1288 /// `step`), with the nested `name` and a per-row-unique `id`.
1289 #[must_use]
1290 pub fn number_input(&self, sub: &str, label: &str) -> maud::Markup {
1291 let errors = self.errors_for(sub);
1292 let has_errors = !errors.is_empty();
1293 let value = self.value(sub).unwrap_or_default();
1294 let name = self.field_name(sub);
1295 let id = self.element_id(sub);
1296 let error_id = format!("{id}-error");
1297 let wrapper_id = format!("{id}-field");
1298
1299 maud::html! {
1300 div id=(wrapper_id) class="autumn-field" {
1301 label for=(id) class="autumn-field__label" { (label) }
1302 input
1303 type="number"
1304 id=(id)
1305 name=(name)
1306 value=(value)
1307 class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
1308 aria-invalid=(if has_errors { "true" } else { "false" })
1309 aria-describedby=(if has_errors { error_id.as_str() } else { "" });
1310 @if has_errors {
1311 div id=(error_id) role="alert" class="autumn-field__errors" {
1312 @for error in errors {
1313 p class="autumn-field__error" { (error) }
1314 }
1315 }
1316 }
1317 }
1318 }
1319 }
1320
1321 /// Render a labeled row-scoped `<textarea>` for subfield `sub`.
1322 ///
1323 /// Mirrors [`crate::form::textarea_input`]: the value is emitted as the
1324 /// element body, with the nested `name` and a per-row-unique `id`.
1325 #[must_use]
1326 pub fn textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
1327 let errors = self.errors_for(sub);
1328 let has_errors = !errors.is_empty();
1329 let value = self.value(sub).unwrap_or_default();
1330 let name = self.field_name(sub);
1331 let id = self.element_id(sub);
1332 let error_id = format!("{id}-error");
1333 let wrapper_id = format!("{id}-field");
1334
1335 maud::html! {
1336 div id=(wrapper_id) class="autumn-field" {
1337 label for=(id) class="autumn-field__label" { (label) }
1338 textarea
1339 id=(id)
1340 name=(name)
1341 class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
1342 aria-invalid=(if has_errors { "true" } else { "false" })
1343 aria-describedby=(if has_errors { error_id.as_str() } else { "" })
1344 { (value) }
1345 @if has_errors {
1346 div id=(error_id) role="alert" class="autumn-field__errors" {
1347 @for error in errors {
1348 p class="autumn-field__error" { (error) }
1349 }
1350 }
1351 }
1352 }
1353 }
1354 }
1355
1356 /// Render a row-scoped `<input type="hidden">` for subfield `sub`.
1357 ///
1358 /// Use this to carry an existing child's primary key (e.g. `id`) on an edit
1359 /// form so the decoder can match the submitted row back to a persisted record.
1360 #[must_use]
1361 pub fn hidden_input(&self, sub: &str, value: &str) -> maud::Markup {
1362 let name = self.field_name(sub);
1363 maud::html! {
1364 input type="hidden" name=(name) value=(value);
1365 }
1366 }
1367
1368 /// Render the durable no-JS removal control: a `_destroy` checkbox whose
1369 /// checked state is preserved on re-render (`checked` iff
1370 /// [`RowScope::is_destroyed`]).
1371 ///
1372 /// The decoder honours a truthy `_destroy` marker
1373 /// ([`decode_nested_urlencoded`]), so ticking this box and submitting the
1374 /// surrounding form removes the row with no JavaScript. htmx/JS row removal
1375 /// (swapping the `.nested-fields__row` node's `outerHTML`) is an optional
1376 /// progressive enhancement layered on top; this checkbox is the required
1377 /// mechanism.
1378 #[must_use]
1379 pub fn destroy_checkbox(&self, label: &str) -> maud::Markup {
1380 let checked = self.is_destroyed();
1381 let name = self.field_name("_destroy");
1382 let id = self.element_id("_destroy");
1383 maud::html! {
1384 div class="autumn-field autumn-field--destroy" {
1385 input
1386 type="checkbox"
1387 id=(id)
1388 name=(name)
1389 value="1"
1390 checked[checked]
1391 class="autumn-field__checkbox";
1392 label for=(id) class="autumn-field__label" { (label) }
1393 }
1394 }
1395 }
1396}
1397
1398/// Options for [`inputs_for`].
1399#[cfg(feature = "maud")]
1400pub struct InputsForOptions {
1401 /// Number of extra blank rows to pre-render after the existing rows. The
1402 /// no-JS fallback lets users fill and submit these without any JavaScript.
1403 /// Defaults to `1`; [`inputs_for`] always emits **at least one** blank
1404 /// template row even when this is `0`.
1405 pub blank_rows: usize,
1406 /// Optional htmx URL for the server "Add row" fragment endpoint (see
1407 /// [`nested_row_fragment`]). When `None`, no Add button is rendered and the
1408 /// no-JS path still works via the pre-rendered blank rows.
1409 pub add_row_url: Option<String>,
1410 /// Container element id. Defaults to `"{collection}-rows"`.
1411 ///
1412 /// This id is load-bearing when [`add_row_url`](Self::add_row_url) is set:
1413 /// the Add-row button's `hx-target` and its `hx-vals` index scan both select
1414 /// `#{container_id}`. HTML ids must be unique in a document, so **when two or
1415 /// more nested forms for the same child collection appear on one page, each
1416 /// MUST pass a distinct `container_id`.** The `{collection}-rows` default is
1417 /// correct for a single such form per page; leaving two same-collection forms
1418 /// on the default id produces duplicate ids (invalid HTML) and makes each
1419 /// Add-row button resolve against — and append rows / compute indices from —
1420 /// whichever container the shared id happens to match first, i.e. the wrong
1421 /// form. Distinct ids are the only requirement; any stable, unique string
1422 /// (e.g. `"shipping-items-rows"` vs `"billing-items-rows"`) works.
1423 pub container_id: Option<String>,
1424}
1425
1426#[cfg(feature = "maud")]
1427impl Default for InputsForOptions {
1428 fn default() -> Self {
1429 Self {
1430 blank_rows: 1,
1431 add_row_url: None,
1432 container_id: None,
1433 }
1434 }
1435}
1436
1437/// Render the repeating child field-group block for a nested (`has_many`) form.
1438///
1439/// Wraps the rows in `<div id="{container_id}" class="nested-fields">`. For each
1440/// existing/submitted row (from [`NestedChangeset::rows`], in order — **including**
1441/// rows re-submitted after a validation failure) it invokes `render_row` with a
1442/// [`RowScope`] carrying that row, so values and per-field errors pre-fill on
1443/// re-render. It then appends [`InputsForOptions::blank_rows`] blank rows (always
1444/// at least one) whose scopes carry `row: None`. Every row is wrapped in
1445/// `<div class="nested-fields__row" data-index="{i}">` so htmx/JS removal can
1446/// target the node's `outerHTML`.
1447///
1448/// The appended blank template row makes adding a child work with **no
1449/// JavaScript**, and it is safe even though the browser submits its empty
1450/// inputs: [`decode_nested_urlencoded`] applies Rails-style
1451/// `reject_if: :all_blank`, so a child row whose every non-`_destroy` subfield
1452/// is blank is ignored rather than decoded as a phantom child. An untouched
1453/// blank row therefore never blocks submission or persists an empty child.
1454///
1455/// So the no-JS path stays submittable, the appended blank template row's inputs
1456/// omit the client-side `required`/`aria-required` constraint by design (see
1457/// [`RowScope::required_text_input`]): a required-variant input renders empty and
1458/// *leaveable* on a `row: None` template row, so the browser's native validation
1459/// does not block submitting a form that still carries a trailing blank row.
1460/// This composes with the decoder's all-blank rejection — the untouched template
1461/// row is dropped server-side — while server-side validation still enforces
1462/// required-ness for any row the user actually fills.
1463///
1464/// When [`InputsForOptions::add_row_url`] is `Some`, an "Add row"
1465/// `<button type="button">` is emitted with `hx-get`, `hx-target="#{container_id}"`,
1466/// `hx-swap="beforeend"`, and — critically — `hx-params="index"` so **only**
1467/// the `index` param (supplied by the `hx-vals` below) is serialized into the
1468/// Add-row GET; every parent + line-item field, and `_csrf`/submit token, is
1469/// dropped, avoiding leaks into the request query string / proxy logs. `"none"`
1470/// can NOT be used: the vendored htmx 2.0.4 merges `hx-vals` into the request
1471/// `FormData` *before* the `hx-params` filter runs, so `"none"` (an empty
1472/// `FormData`) would strip the computed `index` too — naming `index` keeps it.
1473///
1474/// The button also carries an `hx-vals` (`js:` form) that computes a fresh
1475/// `index` for each request — `max(existing data-index) + 1` scanning only
1476/// `#{container_id} .nested-fields__row[data-index]` (scoped by the container
1477/// id so a form reads its own rows). The endpoint should read that `index`
1478/// param and pass it to [`nested_row_fragment`] so every appended row gets a
1479/// name unique within the form; a static request would reuse the same index and
1480/// emit duplicate control names. This is the JS-present enhancement path only —
1481/// the no-JS fallback (pre-rendered blank rows) needs no such counter and is
1482/// unaffected.
1483///
1484/// # Multiple same-collection forms on one page
1485///
1486/// Both the Add-row `hx-target` and its `hx-vals` index scan address the
1487/// container by its `#{container_id}`. That scoping is only distinct if the ids
1488/// are distinct, so **every same-collection nested form that also sets
1489/// `add_row_url` must be given its own [`InputsForOptions::container_id`]** when
1490/// more than one appears on a page. The default `{collection}-rows` id is right
1491/// for a single such form; two forms sharing it emit duplicate HTML ids and each
1492/// Add-row button then targets / scans whichever container the shared id resolves
1493/// to first (the wrong form). A relative, id-free `hx-vals` scan was evaluated
1494/// and rejected: the vendored htmx 2.0.4 evaluates a `js:` `hx-vals` expression
1495/// with `Function("return (" + expr + ")")()` — no receiver — so `this` is the
1496/// global object, not the triggering button, leaving no reliable handle to walk
1497/// to the button's own container. The unique-id contract above is therefore the
1498/// supported way to keep several same-collection forms independent.
1499///
1500/// # CSRF
1501///
1502/// This renders only the child block. The surrounding `<form>` — via
1503/// [`crate::form::form_tag`] / `ChangesetForm` — carries the CSRF and submit-token
1504/// fields exactly as today; do **not** duplicate them here.
1505///
1506/// # Example
1507///
1508/// ```rust,ignore
1509/// use autumn_web::form::{required_text_input, submit_button};
1510/// use autumn_web::nested_form::{inputs_for, InputsForOptions};
1511///
1512/// // `form` is a `NestedChangesetForm<NewOrder, NewLineItem>` re-rendered after
1513/// // a failed submit; it derefs to its inner `NestedChangeset`.
1514/// let opts = InputsForOptions {
1515/// add_row_url: Some("/orders/line-item-row".into()),
1516/// ..InputsForOptions::default()
1517/// };
1518/// // Prefer `form.form_tag` over the standalone `form_tag`: it emits the CSRF
1519/// // hidden field under the app-configured field name (and the submit token),
1520/// // so a customized `security.csrf.form_field` survives the re-render.
1521/// form.form_tag("/orders", "POST", maud::html! {
1522/// // Parent fields (CSRF + submit token are emitted by `form.form_tag`).
1523/// (required_text_input(&form.parent, "name", "Order name"))
1524/// // Repeating child rows.
1525/// (inputs_for(&form, &opts, |row| maud::html! {
1526/// (row.required_text_input("sku", "SKU"))
1527/// (row.number_input("quantity", "Quantity"))
1528/// (row.destroy_checkbox("Remove"))
1529/// }))
1530/// (submit_button("Create order"))
1531/// });
1532/// ```
1533#[cfg(feature = "maud")]
1534#[must_use]
1535pub fn inputs_for<P, C: NestedChild>(
1536 nested: &NestedChangeset<P, C>,
1537 opts: &InputsForOptions,
1538 render_row: impl Fn(&RowScope) -> maud::Markup,
1539) -> maud::Markup {
1540 let collection = C::COLLECTION;
1541 let container_id = opts
1542 .container_id
1543 .clone()
1544 .unwrap_or_else(|| format!("{collection}-rows"));
1545 let rows = nested.rows();
1546 // Always emit at least one blank template row so the no-JS path can add a
1547 // child even when the caller asked for zero.
1548 let blank_count = opts.blank_rows.max(1);
1549
1550 maud::html! {
1551 div id=(container_id) class="nested-fields" {
1552 @for (i, row) in rows.iter().enumerate() {
1553 @let scope = RowScope { collection, index: i, row: Some(row) };
1554 div class="nested-fields__row" data-index=(i) {
1555 (render_row(&scope))
1556 }
1557 }
1558 @for k in 0..blank_count {
1559 @let index = rows.len() + k;
1560 @let scope = RowScope { collection, index, row: None };
1561 div class="nested-fields__row" data-index=(index) {
1562 (render_row(&scope))
1563 }
1564 }
1565 @if let Some(url) = &opts.add_row_url {
1566 // Send a *fresh* unique row index with every Add-row request.
1567 // `nested_row_fragment` requires the returned row's index to be
1568 // unique within this form; a static request would keep reusing
1569 // the same index and produce duplicate control names. Compute
1570 // `max(existing data-index) + 1` from this container's rows,
1571 // scoped by `#{container_id}`. That scoping is only distinct when
1572 // the id is distinct: callers rendering more than one
1573 // same-collection nested form on a page MUST give each a unique
1574 // `container_id` (see `InputsForOptions::container_id`), because
1575 // the vendored htmx 2.0.4 evaluates this `js:` `hx-vals` with no
1576 // `this`/`event` bound to the button, so an id-free relative walk
1577 // to the button's own container is not available. The decoder
1578 // tolerates gaps, so this stays collision-free even after
1579 // client-side row removals.
1580 //
1581 // `hx-params="index"` serializes ONLY the `index` param with
1582 // the Add-row GET: per htmx semantics a bare comma-list keeps
1583 // just the named params, so every parent + line-item field
1584 // (and `_csrf`/submit token) is dropped from the request query
1585 // string / proxy logs, avoiding leaks and URL-length limits.
1586 // We can NOT use `"none"`: in the vendored htmx 2.0.4 the
1587 // `hx-vals` values are merged into the request FormData BEFORE
1588 // the `hx-params` filter runs (see `htmx.min.js`: the request
1589 // builder does `const v=ln(j,V);let w=hn(v,r)` where `V` is the
1590 // resolved `hx-vals` and `hn` is `filterValues`), so `"none"`
1591 // (which returns an empty FormData) would strip the `index` we
1592 // compute here too. Naming `index` keeps exactly that one value.
1593 @let hx_vals = format!(
1594 "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}}"
1595 );
1596 button
1597 type="button"
1598 class="nested-fields__add"
1599 hx-get=(url)
1600 hx-target=(format!("#{container_id}"))
1601 hx-swap="beforeend"
1602 hx-params="index"
1603 hx-vals=(hx_vals)
1604 { "Add row" }
1605 }
1606 }
1607 }
1608}
1609
1610/// Render a single blank child row for the htmx "Add row" fragment endpoint.
1611///
1612/// Returns one `<div class="nested-fields__row" data-index="{index}">` produced
1613/// by `render_row` with a blank [`RowScope`]. Because the decoder tolerates
1614/// non-contiguous indices, `index` only needs to be **unique** within the form
1615/// (e.g. a monotonically increasing counter the client tracks), not contiguous.
1616///
1617/// The [`inputs_for`] Add-row button sends this unique value as an `index`
1618/// param (via `hx-vals`, computed as `max(existing data-index) + 1`). Because
1619/// the button sets `hx-params="index"`, the Add-row request carries ONLY the
1620/// `hx-vals` `index` — no parent/line-item form fields and no CSRF/submit
1621/// token are serialized — and `index` is the only value this endpoint needs.
1622/// The endpoint should read that param from the query/form and pass it straight
1623/// to `nested_row_fragment(index, ..)`. Uniqueness — not contiguity — is the
1624/// contract: the decoder tolerates gaps, so indices left behind by client-side
1625/// row removals never cause collisions.
1626#[cfg(feature = "maud")]
1627#[must_use]
1628pub fn nested_row_fragment<C: NestedChild>(
1629 index: usize,
1630 render_row: impl Fn(&RowScope) -> maud::Markup,
1631) -> maud::Markup {
1632 let scope = RowScope {
1633 collection: C::COLLECTION,
1634 index,
1635 row: None,
1636 };
1637 maud::html! {
1638 div class="nested-fields__row" data-index=(index) {
1639 (render_row(&scope))
1640 }
1641 }
1642}
1643
1644// ── Tests ──────────────────────────────────────────────────────────
1645
1646#[cfg(test)]
1647mod tests {
1648 use super::*;
1649
1650 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1651 struct Order {
1652 #[validate(length(min = 1, message = "name required"))]
1653 name: String,
1654 }
1655
1656 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
1657 struct LineItem {
1658 #[validate(length(min = 1, message = "sku required"))]
1659 sku: String,
1660 #[validate(range(min = 1, message = "quantity must be >= 1"))]
1661 quantity: i32,
1662 }
1663
1664 impl NestedChild for LineItem {
1665 const COLLECTION: &'static str = "items";
1666 }
1667
1668 /// A child with one **required** field (`name`) and one **optional** field
1669 /// (`nickname`), used to exercise the reject-all-blank rules.
1670 #[derive(serde::Deserialize, validator::Validate)]
1671 struct Contact {
1672 #[validate(length(min = 1, message = "name required"))]
1673 name: String,
1674 #[validate(length(min = 1, message = "nickname too short"))]
1675 nickname: Option<String>,
1676 }
1677
1678 impl NestedChild for Contact {
1679 const COLLECTION: &'static str = "contacts";
1680 }
1681
1682 fn p(k: &str, v: &str) -> (String, String) {
1683 (k.to_owned(), v.to_owned())
1684 }
1685
1686 #[test]
1687 fn binds_parent_and_two_children_in_order() {
1688 let pairs = vec![
1689 p("name", "Order 1"),
1690 p("items[0][sku]", "A-1"),
1691 p("items[0][quantity]", "2"),
1692 p("items[1][sku]", "B-2"),
1693 p("items[1][quantity]", "3"),
1694 ];
1695 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1696 assert!(cs.is_valid());
1697 assert_eq!(cs.rows().len(), 2);
1698 assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
1699 assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
1700 assert_eq!(cs.collection_name(), "items");
1701
1702 let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1703 assert_eq!(order.name, "Order 1");
1704 assert_eq!(items.len(), 2);
1705 assert_eq!(items[0].sku, "A-1");
1706 assert_eq!(items[0].quantity, 2);
1707 assert_eq!(items[1].quantity, 3);
1708 }
1709
1710 #[test]
1711 fn non_contiguous_indices_compact_preserving_order() {
1712 let pairs = vec![
1713 p("name", "Order"),
1714 p("items[0][sku]", "A"),
1715 p("items[0][quantity]", "1"),
1716 p("items[2][sku]", "C"),
1717 p("items[2][quantity]", "5"),
1718 ];
1719 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1720 // Gap at index 1 compacts: two rows in ascending order.
1721 assert_eq!(cs.rows().len(), 2);
1722 assert_eq!(cs.rows()[0].value("sku"), Some("A"));
1723 assert_eq!(cs.rows()[1].value("sku"), Some("C"));
1724 assert!(cs.is_valid());
1725
1726 let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1727 assert_eq!(items.len(), 2);
1728 assert_eq!(items[0].sku, "A");
1729 assert_eq!(items[1].sku, "C");
1730 }
1731
1732 #[test]
1733 fn destroy_marker_drops_row_from_children_but_retains_it() {
1734 let pairs = vec![
1735 p("name", "Order"),
1736 p("items[0][sku]", "A"),
1737 p("items[0][quantity]", "1"),
1738 p("items[1][sku]", "X"),
1739 p("items[1][quantity]", "9"),
1740 p("items[1][_destroy]", "1"),
1741 ];
1742 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1743 assert_eq!(cs.rows().len(), 2);
1744 assert!(!cs.rows()[0].is_destroyed());
1745 assert!(cs.rows()[1].is_destroyed());
1746 // Destroyed row is still retained with its raw values for re-render.
1747 assert_eq!(cs.rows()[1].value("sku"), Some("X"));
1748 assert!(cs.is_valid());
1749
1750 let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1751 // Only the non-destroyed row contributes.
1752 assert_eq!(items.len(), 1);
1753 assert_eq!(items[0].sku, "A");
1754 }
1755
1756 #[test]
1757 fn child_validation_failure_surfaces_combined_key_and_blocks_valid() {
1758 let pairs = vec![
1759 p("name", "Order"),
1760 p("items[0][sku]", "A"),
1761 p("items[0][quantity]", "2"),
1762 // quantity = 0 violates range(min = 1)
1763 p("items[1][sku]", "B"),
1764 p("items[1][quantity]", "0"),
1765 ];
1766 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1767 assert!(!cs.is_valid());
1768 assert!(!cs.errors_for("items[1].quantity").is_empty());
1769 // The valid sibling row has no error for that key.
1770 assert!(cs.errors_for("items[0].quantity").is_empty());
1771 assert!(cs.into_valid().is_err());
1772 }
1773
1774 #[test]
1775 fn all_valid_yields_ok_with_coerced_numeric_fields() {
1776 let pairs = vec![
1777 p("name", "Order"),
1778 p("items[0][sku]", "A"),
1779 p("items[0][quantity]", "5"),
1780 ];
1781 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1782 assert!(cs.is_valid());
1783 let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1784 assert_eq!(order.name, "Order");
1785 assert_eq!(items.len(), 1);
1786 // "5" coerced to i32.
1787 let q: i32 = items[0].quantity;
1788 assert_eq!(q, 5);
1789 }
1790
1791 #[test]
1792 fn parent_invalid_blocks_valid_children() {
1793 let pairs = vec![
1794 // Empty parent name violates length(min = 1).
1795 p("name", ""),
1796 p("items[0][sku]", "A"),
1797 p("items[0][quantity]", "1"),
1798 ];
1799 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1800 assert!(!cs.is_valid());
1801 assert!(!cs.errors_for("name").is_empty());
1802 assert!(cs.into_valid().is_err());
1803 }
1804
1805 #[test]
1806 fn child_parse_failure_keys_error_to_offending_field() {
1807 let pairs = vec![
1808 p("name", "Order"),
1809 // `sku` is filled, so the row is NOT all-blank (it is kept, not
1810 // dropped) — contrast `all_blank_template_row_is_ignored`, where an
1811 // empty row is dropped entirely with no error.
1812 p("items[0][sku]", "A"),
1813 // Non-numeric quantity: hard parse failure for i32, before
1814 // validation. `serde_path_to_error` attributes it to `quantity`.
1815 p("items[0][quantity]", "not-a-number"),
1816 ];
1817 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1818 assert!(!cs.is_valid());
1819 assert_eq!(cs.rows().len(), 1);
1820 // Raw values retained for re-render.
1821 assert_eq!(cs.rows()[0].value("sku"), Some("A"));
1822 assert_eq!(cs.rows()[0].value("quantity"), Some("not-a-number"));
1823 // The parse error is keyed to the offending subfield, so it renders next
1824 // to the bad input via `errors_for("quantity")` on that row.
1825 assert!(!cs.errors_for("items[0].quantity").is_empty());
1826 assert!(!cs.rows()[0].errors_for("quantity").is_empty());
1827 // It is NOT stored under the row-level "" key anymore.
1828 assert!(cs.rows()[0].errors_for("").is_empty());
1829 assert!(cs.errors_for("items[0]").is_empty());
1830 assert!(cs.into_valid().is_err());
1831 }
1832
1833 #[test]
1834 fn child_parse_failure_on_blank_typed_field_keys_error_to_field() {
1835 // The Codex case: a row with `sku` filled but the required TYPED
1836 // `quantity` present-but-blank. The blank-optional retry inside
1837 // `decode_urlencoded_dropping_blank_optional_fields` DROPS the blank
1838 // `quantity=` pair, so serde reports `missing field \`quantity\`` at the
1839 // ROW ROOT (empty path). The field-recovery re-decode over the original
1840 // (blank-retained) pairs must still key the error to `quantity` so it
1841 // renders next to the blank input rather than the row-level "" key.
1842 let pairs = vec![
1843 p("name", "Order"),
1844 p("items[0][sku]", "A"),
1845 p("items[0][quantity]", ""),
1846 ];
1847 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
1848 assert!(!cs.is_valid());
1849 assert_eq!(cs.rows().len(), 1);
1850 // Keyed to the offending subfield, so it renders next to the blank input.
1851 assert!(!cs.errors_for("items[0].quantity").is_empty());
1852 assert!(!cs.rows()[0].errors_for("quantity").is_empty());
1853 // NOT stored under the row-level "" / `items[0]` key.
1854 assert!(cs.rows()[0].errors_for("").is_empty());
1855 assert!(cs.errors_for("items[0]").is_empty());
1856 assert!(cs.into_valid().is_err());
1857 }
1858
1859 #[test]
1860 fn child_row_with_blank_optional_typed_field_still_decodes_valid() {
1861 // Regression guard for the fix: a row with the required field filled and
1862 // an OPTIONAL TYPED field present-but-blank (`weight=`) must still decode
1863 // to a VALID row. The primary blank-dropping decode drops the blank
1864 // `weight=` pair so it resolves to `None` and the decode SUCCEEDS — the
1865 // field-recovery re-decode (which runs only on the failed path) is never
1866 // reached, and the row must stay valid.
1867 #[derive(serde::Deserialize, validator::Validate)]
1868 struct Widget {
1869 #[validate(length(min = 1, message = "label required"))]
1870 label: String,
1871 // Typed optional: a blank submission is dropped to `None` by the
1872 // blank-optional decode (an empty string is not a valid `i32`).
1873 weight: Option<i32>,
1874 }
1875 impl NestedChild for Widget {
1876 const COLLECTION: &'static str = "widgets";
1877 }
1878
1879 let pairs = vec![
1880 p("name", "Order"),
1881 p("widgets[0][label]", "Bolt"),
1882 p("widgets[0][weight]", ""),
1883 ];
1884 let cs = decode_nested_urlencoded::<Order, Widget>(&pairs).expect("parent decodes");
1885 assert!(cs.is_valid());
1886 assert_eq!(cs.rows().len(), 1);
1887 assert!(cs.errors_for("widgets[0].weight").is_empty());
1888 assert!(cs.rows()[0].errors_for("weight").is_empty());
1889 let (_order, widgets) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1890 assert_eq!(widgets.len(), 1);
1891 assert_eq!(widgets[0].label, "Bolt");
1892 assert_eq!(widgets[0].weight, None);
1893 }
1894
1895 #[test]
1896 fn child_optional_blank_before_required_blank_keys_error_to_required_field() {
1897 // Codex P2 follow-on: a child whose OPTIONAL typed field (`weight:
1898 // Option<i32>`) is declared BEFORE a REQUIRED typed field (`quantity:
1899 // i32`), with BOTH submitted blank. The blank-dropping decode drops both
1900 // blank pairs and ends at `missing field \`quantity\`` — the required
1901 // one. A raw non-dropping re-decode, by contrast, trips FIRST on the
1902 // harmless optional `weight=` (parsing `""` as an `i32` at `.weight`), so
1903 // it would mis-key the error to `weight`. The field-recovery therefore
1904 // prefers the PRIMARY error's missing-field message over the raw
1905 // re-decode, so the inline error lands on `quantity`, not `weight`.
1906 #[derive(serde::Deserialize, validator::Validate)]
1907 struct Part {
1908 #[validate(length(min = 1, message = "sku required"))]
1909 sku: String,
1910 // Optional, declared FIRST — a blank submission is harmless (→ None).
1911 // Never read on this invalid-row path (the row fails to fully decode),
1912 // so silence the dead-field lint.
1913 #[allow(dead_code)]
1914 weight: Option<i32>,
1915 // Required, declared AFTER — a blank submission is the real error.
1916 #[validate(range(min = 1, message = "quantity must be >= 1"))]
1917 quantity: i32,
1918 }
1919 impl NestedChild for Part {
1920 const COLLECTION: &'static str = "items";
1921 }
1922
1923 let pairs = vec![
1924 p("name", "Order"),
1925 // `sku` filled so the row is retained (not all-blank).
1926 p("items[0][sku]", "Widget"),
1927 p("items[0][weight]", ""),
1928 p("items[0][quantity]", ""),
1929 ];
1930 let cs = decode_nested_urlencoded::<Order, Part>(&pairs).expect("parent decodes");
1931 assert!(!cs.is_valid());
1932 assert_eq!(cs.rows().len(), 1);
1933 // The error is keyed to the truly-missing REQUIRED field…
1934 assert!(!cs.errors_for("items[0].quantity").is_empty());
1935 assert!(!cs.rows()[0].errors_for("quantity").is_empty());
1936 // …and NOT to the harmless OPTIONAL blank the raw re-decode trips on.
1937 assert!(cs.errors_for("items[0].weight").is_empty());
1938 assert!(cs.rows()[0].errors_for("weight").is_empty());
1939 // Not stored under the row-level "" / `items[0]` key either.
1940 assert!(cs.rows()[0].errors_for("").is_empty());
1941 assert!(cs.errors_for("items[0]").is_empty());
1942 assert!(cs.into_valid().is_err());
1943 }
1944
1945 #[test]
1946 fn parent_hard_parse_failure_is_err() {
1947 #[derive(serde::Deserialize, validator::Validate)]
1948 struct NumericParent {
1949 #[validate(range(min = 0))]
1950 count: i32,
1951 }
1952 #[derive(serde::Deserialize, validator::Validate)]
1953 struct Child {
1954 #[validate(length(min = 1))]
1955 name: String,
1956 }
1957 impl NestedChild for Child {
1958 const COLLECTION: &'static str = "kids";
1959 }
1960
1961 let pairs = vec![p("count", "not-a-number")];
1962 let result = decode_nested_urlencoded::<NumericParent, Child>(&pairs);
1963 assert!(result.is_err());
1964 }
1965
1966 // ── reject_if: :all_blank ──────────────────────────────────────
1967
1968 /// The core regression test for the phantom-blank-row P1: `inputs_for`
1969 /// always emits a trailing all-blank template row, and with no JS the
1970 /// browser submits its empty inputs. That row must be ignored, not decoded
1971 /// as a real (empty) child.
1972 #[test]
1973 fn all_blank_template_row_is_ignored() {
1974 let pairs = vec![
1975 p("name", "Order"),
1976 // One filled, valid child row.
1977 p("contacts[0][name]", "Alice"),
1978 p("contacts[0][nickname]", "Al"),
1979 // The auto-rendered blank template row: every subfield empty.
1980 p("contacts[1][name]", ""),
1981 p("contacts[1][nickname]", ""),
1982 ];
1983 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
1984 assert!(cs.is_valid());
1985 // The blank template row was dropped entirely — only the real row remains.
1986 assert_eq!(cs.rows().len(), 1);
1987 assert_eq!(cs.rows()[0].value("name"), Some("Alice"));
1988
1989 let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
1990 assert_eq!(contacts.len(), 1);
1991 assert_eq!(contacts[0].name, "Alice");
1992 }
1993
1994 /// Blank detection trims: a row whose fields are whitespace-only (or empty)
1995 /// is treated as blank and dropped.
1996 #[test]
1997 fn whitespace_only_row_is_ignored() {
1998 let pairs = vec![
1999 p("name", "Order"),
2000 p("contacts[0][name]", "Bob"),
2001 // Whitespace-only + empty: still all-blank after trimming.
2002 p("contacts[1][name]", " "),
2003 p("contacts[1][nickname]", ""),
2004 ];
2005 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2006 assert!(cs.is_valid());
2007 assert_eq!(cs.rows().len(), 1);
2008 assert_eq!(cs.rows()[0].value("name"), Some("Bob"));
2009
2010 let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2011 assert_eq!(contacts.len(), 1);
2012 }
2013
2014 /// A row with at least one non-blank value is a real child the user is
2015 /// adding: it is kept and validated, so a missing **required** field still
2016 /// surfaces a per-row error. We must not over-aggressively drop it.
2017 #[test]
2018 fn partially_filled_row_with_missing_required_still_errors() {
2019 let pairs = vec![
2020 p("name", "Order"),
2021 // Required `name` left blank, but the optional `nickname` is filled,
2022 // so the row is NOT all-blank: it is kept and must error.
2023 p("contacts[0][name]", ""),
2024 p("contacts[0][nickname]", "Ally"),
2025 ];
2026 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2027 assert!(!cs.is_valid());
2028 assert_eq!(cs.rows().len(), 1);
2029 // The kept row surfaces the required-field error under its combined key.
2030 assert!(!cs.errors_for("contacts[0].name").is_empty());
2031 // Raw values retained for re-render.
2032 assert_eq!(cs.rows()[0].value("nickname"), Some("Ally"));
2033 assert!(cs.into_valid().is_err());
2034 }
2035
2036 /// A fully filled row is still bound — reject-all-blank does not regress the
2037 /// happy path when every submitted row carries real values.
2038 #[test]
2039 fn fully_filled_row_is_still_bound() {
2040 let pairs = vec![
2041 p("name", "Order"),
2042 p("contacts[0][name]", "Carol"),
2043 p("contacts[0][nickname]", "Caz"),
2044 ];
2045 let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
2046 assert!(cs.is_valid());
2047 assert_eq!(cs.rows().len(), 1);
2048
2049 let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
2050 assert_eq!(contacts.len(), 1);
2051 assert_eq!(contacts[0].name, "Carol");
2052 assert_eq!(contacts[0].nickname.as_deref(), Some("Caz"));
2053 }
2054
2055 #[test]
2056 fn parse_child_key_matches_and_rejects() {
2057 assert_eq!(parse_child_key("items[0][sku]", "items"), Some((0, "sku")));
2058 assert_eq!(
2059 parse_child_key("items[12][quantity]", "items"),
2060 Some((12, "quantity"))
2061 );
2062 // Wrong collection.
2063 assert_eq!(parse_child_key("other[0][sku]", "items"), None);
2064 // Not a child subfield (parent key).
2065 assert_eq!(parse_child_key("name", "items"), None);
2066 // Trailing junk after the closing bracket.
2067 assert_eq!(parse_child_key("items[0][sku]x", "items"), None);
2068 // Non-numeric index.
2069 assert_eq!(parse_child_key("items[a][sku]", "items"), None);
2070 }
2071
2072 // ── blank (non-validating) constructor ─────────────────────────
2073
2074 #[test]
2075 fn blank_changeset_is_valid_with_no_rows_or_errors() {
2076 // The initial `new`-page path: the required parent `name` is still
2077 // empty, but `blank` does NOT validate, so nothing errors before the
2078 // user types (unlike `decode_nested_urlencoded`).
2079 let cs = NestedChangeset::<Order, LineItem>::blank(Order {
2080 name: String::new(),
2081 });
2082 assert!(cs.is_valid());
2083 assert!(cs.errors_for("name").is_empty());
2084 assert!(cs.rows().is_empty());
2085
2086 let (order, items) = cs
2087 .into_valid()
2088 .unwrap_or_else(|_| panic!("blank changeset is valid"));
2089 assert_eq!(order.name, "");
2090 assert!(items.is_empty());
2091 }
2092
2093 #[test]
2094 fn decoded_empty_parent_errors_unlike_blank() {
2095 // Contrast the POST decode path: it DOES validate, so the same empty
2096 // required parent surfaces an error — exactly what `blank` avoids on the
2097 // initial GET render.
2098 let cs =
2099 decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
2100 assert!(!cs.is_valid());
2101 assert!(!cs.errors_for("name").is_empty());
2102 }
2103
2104 // ── seeded (edit-render) constructor ───────────────────────────
2105
2106 #[test]
2107 fn seeded_changeset_pre_populates_one_row_per_child() {
2108 // The initial `edit`-page path: seed the changeset with the two existing
2109 // children so an edit form can display/preserve them before first submit.
2110 let cs = NestedChangeset::seeded(
2111 Order {
2112 name: "Existing order".to_owned(),
2113 },
2114 vec![
2115 LineItem {
2116 sku: "A-1".to_owned(),
2117 quantity: 2,
2118 },
2119 LineItem {
2120 sku: "B-2".to_owned(),
2121 quantity: 3,
2122 },
2123 ],
2124 );
2125
2126 // One row per seeded child, each pre-filled with its serialized values —
2127 // the same string representation the decoder populates.
2128 assert_eq!(cs.rows().len(), 2);
2129 assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
2130 assert_eq!(cs.rows()[0].value("quantity"), Some("2"));
2131 assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
2132 assert_eq!(cs.rows()[1].value("quantity"), Some("3"));
2133
2134 // Non-validating and clean: no errors, no destroyed rows.
2135 assert!(cs.is_valid());
2136 assert!(cs.errors_for("name").is_empty());
2137 assert!(cs.errors_for("items[0].sku").is_empty());
2138 assert!(cs.errors_for("items[1].quantity").is_empty());
2139 assert!(!cs.rows()[0].is_destroyed());
2140 assert!(!cs.rows()[1].is_destroyed());
2141
2142 // `into_valid` returns the seeded parent + children unchanged.
2143 let (order, items) = cs
2144 .into_valid()
2145 .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2146 assert_eq!(order.name, "Existing order");
2147 assert_eq!(items.len(), 2);
2148 assert_eq!(items[0].sku, "A-1");
2149 assert_eq!(items[0].quantity, 2);
2150 assert_eq!(items[1].sku, "B-2");
2151 assert_eq!(items[1].quantity, 3);
2152 }
2153
2154 #[test]
2155 fn seeded_changeset_with_no_children_is_valid_and_empty() {
2156 // Seeding zero children behaves like `blank` — a valid changeset with no
2157 // rows — so an edit form for a parent that currently has no children is
2158 // handled uniformly.
2159 let cs = NestedChangeset::<Order, LineItem>::seeded(
2160 Order {
2161 name: "Empty".to_owned(),
2162 },
2163 Vec::new(),
2164 );
2165 assert!(cs.is_valid());
2166 assert!(cs.rows().is_empty());
2167 let (_order, items) = cs
2168 .into_valid()
2169 .unwrap_or_else(|_| panic!("seeded changeset is valid"));
2170 assert!(items.is_empty());
2171 }
2172}
2173
2174#[cfg(all(test, feature = "maud"))]
2175mod maud_tests {
2176 use super::*;
2177
2178 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2179 struct Order {
2180 #[validate(length(min = 1, message = "name required"))]
2181 name: String,
2182 }
2183
2184 #[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
2185 struct LineItem {
2186 #[validate(length(min = 1, message = "sku required"))]
2187 sku: String,
2188 #[validate(range(min = 1, message = "quantity must be >= 1"))]
2189 quantity: i32,
2190 }
2191
2192 impl NestedChild for LineItem {
2193 const COLLECTION: &'static str = "items";
2194 }
2195
2196 fn p(k: &str, v: &str) -> (String, String) {
2197 (k.to_owned(), v.to_owned())
2198 }
2199
2200 /// A changeset with two submitted rows, the second failing child validation
2201 /// (`quantity = 0`), so both rows are retained for re-render.
2202 fn two_row_changeset() -> NestedChangeset<Order, LineItem> {
2203 let pairs = vec![
2204 p("name", "Order 1"),
2205 p("items[0][sku]", "A-1"),
2206 p("items[0][quantity]", "2"),
2207 p("items[1][sku]", "B-2"),
2208 // quantity = 0 violates range(min = 1): row 1 is retained with an error.
2209 p("items[1][quantity]", "0"),
2210 ];
2211 decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes")
2212 }
2213
2214 fn render_row(row: &RowScope) -> maud::Markup {
2215 maud::html! {
2216 (row.required_text_input("sku", "SKU"))
2217 (row.number_input("quantity", "Quantity"))
2218 (row.destroy_checkbox("Remove"))
2219 }
2220 }
2221
2222 #[test]
2223 fn existing_rows_render_indexed_names_and_prefilled_values() {
2224 let cs = two_row_changeset();
2225 let opts = InputsForOptions::default();
2226 let html = inputs_for(&cs, &opts, render_row).into_string();
2227
2228 assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
2229 assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
2230 // Pre-filled values from the re-rendered changeset.
2231 assert!(html.contains(r#"value="A-1""#), "{html}");
2232 assert!(html.contains(r#"value="B-2""#), "{html}");
2233 // Container defaults to "{collection}-rows".
2234 assert!(html.contains(r#"id="items-rows""#), "{html}");
2235 }
2236
2237 #[test]
2238 fn per_row_error_renders_scoped_alert_block() {
2239 let cs = two_row_changeset();
2240 let opts = InputsForOptions::default();
2241 let html = inputs_for(&cs, &opts, render_row).into_string();
2242
2243 // The failing row's quantity error is scoped to that row's unique id.
2244 assert!(html.contains(r#"id="items-1-quantity-error""#), "{html}");
2245 assert!(html.contains(r#"role="alert""#), "{html}");
2246 assert!(html.contains("quantity must be >= 1"), "{html}");
2247 // The valid sibling row's quantity has no error block.
2248 assert!(!html.contains(r#"id="items-0-quantity-error""#), "{html}");
2249 }
2250
2251 #[test]
2252 fn appends_blank_template_row_with_next_index() {
2253 let cs = two_row_changeset();
2254 let opts = InputsForOptions::default();
2255 let html = inputs_for(&cs, &opts, render_row).into_string();
2256
2257 // Two existing rows (indices 0,1) plus one blank row at index 2.
2258 assert!(html.contains(r#"data-index="0""#), "{html}");
2259 assert!(html.contains(r#"data-index="1""#), "{html}");
2260 assert!(html.contains(r#"data-index="2""#), "{html}");
2261 assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
2262 }
2263
2264 /// Extract the full `<...>` tag containing the given `name="…"` attribute,
2265 /// so a per-input attribute assertion isn't fooled by a sibling row's tag.
2266 fn tag_with_name(html: &str, name: &str) -> String {
2267 let needle = format!(r#"name="{name}""#);
2268 let at = html
2269 .find(&needle)
2270 .unwrap_or_else(|| panic!("missing {name} in {html}"));
2271 let open = html[..at].rfind('<').expect("tag has an opening '<'");
2272 let close = html[at..].find('>').expect("tag has a closing '>'") + at;
2273 html[open..=close].to_string()
2274 }
2275
2276 /// Fix 1: a required-variant input on a submitted row carries the client-side
2277 /// `required`/`aria-required` constraint, but the auto-appended blank template
2278 /// row (`row: None`) omits it so the form stays submittable with a trailing
2279 /// blank row present (server-side validation still enforces required-ness).
2280 #[test]
2281 fn blank_template_row_omits_client_required_but_submitted_row_keeps_it() {
2282 let cs = two_row_changeset();
2283 let opts = InputsForOptions::default();
2284 let html = inputs_for(&cs, &opts, render_row).into_string();
2285
2286 // Submitted rows (indices 0, 1) keep the client-side constraint.
2287 let submitted = tag_with_name(&html, "items[0][sku]");
2288 assert!(submitted.contains(" required"), "{submitted}");
2289 assert!(submitted.contains(r#"aria-required="true""#), "{submitted}");
2290
2291 // The appended blank template row (index 2) omits both, so it is
2292 // leaveable-empty and does not block submitting the form.
2293 let blank = tag_with_name(&html, "items[2][sku]");
2294 assert!(!blank.contains("required"), "{blank}");
2295 assert!(!blank.contains("aria-required"), "{blank}");
2296 }
2297
2298 /// Fix (Codex P2, near the `required` gate): a submitted row the user ticked
2299 /// `_destroy` on must ALSO omit the client-side `required`/`aria-required`,
2300 /// exactly like a blank template row. Otherwise, in a 422 re-render where the
2301 /// user checked Remove on a row with an empty required field, the browser's
2302 /// constraint validation would block the next submit before the server can
2303 /// honor `_destroy`. A non-destroyed submitted row in the same render keeps
2304 /// the constraint.
2305 #[test]
2306 fn destroyed_row_omits_client_required_while_submitted_row_keeps_it() {
2307 // Row 0: an ordinary submitted row. Row 1: the user ticked Remove on a
2308 // row whose required `sku` is empty (it is retained because `quantity`
2309 // is non-blank, so the all-blank rejection does not drop it).
2310 let pairs = vec![
2311 p("name", "Order 1"),
2312 p("items[0][sku]", "A-1"),
2313 p("items[0][quantity]", "2"),
2314 p("items[1][sku]", ""),
2315 p("items[1][quantity]", "9"),
2316 p("items[1][_destroy]", "1"),
2317 ];
2318 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2319 assert!(cs.rows()[1].is_destroyed());
2320 let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2321
2322 // The non-destroyed submitted row keeps the client-side constraint.
2323 let kept = tag_with_name(&html, "items[0][sku]");
2324 assert!(kept.contains(" required"), "{kept}");
2325 assert!(kept.contains(r#"aria-required="true""#), "{kept}");
2326
2327 // The destroyed row omits both, exactly like a blank template row, so a
2328 // form carrying an empty required field on a to-be-removed row still
2329 // submits and the server can honor `_destroy`.
2330 let destroyed = tag_with_name(&html, "items[1][sku]");
2331 assert!(!destroyed.contains("required"), "{destroyed}");
2332 assert!(!destroyed.contains("aria-required"), "{destroyed}");
2333 }
2334
2335 #[test]
2336 fn always_emits_a_blank_row_even_when_blank_rows_zero() {
2337 let cs = two_row_changeset();
2338 let opts = InputsForOptions {
2339 blank_rows: 0,
2340 ..InputsForOptions::default()
2341 };
2342 let html = inputs_for(&cs, &opts, render_row).into_string();
2343 // Still emits the blank template row at index 2.
2344 assert!(html.contains(r#"data-index="2""#), "{html}");
2345 }
2346
2347 #[test]
2348 fn destroy_checkbox_emits_indexed_marker() {
2349 let cs = two_row_changeset();
2350 let opts = InputsForOptions::default();
2351 let html = inputs_for(&cs, &opts, render_row).into_string();
2352
2353 assert!(html.contains(r#"name="items[0][_destroy]""#), "{html}");
2354 assert!(html.contains(r#"name="items[1][_destroy]""#), "{html}");
2355 assert!(html.contains(r#"type="checkbox""#), "{html}");
2356 }
2357
2358 #[test]
2359 fn add_button_renders_only_with_url_and_carries_htmx_attrs() {
2360 let cs = two_row_changeset();
2361
2362 // No URL: no Add button.
2363 let none = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2364 assert!(!none.contains("Add row"), "{none}");
2365
2366 // With URL: button serializes only the `index` param (`hx-params="index"`)
2367 // and uses a beforeend swap.
2368 let opts = InputsForOptions {
2369 add_row_url: Some("/orders/line-item-row".into()),
2370 ..InputsForOptions::default()
2371 };
2372 let html = inputs_for(&cs, &opts, render_row).into_string();
2373 assert!(html.contains("Add row"), "{html}");
2374 assert!(html.contains(r#"hx-params="index""#), "{html}");
2375 assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2376 assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2377 assert!(html.contains(r##"hx-target="#items-rows""##), "{html}");
2378 }
2379
2380 /// Fix (Codex P2, near the Add-row button): the Add-row button sends a fresh
2381 /// unique `index` with each htmx request via `hx-vals` (`js:` form) computed
2382 /// from THIS container's rows (`max(existing data-index) + 1`), so repeated
2383 /// clicks never reuse an index and emit duplicate control names. The selector
2384 /// is scoped to the form's own container id; keeping several same-collection
2385 /// forms independent requires each to pass a distinct `container_id` (see
2386 /// `add_button_targets_and_scans_custom_container_id`). The pre-existing htmx
2387 /// attrs stay intact.
2388 #[test]
2389 fn add_button_hx_vals_sends_fresh_container_scoped_index() {
2390 let cs = two_row_changeset();
2391 let opts = InputsForOptions {
2392 add_row_url: Some("/orders/line-item-row".into()),
2393 ..InputsForOptions::default()
2394 };
2395 let html = inputs_for(&cs, &opts, render_row).into_string();
2396
2397 // Pre-existing htmx wiring is preserved.
2398 assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
2399 assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
2400 assert!(html.contains(r#"hx-params="index""#), "{html}");
2401
2402 // A JS-computed `hx-vals` that sends a fresh `index`…
2403 assert!(html.contains("hx-vals="), "{html}");
2404 assert!(html.contains("Math.max(-1"), "{html}");
2405 // …referencing the `index` param and reading each row's `data-index`…
2406 assert!(html.contains("parseInt(e.dataset.index,10)"), "{html}");
2407 // …via a selector scoped to THIS form's container id.
2408 assert!(
2409 html.contains("#items-rows .nested-fields__row[data-index]"),
2410 "{html}"
2411 );
2412 }
2413
2414 /// Fix (Codex P2, container-id collisions): a caller rendering two nested
2415 /// forms for the same child collection on one page must give each a distinct
2416 /// `container_id` so their Add-row buttons stay independent. Assert that an
2417 /// explicit `container_id` flows through to BOTH the container `id` and the
2418 /// Add-row button's `hx-target` + `hx-vals` index scan — proving per-form
2419 /// scoping works once ids are distinct — and that the default id is absent.
2420 #[test]
2421 fn add_button_targets_and_scans_custom_container_id() {
2422 let cs = two_row_changeset();
2423 let opts = InputsForOptions {
2424 add_row_url: Some("/orders/line-item-row".into()),
2425 // A second same-collection form on the page would pass its own id;
2426 // this one deliberately avoids containing the `items-rows` default so
2427 // the negative assertions below are exact substring checks.
2428 container_id: Some("shipping-lines".into()),
2429 ..InputsForOptions::default()
2430 };
2431 let html = inputs_for(&cs, &opts, render_row).into_string();
2432
2433 // The container carries the custom id, not the `{collection}-rows` default.
2434 assert!(
2435 html.contains(r#"<div id="shipping-lines" class="nested-fields">"#),
2436 "{html}"
2437 );
2438 // The Add-row button targets the custom container…
2439 assert!(html.contains(r##"hx-target="#shipping-lines""##), "{html}");
2440 // …and its index scan reads that same custom container's rows…
2441 assert!(
2442 html.contains("#shipping-lines .nested-fields__row[data-index]"),
2443 "{html}"
2444 );
2445 // …with no leftover reference to the default id.
2446 assert!(!html.contains("items-rows .nested-fields__row"), "{html}");
2447 assert!(!html.contains(r##"hx-target="#items-rows""##), "{html}");
2448 }
2449
2450 #[test]
2451 fn destroy_checkbox_checked_reflects_destroyed_row() {
2452 let pairs = vec![
2453 p("name", "Order"),
2454 p("items[0][sku]", "A"),
2455 p("items[0][quantity]", "1"),
2456 p("items[0][_destroy]", "1"),
2457 ];
2458 let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
2459 let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2460 // The destroyed row's checkbox is checked.
2461 assert!(
2462 html.contains(r#"name="items[0][_destroy]" value="1" checked"#),
2463 "{html}"
2464 );
2465 }
2466
2467 #[test]
2468 fn nested_row_fragment_renders_single_row_at_index() {
2469 let html = nested_row_fragment::<LineItem>(7, render_row).into_string();
2470 assert!(html.contains(r#"data-index="7""#), "{html}");
2471 assert!(html.contains(r#"name="items[7][sku]""#), "{html}");
2472 // Exactly one row wrapper.
2473 assert_eq!(html.matches("nested-fields__row").count(), 1, "{html}");
2474 }
2475
2476 // ── Fix 1: form_tag preserves the captured CSRF/submit-token fields ─
2477
2478 /// `NestedChangesetForm::form_tag` must emit the CSRF hidden input under the
2479 /// **captured/configured** field name — NOT the standalone `form_tag`'s
2480 /// hardcoded `_csrf` — so an app with a custom `security.csrf.form_field`
2481 /// keeps CSRF parity across a nested re-render.
2482 #[test]
2483 fn form_tag_emits_custom_csrf_field_name_not_default() {
2484 let form = NestedChangesetForm::<Order, LineItem>::blank(
2485 Order {
2486 name: String::new(),
2487 },
2488 Some("tok-123".to_owned()),
2489 )
2490 .with_csrf_field("authenticity_token");
2491
2492 let html = form
2493 .form_tag("/orders", "post", maud::html! {})
2494 .into_string();
2495
2496 // The captured custom field name carries the token…
2497 assert!(
2498 html.contains(r#"name="authenticity_token" value="tok-123""#),
2499 "{html}"
2500 );
2501 // …and the hardcoded default the standalone `form_tag` would emit does not.
2502 assert!(!html.contains(r#"name="_csrf""#), "{html}");
2503 assert!(html.contains(r#"action="/orders""#), "{html}");
2504 assert!(html.contains(r#"method="post""#), "{html}");
2505 }
2506
2507 /// When a submit token is captured, `form_tag` also emits it under its
2508 /// captured field name (alongside the custom CSRF field).
2509 #[test]
2510 fn form_tag_emits_captured_submit_token_field() {
2511 let form = NestedChangesetForm::<Order, LineItem> {
2512 changeset: NestedChangeset::blank(Order {
2513 name: String::new(),
2514 }),
2515 csrf_token: Some("tok".to_owned()),
2516 csrf_field: "authenticity_token".to_owned(),
2517 submit_token: Some("stok-9".to_owned()),
2518 submit_field: "_submit_token".to_owned(),
2519 };
2520
2521 let html = form
2522 .form_tag("/orders", "post", maud::html! {})
2523 .into_string();
2524
2525 assert!(
2526 html.contains(r#"name="authenticity_token" value="tok""#),
2527 "{html}"
2528 );
2529 assert!(
2530 html.contains(r#"name="_submit_token" value="stok-9""#),
2531 "{html}"
2532 );
2533 assert!(!html.contains(r#"name="_csrf""#), "{html}");
2534 }
2535
2536 // ── Fix 2: blank render shows no premature parent errors ────────────
2537
2538 /// The initial `new`-page render from `blank`: the required parent field is
2539 /// empty but un-validated, so no error message or `aria-invalid="true"`
2540 /// appears before the user types.
2541 #[test]
2542 fn blank_form_render_shows_no_parent_validation_error() {
2543 let form = NestedChangesetForm::<Order, LineItem>::blank(
2544 Order {
2545 name: String::new(),
2546 },
2547 Some("tok".to_owned()),
2548 );
2549
2550 let html = form
2551 .form_tag(
2552 "/orders",
2553 "post",
2554 maud::html! {
2555 (crate::form::required_text_input(&form.parent, "name", "Order name"))
2556 (inputs_for(&*form, &InputsForOptions::default(), render_row))
2557 },
2558 )
2559 .into_string();
2560
2561 assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
2562 assert!(!html.contains("name required"), "{html}");
2563 }
2564
2565 /// Contrast: a re-render from a failed decode DOES surface the parent error
2566 /// and marks the field invalid — the exact behaviour `blank` suppresses on
2567 /// the initial GET render.
2568 #[test]
2569 fn decoded_invalid_parent_render_shows_error_unlike_blank() {
2570 let cs =
2571 decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
2572 let form = NestedChangesetForm::from_changeset(cs);
2573
2574 let html = form
2575 .form_tag(
2576 "/orders",
2577 "post",
2578 maud::html! {
2579 (crate::form::required_text_input(&form.parent, "name", "Order name"))
2580 },
2581 )
2582 .into_string();
2583
2584 assert!(html.contains(r#"aria-invalid="true""#), "{html}");
2585 assert!(html.contains("name required"), "{html}");
2586 }
2587
2588 // ── Fix 3: blank forms can carry the current submit token ───────────
2589
2590 /// A `blank` form given a submit token (and a custom submit field name) emits
2591 /// the hidden submit-token input on the initial GET render, so the FIRST
2592 /// submission is protected against double-submit — not just later 422
2593 /// re-renders.
2594 #[test]
2595 fn blank_form_with_submit_token_emits_hidden_submit_field() {
2596 let form = NestedChangesetForm::<Order, LineItem>::blank(
2597 Order {
2598 name: String::new(),
2599 },
2600 Some("csrf".to_owned()),
2601 )
2602 .with_submit_field("authenticity_submit")
2603 .with_submit_token(Some("tok".to_owned()));
2604
2605 let html = form
2606 .form_tag("/orders", "post", maud::html! {})
2607 .into_string();
2608
2609 assert!(
2610 html.contains(r#"name="authenticity_submit" value="tok""#),
2611 "{html}"
2612 );
2613 }
2614
2615 /// Without supplying a submit token, a `blank` form emits NO submit-token
2616 /// hidden input — documenting the default and that the caller must supply the
2617 /// minted token on the initial GET to protect the first submit.
2618 #[test]
2619 fn blank_form_without_submit_token_omits_submit_field() {
2620 let form = NestedChangesetForm::<Order, LineItem>::blank(
2621 Order {
2622 name: String::new(),
2623 },
2624 Some("csrf".to_owned()),
2625 );
2626
2627 let html = form
2628 .form_tag("/orders", "post", maud::html! {})
2629 .into_string();
2630
2631 assert!(!html.contains("_submit_token"), "{html}");
2632 assert!(!html.contains(r#"name="_submit_token""#), "{html}");
2633 }
2634
2635 // ── seeded (edit-render) rendering ──────────────────────────────
2636
2637 /// A `seeded` changeset pre-renders its existing children through
2638 /// `inputs_for`: each seeded row's inputs carry the child's serialized values
2639 /// so an edit form shows the current line items, and a trailing blank template
2640 /// row is still appended for adding another child with no JS.
2641 #[test]
2642 fn seeded_rows_render_with_prefilled_values() {
2643 let cs = NestedChangeset::seeded(
2644 Order {
2645 name: "Existing order".to_owned(),
2646 },
2647 vec![
2648 LineItem {
2649 sku: "A-1".to_owned(),
2650 quantity: 2,
2651 },
2652 LineItem {
2653 sku: "B-2".to_owned(),
2654 quantity: 3,
2655 },
2656 ],
2657 );
2658 let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
2659
2660 // Both seeded rows render their indexed inputs with pre-filled values.
2661 assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
2662 assert!(html.contains(r#"value="A-1""#), "{html}");
2663 assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
2664 assert!(html.contains(r#"value="B-2""#), "{html}");
2665 // The trailing blank template row is still appended (index 2).
2666 assert!(html.contains(r#"data-index="2""#), "{html}");
2667 assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
2668 }
2669}