crates/cli/ents-web/src/form.rs
form.rshistorycomment on this file
| 1 | //! Action-shape-derived entity forms: the same `#[derive(Facet)]` action |
| 2 | //! enums the CLI parses (`ents_forge::issue::IssueAction`, |
| 3 | //! `ents_forge::review::ReviewAction`) drive a web form's controls and its |
| 4 | //! parse — one field list, declared on the action variant, obeyed by both |
| 5 | //! frontends (`lens.parity`) instead of a hand-declared form struct per |
| 6 | //! route. [`action_form`] renders one control per variant field |
| 7 | //! (`Vec` → comma-separated input, `bool` → checkbox, an `ents::compose` |
| 8 | //! field → textarea, anything else → text input), with per-field |
| 9 | //! overrides for the controls a page legitimately customizes (a state |
| 10 | //! picker, a verdict picker); [`parse_action`] reads the posted pairs |
| 11 | //! back into the action variant itself. CSRF stays |
| 12 | //! [`crate::pages::csrf_input`]'s hidden field and the handler's |
| 13 | //! `require_csrf` check; the PRG redirect stays the handler's own. |
| 14 | //! |
| 15 | //! Path-bound values (an `args::positional` id, a review's target) never |
| 16 | //! render as controls — the caller appends them to the posted pairs |
| 17 | //! before parsing. `PathBuf`-shaped fields (`--key`, a local signing-key |
| 18 | //! path no browser can supply) are skipped by both directions. |
| 19 | |
| 20 | use std::path::PathBuf; |
| 21 | |
| 22 | use facet::{Facet, Field, Type, UserType}; |
| 23 | use facet_reflect::Partial; |
| 24 | use maud::{Markup, html}; |
| 25 | |
| 26 | use crate::error::{Error, Result}; |
| 27 | use crate::session::Session; |
| 28 | |
| 29 | /// The web-varying parts of one derived form: where it posts, what its |
| 30 | /// submit control says, and the per-field prefills and overrides. |
| 31 | pub struct Spec<'a> { |
| 32 | /// The form's POST target. |
| 33 | pub action: &'a str, |
| 34 | /// The submit button's label. |
| 35 | pub submit: &'a str, |
| 36 | /// A cancel link's href, rendered beside the submit button. |
| 37 | pub cancel: Option<&'a str>, |
| 38 | /// Prefill values by field name (a `Vec` field prefills comma-joined). |
| 39 | pub values: &'a [(&'a str, String)], |
| 40 | /// Custom controls by field name; an empty override omits the field. |
| 41 | pub overrides: &'a [(&'a str, Markup)], |
| 42 | } |
| 43 | |
| 44 | /// Render the form `T`'s variant `variant` declares: one derived control |
| 45 | /// per web-suppliable field in declaration order, `spec.overrides` |
| 46 | /// slotted in place, the session's CSRF hidden field leading. |
| 47 | // @relation(lens.forms, scope=function) |
| 48 | #[must_use] |
| 49 | pub fn action_form<T: Facet<'static>>(variant: &str, session: &Session, spec: &Spec<'_>) -> Markup { |
| 50 | let fields = variant_fields::<T>(variant).unwrap_or_default(); |
| 51 | html! { |
| 52 | form method="post" action=(spec.action) { |
| 53 | (crate::pages::csrf_input(session)) |
| 54 | @for field in fields { |
| 55 | @if let Some((_, markup)) = spec.overrides.iter().find(|(name, _)| *name == field.name) { |
| 56 | (markup) |
| 57 | } @else if let Some(markup) = control(field, value_of(spec, field.name)) { |
| 58 | (markup) |
| 59 | } |
| 60 | } |
| 61 | @if let Some(cancel) = spec.cancel { |
| 62 | div.composer-buttons { |
| 63 | a.composer-cancel href=(cancel) { "Cancel" } |
| 64 | button type="submit" { (spec.submit) } |
| 65 | } |
| 66 | } @else { |
| 67 | button type="submit" { (spec.submit) } |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Parse posted `pairs` (path-bound values appended by the caller) into |
| 74 | /// `T`'s variant `variant`, by the same shape-to-control mapping |
| 75 | /// [`action_form`] renders: a `Vec` splits on commas/whitespace across |
| 76 | /// every posted occurrence, an empty `Option<String>` is `None`, a `bool` |
| 77 | /// is its checkbox's presence, and an unposted field takes its declared |
| 78 | /// default. Unknown pairs (the CSRF token, a `return_to`) are ignored. |
| 79 | /// |
| 80 | /// # Errors |
| 81 | /// |
| 82 | /// [`Error::InvalidArgument`] if `T` has no such variant, a field's shape |
| 83 | /// is not one this mapping speaks, or the value cannot be set. |
| 84 | // @relation(lens.forms, scope=function) |
| 85 | pub fn parse_action<T: Facet<'static>>(variant: &str, pairs: &[(String, String)]) -> Result<T> { |
| 86 | let malformed = |source: &dyn std::fmt::Display| { |
| 87 | Error::InvalidArgument(format!("malformed {variant} form: {source}")) |
| 88 | }; |
| 89 | let fields = variant_fields::<T>(variant)?; |
| 90 | let mut partial = Partial::alloc::<T>() |
| 91 | .map_err(|source| malformed(&source))? |
| 92 | .select_variant_named(variant) |
| 93 | .map_err(|source| malformed(&source))?; |
| 94 | for (index, field) in fields.iter().enumerate() { |
| 95 | let posted: Vec<&str> = pairs |
| 96 | .iter() |
| 97 | .filter(|(name, _)| name == field.name) |
| 98 | .map(|(_, value)| value.as_str()) |
| 99 | .collect(); |
| 100 | let shape = field.shape(); |
| 101 | partial = if shape.is_type::<Vec<String>>() { |
| 102 | partial.set_field(field.name, split_list(&posted)) |
| 103 | } else if let Some(first) = posted.first() { |
| 104 | if shape.is_type::<String>() { |
| 105 | partial.set_field(field.name, (*first).to_owned()) |
| 106 | } else if shape.is_type::<Option<String>>() { |
| 107 | let value = posted.iter().find(|value| !value.trim().is_empty()); |
| 108 | partial.set_field(field.name, value.map(|value| (*value).to_owned())) |
| 109 | } else if shape.is_type::<bool>() { |
| 110 | partial.set_field(field.name, matches!(*first, "true" | "on" | "1")) |
| 111 | } else { |
| 112 | return Err(Error::InvalidArgument(format!( |
| 113 | "unsupported form field: {}", |
| 114 | field.name |
| 115 | ))); |
| 116 | } |
| 117 | } else { |
| 118 | partial.set_nth_field_to_default(index) |
| 119 | } |
| 120 | .map_err(|source| malformed(&source))?; |
| 121 | } |
| 122 | partial |
| 123 | .build() |
| 124 | .map_err(|source| malformed(&source))? |
| 125 | .materialize::<T>() |
| 126 | .map_err(|source| malformed(&source)) |
| 127 | } |
| 128 | |
| 129 | /// The posted CSRF token, or empty when the field is absent — feeding |
| 130 | /// `require_csrf`, which then refuses the empty token like any other |
| 131 | /// mismatch. |
| 132 | #[must_use] |
| 133 | pub fn posted_csrf(pairs: &[(String, String)]) -> &str { |
| 134 | pairs |
| 135 | .iter() |
| 136 | .find(|(name, _)| name == crate::session::CSRF_FIELD) |
| 137 | .map_or("", |(_, value)| value.as_str()) |
| 138 | } |
| 139 | |
| 140 | /// `variant`'s field list on the action enum `T`. |
| 141 | fn variant_fields<T: Facet<'static>>(variant: &str) -> Result<&'static [Field]> { |
| 142 | let Type::User(UserType::Enum(shape)) = T::SHAPE.ty else { |
| 143 | return Err(Error::InvalidArgument(format!( |
| 144 | "{} is not an action enum", |
| 145 | T::SHAPE |
| 146 | ))); |
| 147 | }; |
| 148 | shape |
| 149 | .variants |
| 150 | .iter() |
| 151 | .find(|candidate| candidate.name == variant) |
| 152 | .map(|found| found.data.fields) |
| 153 | .ok_or_else(|| Error::InvalidArgument(format!("no such action: {variant}"))) |
| 154 | } |
| 155 | |
| 156 | /// `field`'s derived control, or `None` for a field the web never |
| 157 | /// renders: an `args::positional` value (bound into the route's own |
| 158 | /// path) or a `PathBuf` (a local file path no browser form supplies). |
| 159 | fn control(field: &Field, value: Option<&str>) -> Option<Markup> { |
| 160 | if field.has_attr(Some("args"), "positional") { |
| 161 | return None; |
| 162 | } |
| 163 | let shape = field.shape(); |
| 164 | if shape.is_type::<PathBuf>() || shape.is_type::<Option<PathBuf>>() { |
| 165 | return None; |
| 166 | } |
| 167 | let name = field.name; |
| 168 | let label = title_case(name); |
| 169 | Some(if shape.is_type::<bool>() { |
| 170 | html! { |
| 171 | label { |
| 172 | input type="checkbox" name=(name) checked[value == Some("true")]; |
| 173 | " " (label) |
| 174 | } |
| 175 | } |
| 176 | } else if shape.is_type::<Vec<String>>() { |
| 177 | html! { |
| 178 | label { (label) input type="text" name=(name) value=[value] placeholder="a, b"; } |
| 179 | } |
| 180 | } else if field.has_attr(Some("ents"), "compose") { |
| 181 | html! { |
| 182 | label { (label) textarea name=(name) { @if let Some(value) = value { (value) } } } |
| 183 | } |
| 184 | } else { |
| 185 | html! { |
| 186 | label { (label) input type="text" name=(name) value=[value]; } |
| 187 | } |
| 188 | }) |
| 189 | } |
| 190 | |
| 191 | /// `spec.values`'s prefill for `name`, if any. |
| 192 | fn value_of<'a>(spec: &'a Spec<'_>, name: &str) -> Option<&'a str> { |
| 193 | spec.values |
| 194 | .iter() |
| 195 | .find(|(field, _)| *field == name) |
| 196 | .map(|(_, value)| value.as_str()) |
| 197 | } |
| 198 | |
| 199 | /// Every posted occurrence split on commas and whitespace, trimmed, |
| 200 | /// empties dropped — one text input carries a whole `Vec` field. |
| 201 | fn split_list(posted: &[&str]) -> Vec<String> { |
| 202 | posted |
| 203 | .iter() |
| 204 | .flat_map(|value| value.split([',', ' ', '\t', '\n'])) |
| 205 | .map(str::trim) |
| 206 | .filter(|segment| !segment.is_empty()) |
| 207 | .map(str::to_owned) |
| 208 | .collect() |
| 209 | } |
| 210 | |
| 211 | /// A field name as its control's label: first letter upper-cased. |
| 212 | fn title_case(name: &str) -> String { |
| 213 | let mut chars = name.chars(); |
| 214 | chars.next().map_or_else(String::new, |first| { |
| 215 | first.to_uppercase().chain(chars).collect() |
| 216 | }) |
| 217 | } |
| 218 | |
| 219 | #[cfg(test)] |
| 220 | mod tests { |
| 221 | #![allow(clippy::expect_used, clippy::panic, reason = "unit test")] |
| 222 | |
| 223 | use ents_forge::issue::IssueAction; |
| 224 | use ents_forge::review::ReviewAction; |
| 225 | use rstest::rstest; |
| 226 | |
| 227 | use super::*; |
| 228 | |
| 229 | fn session() -> Session { |
| 230 | Session { |
| 231 | csrf: "tok".to_owned(), |
| 232 | member: None, |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | fn pairs(entries: &[(&str, &str)]) -> Vec<(String, String)> { |
| 237 | entries |
| 238 | .iter() |
| 239 | .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) |
| 240 | .collect() |
| 241 | } |
| 242 | |
| 243 | /// The action variant's own shape decides the controls: a compose |
| 244 | /// field is a textarea, a `Vec` a comma input, a `PathBuf` (`--key`) |
| 245 | /// and a positional id never render, and an override slots in place. |
| 246 | #[rstest] |
| 247 | // @relation(lens.forms, lens.parity, scope=function, role=Verifies) |
| 248 | fn action_form_derives_controls_from_the_variant_shape() { |
| 249 | let markup = action_form::<IssueAction>( |
| 250 | "New", |
| 251 | &session(), |
| 252 | &Spec { |
| 253 | action: "/issues", |
| 254 | submit: "Open Issue", |
| 255 | cancel: None, |
| 256 | values: &[], |
| 257 | overrides: &[("state", html! { span.custom-state {} })], |
| 258 | }, |
| 259 | ) |
| 260 | .into_string(); |
| 261 | assert!(markup.contains("<textarea name=\"body\">")); |
| 262 | assert!(markup.contains("name=\"label\"") && markup.contains("name=\"assignee\"")); |
| 263 | assert!(markup.contains("custom-state") && !markup.contains("name=\"state\"")); |
| 264 | assert!(!markup.contains("name=\"key\""), "{markup}"); |
| 265 | assert!(markup.contains("name=\"csrf\" value=\"tok\"")); |
| 266 | |
| 267 | let edit = action_form::<IssueAction>( |
| 268 | "Edit", |
| 269 | &session(), |
| 270 | &Spec { |
| 271 | action: "", |
| 272 | submit: "Save", |
| 273 | cancel: None, |
| 274 | values: &[("label", "bug, gate".to_owned())], |
| 275 | overrides: &[], |
| 276 | }, |
| 277 | ) |
| 278 | .into_string(); |
| 279 | assert!(!edit.contains("name=\"id\""), "positional ids are path-bound"); |
| 280 | assert!(edit.contains("value=\"bug, gate\"")); |
| 281 | } |
| 282 | |
| 283 | /// The same shape parses the post back: `Vec` fields split on commas |
| 284 | /// and whitespace, an empty optional is `None`, the unposted `--key` |
| 285 | /// defaults, and unknown pairs (csrf) are ignored. |
| 286 | #[rstest] |
| 287 | // @relation(lens.forms, lens.parity, scope=function, role=Verifies) |
| 288 | fn parse_action_reads_the_posted_pairs_into_the_variant() { |
| 289 | let action: IssueAction = parse_action( |
| 290 | "New", |
| 291 | &pairs(&[ |
| 292 | ("title", "gate rejects a valid signature"), |
| 293 | ("body", ""), |
| 294 | ("state", "open"), |
| 295 | ("label", "bug, gate"), |
| 296 | ("assignee", "jdc alice"), |
| 297 | ("csrf", "tok"), |
| 298 | ]), |
| 299 | ) |
| 300 | .expect("parses"); |
| 301 | let IssueAction::New { |
| 302 | title, |
| 303 | body, |
| 304 | state, |
| 305 | label, |
| 306 | assignee, |
| 307 | key, |
| 308 | } = action |
| 309 | else { |
| 310 | panic!("wrong variant"); |
| 311 | }; |
| 312 | assert_eq!(title.as_deref(), Some("gate rejects a valid signature")); |
| 313 | assert_eq!(body, None); |
| 314 | assert_eq!(state, "open"); |
| 315 | assert_eq!(label, vec!["bug".to_owned(), "gate".to_owned()]); |
| 316 | assert_eq!(assignee, vec!["jdc".to_owned(), "alice".to_owned()]); |
| 317 | assert_eq!(key, None); |
| 318 | } |
| 319 | |
| 320 | /// An unposted field takes the declaration's own default — the same |
| 321 | /// `default = "open"` the CLI applies to an omitted flag. |
| 322 | #[rstest] |
| 323 | // @relation(lens.forms, lens.parity, scope=function, role=Verifies) |
| 324 | fn parse_action_defaults_an_unposted_field_from_the_declaration() { |
| 325 | let action: ReviewAction = |
| 326 | parse_action("New", &pairs(&[("verdict", "approve")])).expect("parses"); |
| 327 | let ReviewAction::New { |
| 328 | target, |
| 329 | verdict, |
| 330 | body, |
| 331 | key, |
| 332 | } = action |
| 333 | else { |
| 334 | panic!("wrong variant"); |
| 335 | }; |
| 336 | assert_eq!(target, "HEAD"); |
| 337 | assert_eq!(verdict, "approve"); |
| 338 | assert_eq!(body, None); |
| 339 | assert_eq!(key, None); |
| 340 | } |
| 341 | |
| 342 | #[rstest] |
| 343 | fn parse_action_refuses_an_unknown_variant() { |
| 344 | assert!(parse_action::<IssueAction>("Explode", &[]).is_err()); |
| 345 | } |
| 346 | } |