Skip to main content

laterite_admin/
form.rs

1//! Descriptor-driven create and edit forms.
2//!
3//! A [`FormConfig`] describes a table and its editable fields. Generic handlers
4//! render an empty form (new), a populated form (edit), and persist through a
5//! [`crate::persist::Persister`]: the descriptor-driven default (a parameterized
6//! insert/update over the form's columns), or a named handler for a custom write.
7//! A submission is checked by the framework validation engine
8//! ([`laterite_core::validation`]); a failure re-renders the form with per-field
9//! messages instead of writing.
10//!
11//! Field types resolve through the registry ([`crate::field`]); `text`,
12//! `textarea`, `select`, and `reference` ship built-in. Fields needing a typed
13//! stored value (a switch over a bool column, password hashing) arrive with the
14//! typed-save contract.
15//!
16//! The primary key is a `bigint` auto-increment column the database assigns, so
17//! create inserts only the descriptor's fields and never sets the id. An entity
18//! with other required columns that lack defaults (for example audit timestamps)
19//! is beyond this slice; those are filled by a later timestamp-aware widget.
20
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use askama::Template;
25use axum::http::StatusCode;
26use axum::response::{IntoResponse, Redirect, Response};
27use laterite_core::query::{bind_values, build as to_sql, text_cast};
28use laterite_core::validation::{validate, FieldRules, Mode, Rule};
29use laterite_core::{
30    t, Actor, AnyRowExt, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
31};
32use sea_query::{Alias, Expr, Query};
33use serde::{Deserialize, Serialize};
34
35use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
36use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
37use crate::sql::valid_ident;
38use crate::{not_found, render, render_error, AdminState};
39
40/// One editable field: the column, its label, its field-type key (resolved
41/// through the field-type registry), typed options for that type, the validation
42/// rules it carries, and whether it holds translatable content. A serde
43/// descriptor, so it is authorable as data (later YAML) as well as by builder.
44#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct FormField {
47    pub name: String,
48    /// The field's label, localized at render. Serde stays a plain string.
49    pub label: Text,
50    /// The field-type registry key (`text`, `textarea`, or a plugin's
51    /// `vendor.name`), resolved to behaviour at render time (see [`crate::field`]).
52    pub field_type: String,
53    /// The keys that belong to the field's type, as an object: in a file they
54    /// are written on the field itself, beside its own. Null when there are none.
55    pub options: serde_json::Value,
56    /// Validation rules run on submit (see [`laterite_core::validation`]).
57    pub rules: Vec<Rule>,
58    /// Marks a field whose value is translatable content. A reserved seam: the
59    /// framework stores the value verbatim; a content-translation plugin reads
60    /// the flag to manage per-locale values.
61    pub translatable: bool,
62    /// Help text shown beneath the control, localized at render.
63    pub help: Option<Text>,
64    /// What Enter does in this field: `submit` (the form's rule), `off`, or
65    /// `next` (moves to the next field).
66    pub enter: FieldEnter,
67    /// The share of the row the field takes, on a twelve-column grid.
68    pub span: Span,
69    /// Starts a new row, whatever room the current one has left.
70    pub break_row: bool,
71    /// Follows another field as it is typed, until this one is edited.
72    pub preset: Option<Preset>,
73    /// Changes when another field's state meets a condition.
74    pub trigger: Option<Trigger>,
75}
76
77/// How a preset shapes the text it copies.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum PresetShape {
81    /// As typed.
82    Exact,
83    /// Lowercase words joined by hyphens: `hello-world`.
84    #[default]
85    Slug,
86    /// A slug with a leading slash: `/hello-world`.
87    Url,
88    /// A file name: spaces to hyphens, the rest kept.
89    File,
90}
91
92impl PresetShape {
93    /// The word the page carries, for the island.
94    pub fn as_str(self) -> &'static str {
95        match self {
96            PresetShape::Exact => "exact",
97            PresetShape::Slug => "slug",
98            PresetShape::Url => "url",
99            PresetShape::File => "file",
100        }
101    }
102}
103
104/// Fills a field from another as it is typed, until the field is edited.
105///
106/// In YAML, the followed field's name alone (`preset: title`, a slug), or a
107/// map: `preset: { field: title, type: url }`.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub struct Preset {
110    pub field: String,
111    pub shape: PresetShape,
112}
113
114impl Preset {
115    pub fn new(field: impl Into<String>, shape: PresetShape) -> Self {
116        Self {
117            field: field.into(),
118            shape,
119        }
120    }
121}
122
123impl<'de> Deserialize<'de> for Preset {
124    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
125        #[derive(Deserialize)]
126        #[serde(deny_unknown_fields)]
127        struct Full {
128            field: String,
129            #[serde(rename = "type", default)]
130            shape: PresetShape,
131        }
132        #[derive(Deserialize)]
133        #[serde(untagged)]
134        enum Raw {
135            Field(String),
136            Full(Full),
137        }
138        Ok(match Raw::deserialize(de)? {
139            Raw::Field(field) => Preset::new(field, PresetShape::Slug),
140            Raw::Full(full) => Preset::new(full.field, full.shape),
141        })
142    }
143}
144
145/// Changes a field when another field's state meets a condition.
146///
147/// ```yaml
148/// send_at:
149///   type: date
150///   trigger: { action: show, field: is_delayed, condition: checked }
151/// ```
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct Trigger {
155    /// What happens when the condition holds: `show`, `hide`, `enable`,
156    /// `disable`, `empty`, `fill[value]`, or several joined by `|`.
157    pub action: String,
158    /// The field watched. `name[]` watches every value of a checklist.
159    pub field: String,
160    /// `checked`, `unchecked`, `value[x]`, `value[x][y]` (either), `value[]`
161    /// (empty), `value[*]` (anything), `value[foo*]` (a prefix).
162    pub condition: String,
163}
164
165impl Trigger {
166    pub fn new(
167        action: impl Into<String>,
168        field: impl Into<String>,
169        condition: impl Into<String>,
170    ) -> Self {
171        Self {
172            action: action.into(),
173            field: field.into(),
174            condition: condition.into(),
175        }
176    }
177
178    /// Refuses an action or condition the island does not know.
179    pub fn check(&self) -> Result<(), String> {
180        for action in self.action.split('|') {
181            let known = matches!(action, "show" | "hide" | "enable" | "disable" | "empty")
182                || (action.starts_with("fill[") && action.ends_with(']'));
183            if !known {
184                return Err(format!(
185                    "unknown trigger action `{action}`: show, hide, enable, disable, empty or fill[value]"
186                ));
187            }
188        }
189        let known = matches!(self.condition.as_str(), "checked" | "unchecked")
190            || (self.condition.starts_with("value[") && self.condition.ends_with(']'));
191        if !known {
192            return Err(format!(
193                "unknown trigger condition `{}`: checked, unchecked or value[..]",
194                self.condition
195            ));
196        }
197        Ok(())
198    }
199
200    /// The name of the field watched, without a `[]` suffix.
201    pub fn watched(&self) -> &str {
202        self.field.trim_end_matches("[]")
203    }
204}
205
206/// The attributes a field's wrapper carries for its preset and trigger, each
207/// led by a space, empty when it has neither.
208pub(crate) fn behaviour_attrs(field: &FormField) -> String {
209    fn escaped(text: &str) -> String {
210        text.replace('&', "&amp;")
211            .replace('<', "&lt;")
212            .replace('>', "&gt;")
213            .replace('"', "&quot;")
214    }
215    let mut attrs = String::new();
216    if let Some(preset) = &field.preset {
217        attrs.push_str(&format!(
218            r#" data-lat-preset="{}" data-lat-preset-type="{}""#,
219            escaped(&preset.field),
220            preset.shape.as_str()
221        ));
222    }
223    if let Some(trigger) = &field.trigger {
224        attrs.push_str(&format!(
225            r#" data-lat-trigger-action="{}" data-lat-trigger-field="{}" data-lat-trigger-condition="{}""#,
226            escaped(&trigger.action),
227            escaped(&trigger.field),
228            escaped(&trigger.condition)
229        ));
230    }
231    attrs
232}
233
234/// The share of a row a field takes, in twelfths. Written in YAML as a
235/// fraction (`1/2`, `1/3`, `2/3`, `1/4`, `3/4`), `full`, a count of columns
236/// (`1` to `12`), or the words `half`, `third`, `quarter`, `left`, `right` and
237/// `auto` (each a half). Fields flow left to right and wrap when a row is full.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub struct Span(u8);
240
241impl Span {
242    /// The whole row.
243    pub const FULL: Span = Span(12);
244
245    /// `columns` twelfths of the row, clamped to 1 to 12.
246    pub fn columns(columns: u8) -> Self {
247        Span(columns.clamp(1, 12))
248    }
249
250    /// The twelfths this span covers.
251    pub fn width(self) -> u8 {
252        self.0
253    }
254
255    /// Reads the YAML spellings.
256    pub fn parse(text: &str) -> Result<Self, String> {
257        let columns = match text.trim() {
258            "full" | "12/12" => 12,
259            "1/2" | "half" | "left" | "right" | "auto" => 6,
260            "1/3" | "third" => 4,
261            "2/3" => 8,
262            "1/4" | "quarter" => 3,
263            "3/4" => 9,
264            other => match other.parse::<u8>() {
265                Ok(n) if (1..=12).contains(&n) => n,
266                _ => {
267                    return Err(format!(
268                        "unknown span `{other}`, expected full, 1/2, 1/3, 2/3, 1/4, 3/4 or 1 to 12"
269                    ))
270                }
271            },
272        };
273        Ok(Span(columns))
274    }
275
276    /// The CSS class the field wrapper carries; none for a full row.
277    fn class(self) -> String {
278        if self.0 == 12 {
279            String::new()
280        } else {
281            format!(" lat-field--{}", self.0)
282        }
283    }
284}
285
286impl Default for Span {
287    fn default() -> Self {
288        Span::FULL
289    }
290}
291
292impl Serialize for Span {
293    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
294        match self.0 {
295            12 => serializer.serialize_str("full"),
296            6 => serializer.serialize_str("1/2"),
297            4 => serializer.serialize_str("1/3"),
298            8 => serializer.serialize_str("2/3"),
299            3 => serializer.serialize_str("1/4"),
300            9 => serializer.serialize_str("3/4"),
301            n => serializer.serialize_u8(n),
302        }
303    }
304}
305
306impl<'de> Deserialize<'de> for Span {
307    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
308        #[derive(Deserialize)]
309        #[serde(untagged)]
310        enum Raw {
311            Columns(u8),
312            Text(String),
313        }
314        match Raw::deserialize(deserializer)? {
315            Raw::Columns(n) if (1..=12).contains(&n) => Ok(Span(n)),
316            Raw::Columns(n) => Err(serde::de::Error::custom(format!(
317                "unknown span `{n}`, expected 1 to 12"
318            ))),
319            Raw::Text(text) => Span::parse(&text).map_err(serde::de::Error::custom),
320        }
321    }
322}
323
324/// The wrapper classes a field's layout adds: its span, and a row break.
325pub(crate) fn layout_classes(span: Span, break_row: bool) -> String {
326    let mut classes = span.class();
327    if break_row {
328        classes.push_str(" lat-field--break");
329    }
330    classes
331}
332
333/// What Enter does in a form's single-line fields.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
335#[serde(rename_all = "lowercase")]
336pub enum EnterPolicy {
337    /// Submits the form. Cmd/Ctrl+Enter submits from a textarea as well.
338    #[default]
339    Submit,
340    /// Enter never submits; Cmd/Ctrl+Enter still does.
341    Off,
342}
343
344/// Where the cursor goes when a form opens.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum FormFocus {
348    /// A new record opens with the cursor in its first field. A refused save
349    /// comes back with the cursor in the first field it refused.
350    #[default]
351    Auto,
352    /// The cursor is left where the browser puts it.
353    Off,
354}
355
356/// What Enter does in one field, over the form's [`EnterPolicy`].
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
358#[serde(rename_all = "lowercase")]
359pub enum FieldEnter {
360    /// The form's rule.
361    #[default]
362    Submit,
363    /// Nothing.
364    Off,
365    /// Focus moves to the next field.
366    Next,
367}
368
369impl FieldEnter {
370    /// The `data-lat-enter` value the field wrapper carries; empty for the
371    /// form's rule.
372    fn attribute(self) -> &'static str {
373        match self {
374            FieldEnter::Submit => "",
375            FieldEnter::Off => "off",
376            FieldEnter::Next => "next",
377        }
378    }
379}
380
381impl Default for FormConfig {
382    /// Everything a descriptor file leaves to the resource level: the entity,
383    /// the path and the id column are filled in when the file is read.
384    fn default() -> Self {
385        Self {
386            entity: String::new(),
387            title: Text::new(""),
388            base_path: String::new(),
389            id_field: "id".to_string(),
390            fields: Vec::new(),
391            enter: EnterPolicy::default(),
392            confirm_leave: true,
393            focus: FormFocus::default(),
394            persist: None,
395            timestamps: false,
396        }
397    }
398}
399
400impl Default for FormField {
401    /// A text field, named by the key it is written under.
402    fn default() -> Self {
403        Self::of("", Text::new(""), "text")
404    }
405}
406
407impl<'de> Deserialize<'de> for FormField {
408    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
409        struct Entry;
410        impl<'de> serde::de::Visitor<'de> for Entry {
411            type Value = FormField;
412
413            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
414                f.write_str("a field: its own keys and its type's")
415            }
416
417            fn visit_map<A: serde::de::MapAccess<'de>>(
418                self,
419                map: A,
420            ) -> Result<FormField, A::Error> {
421                let mut field = FormField::default();
422                let theirs = crate::keyed::split_entry(map, |key, map| {
423                    match key {
424                        "name" => field.name = map.next_value()?,
425                        "label" => field.label = map.next_value()?,
426                        "type" => field.field_type = map.next_value()?,
427                        "rules" => field.rules = map.next_value()?,
428                        "translatable" => field.translatable = map.next_value()?,
429                        "help" => field.help = map.next_value()?,
430                        "enter" => field.enter = map.next_value()?,
431                        "span" => field.span = map.next_value()?,
432                        "break" => field.break_row = map.next_value()?,
433                        "preset" => field.preset = map.next_value()?,
434                        "trigger" => field.trigger = map.next_value()?,
435                        _ => return Ok(false),
436                    }
437                    Ok(true)
438                })?;
439                field.options = theirs;
440                if let Some(trigger) = &field.trigger {
441                    trigger.check().map_err(serde::de::Error::custom)?;
442                }
443                Ok(field)
444            }
445
446            /// A key written bare (`title:`) is a field with every default.
447            fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
448                Ok(FormField::default())
449            }
450        }
451        de.deserialize_map(Entry)
452    }
453}
454
455impl Serialize for FormField {
456    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
457        use serde::ser::SerializeMap;
458        let mut map = ser.serialize_map(None)?;
459        map.serialize_entry("name", &self.name)?;
460        map.serialize_entry("label", &self.label)?;
461        map.serialize_entry("type", &self.field_type)?;
462        map.serialize_entry("rules", &self.rules)?;
463        map.serialize_entry("translatable", &self.translatable)?;
464        map.serialize_entry("help", &self.help)?;
465        map.serialize_entry("enter", &self.enter)?;
466        map.serialize_entry("span", &self.span)?;
467        map.serialize_entry("break", &self.break_row)?;
468        if let Some(preset) = &self.preset {
469            map.serialize_entry("preset", preset)?;
470        }
471        if let Some(trigger) = &self.trigger {
472            map.serialize_entry("trigger", trigger)?;
473        }
474        crate::keyed::serialize_theirs(&mut map, &self.options)?;
475        map.end()
476    }
477}
478
479impl crate::keyed::Keyed for FormField {
480    fn key(&self) -> &str {
481        &self.name
482    }
483    fn set_key(&mut self, key: String) {
484        self.name = key;
485    }
486}
487
488impl FormField {
489    /// A field of a registered type by key.
490    pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
491        Self {
492            name: name.to_string(),
493            label: label.into(),
494            field_type: field_type.to_string(),
495            options: serde_json::Value::Null,
496            rules: Vec::new(),
497            translatable: false,
498            help: None,
499            enter: FieldEnter::default(),
500            span: Span::default(),
501            break_row: false,
502            preset: None,
503            trigger: None,
504        }
505    }
506
507    /// Follows `field` as it is typed, shaped as `shape`, until this field is
508    /// edited.
509    pub fn preset(mut self, field: &str, shape: PresetShape) -> Self {
510        self.preset = Some(Preset::new(field, shape));
511        self
512    }
513
514    /// Changes when `trigger`'s condition holds on the field it watches.
515    pub fn trigger(mut self, trigger: Trigger) -> Self {
516        self.trigger = Some(trigger);
517        self
518    }
519
520    pub fn text(name: &str, label: impl Into<Text>) -> Self {
521        Self::of(name, label, "text")
522    }
523
524    pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
525        Self::of(name, label, "textarea")
526    }
527
528    /// A reference field: a picker over a registered picker source (a `vendor.name`
529    /// key), which the field searches and resolves against. Builds the `source`
530    /// option so a descriptor needs no raw JSON.
531    pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
532        Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
533    }
534
535    /// A dropdown over a fixed set of `(value, label)` choices.
536    pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
537        Self::of(name, label, "select").options(Self::choices(options))
538    }
539
540    /// The same choices rendered as radio buttons.
541    pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
542        Self::of(name, label, "radio").options(Self::choices(options))
543    }
544
545    /// A checkbox over a boolean column.
546    pub fn switch(name: &str, label: impl Into<Text>) -> Self {
547        Self::of(name, label, "switch")
548    }
549
550    /// A calendar date, stored as `YYYY-MM-DD`.
551    pub fn date(name: &str, label: impl Into<Text>) -> Self {
552        Self::of(name, label, "date")
553    }
554
555    /// A password: hashed on save, never rendered back, and left alone when the
556    /// field is submitted blank on an edit.
557    pub fn password(name: &str, label: impl Into<Text>) -> Self {
558        Self::of(name, label, "password")
559    }
560
561    /// A list of rows, each holding `fields`, stored as an array of objects.
562    pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
563        Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
564    }
565
566    /// A repeater whose rows collapse to the line that names them, opening one
567    /// at a time. `summary_field` names the sub-field that titles a row; unset,
568    /// the first one does.
569    ///
570    /// Use it when a row holds more than one or two fields: rendered inline
571    /// those become columns, and a row of four is already unreadable. A single
572    /// narrow column is better left inline, where collapsing would hide the one
573    /// thing worth seeing.
574    pub fn repeater_list(
575        name: &str,
576        label: impl Into<Text>,
577        fields: Vec<FormField>,
578        summary_field: Option<&str>,
579    ) -> Self {
580        Self::of(name, label, "repeater").options(serde_json::json!({
581            "fields": fields,
582            "display": "list",
583            "summary_field": summary_field,
584        }))
585    }
586
587    /// The options blob the choice-shaped types read.
588    fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
589        let options: Vec<serde_json::Value> = options
590            .into_iter()
591            .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
592            .collect();
593        serde_json::json!({ "options": options })
594    }
595
596    /// Sets the field type's typed options (the type validates them at render).
597    pub fn options(mut self, options: serde_json::Value) -> Self {
598        self.options = options;
599        self
600    }
601
602    /// Requires a non-empty value in every mode.
603    pub fn required(mut self) -> Self {
604        self.rules.push(Rule::Required);
605        self
606    }
607
608    /// Requires a non-empty value in one mode only (for example a password set on
609    /// create but left unchanged on edit).
610    pub fn required_on(mut self, mode: Mode) -> Self {
611        self.rules.push(Rule::RequiredOn(mode));
612        self
613    }
614
615    /// Requires the value to be unique in this field's column (a DB probe that
616    /// ignores the edited row on update).
617    pub fn unique(mut self) -> Self {
618        self.rules.push(Rule::Unique);
619        self
620    }
621
622    /// Caps the value's length.
623    pub fn max_length(mut self, n: usize) -> Self {
624        self.rules.push(Rule::MaxLength(n));
625        self
626    }
627
628    /// Requires at least `n` characters when the value is non-empty.
629    pub fn min_length(mut self, n: usize) -> Self {
630        self.rules.push(Rule::MinLength(n));
631        self
632    }
633
634    /// Requires a syntactically valid email address.
635    pub fn email(mut self) -> Self {
636        self.rules.push(Rule::Email);
637        self
638    }
639
640    /// Marks this field as translatable content (see [`FormField::translatable`]).
641    pub fn translatable(mut self) -> Self {
642        self.translatable = true;
643        self
644    }
645
646    /// Help text shown beneath the control.
647    pub fn help(mut self, text: impl Into<Text>) -> Self {
648        self.help = Some(text.into());
649        self
650    }
651
652    /// The share of the row this field takes.
653    pub fn span(mut self, span: Span) -> Self {
654        self.span = span;
655        self
656    }
657
658    /// Starts a new row.
659    pub fn break_row(mut self) -> Self {
660        self.break_row = true;
661        self
662    }
663}
664
665/// A form descriptor: which table, its editable fields, the id column, and the
666/// base path the form lives under (`{base_path}/new`, `{base_path}/{id}/edit`).
667///
668/// Built with [`FormConfig::new`] plus its builder methods. Non-exhaustive so the
669/// framework can learn something new about a form after 1.0 without a major bump.
670#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(deny_unknown_fields, default)]
672#[non_exhaustive]
673pub struct FormConfig {
674    pub entity: String,
675    /// The screen title, localized at render. Serde stays a plain string.
676    pub title: Text,
677    pub base_path: String,
678    pub id_field: String,
679    #[serde(
680        deserialize_with = "crate::keyed::deserialize",
681        serialize_with = "crate::keyed::serialize"
682    )]
683    pub fields: Vec<FormField>,
684    /// What Enter does in the form's single-line fields. Default `submit`.
685    #[serde(default)]
686    pub enter: EnterPolicy,
687    /// Ask before leaving the form with changes unsaved. Default `true`.
688    pub confirm_leave: bool,
689    /// Where the cursor goes when the form opens. Default `auto`.
690    pub focus: FormFocus,
691    /// The registered persister that writes this form (a dotted `vendor.name`),
692    /// or `None` for the built-in descriptor insert/update. See [`crate::persist`].
693    pub persist: Option<String>,
694    /// Stamp `created_at` and `updated_at` on write. Opt in per form, because
695    /// the columns have to exist on this entity's table.
696    #[serde(default)]
697    pub timestamps: bool,
698}
699
700impl FormConfig {
701    /// A form over `entity`, mounted under `base_path`, keyed by `id_field`.
702    ///
703    /// The four arguments are the ones with no sensible default; everything else
704    /// is opt-in through a builder method, so a later field costs nothing.
705    pub fn new(
706        entity: impl Into<String>,
707        title: impl Into<Text>,
708        base_path: impl Into<String>,
709        id_field: impl Into<String>,
710        fields: Vec<FormField>,
711    ) -> Self {
712        Self {
713            enter: Default::default(),
714            confirm_leave: true,
715            focus: FormFocus::default(),
716            entity: entity.into(),
717            title: title.into(),
718            base_path: base_path.into(),
719            id_field: id_field.into(),
720            fields,
721            persist: None,
722            timestamps: false,
723        }
724    }
725
726    /// Writes this form through a registered persister instead of the built-in
727    /// descriptor insert/update.
728    pub fn persist(mut self, name: impl Into<String>) -> Self {
729        self.persist = Some(name.into());
730        self
731    }
732
733    /// Whether leaving the form with changes unsaved asks first. Default `true`.
734    pub fn confirm_leave(mut self, ask: bool) -> Self {
735        self.confirm_leave = ask;
736        self
737    }
738
739    /// Where the cursor goes when the form opens.
740    pub fn focus(mut self, focus: FormFocus) -> Self {
741        self.focus = focus;
742        self
743    }
744
745    /// Stamps `created_at` and `updated_at` on write. Opt in, because the
746    /// columns have to exist on this entity's table.
747    pub fn timestamps(mut self) -> Self {
748        self.timestamps = true;
749        self
750    }
751
752    fn idents_valid(&self) -> bool {
753        valid_ident(&self.entity)
754            && valid_ident(&self.id_field)
755            && self.fields.iter().all(|f| valid_ident(&f.name))
756    }
757}
758
759/// A [`FormConfig`] with each field's options resolved once, at router build.
760/// Handlers carry this so option resolution and intrinsic-rule merging happen a
761/// single time (not per render), and a malformed option or unregistered type
762/// aborts boot rather than degrading silently to plain text.
763pub(crate) struct PreparedForm {
764    config: FormConfig,
765    /// Parallel to `config.fields`.
766    fields: Vec<PreparedField>,
767    /// The write handler: the named persister, or the built-in default.
768    persister: Arc<dyn Persister>,
769    /// Listeners for this form's entity, in registration order.
770    listeners: Vec<Arc<dyn ModelListener>>,
771}
772
773/// One field's boot-resolved state: its typed options and its merged rules (the
774/// type's intrinsic rules ahead of the descriptor's own).
775struct PreparedField {
776    /// Resolved once at boot, so the save and load hooks need no lookup.
777    field_type: Arc<dyn crate::field::FieldType>,
778    opts: ResolvedOptions,
779    rules: Vec<Rule>,
780}
781
782impl PreparedForm {
783    /// Resolves every field against the registry. Returns the offending field
784    /// and reason (for a boot abort) when a type is unregistered or rejects its
785    /// options.
786    pub(crate) fn prepare(
787        config: FormConfig,
788        field_types: &crate::field::FieldRegistry,
789        persisters: &PersisterRegistry,
790        listeners: &[ModelListenerReg],
791    ) -> Result<Self, String> {
792        let mut fields = Vec::with_capacity(config.fields.len());
793        for f in &config.fields {
794            let names = || config.fields.iter().map(|other| other.name.as_str());
795            if let Some(preset) = &f.preset {
796                if !names().any(|n| n == preset.field) {
797                    return Err(format!(
798                        "field `{}` presets from `{}`, which is not in this form",
799                        f.name, preset.field
800                    ));
801                }
802            }
803            if let Some(trigger) = &f.trigger {
804                trigger
805                    .check()
806                    .map_err(|e| format!("field `{}`: {e}", f.name))?;
807                if !names().any(|n| n == trigger.watched()) {
808                    return Err(format!(
809                        "field `{}` watches `{}`, which is not in this form",
810                        f.name,
811                        trigger.watched()
812                    ));
813                }
814            }
815            let ft = field_types.get(&f.field_type).ok_or_else(|| {
816                format!(
817                    "field `{}` uses unregistered type `{}`",
818                    f.name, f.field_type
819                )
820            })?;
821            crate::field::check_field_keys(f, ft.as_ref())
822                .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
823            let opts = ft
824                .resolve_options(&f.options, field_types)
825                .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
826            let mut rules = ft.intrinsic_rules(&opts);
827            rules.extend(f.rules.clone());
828            fields.push(PreparedField {
829                field_type: ft.clone(),
830                opts,
831                rules,
832            });
833        }
834        let persister: Arc<dyn Persister> = match &config.persist {
835            Some(key) => persisters
836                .get(key)
837                .cloned()
838                .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
839            None => Arc::new(DefaultPersister::from_config(&config)),
840        };
841        // The framework's own listeners run first, so a contributed one can see
842        // or override what they set.
843        let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
844        if config.timestamps {
845            resolved.push(Arc::new(laterite_core::Timestamps));
846        }
847        resolved.extend(
848            listeners
849                .iter()
850                .filter(|reg| reg.matches(&config.entity))
851                .map(|reg| reg.listener.clone()),
852        );
853        let listeners = resolved;
854        Ok(Self {
855            config,
856            fields,
857            persister,
858            listeners,
859        })
860    }
861}
862
863/// The submission as a record, carrying only the fields the descriptor declares.
864/// A request cannot introduce an attribute this way, so the persister may widen
865/// its write to whatever the record holds; only registered listeners add keys.
866///
867/// Each value passes through its field type, which decides what gets stored and
868/// may leave the attribute out entirely (a blank password on an edit).
869fn declared_record(
870    form: &PreparedForm,
871    data: &HashMap<String, String>,
872    mode: Mode,
873) -> Result<Record, String> {
874    let mut rec = Record::new(&form.config.entity);
875    for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
876        let submitted = crate::field::SubmittedField::new(&field.name, data);
877        if let Some(value) = prepared
878            .field_type
879            .to_attr(&submitted, &prepared.opts, mode)?
880        {
881            rec.set(field.name.clone(), value);
882        }
883    }
884    Ok(rec)
885}
886
887/// The submission with every field present under its own name.
888///
889/// A field of several controls submits keys of its own and none under its name.
890/// Its type gathers them into one value, so the rules see the field and a
891/// refused save is re-rendered from what the operator entered. A key already
892/// present is left as submitted.
893fn gathered(form: &PreparedForm, mut data: HashMap<String, String>) -> HashMap<String, String> {
894    for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
895        if data.contains_key(&field.name) {
896            continue;
897        }
898        let value = prepared.field_type.submitted(
899            &crate::field::SubmittedField::new(&field.name, &data),
900            &prepared.opts,
901        );
902        if let Some(value) = value {
903            data.insert(field.name.clone(), value);
904        }
905    }
906    data
907}
908
909/// The merged validation rules for every field, in order.
910fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
911    form.config
912        .fields
913        .iter()
914        .zip(&form.fields)
915        // The rules carry the label's source string; the validation message re-wraps
916        // it as a nested Text, so it localizes at render like any other label.
917        .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
918        .collect()
919}
920
921/// The longest value the rules accept, when they set one.
922pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
923    rules
924        .iter()
925        .filter_map(|r| match r {
926            Rule::MaxLength(n) => Some(*n),
927            _ => None,
928        })
929        .min()
930}
931
932/// Whether any rule marks the field required.
933fn has_required(rules: &[Rule]) -> bool {
934    rules
935        .iter()
936        .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
937}
938
939/// Renders an empty create form.
940pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
941    render(build(
942        state,
943        form,
944        &format!("{}/new", form.config.base_path),
945        None,
946        &HashMap::new(),
947        &ErrorBag::default(),
948        &shell,
949    ))
950}
951
952/// Persists a new record, then redirects to the list. Re-renders with per-field
953/// errors when validation fails.
954pub(crate) async fn create(
955    state: &AdminState,
956    form: &PreparedForm,
957    data: HashMap<String, String>,
958    shell: crate::Shell,
959    user: &laterite_auth::AuthenticatedUser,
960    session: &crate::session::SessionHandle,
961    headers: &axum::http::HeaderMap,
962) -> Response {
963    if !form.config.idents_valid() {
964        return render_error();
965    }
966    let htmx = is_htmx(headers);
967    let action = format!("{}/new", form.config.base_path);
968    let data = gathered(form, data);
969
970    let bag = match validate(
971        &state.db,
972        &form.config.entity,
973        &form.config.id_field,
974        &merged_field_rules(form),
975        &data,
976        Mode::Create,
977        None,
978    )
979    .await
980    {
981        Ok(bag) => bag,
982        Err(_) => return render_error(),
983    };
984    if !bag.is_empty() {
985        // A failed submission re-renders the form with per-field errors as 422,
986        // the cross-surface "validation failure" status.
987        return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
988    }
989
990    let mut rec = match declared_record(form, &data, Mode::Create) {
991        Ok(rec) => rec,
992        Err(e) => {
993            tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
994            return render_error();
995        }
996    };
997    let actor = Actor::from(user);
998    match persist::save(
999        persist::SaveRequest {
1000            db: &state.db,
1001            listeners: &form.listeners,
1002            persister: form.persister.as_ref(),
1003            actor: &actor,
1004            op: Op::Create,
1005            id: None,
1006        },
1007        &mut rec,
1008    )
1009    .await
1010    {
1011        // The audit entry is written by the framework's audit listener, which
1012        // runs on every entity after the commit.
1013        Ok(()) => {
1014            session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1015            saved_response(htmx, &form.config.base_path)
1016        }
1017        // A persist-time domain check re-renders 422 with its per-field messages.
1018        Err(SaveError::Invalid(bag)) => {
1019            invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1020        }
1021        Err(SaveError::Failed(msg)) => {
1022            tracing::error!(error = %msg, "admin create failed");
1023            render(build(
1024                state,
1025                form,
1026                &action,
1027                Some(t!("Could not save. Check the values and try again.")),
1028                &data,
1029                &ErrorBag::default(),
1030                &shell,
1031            ))
1032        }
1033    }
1034}
1035
1036/// Renders a form populated with an existing record.
1037pub(crate) async fn edit_form(
1038    state: &AdminState,
1039    form: &PreparedForm,
1040    id: String,
1041    shell: crate::Shell,
1042) -> Response {
1043    if !form.config.idents_valid() {
1044        return render_error();
1045    }
1046    // Scope the sea-query builder so it is dropped before the await below: its
1047    // identifiers are reference-counted (not `Send`), and a live builder across
1048    // the await would make this handler's future non-`Send`.
1049    let (sql, values) = {
1050        let cast = text_cast(state.db.backend);
1051        let mut select = Query::select();
1052        for field in &form.config.fields {
1053            select.expr_as(
1054                Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1055                Alias::new(&field.name),
1056            );
1057        }
1058        select.from(Alias::new(&form.config.entity)).and_where(
1059            Expr::col(Alias::new(&form.config.id_field))
1060                .cast_as(Alias::new(cast))
1061                .eq(id.clone()),
1062        );
1063        to_sql(state.db.backend, select)
1064    };
1065    let row = match bind_values(sqlx::query(&sql), values)
1066        .fetch_optional(&state.db.pool)
1067        .await
1068    {
1069        Ok(row) => row,
1070        Err(_) => return render_error(),
1071    };
1072    let Some(row) = row else {
1073        return not_found();
1074    };
1075
1076    // Each stored value goes back through its field type: what a column holds and
1077    // what its control shows are not always the same string.
1078    let values = form
1079        .config
1080        .fields
1081        .iter()
1082        .zip(&form.fields)
1083        .map(|(f, prepared)| {
1084            let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1085            let value = prepared
1086                .field_type
1087                .to_control(stored.as_deref(), &prepared.opts);
1088            (f.name.clone(), value)
1089        })
1090        .collect();
1091
1092    render(build(
1093        state,
1094        form,
1095        &format!("{}/{}/edit", form.config.base_path, id),
1096        None,
1097        &values,
1098        &ErrorBag::default(),
1099        &shell,
1100    ))
1101}
1102
1103/// Persists an edited record, then redirects to the list. Re-renders with
1104/// per-field errors when validation fails.
1105// The generic handlers thread the request's pieces explicitly rather than through
1106// a context struct; the list is long but each argument is named at the call site.
1107#[allow(clippy::too_many_arguments)]
1108pub(crate) async fn update(
1109    state: &AdminState,
1110    form: &PreparedForm,
1111    id: String,
1112    data: HashMap<String, String>,
1113    shell: crate::Shell,
1114    user: &laterite_auth::AuthenticatedUser,
1115    session: &crate::session::SessionHandle,
1116    headers: &axum::http::HeaderMap,
1117) -> Response {
1118    if !form.config.idents_valid() {
1119        return render_error();
1120    }
1121    let htmx = is_htmx(headers);
1122    let action = format!("{}/{}/edit", form.config.base_path, id);
1123    let data = gathered(form, data);
1124
1125    let bag = match validate(
1126        &state.db,
1127        &form.config.entity,
1128        &form.config.id_field,
1129        &merged_field_rules(form),
1130        &data,
1131        Mode::Update,
1132        Some(&id),
1133    )
1134    .await
1135    {
1136        Ok(bag) => bag,
1137        Err(_) => return render_error(),
1138    };
1139    if !bag.is_empty() {
1140        // A failed submission re-renders the form with per-field errors as 422,
1141        // the cross-surface "validation failure" status.
1142        return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
1143    }
1144
1145    let mut rec = match declared_record(form, &data, Mode::Update) {
1146        Ok(rec) => rec,
1147        Err(e) => {
1148            tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
1149            return render_error();
1150        }
1151    };
1152    if let Ok(n) = id.parse::<i64>() {
1153        rec.set_id(n);
1154    }
1155    let actor = Actor::from(user);
1156    match persist::save(
1157        persist::SaveRequest {
1158            db: &state.db,
1159            listeners: &form.listeners,
1160            persister: form.persister.as_ref(),
1161            actor: &actor,
1162            op: Op::Update,
1163            id: Some(&id),
1164        },
1165        &mut rec,
1166    )
1167    .await
1168    {
1169        Ok(()) => {
1170            session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1171            saved_response(htmx, &form.config.base_path)
1172        }
1173        Err(SaveError::Invalid(bag)) => {
1174            invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1175        }
1176        Err(SaveError::Failed(msg)) => {
1177            tracing::error!(error = %msg, "admin update failed");
1178            render(build(
1179                state,
1180                form,
1181                &action,
1182                Some(t!("Could not save. Check the values and try again.")),
1183                &data,
1184                &ErrorBag::default(),
1185                &shell,
1186            ))
1187        }
1188    }
1189}
1190
1191/// The 422 response for a failed save. An HTMX submit gets the form alone, which
1192/// replaces the form in place; anything else gets the whole page, so the admin
1193/// still works with scripting off.
1194#[allow(clippy::too_many_arguments)]
1195fn invalid_response(
1196    htmx: bool,
1197    state: &AdminState,
1198    form: &PreparedForm,
1199    action: &str,
1200    error: Option<Text>,
1201    values: &HashMap<String, String>,
1202    bag: &ErrorBag,
1203    shell: &crate::Shell,
1204) -> Response {
1205    let page = build(state, form, action, error, values, bag, shell);
1206    let body = if htmx {
1207        render(FormFragment {
1208            shell: page.shell,
1209            action: page.action,
1210            cancel_path: page.cancel_path,
1211            error: page.error,
1212            fields: page.fields,
1213            enter_off: page.enter_off,
1214            refused: page.refused,
1215            leave_off: page.leave_off,
1216            focus: page.focus,
1217        })
1218    } else {
1219        render(page)
1220    };
1221    (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1222}
1223
1224/// Where to send the browser after a successful save. HTMX will not follow a
1225/// 303 usefully (it would swap the redirected page into the form), so it gets
1226/// the header it understands instead.
1227pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1228    if htmx {
1229        ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1230    } else {
1231        Redirect::to(to).into_response()
1232    }
1233}
1234
1235/// Whether this request came from HTMX rather than a plain form post.
1236pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1237    headers.contains_key("hx-request")
1238}
1239
1240fn build(
1241    state: &AdminState,
1242    form: &PreparedForm,
1243    action: &str,
1244    error: Option<Text>,
1245    values: &HashMap<String, String>,
1246    bag: &ErrorBag,
1247    shell: &crate::Shell,
1248) -> FormTemplate {
1249    let fields = form
1250        .config
1251        .fields
1252        .iter()
1253        .zip(&form.fields)
1254        .map(|(f, pf)| {
1255            let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1256            let required = has_required(&pf.rules);
1257            // Localize the label once: the field type sees it (aria/inline labels)
1258            // and the framework chrome renders it.
1259            let label = shell.tt(&f.label);
1260            let cx = FieldCx {
1261                name: &f.name,
1262                id: &f.name,
1263                label: &label,
1264                value: &value,
1265                required,
1266                max_length: max_length(&pf.rules),
1267                opts: &pf.opts,
1268                base: &shell.base,
1269                i18n: shell.i18n(),
1270            };
1271            // The type is registered (prepare validated it); the lookup here is
1272            // only to render.
1273            let control = match state.field_types.get(&f.field_type) {
1274                Some(ft) => {
1275                    let scope = OverrideScope {
1276                        surface: Surface::Field,
1277                        view_key: &f.field_type,
1278                        resource: Some(&form.config.base_path),
1279                        field: Some(&f.name),
1280                    };
1281                    render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1282                }
1283                None => String::new(),
1284            };
1285            FieldView {
1286                id: f.name.clone(),
1287                label,
1288                control,
1289                required,
1290                enter: f.enter.attribute(),
1291                layout: layout_classes(f.span, f.break_row),
1292                attrs: behaviour_attrs(f),
1293                // Localize each per-field message through the request translator.
1294                errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1295            }
1296        })
1297        .collect();
1298    // Collect the widget assets the rendered field types declare, deduped and
1299    // resolved to URLs for the head. Most declare none (their widgets ship in
1300    // core laterite.js).
1301    let keys: Vec<&str> = form
1302        .config
1303        .fields
1304        .iter()
1305        .zip(&form.fields)
1306        .filter_map(|(f, pf)| {
1307            state
1308                .field_types
1309                .get(&f.field_type)
1310                .map(|ft| ft.assets(&pf.opts))
1311        })
1312        .flatten()
1313        .collect();
1314    // Localize the title and banner before the shell is moved into the template.
1315    let shell_title = shell.tt(&form.config.title);
1316    let refused = error.is_some() || !bag.is_empty();
1317    let error = error.map(|m| shell.tt(&m));
1318    let mut shell = shell.clone();
1319    shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1320    FormTemplate {
1321        shell,
1322        title: shell_title,
1323        action: action.to_string(),
1324        cancel_path: form.config.base_path.clone(),
1325        error,
1326        fields,
1327        enter_off: form.config.enter == EnterPolicy::Off,
1328        refused,
1329        leave_off: !form.config.confirm_leave,
1330        focus: match form.config.focus {
1331            FormFocus::Off => "off",
1332            // A new record has nothing in it yet, so the first field is where
1333            // the operator starts. An edit opens for reading first.
1334            FormFocus::Auto if action.ends_with("/new") => "first",
1335            FormFocus::Auto => "",
1336        },
1337    }
1338}
1339
1340struct FieldView {
1341    /// DOM id for the control and its label's `for`.
1342    id: String,
1343    label: String,
1344    /// The control HTML, rendered by the field-type registry (override-aware).
1345    /// The framework owns the surrounding chrome (label, required marker, errors).
1346    control: String,
1347    required: bool,
1348    errors: Vec<String>,
1349    /// The field's own Enter rule (`off`, `next`), or empty for the form's.
1350    enter: &'static str,
1351    /// Wrapper classes for the span and a row break, each led by a space.
1352    layout: String,
1353    /// The wrapper's preset and trigger attributes, each led by a space.
1354    attrs: String,
1355}
1356
1357/// Just the form element, for an HTMX submit that failed validation: the
1358/// response replaces the form in place rather than reloading the page.
1359#[derive(Template)]
1360#[template(path = "_form_fields.html")]
1361struct FormFragment {
1362    shell: crate::Shell,
1363    action: String,
1364    cancel_path: String,
1365    error: Option<String>,
1366    fields: Vec<FieldView>,
1367    /// Enter never submits this form.
1368    enter_off: bool,
1369    /// The form holds a submission that was not saved.
1370    refused: bool,
1371    /// Leaving with changes unsaved does not ask.
1372    leave_off: bool,
1373    /// `first`, `off`, or empty for the default.
1374    focus: &'static str,
1375}
1376
1377#[derive(Template)]
1378#[template(path = "form.html")]
1379struct FormTemplate {
1380    shell: crate::Shell,
1381    title: String,
1382    action: String,
1383    cancel_path: String,
1384    error: Option<String>,
1385    fields: Vec<FieldView>,
1386    /// Enter never submits this form.
1387    enter_off: bool,
1388    /// The form holds a submission that was not saved.
1389    refused: bool,
1390    /// Leaving with changes unsaved does not ask.
1391    leave_off: bool,
1392    /// `first`, `off`, or empty for the default.
1393    focus: &'static str,
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399    use axum::http::StatusCode;
1400    use laterite_core::strata::{
1401        async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1402    };
1403    use laterite_core::testing::{connect_test, TestGuard};
1404    use laterite_core::Db;
1405    use laterite_core::SaveCx;
1406
1407    /// A minimal table for exercising the generic insert/update path in isolation,
1408    /// defined as a portable migration so the test runs on any backend.
1409    struct CreateSamples;
1410    #[async_trait(?Send)]
1411    impl Migration for CreateSamples {
1412        fn name(&self) -> &str {
1413            "0001_create_samples"
1414        }
1415        async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1416            s.exec(
1417                Table::create()
1418                    .table(Alias::new("samples"))
1419                    .if_not_exists()
1420                    .col(
1421                        ColumnDef::new(Alias::new("id"))
1422                            .big_integer()
1423                            .not_null()
1424                            .auto_increment()
1425                            .primary_key(),
1426                    )
1427                    .col(ColumnDef::new(Alias::new("code")).text().not_null())
1428                    .col(ColumnDef::new(Alias::new("name")).text().not_null())
1429                    // Nullable, so a form that does not opt into timestamps
1430                    // still inserts.
1431                    .col(ColumnDef::new(Alias::new("created_at")).text())
1432                    .col(ColumnDef::new(Alias::new("updated_at")).text())
1433                    .to_owned(),
1434            )
1435            .await
1436        }
1437    }
1438
1439    fn config() -> PreparedForm {
1440        let config = FormConfig {
1441            enter: Default::default(),
1442            confirm_leave: true,
1443            focus: Default::default(),
1444            entity: "samples".to_string(),
1445            title: "Sample".into(),
1446            base_path: "/admin/samples".to_string(),
1447            id_field: "id".to_string(),
1448            fields: vec![
1449                FormField::text("code", "Code").required().unique(),
1450                FormField::text("name", "Name").required(),
1451            ],
1452            persist: None,
1453            timestamps: false,
1454        };
1455        PreparedForm::prepare(
1456            config,
1457            &crate::field::builtin_registry(),
1458            &PersisterRegistry::new(),
1459            &[],
1460        )
1461        .unwrap()
1462    }
1463
1464    /// The whole delete path against a real table: the row is read, the
1465    /// listeners run, the row goes, and the after-stage sees what went.
1466    #[tokio::test]
1467    async fn a_delete_removes_the_row_through_the_pipeline() {
1468        use crate::persist::{delete, DefaultPersister, DeleteRequest};
1469
1470        let (db, _guard) = test_db().await;
1471        let st = state(db.clone());
1472        let form = config();
1473
1474        let mut data = HashMap::new();
1475        data.insert("code".to_string(), "c1".to_string());
1476        data.insert("name".to_string(), "Chair".to_string());
1477        create(
1478            &st,
1479            &form,
1480            data,
1481            crate::Shell::test(),
1482            &crate::audit::test_actor(),
1483            &crate::session::SessionHandle::from_blob(None),
1484            &axum::http::HeaderMap::new(),
1485        )
1486        .await;
1487        assert_eq!(
1488            fetch_text(&db, "name", "c1").await.as_deref(),
1489            Some("Chair")
1490        );
1491
1492        let id = row_id(&db, "c1").await;
1493        let persister = DefaultPersister::from_config(&form.config);
1494        let rec = delete(DeleteRequest {
1495            db: &db,
1496            listeners: &[],
1497            persister: &persister,
1498            actor: &laterite_core::Actor::system("test"),
1499            entity: "samples",
1500            id: &id,
1501        })
1502        .await
1503        .expect("deleted");
1504
1505        // Gone from the table, and the caller learns what it removed.
1506        assert_eq!(fetch_text(&db, "name", "c1").await, None);
1507        assert_eq!(rec.text("code"), Some("c1"));
1508    }
1509
1510    /// The stored id for a row, as text, the shape the delete path takes.
1511    async fn row_id(db: &Db, code: &str) -> String {
1512        let stmt = Query::select()
1513            .expr_as(
1514                Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1515                Alias::new("v"),
1516            )
1517            .from(Alias::new("samples"))
1518            .and_where(Expr::col(Alias::new("code")).eq(code))
1519            .to_owned();
1520        let (sql, values) = laterite_core::query::build(db.backend, stmt);
1521        let row = bind_values(sqlx::query(&sql), values)
1522            .fetch_one(&db.pool)
1523            .await
1524            .unwrap();
1525        row.get_text("v").unwrap()
1526    }
1527
1528    pub(super) fn state(db: Db) -> AdminState {
1529        AdminState::new(
1530            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1531            db,
1532        )
1533    }
1534
1535    /// A fresh test database holding a minimal `samples` table, on whichever
1536    /// backend the run targets. Hold the returned guard for the test's lifetime.
1537    pub(super) async fn test_db() -> (Db, TestGuard) {
1538        let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1539        connect_test(&[samples]).await
1540    }
1541
1542    /// Reads a single text column from the one row matching `code`, so a test can
1543    /// assert what was persisted without depending on the read path under test.
1544    pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1545        let stmt = Query::select()
1546            .expr_as(
1547                Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1548                Alias::new("v"),
1549            )
1550            .from(Alias::new("samples"))
1551            .and_where(Expr::col(Alias::new("code")).eq(code))
1552            .to_owned();
1553        let (sql, values) = to_sql(db.backend, stmt);
1554        let row = bind_values(sqlx::query(&sql), values)
1555            .fetch_optional(&db.pool)
1556            .await
1557            .unwrap()?;
1558        row.get_text_opt("v").ok().flatten()
1559    }
1560
1561    async fn body_of(resp: Response) -> String {
1562        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1563            .await
1564            .unwrap();
1565        String::from_utf8(bytes.to_vec()).unwrap()
1566    }
1567
1568    async fn count(db: &Db) -> i64 {
1569        sqlx::query_scalar("select count(*) from samples")
1570            .fetch_one(&db.pool)
1571            .await
1572            .unwrap()
1573    }
1574
1575    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1576        pairs
1577            .iter()
1578            .map(|(k, v)| (k.to_string(), v.to_string()))
1579            .collect()
1580    }
1581
1582    /// The header htmx sets on every request it makes.
1583    pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1584        let mut h = axum::http::HeaderMap::new();
1585        h.insert("hx-request", "true".parse().unwrap());
1586        h
1587    }
1588
1589    #[tokio::test]
1590    async fn create_then_fetch() {
1591        let (db, _guard) = test_db().await;
1592        let cfg = config();
1593        let st = state(db.clone());
1594
1595        let resp = create(
1596            &st,
1597            &cfg,
1598            data(&[("code", "editor"), ("name", "Content Editor")]),
1599            crate::Shell::test(),
1600            &crate::audit::test_actor(),
1601            &crate::session::SessionHandle::from_blob(None),
1602            &axum::http::HeaderMap::new(),
1603        )
1604        .await;
1605        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1606
1607        assert_eq!(
1608            fetch_text(&db, "name", "editor").await.as_deref(),
1609            Some("Content Editor")
1610        );
1611    }
1612
1613    #[tokio::test]
1614    async fn update_changes_the_row() {
1615        let (db, _guard) = test_db().await;
1616        let cfg = config();
1617        let st = state(db.clone());
1618        create(
1619            &st,
1620            &cfg,
1621            data(&[("code", "editor"), ("name", "Editor")]),
1622            crate::Shell::test(),
1623            &crate::audit::test_actor(),
1624            &crate::session::SessionHandle::from_blob(None),
1625            &axum::http::HeaderMap::new(),
1626        )
1627        .await;
1628
1629        let id = fetch_text(&db, "id", "editor")
1630            .await
1631            .expect("row should exist after create");
1632
1633        // The code is unchanged, so its unique rule must ignore the edited row.
1634        let resp = update(
1635            &st,
1636            &cfg,
1637            id,
1638            data(&[("code", "editor"), ("name", "Senior Editor")]),
1639            crate::Shell::test(),
1640            &crate::audit::test_actor(),
1641            &crate::session::SessionHandle::from_blob(None),
1642            &axum::http::HeaderMap::new(),
1643        )
1644        .await;
1645        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1646
1647        assert_eq!(
1648            fetch_text(&db, "name", "editor").await.as_deref(),
1649            Some("Senior Editor")
1650        );
1651    }
1652
1653    #[tokio::test]
1654    async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1655        let (db, _guard) = test_db().await;
1656        let cfg = config();
1657        let st = state(db.clone());
1658
1659        let resp = create(
1660            &st,
1661            &cfg,
1662            data(&[("code", ""), ("name", "No Code")]),
1663            crate::Shell::test(),
1664            &crate::audit::test_actor(),
1665            &crate::session::SessionHandle::from_blob(None),
1666            &axum::http::HeaderMap::new(),
1667        )
1668        .await;
1669        // Re-renders the form (200), does not redirect.
1670        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1671        assert_eq!(count(&db).await, 0);
1672        // The per-field message is rendered.
1673        assert!(body_of(resp).await.contains("Code is required."));
1674    }
1675
1676    #[tokio::test]
1677    async fn create_rejects_a_duplicate_unique_value() {
1678        let (db, _guard) = test_db().await;
1679        let cfg = config();
1680        let st = state(db.clone());
1681        create(
1682            &st,
1683            &cfg,
1684            data(&[("code", "editor"), ("name", "Editor")]),
1685            crate::Shell::test(),
1686            &crate::audit::test_actor(),
1687            &crate::session::SessionHandle::from_blob(None),
1688            &axum::http::HeaderMap::new(),
1689        )
1690        .await;
1691
1692        // A second row with the same code is refused by the unique rule.
1693        let resp = create(
1694            &st,
1695            &cfg,
1696            data(&[("code", "editor"), ("name", "Other")]),
1697            crate::Shell::test(),
1698            &crate::audit::test_actor(),
1699            &crate::session::SessionHandle::from_blob(None),
1700            &axum::http::HeaderMap::new(),
1701        )
1702        .await;
1703        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1704        assert_eq!(count(&db).await, 1);
1705        assert!(body_of(resp).await.contains("already taken"));
1706    }
1707
1708    #[tokio::test]
1709    async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1710        let (db, _guard) = test_db().await;
1711        let st = state(db.clone());
1712        // The `name` column is a text field with the `email` input, which
1713        // contributes Rule::Email without the descriptor listing it.
1714        let cfg = PreparedForm::prepare(
1715            FormConfig {
1716                enter: Default::default(),
1717                confirm_leave: true,
1718                focus: Default::default(),
1719                entity: "samples".to_string(),
1720                title: "Sample".into(),
1721                base_path: "/admin/samples".to_string(),
1722                id_field: "id".to_string(),
1723                fields: vec![
1724                    FormField::text("code", "Code").required(),
1725                    FormField::of("name", "Email", "text")
1726                        .options(serde_json::json!({ "input": "email" }))
1727                        .required(),
1728                ],
1729                persist: None,
1730                timestamps: false,
1731            },
1732            &st.field_types,
1733            &PersisterRegistry::new(),
1734            &[],
1735        )
1736        .unwrap();
1737
1738        let resp = create(
1739            &st,
1740            &cfg,
1741            data(&[("code", "c1"), ("name", "nope")]),
1742            crate::Shell::test(),
1743            &crate::audit::test_actor(),
1744            &crate::session::SessionHandle::from_blob(None),
1745            &axum::http::HeaderMap::new(),
1746        )
1747        .await;
1748        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1749        assert_eq!(count(&db).await, 0);
1750
1751        let resp = create(
1752            &st,
1753            &cfg,
1754            data(&[("code", "c1"), ("name", "a@b.test")]),
1755            crate::Shell::test(),
1756            &crate::audit::test_actor(),
1757            &crate::session::SessionHandle::from_blob(None),
1758            &axum::http::HeaderMap::new(),
1759        )
1760        .await;
1761        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1762        assert_eq!(count(&db).await, 1);
1763    }
1764
1765    #[test]
1766    fn prepare_rejects_an_unregistered_field_type() {
1767        let config = FormConfig {
1768            enter: Default::default(),
1769            confirm_leave: true,
1770            focus: Default::default(),
1771            entity: "samples".to_string(),
1772            title: "Sample".into(),
1773            base_path: "/admin/samples".to_string(),
1774            id_field: "id".to_string(),
1775            fields: vec![FormField::of("place", "Place", "no.such.type")],
1776            persist: None,
1777            timestamps: false,
1778        };
1779        let Err(err) = PreparedForm::prepare(
1780            config,
1781            &crate::field::builtin_registry(),
1782            &PersisterRegistry::new(),
1783            &[],
1784        ) else {
1785            panic!("expected prepare to reject an unregistered type");
1786        };
1787        assert!(err.contains("no.such.type"), "{err}");
1788    }
1789
1790    #[test]
1791    fn prepare_rejects_a_malformed_option() {
1792        // The text field's `input` option is a string; a wrong JSON type for it
1793        // aborts prepare naming the field.
1794        let config = FormConfig {
1795            enter: Default::default(),
1796            confirm_leave: true,
1797            focus: Default::default(),
1798            entity: "samples".to_string(),
1799            title: "Sample".into(),
1800            base_path: "/admin/samples".to_string(),
1801            id_field: "id".to_string(),
1802            fields: vec![
1803                FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1804            ],
1805            persist: None,
1806            timestamps: false,
1807        };
1808        let Err(err) = PreparedForm::prepare(
1809            config,
1810            &crate::field::builtin_registry(),
1811            &PersisterRegistry::new(),
1812            &[],
1813        ) else {
1814            panic!("expected prepare to reject a malformed option");
1815        };
1816        assert!(err.contains("`email`"), "{err}");
1817    }
1818
1819    /// A persister that inserts a row then fails. The pipeline owns the
1820    /// transaction, so the insert must roll back with it.
1821    struct RollbackPersister;
1822    #[laterite_core::strata::async_trait]
1823    impl Persister for RollbackPersister {
1824        async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1825            let backend = cx.backend();
1826            let insert = Query::insert()
1827                .into_table(Alias::new("samples"))
1828                .columns([Alias::new("code"), Alias::new("name")])
1829                .values_panic(["rolled".into(), "back".into()])
1830                .to_owned();
1831            let (sql, values) = to_sql(backend, insert);
1832            bind_values(sqlx::query(&sql), values)
1833                .execute(cx.conn())
1834                .await
1835                .map_err(|e| SaveError::Failed(e.to_string()))?;
1836            Err(SaveError::Failed("deliberate".to_string()))
1837        }
1838        async fn update(
1839            &self,
1840            _cx: &mut SaveCx<'_>,
1841            _id: &str,
1842            _rec: &Record,
1843        ) -> Result<(), SaveError> {
1844            Ok(())
1845        }
1846    }
1847
1848    /// A persister that always rejects create with a per-field validation error.
1849    struct RejectingPersister;
1850    #[laterite_core::strata::async_trait]
1851    impl Persister for RejectingPersister {
1852        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1853            let mut bag = ErrorBag::default();
1854            bag.add("code", t!("Code is not allowed here."));
1855            Err(SaveError::Invalid(bag))
1856        }
1857        async fn update(
1858            &self,
1859            _cx: &mut SaveCx<'_>,
1860            _id: &str,
1861            _rec: &Record,
1862        ) -> Result<(), SaveError> {
1863            Ok(())
1864        }
1865    }
1866
1867    fn config_with(persister: &str) -> FormConfig {
1868        FormConfig {
1869            enter: Default::default(),
1870            confirm_leave: true,
1871            focus: Default::default(),
1872            entity: "samples".to_string(),
1873            title: "Sample".into(),
1874            base_path: "/admin/samples".to_string(),
1875            id_field: "id".to_string(),
1876            fields: vec![
1877                FormField::text("code", "Code").required(),
1878                FormField::text("name", "Name").required(),
1879            ],
1880            persist: Some(persister.to_string()),
1881            timestamps: false,
1882        }
1883    }
1884
1885    #[test]
1886    fn prepare_rejects_an_unregistered_persister() {
1887        let Err(err) = PreparedForm::prepare(
1888            config_with("no.such.persister"),
1889            &crate::field::builtin_registry(),
1890            &PersisterRegistry::new(),
1891            &[],
1892        ) else {
1893            panic!("expected prepare to reject an unregistered persister");
1894        };
1895        assert!(err.contains("no.such.persister"), "{err}");
1896    }
1897
1898    #[tokio::test]
1899    async fn a_persister_transaction_rolls_back_on_error() {
1900        let (db, _guard) = test_db().await;
1901        let st = state(db.clone());
1902        let mut persisters = PersisterRegistry::new();
1903        persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1904        let form = PreparedForm::prepare(
1905            config_with("test.rollback"),
1906            &st.field_types,
1907            &persisters,
1908            &[],
1909        )
1910        .unwrap();
1911
1912        let resp = create(
1913            &st,
1914            &form,
1915            data(&[("code", "rolled"), ("name", "back")]),
1916            crate::Shell::test(),
1917            &crate::audit::test_actor(),
1918            &crate::session::SessionHandle::from_blob(None),
1919            &axum::http::HeaderMap::new(),
1920        )
1921        .await;
1922        // The persister failed, so the form re-renders (200) and its in-tx insert
1923        // rolled back: no row landed.
1924        assert_eq!(resp.status(), StatusCode::OK);
1925        assert_eq!(count(&db).await, 0);
1926    }
1927
1928    #[tokio::test]
1929    async fn a_persister_invalid_error_re_renders_422() {
1930        let (db, _guard) = test_db().await;
1931        let st = state(db.clone());
1932        let mut persisters = PersisterRegistry::new();
1933        persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1934        let form = PreparedForm::prepare(
1935            config_with("test.reject"),
1936            &st.field_types,
1937            &persisters,
1938            &[],
1939        )
1940        .unwrap();
1941
1942        let resp = create(
1943            &st,
1944            &form,
1945            data(&[("code", "editor"), ("name", "Editor")]),
1946            crate::Shell::test(),
1947            &crate::audit::test_actor(),
1948            &crate::session::SessionHandle::from_blob(None),
1949            &axum::http::HeaderMap::new(),
1950        )
1951        .await;
1952        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1953        assert!(body_of(resp).await.contains("Code is not allowed here."));
1954    }
1955}
1956
1957#[cfg(test)]
1958mod timestamp_tests {
1959    use super::tests::*;
1960    use super::*;
1961    use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1962    use laterite_core::Actor;
1963
1964    fn timestamped(on: bool) -> FormConfig {
1965        FormConfig {
1966            enter: Default::default(),
1967            confirm_leave: true,
1968            focus: Default::default(),
1969            entity: "samples".to_string(),
1970            title: "Sample".into(),
1971            base_path: "/admin/samples".to_string(),
1972            id_field: "id".to_string(),
1973            fields: vec![
1974                FormField::text("code", "Code").required(),
1975                FormField::text("name", "Name").required(),
1976            ],
1977            persist: None,
1978            timestamps: on,
1979        }
1980    }
1981
1982    async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1983        let form = PreparedForm::prepare(
1984            timestamped(on),
1985            &crate::field::builtin_registry(),
1986            &PersisterRegistry::new(),
1987            &[],
1988        )
1989        .unwrap();
1990        let mut data = HashMap::new();
1991        data.insert("code".to_string(), "c1".to_string());
1992        data.insert("name".to_string(), "Chair".to_string());
1993        let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1994        let actor = Actor::system("test");
1995        persist::save(
1996            persist::SaveRequest {
1997                db,
1998                listeners: &form.listeners,
1999                persister: form.persister.as_ref(),
2000                actor: &actor,
2001                op: Op::Create,
2002                id: None,
2003            },
2004            &mut rec,
2005        )
2006        .await
2007        .expect("the insert should succeed");
2008        rec
2009    }
2010
2011    #[tokio::test]
2012    async fn opting_in_stamps_the_row() {
2013        let (db, _guard) = test_db().await;
2014        let rec = create_through_pipeline(&db, true).await;
2015        assert!(rec.datetime(CREATED_AT).is_some());
2016        assert!(rec.datetime(UPDATED_AT).is_some());
2017        // The stamp reached the table, not just the record.
2018        assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2019    }
2020
2021    #[tokio::test]
2022    async fn leaving_it_off_writes_no_timestamps() {
2023        let (db, _guard) = test_db().await;
2024        let rec = create_through_pipeline(&db, false).await;
2025        assert!(rec.get(CREATED_AT).is_none());
2026        assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2027    }
2028}
2029
2030#[cfg(test)]
2031mod htmx_tests {
2032    use super::tests::*;
2033    use super::*;
2034    use axum::http::StatusCode;
2035
2036    /// A submission missing a required field, so validation refuses it.
2037    fn invalid() -> HashMap<String, String> {
2038        let mut d = HashMap::new();
2039        d.insert("code".to_string(), String::new());
2040        d.insert("name".to_string(), "No code".to_string());
2041        d
2042    }
2043
2044    fn valid() -> HashMap<String, String> {
2045        let mut d = HashMap::new();
2046        d.insert("code".to_string(), "c1".to_string());
2047        d.insert("name".to_string(), "Chair".to_string());
2048        d
2049    }
2050
2051    async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2052        let (db, _guard) = test_db().await;
2053        let st = state(db);
2054        let form = PreparedForm::prepare(
2055            FormConfig {
2056                enter: Default::default(),
2057                confirm_leave: true,
2058                focus: Default::default(),
2059                entity: "samples".to_string(),
2060                title: "Sample".into(),
2061                base_path: "/admin/samples".to_string(),
2062                id_field: "id".to_string(),
2063                fields: vec![
2064                    FormField::text("code", "Code").required(),
2065                    FormField::text("name", "Name").required(),
2066                ],
2067                persist: None,
2068                timestamps: false,
2069            },
2070            &st.field_types,
2071            &PersisterRegistry::new(),
2072            &[],
2073        )
2074        .unwrap();
2075        create(
2076            &st,
2077            &form,
2078            data,
2079            crate::Shell::test(),
2080            &crate::audit::test_actor(),
2081            &crate::session::SessionHandle::from_blob(None),
2082            &headers,
2083        )
2084        .await
2085    }
2086
2087    async fn body_of(resp: Response) -> String {
2088        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2089            .await
2090            .unwrap();
2091        String::from_utf8(bytes.to_vec()).unwrap()
2092    }
2093
2094    #[tokio::test]
2095    async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2096        let resp = post(invalid(), htmx_headers()).await;
2097        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2098        let html = body_of(resp).await;
2099        assert!(html.contains("<form"), "the form comes back");
2100        assert!(html.contains("lat-field__error"), "with its errors");
2101        // A fragment, not a page: swapping a whole document into the form would
2102        // nest the admin inside itself.
2103        assert!(!html.contains("<body"), "no page chrome");
2104    }
2105
2106    #[tokio::test]
2107    async fn a_plain_submit_that_fails_returns_the_whole_page() {
2108        let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2109        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2110        let html = body_of(resp).await;
2111        assert!(html.contains("<body"), "the admin still works without htmx");
2112        assert!(html.contains("lat-field__error"));
2113    }
2114
2115    #[tokio::test]
2116    async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2117        let resp = post(valid(), htmx_headers()).await;
2118        // htmx cannot follow a 303 usefully; it reads this header instead.
2119        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2120        assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2121    }
2122
2123    #[tokio::test]
2124    async fn a_plain_submit_that_succeeds_still_redirects() {
2125        let resp = post(valid(), axum::http::HeaderMap::new()).await;
2126        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2127    }
2128}