1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! #1346 (AC7) — end-to-end "order with line items" create flow.
//!
//! This is the worked example for nested (`has_many`) forms: a parent `Order`
//! with a repeating collection of `LineItem` children, from the rendered `new`
//! view through the `create` handler to the atomic save.
//!
//! It lives as an integration test (rather than a standalone `examples/` binary)
//! so `cargo test` compiles and exercises it — the example cannot rot. The
//! view + failed-submit re-render path needs only the `maud` feature and runs
//! in any test run; the persistence half (the `create` handler + one-`Db::tx`
//! save) is gated behind `db` + `test-support` and driven through a real
//! Postgres testcontainer under `--include-ignored`.
//!
//! The flow:
//!
//! 1. A Maud `new` view built from `NestedChangesetForm::form_tag` + a parent
//! [`required_text_input`] + [`inputs_for`] (the repeating child rows) +
//! [`submit_button`]. CSRF/submit-token fields ride on the `<form>` tag,
//! emitted under the app-configured field names.
//! 2. A `create` handler taking `NestedChangesetForm<OrderForm, LineItemForm>`,
//! calling `.into_valid()`:
//! - `Ok((order, items))` → save atomically in one `Db::tx` (parent insert,
//! read back id, stamp each child's FK, insert children).
//! - `Err(form)` → re-render the same view with per-row errors and the
//! user's values pre-filled, returning `422`.
#![cfg(feature = "maud")]
use autumn_web::form::{required_text_input, submit_button};
use autumn_web::nested_form::{
InputsForOptions, NestedChangesetForm, NestedChild, decode_nested_urlencoded, inputs_for,
};
// ── Form types ─────────────────────────────────────────────────────
//
// `Serialize` is required by the parent field helpers (`required_text_input`
// reads the changeset's values back to pre-fill inputs); `Deserialize` +
// `Validate` drive decoding and per-field validation.
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct OrderForm {
#[validate(length(min = 1, message = "Order name is required"))]
name: String,
}
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct LineItemForm {
#[validate(length(min = 1, message = "SKU is required"))]
sku: String,
#[validate(range(min = 1, message = "Quantity must be at least 1"))]
quantity: i32,
}
impl NestedChild for LineItemForm {
const COLLECTION: &'static str = "items";
}
/// The child form for an **edit** render: like [`LineItemForm`] but also
/// carrying the persisted `id`, so an edit form can round-trip each existing
/// line item's identity through a hidden input (`items[i][id]`). `Serialize` is
/// what lets [`NestedChangesetForm::seeded`] pre-fill the row values (including
/// `id`) from the loaded records.
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct EditLineItemForm {
id: i64,
#[validate(length(min = 1, message = "SKU is required"))]
sku: String,
#[validate(range(min = 1, message = "Quantity must be at least 1"))]
quantity: i32,
}
impl NestedChild for EditLineItemForm {
const COLLECTION: &'static str = "items";
}
// ── The view ───────────────────────────────────────────────────────
/// Render the order form: parent field, repeating line-item rows, submit.
///
/// Used both for the initial `new` page (built via
/// [`NestedChangesetForm::blank`], a blank-but-present parent with one blank
/// child row) and to re-render after a failed submit, where the same `form`
/// carries the user's values and per-row errors. Rendering goes through
/// [`NestedChangesetForm::form_tag`] so the CSRF hidden field is emitted under
/// the app-configured field name (surviving a custom `security.csrf.form_field`
/// across the re-render), not the standalone helper's hardcoded `_csrf`.
fn render_order_form(form: &NestedChangesetForm<OrderForm, LineItemForm>) -> maud::Markup {
let opts = InputsForOptions {
// Optional htmx "Add row" endpoint; the no-JS path still works via the
// pre-rendered blank row `inputs_for` always emits.
add_row_url: Some("/orders/line-item-row".to_string()),
..InputsForOptions::default()
};
form.form_tag(
"/orders",
"POST",
maud::html! {
// Parent field. The CSRF (and submit-token) hidden inputs are
// emitted by `form.form_tag` under the app-configured field names.
(required_text_input(&form.parent, "name", "Order name"))
// Repeating child rows: each submitted row (pre-filled + inline
// errors) plus a trailing blank template row.
(inputs_for(&**form, &opts, |row| maud::html! {
(row.required_text_input("sku", "SKU"))
(row.number_input("quantity", "Quantity"))
(row.destroy_checkbox("Remove"))
}))
(submit_button("Create order"))
},
)
}
/// Render the order **edit** form: the parent field plus a pre-rendered row for
/// every existing line item, each carrying its persisted `id` as a hidden input
/// (via [`RowScope::hidden_input`] reading the seeded row's `id` value) so the
/// child round-trips its identity, plus the editable fields and a `_destroy`
/// checkbox for no-JS removal. Built from [`NestedChangesetForm::seeded`], which
/// pre-fills each row from the loaded records; `inputs_for` still appends a
/// trailing blank template row so a user can add another line item without JS.
fn render_edit_order_form(form: &NestedChangesetForm<OrderForm, EditLineItemForm>) -> maud::Markup {
let opts = InputsForOptions::default();
form.form_tag(
"/orders/1",
"PUT",
maud::html! {
(required_text_input(&form.parent, "name", "Order name"))
(inputs_for(&**form, &opts, |row| maud::html! {
// Existing (seeded) rows carry their persisted id; the trailing
// blank template row has no `id` value, so none is emitted for it.
@if let Some(id) = row.value("id") {
(row.hidden_input("id", id))
}
(row.required_text_input("sku", "SKU"))
(row.number_input("quantity", "Quantity"))
(row.destroy_checkbox("Remove"))
}))
(submit_button("Update order"))
},
)
}
// ── View / re-render tests (maud only, no DB) ──────────────────────
#[test]
fn new_view_renders_the_form_scaffold() {
// A blank `new` page built via the NON-validating `blank` constructor:
// parent name present-but-empty, no submitted rows, and — crucially — no
// premature validation error before the user types. The GET handler supplies
// the minted CSRF token (via `blank`) AND the one-time submit token (via
// `with_submit_token`) so the FIRST submission carries both hidden fields and
// is protected against double-submit, not just later 422 re-renders.
let form = NestedChangesetForm::<OrderForm, LineItemForm>::blank(
OrderForm {
name: String::new(),
},
Some("csrf-token-123".to_owned()),
)
.with_submit_token(Some("submit-token-abc".to_owned()));
let html = render_order_form(&form).into_string();
assert!(html.contains(r#"action="/orders""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
// CSRF hidden field emitted by `form.form_tag`.
assert!(
html.contains(r#"name="_csrf" value="csrf-token-123""#),
"{html}"
);
// Submit-token hidden field emitted by `form.form_tag` on the initial GET,
// so the first submit is protected against double-submit.
assert!(
html.contains(r#"name="_submit_token" value="submit-token-abc""#),
"{html}"
);
// Parent field and a blank child row.
assert!(html.contains(r#"name="name""#), "{html}");
assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
assert!(html.contains("Create order"), "{html}");
// The blank page shows NO premature validation error / invalid state for
// the still-empty required parent field.
assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
assert!(!html.contains("Order name is required"), "{html}");
}
#[test]
fn failed_submit_re_renders_per_row_errors_and_prefills_values() {
// A submission whose SECOND line item is invalid (quantity = 0).
let submitted = vec![
("name".to_string(), "Widgets order".to_string()),
("items[0][sku]".to_string(), "A-1".to_string()),
("items[0][quantity]".to_string(), "2".to_string()),
("items[1][sku]".to_string(), "B-2".to_string()),
("items[1][quantity]".to_string(), "0".to_string()),
];
let changeset =
decode_nested_urlencoded::<OrderForm, LineItemForm>(&submitted).expect("parent decodes");
// The handler's `into_valid()` would return `Err(form)` here — the child is
// invalid, so nothing is saved and the view is re-rendered.
assert!(!changeset.is_valid());
assert!(
!changeset.errors_for("items[1].quantity").is_empty(),
"the invalid row must surface under its combined key"
);
let form = NestedChangesetForm::from_changeset(changeset);
let html = render_order_form(&form).into_string();
// The offending row's message renders, scoped to that row's unique id.
assert!(html.contains("Quantity must be at least 1"), "{html}");
assert!(html.contains(r#"id="items-1-quantity-error""#), "{html}");
// The valid sibling row has no quantity error block.
assert!(!html.contains(r#"id="items-0-quantity-error""#), "{html}");
// The user's values are pre-filled so nothing is retyped.
assert!(html.contains(r#"value="A-1""#), "{html}");
assert!(html.contains(r#"value="B-2""#), "{html}");
}
#[test]
fn edit_view_seeds_existing_children_with_hidden_ids() {
// The initial `edit`-page path: the order already has two persisted line
// items. `seeded` pre-renders one row per existing child (pre-filled values +
// the persisted `id` as a hidden input) so the edit form shows and preserves
// the current line items — and the no-JS `_destroy` removal works — before the
// first submit, all without validating the still-loaded parent.
let existing = vec![
EditLineItemForm {
id: 10,
sku: "A-1".to_string(),
quantity: 2,
},
EditLineItemForm {
id: 20,
sku: "B-2".to_string(),
quantity: 5,
},
];
let form = NestedChangesetForm::<OrderForm, EditLineItemForm>::seeded(
OrderForm {
name: "Widgets order".to_string(),
},
existing,
Some("csrf-token-123".to_owned()),
);
let html = render_edit_order_form(&form).into_string();
// Each existing child renders pre-filled with its loaded values…
assert!(html.contains(r#"value="A-1""#), "{html}");
assert!(html.contains(r#"value="B-2""#), "{html}");
// …and carries its persisted id as a hidden input so identity round-trips.
assert!(html.contains(r#"name="items[0][id]" value="10""#), "{html}");
assert!(html.contains(r#"name="items[1][id]" value="20""#), "{html}");
// A trailing blank template row is still appended for adding another line
// item with no JS — and, being a blank row, it carries NO hidden id input.
assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
assert!(!html.contains(r#"name="items[2][id]""#), "{html}");
// The seeded parent is un-validated: no premature error on the initial render.
assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
}
// ── The create handler + atomic save (DB) ──────────────────────────
#[cfg(all(feature = "db", feature = "test-support"))]
mod persisted {
use super::{LineItemForm, OrderForm, render_order_form};
use autumn_web::nested_form::NestedChangesetForm;
use autumn_web::prelude::*;
use autumn_web::test::{TestApp, TestDb};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use scoped_futures::ScopedFutureExt;
mod schema {
diesel::table! {
example_orders (id) {
id -> Int8,
name -> Text,
}
}
diesel::table! {
example_line_items (id) {
id -> Int8,
order_id -> Int8,
sku -> Text,
quantity -> Int4,
}
}
}
use schema::{example_line_items, example_orders};
/// Persist the validated order and its items in one transaction — the same
/// atomic pattern proven in `nested_form_atomic_save.rs`.
async fn save_order_with_items(
db: &mut Db,
order: OrderForm,
items: Vec<LineItemForm>,
) -> AutumnResult<i64> {
db.tx(|conn| {
async move {
let order_id: i64 = diesel::insert_into(example_orders::table)
.values(example_orders::name.eq(&order.name))
.returning(example_orders::id)
.get_result(conn)
.await?;
for item in &items {
diesel::insert_into(example_line_items::table)
.values((
example_line_items::order_id.eq(order_id),
example_line_items::sku.eq(item.sku.as_str()),
example_line_items::quantity.eq(item.quantity),
))
.execute(conn)
.await?;
}
Ok::<_, diesel::result::Error>(order_id)
}
.scope_boxed()
})
.await
}
/// `create`: validate the nested form, then save atomically on success or
/// re-render with per-row errors on failure.
#[post("/orders")]
async fn create_order(
mut db: Db,
form: NestedChangesetForm<OrderForm, LineItemForm>,
) -> axum::response::Response {
match form.into_valid() {
Ok((order, items)) => match save_order_with_items(&mut db, order, items).await {
Ok(id) => (StatusCode::CREATED, format!("created order {id}")).into_response(),
Err(e) => e.into_response(),
},
// The `Err` branch retains the CSRF/submit-token context, so
// `render_order_form` re-renders via `form.form_tag` with the right
// hidden fields — no need to thread the token through by hand.
Err(form) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(render_order_form(&form).into_string()),
)
.into_response(),
}
}
/// Two tests share these tables and assert global counts, so serialize them.
static TABLES_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn setup_tables(db: &TestDb) {
db.execute_sql(
"CREATE TABLE IF NOT EXISTS example_orders (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
)",
)
.await;
db.execute_sql(
"CREATE TABLE IF NOT EXISTS example_line_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES example_orders(id),
sku TEXT NOT NULL,
quantity INTEGER NOT NULL
)",
)
.await;
db.execute_sql("TRUNCATE example_line_items, example_orders RESTART IDENTITY CASCADE")
.await;
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn create_persists_valid_order_with_children() {
let db = TestDb::shared().await;
let _guard = TABLES_LOCK.lock().await;
setup_tables(db).await;
let client = TestApp::new()
.routes(routes![create_order])
.with_db(db.pool())
.build();
let body = "name=Widgets+order\
&items[0][sku]=A-1&items[0][quantity]=2\
&items[1][sku]=B-2&items[1][quantity]=3";
client
.post("/orders")
.form(body)
.send()
.await
.assert_status(201);
let mut conn = db.pool().get().await.expect("db connection");
let orders: i64 = example_orders::table
.count()
.get_result(&mut *conn)
.await
.expect("count orders");
let items: i64 = example_line_items::table
.count()
.get_result(&mut *conn)
.await
.expect("count line items");
assert_eq!(orders, 1, "the order must be persisted");
assert_eq!(items, 2, "both line items must be persisted");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn create_rejects_invalid_child_and_persists_nothing() {
let db = TestDb::shared().await;
let _guard = TABLES_LOCK.lock().await;
setup_tables(db).await;
let client = TestApp::new()
.routes(routes![create_order])
.with_db(db.pool())
.build();
// Second child quantity = 0 fails validation: no save happens at all.
let body = "name=Widgets+order\
&items[0][sku]=A-1&items[0][quantity]=2\
&items[1][sku]=B-2&items[1][quantity]=0";
let resp = client.post("/orders").form(body).send().await;
resp.assert_status(422);
resp.assert_body_contains("Quantity must be at least 1");
// Validation ran before any DB work, so nothing is persisted.
let mut conn = db.pool().get().await.expect("db connection");
let orders: i64 = example_orders::table
.count()
.get_result(&mut *conn)
.await
.expect("count orders");
assert_eq!(orders, 0, "a rejected submission must persist no order");
}
}