#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
use std::collections::{BTreeMap, HashMap};
use axum::extract::{FromRequest, Request};
use axum::response::IntoResponse;
use crate::form::{
Changeset, IntoChangeset, decode_urlencoded_dropping_blank_optional_fields,
validation_errors_to_map,
};
pub trait NestedChild: serde::de::DeserializeOwned + validator::Validate + Send {
const COLLECTION: &'static str;
}
#[derive(Debug, Clone)]
pub struct NestedRow {
values: HashMap<String, String>,
errors: HashMap<String, Vec<String>>,
destroyed: bool,
}
impl NestedRow {
#[must_use]
pub fn value(&self, sub: &str) -> Option<&str> {
self.values.get(sub).map(String::as_str)
}
#[must_use]
pub fn errors_for(&self, sub: &str) -> &[String] {
self.errors.get(sub).map_or(&[], Vec::as_slice)
}
#[must_use]
pub const fn is_destroyed(&self) -> bool {
self.destroyed
}
#[must_use]
pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
&self.errors
}
}
#[derive(Debug)]
pub struct NestedChangeset<P, C> {
pub parent: Changeset<P>,
rows: Vec<NestedRow>,
valid_children: Option<Vec<C>>,
}
impl<P, C: NestedChild> NestedChangeset<P, C> {
#[must_use]
pub fn blank(parent: P) -> Self {
Self {
parent: Changeset::new(parent),
rows: Vec::new(),
valid_children: Some(Vec::new()),
}
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.parent.is_valid()
&& self.valid_children.is_some()
&& self.rows.iter().all(|r| r.destroyed || r.errors.is_empty())
}
#[allow(
clippy::result_large_err,
reason = "the Err variant intentionally returns the whole changeset (rows + raw \
values) so the handler can re-render the form inline with errors"
)]
pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
if !self.is_valid() {
return Err(self);
}
let Self {
parent,
rows,
valid_children,
} = self;
match valid_children {
Some(children) => match parent.into_valid() {
Ok(p) => Ok((p, children)),
Err(parent) => Err(Self {
parent,
rows,
valid_children: None,
}),
},
None => Err(Self {
parent,
rows,
valid_children: None,
}),
}
}
#[must_use]
pub fn errors_for(&self, key: &str) -> &[String] {
if let Some((idx, sub)) = parse_combined_child_key(key, C::COLLECTION) {
return self.rows.get(idx).map_or(&[], |r| r.errors_for(sub));
}
self.parent.errors_for(key)
}
#[must_use]
pub fn rows(&self) -> &[NestedRow] {
&self.rows
}
#[must_use]
pub const fn collection_name(&self) -> &'static str {
C::COLLECTION
}
}
impl<P, C: NestedChild + serde::Serialize> NestedChangeset<P, C> {
#[must_use]
pub fn seeded(parent: P, children: Vec<C>) -> Self {
let rows = children
.iter()
.map(|child| {
let mut values: HashMap<String, String> = HashMap::new();
if let Ok(encoded) = serde_urlencoded::to_string(child) {
for (k, v) in url::form_urlencoded::parse(encoded.as_bytes()) {
values.insert(k.into_owned(), v.into_owned());
}
}
NestedRow {
values,
errors: HashMap::new(),
destroyed: false,
}
})
.collect();
Self {
parent: Changeset::new(parent),
rows,
valid_children: Some(children),
}
}
}
pub fn decode_nested_urlencoded<P, C>(
pairs: &[(String, String)],
) -> Result<NestedChangeset<P, C>, String>
where
P: serde::de::DeserializeOwned + validator::Validate,
C: NestedChild,
{
let collection = C::COLLECTION;
let mut parent_pairs: Vec<(String, String)> = Vec::new();
let mut child_groups: BTreeMap<usize, Vec<(String, String)>> = BTreeMap::new();
for (key, value) in pairs {
if let Some((idx, sub)) = parse_child_key(key, collection) {
child_groups
.entry(idx)
.or_default()
.push((sub.to_string(), value.clone()));
} else {
parent_pairs.push((key.clone(), value.clone()));
}
}
let parent_encoded = encode_pairs(&parent_pairs);
let parent_data: P =
decode_urlencoded_dropping_blank_optional_fields::<P>(parent_encoded.as_bytes())
.map_err(|e| e.to_string())?;
let parent = parent_data.into_changeset();
let mut rows: Vec<NestedRow> = Vec::new();
let mut children: Vec<C> = Vec::new();
let mut all_children_ok = true;
for subfields in child_groups.into_values() {
let mut values: HashMap<String, String> = HashMap::new();
let mut destroyed = false;
let mut decode_pairs: Vec<(String, String)> = Vec::new();
for (sub, val) in &subfields {
values.insert(sub.clone(), val.clone());
if sub == "_destroy" {
if is_truthy(val) {
destroyed = true;
}
} else {
decode_pairs.push((sub.clone(), val.clone()));
}
}
let all_blank = decode_pairs.iter().all(|(_, val)| val.trim().is_empty());
if all_blank {
continue;
}
let mut errors: HashMap<String, Vec<String>> = HashMap::new();
if destroyed {
rows.push(NestedRow {
values,
errors,
destroyed,
});
continue;
}
let encoded = encode_pairs(&decode_pairs);
match decode_urlencoded_dropping_blank_optional_fields::<C>(encoded.as_bytes()) {
Ok(child) => match validator::Validate::validate(&child) {
Ok(()) => children.push(child),
Err(ve) => {
errors = validation_errors_to_map(&ve);
all_children_ok = false;
}
},
Err(e) => {
let field = recover_child_error_field::<C>(&e, &decode_pairs);
errors.entry(field).or_default().push(e.to_string());
all_children_ok = false;
}
}
rows.push(NestedRow {
values,
errors,
destroyed,
});
}
let valid_children = if parent.is_valid() && all_children_ok {
Some(children)
} else {
None
};
Ok(NestedChangeset {
parent,
rows,
valid_children,
})
}
fn parse_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
let rest = key.strip_prefix(collection)?;
let rest = rest.strip_prefix('[')?;
let close = rest.find(']')?;
let (idx_str, after) = rest.split_at(close);
let idx: usize = idx_str.parse().ok()?;
let after = after.strip_prefix(']')?;
let after = after.strip_prefix('[')?;
let close2 = after.find(']')?;
let (sub, tail) = after.split_at(close2);
if tail != "]" || sub.is_empty() {
return None;
}
Some((idx, sub))
}
fn parse_combined_child_key<'a>(key: &'a str, collection: &str) -> Option<(usize, &'a str)> {
let rest = key.strip_prefix(collection)?;
let rest = rest.strip_prefix('[')?;
let close = rest.find(']')?;
let (idx_str, after) = rest.split_at(close);
let idx: usize = idx_str.parse().ok()?;
let after = after.strip_prefix(']')?;
if after.is_empty() {
return Some((idx, ""));
}
let sub = after.strip_prefix('.')?;
if sub.is_empty() {
return None;
}
Some((idx, sub))
}
fn encode_pairs(pairs: &[(String, String)]) -> String {
url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.finish()
}
fn last_path_field(path: &serde_path_to_error::Path) -> Option<String> {
path.iter()
.filter_map(|seg| match seg {
serde_path_to_error::Segment::Map { key }
| serde_path_to_error::Segment::Enum { variant: key } => Some(key.clone()),
serde_path_to_error::Segment::Seq { .. } | serde_path_to_error::Segment::Unknown => {
None
}
})
.next_back()
.filter(|f| !f.is_empty())
}
fn missing_field_name(msg: &str) -> Option<String> {
let after = msg.split_once("missing field `")?.1;
let name = after.split_once('`')?.0;
(!name.is_empty()).then(|| name.to_owned())
}
fn recover_child_error_field<C: serde::de::DeserializeOwned>(
primary: &serde_path_to_error::Error<serde_urlencoded::de::Error>,
decode_pairs: &[(String, String)],
) -> String {
if let Some(field) = last_path_field(primary.path()) {
return field;
}
if let Some(field) = missing_field_name(&primary.inner().to_string()) {
return field;
}
let encoded = encode_pairs(decode_pairs);
let deserializer =
serde_urlencoded::Deserializer::new(url::form_urlencoded::parse(encoded.as_bytes()));
if let Err(raw_err) = serde_path_to_error::deserialize::<_, C>(deserializer)
&& let Some(field) = last_path_field(raw_err.path())
{
return field;
}
String::new()
}
fn is_truthy(value: &str) -> bool {
let trimmed = value.trim();
trimmed == "1" || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("on")
}
pub struct NestedChangesetForm<P, C> {
pub changeset: NestedChangeset<P, C>,
csrf_token: Option<String>,
csrf_field: String,
submit_token: Option<String>,
submit_field: String,
}
impl<P, C: NestedChild> NestedChangesetForm<P, C> {
#[must_use]
pub fn blank(parent: P, csrf_token: Option<String>) -> Self {
Self {
changeset: NestedChangeset::blank(parent),
csrf_token,
csrf_field: "_csrf".to_owned(),
submit_token: None,
submit_field: "_submit_token".to_owned(),
}
}
#[must_use]
pub fn from_changeset(changeset: NestedChangeset<P, C>) -> Self {
Self {
changeset,
csrf_token: None,
csrf_field: "_csrf".to_owned(),
submit_token: None,
submit_field: "_submit_token".to_owned(),
}
}
#[must_use]
pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
self.csrf_field = field.into();
self
}
#[must_use]
pub fn with_submit_token(mut self, token: Option<String>) -> Self {
self.submit_token = token;
self
}
#[must_use]
pub fn with_submit_field(mut self, field: impl Into<String>) -> Self {
self.submit_field = field.into();
self
}
#[must_use]
pub fn csrf_token(&self) -> Option<&str> {
self.csrf_token.as_deref()
}
#[must_use]
pub fn csrf_field(&self) -> &str {
&self.csrf_field
}
#[must_use]
pub fn submit_token(&self) -> Option<&str> {
self.submit_token.as_deref()
}
#[must_use]
pub fn submit_field(&self) -> &str {
&self.submit_field
}
pub fn into_changeset(self) -> NestedChangeset<P, C> {
self.changeset
}
#[allow(
clippy::result_large_err,
reason = "the Err variant intentionally returns the whole form (changeset + CSRF/submit \
context) so the handler can re-render inline with errors"
)]
pub fn into_valid(self) -> Result<(P, Vec<C>), Self> {
let Self {
changeset,
csrf_token,
csrf_field,
submit_token,
submit_field,
} = self;
match changeset.into_valid() {
Ok(pair) => Ok(pair),
Err(changeset) => Err(Self {
changeset,
csrf_token,
csrf_field,
submit_token,
submit_field,
}),
}
}
}
impl<P, C: NestedChild + serde::Serialize> NestedChangesetForm<P, C> {
#[must_use]
pub fn seeded(parent: P, children: Vec<C>, csrf_token: Option<String>) -> Self {
Self {
changeset: NestedChangeset::seeded(parent, children),
csrf_token,
csrf_field: "_csrf".to_owned(),
submit_token: None,
submit_field: "_submit_token".to_owned(),
}
}
}
#[cfg(feature = "maud")]
impl<P, C: NestedChild> NestedChangesetForm<P, C> {
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
crate::form::form_tag_inner(
action,
method,
&self.csrf_field,
self.csrf_token.as_deref(),
None,
maud::html! {
@if let Some(token) = self.submit_token.as_deref() {
input type="hidden" name=(self.submit_field) value=(token);
}
(content)
},
)
}
}
impl<P, C> std::ops::Deref for NestedChangesetForm<P, C> {
type Target = NestedChangeset<P, C>;
fn deref(&self) -> &Self::Target {
&self.changeset
}
}
impl<S, P, C> FromRequest<S> for NestedChangesetForm<P, C>
where
S: Send + Sync,
P: serde::de::DeserializeOwned + validator::Validate + Send,
C: NestedChild,
{
type Rejection = axum::response::Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let csrf_token = req
.extensions()
.get::<crate::security::CsrfToken>()
.map(|t| t.token().to_string());
let csrf_field = req
.extensions()
.get::<crate::security::csrf::CsrfFormField>()
.map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
let submit_token = req
.extensions()
.get::<crate::security::SubmitToken>()
.map(|t| t.token().to_string());
let submit_field = req
.extensions()
.get::<crate::security::SubmitFormField>()
.map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
let content_type = req
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
if !content_type.starts_with("application/x-www-form-urlencoded") {
return Err((
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Nested form requests must have `Content-Type: application/x-www-form-urlencoded`",
)
.into_response());
}
let (parts, body) = req.into_parts();
let bytes_req = Request::from_parts(parts, body);
let bytes = axum::body::Bytes::from_request(bytes_req, state)
.await
.map_err(IntoResponse::into_response)?;
let pairs: Vec<(String, String)> = url::form_urlencoded::parse(&bytes)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
let changeset = decode_nested_urlencoded::<P, C>(&pairs)
.map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e).into_response())?;
Ok(Self {
changeset,
csrf_token,
csrf_field,
submit_token,
submit_field,
})
}
}
#[cfg(feature = "maud")]
pub struct RowScope<'a> {
collection: &'a str,
index: usize,
row: Option<&'a NestedRow>,
}
#[cfg(feature = "maud")]
impl RowScope<'_> {
#[must_use]
pub const fn index(&self) -> usize {
self.index
}
#[must_use]
pub fn field_name(&self, sub: &str) -> String {
format!("{}[{}][{}]", self.collection, self.index, sub)
}
#[must_use]
pub fn value(&self, sub: &str) -> Option<&str> {
self.row.and_then(|r| r.value(sub))
}
#[must_use]
pub fn errors_for(&self, sub: &str) -> &[String] {
self.row.map_or(&[], |r| r.errors_for(sub))
}
#[must_use]
pub fn is_destroyed(&self) -> bool {
self.row.is_some_and(NestedRow::is_destroyed)
}
fn element_id(&self, sub: &str) -> String {
format!("{}-{}-{}", self.collection, self.index, sub)
}
#[must_use]
pub fn text_input(&self, sub: &str, label: &str) -> maud::Markup {
self.text_like_input(sub, label, false)
}
#[must_use]
pub fn required_text_input(&self, sub: &str, label: &str) -> maud::Markup {
self.text_like_input(sub, label, true)
}
fn text_like_input(&self, sub: &str, label: &str, required: bool) -> maud::Markup {
let required = required && self.row.is_some() && !self.is_destroyed();
let errors = self.errors_for(sub);
let has_errors = !errors.is_empty();
let value = self.value(sub).unwrap_or_default();
let name = self.field_name(sub);
let id = self.element_id(sub);
let error_id = format!("{id}-error");
let wrapper_id = format!("{id}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(id) class="autumn-field__label" { (label) }
input
type="text"
id=(id)
name=(name)
value=(value)
required[required]
aria-required=[required.then_some("true")]
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" });
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[must_use]
pub fn number_input(&self, sub: &str, label: &str) -> maud::Markup {
let errors = self.errors_for(sub);
let has_errors = !errors.is_empty();
let value = self.value(sub).unwrap_or_default();
let name = self.field_name(sub);
let id = self.element_id(sub);
let error_id = format!("{id}-error");
let wrapper_id = format!("{id}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(id) class="autumn-field__label" { (label) }
input
type="number"
id=(id)
name=(name)
value=(value)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" });
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[must_use]
pub fn textarea_input(&self, sub: &str, label: &str) -> maud::Markup {
let errors = self.errors_for(sub);
let has_errors = !errors.is_empty();
let value = self.value(sub).unwrap_or_default();
let name = self.field_name(sub);
let id = self.element_id(sub);
let error_id = format!("{id}-error");
let wrapper_id = format!("{id}-field");
maud::html! {
div id=(wrapper_id) class="autumn-field" {
label for=(id) class="autumn-field__label" { (label) }
textarea
id=(id)
name=(name)
class=(if has_errors { "autumn-field__input autumn-field__input--invalid" } else { "autumn-field__input" })
aria-invalid=(if has_errors { "true" } else { "false" })
aria-describedby=(if has_errors { error_id.as_str() } else { "" })
{ (value) }
@if has_errors {
div id=(error_id) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
}
}
}
#[must_use]
pub fn hidden_input(&self, sub: &str, value: &str) -> maud::Markup {
let name = self.field_name(sub);
maud::html! {
input type="hidden" name=(name) value=(value);
}
}
#[must_use]
pub fn destroy_checkbox(&self, label: &str) -> maud::Markup {
let checked = self.is_destroyed();
let name = self.field_name("_destroy");
let id = self.element_id("_destroy");
maud::html! {
div class="autumn-field autumn-field--destroy" {
input
type="checkbox"
id=(id)
name=(name)
value="1"
checked[checked]
class="autumn-field__checkbox";
label for=(id) class="autumn-field__label" { (label) }
}
}
}
}
#[cfg(feature = "maud")]
pub struct InputsForOptions {
pub blank_rows: usize,
pub add_row_url: Option<String>,
pub container_id: Option<String>,
}
#[cfg(feature = "maud")]
impl Default for InputsForOptions {
fn default() -> Self {
Self {
blank_rows: 1,
add_row_url: None,
container_id: None,
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn inputs_for<P, C: NestedChild>(
nested: &NestedChangeset<P, C>,
opts: &InputsForOptions,
render_row: impl Fn(&RowScope) -> maud::Markup,
) -> maud::Markup {
let collection = C::COLLECTION;
let container_id = opts
.container_id
.clone()
.unwrap_or_else(|| format!("{collection}-rows"));
let rows = nested.rows();
let blank_count = opts.blank_rows.max(1);
maud::html! {
div id=(container_id) class="nested-fields" {
@for (i, row) in rows.iter().enumerate() {
@let scope = RowScope { collection, index: i, row: Some(row) };
div class="nested-fields__row" data-index=(i) {
(render_row(&scope))
}
}
@for k in 0..blank_count {
@let index = rows.len() + k;
@let scope = RowScope { collection, index, row: None };
div class="nested-fields__row" data-index=(index) {
(render_row(&scope))
}
}
@if let Some(url) = &opts.add_row_url {
@let hx_vals = format!(
"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}}"
);
button
type="button"
class="nested-fields__add"
hx-get=(url)
hx-target=(format!("#{container_id}"))
hx-swap="beforeend"
hx-params="index"
hx-vals=(hx_vals)
{ "Add row" }
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn nested_row_fragment<C: NestedChild>(
index: usize,
render_row: impl Fn(&RowScope) -> maud::Markup,
) -> maud::Markup {
let scope = RowScope {
collection: C::COLLECTION,
index,
row: None,
};
maud::html! {
div class="nested-fields__row" data-index=(index) {
(render_row(&scope))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct Order {
#[validate(length(min = 1, message = "name required"))]
name: String,
}
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct LineItem {
#[validate(length(min = 1, message = "sku required"))]
sku: String,
#[validate(range(min = 1, message = "quantity must be >= 1"))]
quantity: i32,
}
impl NestedChild for LineItem {
const COLLECTION: &'static str = "items";
}
#[derive(serde::Deserialize, validator::Validate)]
struct Contact {
#[validate(length(min = 1, message = "name required"))]
name: String,
#[validate(length(min = 1, message = "nickname too short"))]
nickname: Option<String>,
}
impl NestedChild for Contact {
const COLLECTION: &'static str = "contacts";
}
fn p(k: &str, v: &str) -> (String, String) {
(k.to_owned(), v.to_owned())
}
#[test]
fn binds_parent_and_two_children_in_order() {
let pairs = vec![
p("name", "Order 1"),
p("items[0][sku]", "A-1"),
p("items[0][quantity]", "2"),
p("items[1][sku]", "B-2"),
p("items[1][quantity]", "3"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(cs.is_valid());
assert_eq!(cs.rows().len(), 2);
assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
assert_eq!(cs.collection_name(), "items");
let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(order.name, "Order 1");
assert_eq!(items.len(), 2);
assert_eq!(items[0].sku, "A-1");
assert_eq!(items[0].quantity, 2);
assert_eq!(items[1].quantity, 3);
}
#[test]
fn non_contiguous_indices_compact_preserving_order() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", "1"),
p("items[2][sku]", "C"),
p("items[2][quantity]", "5"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert_eq!(cs.rows().len(), 2);
assert_eq!(cs.rows()[0].value("sku"), Some("A"));
assert_eq!(cs.rows()[1].value("sku"), Some("C"));
assert!(cs.is_valid());
let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(items.len(), 2);
assert_eq!(items[0].sku, "A");
assert_eq!(items[1].sku, "C");
}
#[test]
fn destroy_marker_drops_row_from_children_but_retains_it() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", "1"),
p("items[1][sku]", "X"),
p("items[1][quantity]", "9"),
p("items[1][_destroy]", "1"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert_eq!(cs.rows().len(), 2);
assert!(!cs.rows()[0].is_destroyed());
assert!(cs.rows()[1].is_destroyed());
assert_eq!(cs.rows()[1].value("sku"), Some("X"));
assert!(cs.is_valid());
let (_order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(items.len(), 1);
assert_eq!(items[0].sku, "A");
}
#[test]
fn child_validation_failure_surfaces_combined_key_and_blocks_valid() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", "2"),
p("items[1][sku]", "B"),
p("items[1][quantity]", "0"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(!cs.is_valid());
assert!(!cs.errors_for("items[1].quantity").is_empty());
assert!(cs.errors_for("items[0].quantity").is_empty());
assert!(cs.into_valid().is_err());
}
#[test]
fn all_valid_yields_ok_with_coerced_numeric_fields() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", "5"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(cs.is_valid());
let (order, items) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(order.name, "Order");
assert_eq!(items.len(), 1);
let q: i32 = items[0].quantity;
assert_eq!(q, 5);
}
#[test]
fn parent_invalid_blocks_valid_children() {
let pairs = vec![
p("name", ""),
p("items[0][sku]", "A"),
p("items[0][quantity]", "1"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(!cs.is_valid());
assert!(!cs.errors_for("name").is_empty());
assert!(cs.into_valid().is_err());
}
#[test]
fn child_parse_failure_keys_error_to_offending_field() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", "not-a-number"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(!cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert_eq!(cs.rows()[0].value("sku"), Some("A"));
assert_eq!(cs.rows()[0].value("quantity"), Some("not-a-number"));
assert!(!cs.errors_for("items[0].quantity").is_empty());
assert!(!cs.rows()[0].errors_for("quantity").is_empty());
assert!(cs.rows()[0].errors_for("").is_empty());
assert!(cs.errors_for("items[0]").is_empty());
assert!(cs.into_valid().is_err());
}
#[test]
fn child_parse_failure_on_blank_typed_field_keys_error_to_field() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", ""),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(!cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert!(!cs.errors_for("items[0].quantity").is_empty());
assert!(!cs.rows()[0].errors_for("quantity").is_empty());
assert!(cs.rows()[0].errors_for("").is_empty());
assert!(cs.errors_for("items[0]").is_empty());
assert!(cs.into_valid().is_err());
}
#[test]
fn child_row_with_blank_optional_typed_field_still_decodes_valid() {
#[derive(serde::Deserialize, validator::Validate)]
struct Widget {
#[validate(length(min = 1, message = "label required"))]
label: String,
weight: Option<i32>,
}
impl NestedChild for Widget {
const COLLECTION: &'static str = "widgets";
}
let pairs = vec![
p("name", "Order"),
p("widgets[0][label]", "Bolt"),
p("widgets[0][weight]", ""),
];
let cs = decode_nested_urlencoded::<Order, Widget>(&pairs).expect("parent decodes");
assert!(cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert!(cs.errors_for("widgets[0].weight").is_empty());
assert!(cs.rows()[0].errors_for("weight").is_empty());
let (_order, widgets) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(widgets.len(), 1);
assert_eq!(widgets[0].label, "Bolt");
assert_eq!(widgets[0].weight, None);
}
#[test]
fn child_optional_blank_before_required_blank_keys_error_to_required_field() {
#[derive(serde::Deserialize, validator::Validate)]
struct Part {
#[validate(length(min = 1, message = "sku required"))]
sku: String,
#[allow(dead_code)]
weight: Option<i32>,
#[validate(range(min = 1, message = "quantity must be >= 1"))]
quantity: i32,
}
impl NestedChild for Part {
const COLLECTION: &'static str = "items";
}
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "Widget"),
p("items[0][weight]", ""),
p("items[0][quantity]", ""),
];
let cs = decode_nested_urlencoded::<Order, Part>(&pairs).expect("parent decodes");
assert!(!cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert!(!cs.errors_for("items[0].quantity").is_empty());
assert!(!cs.rows()[0].errors_for("quantity").is_empty());
assert!(cs.errors_for("items[0].weight").is_empty());
assert!(cs.rows()[0].errors_for("weight").is_empty());
assert!(cs.rows()[0].errors_for("").is_empty());
assert!(cs.errors_for("items[0]").is_empty());
assert!(cs.into_valid().is_err());
}
#[test]
fn parent_hard_parse_failure_is_err() {
#[derive(serde::Deserialize, validator::Validate)]
struct NumericParent {
#[validate(range(min = 0))]
count: i32,
}
#[derive(serde::Deserialize, validator::Validate)]
struct Child {
#[validate(length(min = 1))]
name: String,
}
impl NestedChild for Child {
const COLLECTION: &'static str = "kids";
}
let pairs = vec![p("count", "not-a-number")];
let result = decode_nested_urlencoded::<NumericParent, Child>(&pairs);
assert!(result.is_err());
}
#[test]
fn all_blank_template_row_is_ignored() {
let pairs = vec![
p("name", "Order"),
p("contacts[0][name]", "Alice"),
p("contacts[0][nickname]", "Al"),
p("contacts[1][name]", ""),
p("contacts[1][nickname]", ""),
];
let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
assert!(cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert_eq!(cs.rows()[0].value("name"), Some("Alice"));
let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].name, "Alice");
}
#[test]
fn whitespace_only_row_is_ignored() {
let pairs = vec![
p("name", "Order"),
p("contacts[0][name]", "Bob"),
p("contacts[1][name]", " "),
p("contacts[1][nickname]", ""),
];
let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
assert!(cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert_eq!(cs.rows()[0].value("name"), Some("Bob"));
let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(contacts.len(), 1);
}
#[test]
fn partially_filled_row_with_missing_required_still_errors() {
let pairs = vec![
p("name", "Order"),
p("contacts[0][name]", ""),
p("contacts[0][nickname]", "Ally"),
];
let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
assert!(!cs.is_valid());
assert_eq!(cs.rows().len(), 1);
assert!(!cs.errors_for("contacts[0].name").is_empty());
assert_eq!(cs.rows()[0].value("nickname"), Some("Ally"));
assert!(cs.into_valid().is_err());
}
#[test]
fn fully_filled_row_is_still_bound() {
let pairs = vec![
p("name", "Order"),
p("contacts[0][name]", "Carol"),
p("contacts[0][nickname]", "Caz"),
];
let cs = decode_nested_urlencoded::<Order, Contact>(&pairs).expect("parent decodes");
assert!(cs.is_valid());
assert_eq!(cs.rows().len(), 1);
let (_order, contacts) = cs.into_valid().unwrap_or_else(|_| panic!("valid"));
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].name, "Carol");
assert_eq!(contacts[0].nickname.as_deref(), Some("Caz"));
}
#[test]
fn parse_child_key_matches_and_rejects() {
assert_eq!(parse_child_key("items[0][sku]", "items"), Some((0, "sku")));
assert_eq!(
parse_child_key("items[12][quantity]", "items"),
Some((12, "quantity"))
);
assert_eq!(parse_child_key("other[0][sku]", "items"), None);
assert_eq!(parse_child_key("name", "items"), None);
assert_eq!(parse_child_key("items[0][sku]x", "items"), None);
assert_eq!(parse_child_key("items[a][sku]", "items"), None);
}
#[test]
fn blank_changeset_is_valid_with_no_rows_or_errors() {
let cs = NestedChangeset::<Order, LineItem>::blank(Order {
name: String::new(),
});
assert!(cs.is_valid());
assert!(cs.errors_for("name").is_empty());
assert!(cs.rows().is_empty());
let (order, items) = cs
.into_valid()
.unwrap_or_else(|_| panic!("blank changeset is valid"));
assert_eq!(order.name, "");
assert!(items.is_empty());
}
#[test]
fn decoded_empty_parent_errors_unlike_blank() {
let cs =
decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
assert!(!cs.is_valid());
assert!(!cs.errors_for("name").is_empty());
}
#[test]
fn seeded_changeset_pre_populates_one_row_per_child() {
let cs = NestedChangeset::seeded(
Order {
name: "Existing order".to_owned(),
},
vec![
LineItem {
sku: "A-1".to_owned(),
quantity: 2,
},
LineItem {
sku: "B-2".to_owned(),
quantity: 3,
},
],
);
assert_eq!(cs.rows().len(), 2);
assert_eq!(cs.rows()[0].value("sku"), Some("A-1"));
assert_eq!(cs.rows()[0].value("quantity"), Some("2"));
assert_eq!(cs.rows()[1].value("sku"), Some("B-2"));
assert_eq!(cs.rows()[1].value("quantity"), Some("3"));
assert!(cs.is_valid());
assert!(cs.errors_for("name").is_empty());
assert!(cs.errors_for("items[0].sku").is_empty());
assert!(cs.errors_for("items[1].quantity").is_empty());
assert!(!cs.rows()[0].is_destroyed());
assert!(!cs.rows()[1].is_destroyed());
let (order, items) = cs
.into_valid()
.unwrap_or_else(|_| panic!("seeded changeset is valid"));
assert_eq!(order.name, "Existing order");
assert_eq!(items.len(), 2);
assert_eq!(items[0].sku, "A-1");
assert_eq!(items[0].quantity, 2);
assert_eq!(items[1].sku, "B-2");
assert_eq!(items[1].quantity, 3);
}
#[test]
fn seeded_changeset_with_no_children_is_valid_and_empty() {
let cs = NestedChangeset::<Order, LineItem>::seeded(
Order {
name: "Empty".to_owned(),
},
Vec::new(),
);
assert!(cs.is_valid());
assert!(cs.rows().is_empty());
let (_order, items) = cs
.into_valid()
.unwrap_or_else(|_| panic!("seeded changeset is valid"));
assert!(items.is_empty());
}
}
#[cfg(all(test, feature = "maud"))]
mod maud_tests {
use super::*;
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct Order {
#[validate(length(min = 1, message = "name required"))]
name: String,
}
#[derive(serde::Serialize, serde::Deserialize, validator::Validate)]
struct LineItem {
#[validate(length(min = 1, message = "sku required"))]
sku: String,
#[validate(range(min = 1, message = "quantity must be >= 1"))]
quantity: i32,
}
impl NestedChild for LineItem {
const COLLECTION: &'static str = "items";
}
fn p(k: &str, v: &str) -> (String, String) {
(k.to_owned(), v.to_owned())
}
fn two_row_changeset() -> NestedChangeset<Order, LineItem> {
let pairs = vec![
p("name", "Order 1"),
p("items[0][sku]", "A-1"),
p("items[0][quantity]", "2"),
p("items[1][sku]", "B-2"),
p("items[1][quantity]", "0"),
];
decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes")
}
fn render_row(row: &RowScope) -> maud::Markup {
maud::html! {
(row.required_text_input("sku", "SKU"))
(row.number_input("quantity", "Quantity"))
(row.destroy_checkbox("Remove"))
}
}
#[test]
fn existing_rows_render_indexed_names_and_prefilled_values() {
let cs = two_row_changeset();
let opts = InputsForOptions::default();
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
assert!(html.contains(r#"value="A-1""#), "{html}");
assert!(html.contains(r#"value="B-2""#), "{html}");
assert!(html.contains(r#"id="items-rows""#), "{html}");
}
#[test]
fn per_row_error_renders_scoped_alert_block() {
let cs = two_row_changeset();
let opts = InputsForOptions::default();
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains(r#"id="items-1-quantity-error""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("quantity must be >= 1"), "{html}");
assert!(!html.contains(r#"id="items-0-quantity-error""#), "{html}");
}
#[test]
fn appends_blank_template_row_with_next_index() {
let cs = two_row_changeset();
let opts = InputsForOptions::default();
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains(r#"data-index="0""#), "{html}");
assert!(html.contains(r#"data-index="1""#), "{html}");
assert!(html.contains(r#"data-index="2""#), "{html}");
assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
}
fn tag_with_name(html: &str, name: &str) -> String {
let needle = format!(r#"name="{name}""#);
let at = html
.find(&needle)
.unwrap_or_else(|| panic!("missing {name} in {html}"));
let open = html[..at].rfind('<').expect("tag has an opening '<'");
let close = html[at..].find('>').expect("tag has a closing '>'") + at;
html[open..=close].to_string()
}
#[test]
fn blank_template_row_omits_client_required_but_submitted_row_keeps_it() {
let cs = two_row_changeset();
let opts = InputsForOptions::default();
let html = inputs_for(&cs, &opts, render_row).into_string();
let submitted = tag_with_name(&html, "items[0][sku]");
assert!(submitted.contains(" required"), "{submitted}");
assert!(submitted.contains(r#"aria-required="true""#), "{submitted}");
let blank = tag_with_name(&html, "items[2][sku]");
assert!(!blank.contains("required"), "{blank}");
assert!(!blank.contains("aria-required"), "{blank}");
}
#[test]
fn destroyed_row_omits_client_required_while_submitted_row_keeps_it() {
let pairs = vec![
p("name", "Order 1"),
p("items[0][sku]", "A-1"),
p("items[0][quantity]", "2"),
p("items[1][sku]", ""),
p("items[1][quantity]", "9"),
p("items[1][_destroy]", "1"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
assert!(cs.rows()[1].is_destroyed());
let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
let kept = tag_with_name(&html, "items[0][sku]");
assert!(kept.contains(" required"), "{kept}");
assert!(kept.contains(r#"aria-required="true""#), "{kept}");
let destroyed = tag_with_name(&html, "items[1][sku]");
assert!(!destroyed.contains("required"), "{destroyed}");
assert!(!destroyed.contains("aria-required"), "{destroyed}");
}
#[test]
fn always_emits_a_blank_row_even_when_blank_rows_zero() {
let cs = two_row_changeset();
let opts = InputsForOptions {
blank_rows: 0,
..InputsForOptions::default()
};
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains(r#"data-index="2""#), "{html}");
}
#[test]
fn destroy_checkbox_emits_indexed_marker() {
let cs = two_row_changeset();
let opts = InputsForOptions::default();
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains(r#"name="items[0][_destroy]""#), "{html}");
assert!(html.contains(r#"name="items[1][_destroy]""#), "{html}");
assert!(html.contains(r#"type="checkbox""#), "{html}");
}
#[test]
fn add_button_renders_only_with_url_and_carries_htmx_attrs() {
let cs = two_row_changeset();
let none = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
assert!(!none.contains("Add row"), "{none}");
let opts = InputsForOptions {
add_row_url: Some("/orders/line-item-row".into()),
..InputsForOptions::default()
};
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains("Add row"), "{html}");
assert!(html.contains(r#"hx-params="index""#), "{html}");
assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
assert!(html.contains(r##"hx-target="#items-rows""##), "{html}");
}
#[test]
fn add_button_hx_vals_sends_fresh_container_scoped_index() {
let cs = two_row_changeset();
let opts = InputsForOptions {
add_row_url: Some("/orders/line-item-row".into()),
..InputsForOptions::default()
};
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(html.contains(r#"hx-get="/orders/line-item-row""#), "{html}");
assert!(html.contains(r#"hx-swap="beforeend""#), "{html}");
assert!(html.contains(r#"hx-params="index""#), "{html}");
assert!(html.contains("hx-vals="), "{html}");
assert!(html.contains("Math.max(-1"), "{html}");
assert!(html.contains("parseInt(e.dataset.index,10)"), "{html}");
assert!(
html.contains("#items-rows .nested-fields__row[data-index]"),
"{html}"
);
}
#[test]
fn add_button_targets_and_scans_custom_container_id() {
let cs = two_row_changeset();
let opts = InputsForOptions {
add_row_url: Some("/orders/line-item-row".into()),
container_id: Some("shipping-lines".into()),
..InputsForOptions::default()
};
let html = inputs_for(&cs, &opts, render_row).into_string();
assert!(
html.contains(r#"<div id="shipping-lines" class="nested-fields">"#),
"{html}"
);
assert!(html.contains(r##"hx-target="#shipping-lines""##), "{html}");
assert!(
html.contains("#shipping-lines .nested-fields__row[data-index]"),
"{html}"
);
assert!(!html.contains("items-rows .nested-fields__row"), "{html}");
assert!(!html.contains(r##"hx-target="#items-rows""##), "{html}");
}
#[test]
fn destroy_checkbox_checked_reflects_destroyed_row() {
let pairs = vec![
p("name", "Order"),
p("items[0][sku]", "A"),
p("items[0][quantity]", "1"),
p("items[0][_destroy]", "1"),
];
let cs = decode_nested_urlencoded::<Order, LineItem>(&pairs).expect("parent decodes");
let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
assert!(
html.contains(r#"name="items[0][_destroy]" value="1" checked"#),
"{html}"
);
}
#[test]
fn nested_row_fragment_renders_single_row_at_index() {
let html = nested_row_fragment::<LineItem>(7, render_row).into_string();
assert!(html.contains(r#"data-index="7""#), "{html}");
assert!(html.contains(r#"name="items[7][sku]""#), "{html}");
assert_eq!(html.matches("nested-fields__row").count(), 1, "{html}");
}
#[test]
fn form_tag_emits_custom_csrf_field_name_not_default() {
let form = NestedChangesetForm::<Order, LineItem>::blank(
Order {
name: String::new(),
},
Some("tok-123".to_owned()),
)
.with_csrf_field("authenticity_token");
let html = form
.form_tag("/orders", "post", maud::html! {})
.into_string();
assert!(
html.contains(r#"name="authenticity_token" value="tok-123""#),
"{html}"
);
assert!(!html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"action="/orders""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
}
#[test]
fn form_tag_emits_captured_submit_token_field() {
let form = NestedChangesetForm::<Order, LineItem> {
changeset: NestedChangeset::blank(Order {
name: String::new(),
}),
csrf_token: Some("tok".to_owned()),
csrf_field: "authenticity_token".to_owned(),
submit_token: Some("stok-9".to_owned()),
submit_field: "_submit_token".to_owned(),
};
let html = form
.form_tag("/orders", "post", maud::html! {})
.into_string();
assert!(
html.contains(r#"name="authenticity_token" value="tok""#),
"{html}"
);
assert!(
html.contains(r#"name="_submit_token" value="stok-9""#),
"{html}"
);
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[test]
fn blank_form_render_shows_no_parent_validation_error() {
let form = NestedChangesetForm::<Order, LineItem>::blank(
Order {
name: String::new(),
},
Some("tok".to_owned()),
);
let html = form
.form_tag(
"/orders",
"post",
maud::html! {
(crate::form::required_text_input(&form.parent, "name", "Order name"))
(inputs_for(&*form, &InputsForOptions::default(), render_row))
},
)
.into_string();
assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
assert!(!html.contains("name required"), "{html}");
}
#[test]
fn decoded_invalid_parent_render_shows_error_unlike_blank() {
let cs =
decode_nested_urlencoded::<Order, LineItem>(&[p("name", "")]).expect("parent decodes");
let form = NestedChangesetForm::from_changeset(cs);
let html = form
.form_tag(
"/orders",
"post",
maud::html! {
(crate::form::required_text_input(&form.parent, "name", "Order name"))
},
)
.into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains("name required"), "{html}");
}
#[test]
fn blank_form_with_submit_token_emits_hidden_submit_field() {
let form = NestedChangesetForm::<Order, LineItem>::blank(
Order {
name: String::new(),
},
Some("csrf".to_owned()),
)
.with_submit_field("authenticity_submit")
.with_submit_token(Some("tok".to_owned()));
let html = form
.form_tag("/orders", "post", maud::html! {})
.into_string();
assert!(
html.contains(r#"name="authenticity_submit" value="tok""#),
"{html}"
);
}
#[test]
fn blank_form_without_submit_token_omits_submit_field() {
let form = NestedChangesetForm::<Order, LineItem>::blank(
Order {
name: String::new(),
},
Some("csrf".to_owned()),
);
let html = form
.form_tag("/orders", "post", maud::html! {})
.into_string();
assert!(!html.contains("_submit_token"), "{html}");
assert!(!html.contains(r#"name="_submit_token""#), "{html}");
}
#[test]
fn seeded_rows_render_with_prefilled_values() {
let cs = NestedChangeset::seeded(
Order {
name: "Existing order".to_owned(),
},
vec![
LineItem {
sku: "A-1".to_owned(),
quantity: 2,
},
LineItem {
sku: "B-2".to_owned(),
quantity: 3,
},
],
);
let html = inputs_for(&cs, &InputsForOptions::default(), render_row).into_string();
assert!(html.contains(r#"name="items[0][sku]""#), "{html}");
assert!(html.contains(r#"value="A-1""#), "{html}");
assert!(html.contains(r#"name="items[1][sku]""#), "{html}");
assert!(html.contains(r#"value="B-2""#), "{html}");
assert!(html.contains(r#"data-index="2""#), "{html}");
assert!(html.contains(r#"name="items[2][sku]""#), "{html}");
}
}