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, Db, 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 write a submit and a [`FormHandle`] share: `data` (already gathered)
922/// validated against the merged rules, shaped into the declared record and saved
923/// through the listeners and the persister. `id` names the row an edit updates;
924/// `None` creates. Returns the record as saved, its id set on a create.
925pub(crate) async fn write(
926    db: &Db,
927    form: &PreparedForm,
928    data: &HashMap<String, String>,
929    id: Option<&str>,
930    actor: &Actor,
931) -> Result<Record, SaveError> {
932    let (mut rec, op) = prepare(db, form, data, id).await?;
933    persist::save(
934        persist::SaveRequest {
935            db,
936            listeners: &form.listeners,
937            persister: form.persister.as_ref(),
938            actor,
939            op,
940            id,
941        },
942        &mut rec,
943    )
944    .await?;
945    Ok(rec)
946}
947
948/// The write up to the point of writing: the data validated and shaped into
949/// the record, with the operation it would be. What a check runs, and what a
950/// write runs first.
951async fn prepare(
952    db: &Db,
953    form: &PreparedForm,
954    data: &HashMap<String, String>,
955    id: Option<&str>,
956) -> Result<(Record, Op), SaveError> {
957    if !form.config.idents_valid() {
958        return Err(SaveError::Failed(format!(
959            "form `{}` names an invalid entity or field identifier",
960            form.config.entity
961        )));
962    }
963    let (mode, op) = match id {
964        None => (Mode::Create, Op::Create),
965        Some(_) => (Mode::Update, Op::Update),
966    };
967    let bag = validate(
968        db,
969        &form.config.entity,
970        &form.config.id_field,
971        &merged_field_rules(form),
972        data,
973        mode,
974        id,
975    )
976    .await
977    .map_err(|e| SaveError::Failed(e.to_string()))?;
978    if !bag.is_empty() {
979        return Err(SaveError::Invalid(bag));
980    }
981    let mut rec = declared_record(form, data, mode).map_err(|e| {
982        tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
983        SaveError::Failed(e)
984    })?;
985    if let Some(n) = id.and_then(|id| id.parse::<i64>().ok()) {
986        rec.set_id(n);
987    }
988    Ok((rec, op))
989}
990
991/// The forms the resources mounted, keyed by each resource's path as its
992/// descriptor declares it, relative to the admin root.
993pub(crate) type Forms = Arc<HashMap<String, Arc<PreparedForm>>>;
994
995/// A resource's form, written through from code.
996///
997/// Runs what the form's own submit runs, with no request in hand: the values
998/// are validated against the form's rules, shaped by its field types and saved
999/// through its listeners and persister, so an importer or a sync writes exactly
1000/// what an operator could have typed, audited the same way. A screen reaches one
1001/// through [`crate::routes::RouteCtx::form`].
1002#[derive(Clone)]
1003pub struct FormHandle {
1004    db: Db,
1005    form: Arc<PreparedForm>,
1006}
1007
1008impl FormHandle {
1009    pub(crate) fn new(db: Db, form: Arc<PreparedForm>) -> Self {
1010        Self { db, form }
1011    }
1012
1013    /// The table the form writes.
1014    pub fn entity(&self) -> &str {
1015        &self.form.config.entity
1016    }
1017
1018    /// The primary key column.
1019    pub fn id_field(&self) -> &str {
1020        &self.form.config.id_field
1021    }
1022
1023    /// The fields the form declares, in order. `data` is keyed by their names.
1024    pub fn fields(&self) -> &[FormField] {
1025        &self.form.config.fields
1026    }
1027
1028    /// Validates `data` as a submission and saves a new record, returning its id.
1029    ///
1030    /// `data` holds each field's value under the field's name, as the form's
1031    /// control submits it; a field left out is blank. `Invalid` carries the
1032    /// per-field messages a refused submit would show.
1033    pub async fn create(
1034        &self,
1035        data: HashMap<String, String>,
1036        actor: &Actor,
1037    ) -> Result<i64, SaveError> {
1038        let data = gathered(&self.form, data);
1039        let rec = write(&self.db, &self.form, &data, None, actor).await?;
1040        rec.id()
1041            .ok_or_else(|| SaveError::Failed("the persister returned no id".to_string()))
1042    }
1043
1044    /// The record `id` as the edit form shows it: each stored value back through
1045    /// its field type, keyed by field name. `None` when no record has that id.
1046    /// Lay a partial `data` over it before [`update`](Self::update) to change
1047    /// some fields and keep the rest.
1048    pub async fn load(&self, id: i64) -> Result<Option<HashMap<String, String>>, SaveError> {
1049        load_values(&self.db, &self.form, &id.to_string())
1050            .await
1051            .map_err(|e| SaveError::Failed(e.to_string()))
1052    }
1053
1054    /// What [`create`](Self::create) or [`update`](Self::update) would do with
1055    /// `data`, with nothing written: the rules and the field conversions run,
1056    /// the listeners and the persister do not. `id` names the record an edit
1057    /// would change; `None` checks as a create.
1058    pub async fn check(
1059        &self,
1060        id: Option<i64>,
1061        data: HashMap<String, String>,
1062    ) -> Result<(), SaveError> {
1063        let data = gathered(&self.form, data);
1064        let id = id.map(|id| id.to_string());
1065        prepare(&self.db, &self.form, &data, id.as_deref())
1066            .await
1067            .map(|_| ())
1068    }
1069
1070    /// Validates `data` as an edit of the record `id` and saves it. Every field
1071    /// is written, so a field left out of `data` is stored blank.
1072    pub async fn update(
1073        &self,
1074        id: i64,
1075        data: HashMap<String, String>,
1076        actor: &Actor,
1077    ) -> Result<(), SaveError> {
1078        let data = gathered(&self.form, data);
1079        write(&self.db, &self.form, &data, Some(&id.to_string()), actor)
1080            .await
1081            .map(|_| ())
1082    }
1083}
1084
1085/// The longest value the rules accept, when they set one.
1086pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
1087    rules
1088        .iter()
1089        .filter_map(|r| match r {
1090            Rule::MaxLength(n) => Some(*n),
1091            _ => None,
1092        })
1093        .min()
1094}
1095
1096/// Whether any rule marks the field required.
1097fn has_required(rules: &[Rule]) -> bool {
1098    rules
1099        .iter()
1100        .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
1101}
1102
1103/// Renders an empty create form.
1104pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
1105    render(build(
1106        state,
1107        form,
1108        &format!("{}/new", form.config.base_path),
1109        None,
1110        &HashMap::new(),
1111        &ErrorBag::default(),
1112        &shell,
1113    ))
1114}
1115
1116/// Persists a new record, then redirects to the list. Re-renders with per-field
1117/// errors when validation fails.
1118pub(crate) async fn create(
1119    state: &AdminState,
1120    form: &PreparedForm,
1121    data: HashMap<String, String>,
1122    shell: crate::Shell,
1123    user: &laterite_auth::AuthenticatedUser,
1124    session: &crate::session::SessionHandle,
1125    headers: &axum::http::HeaderMap,
1126) -> Response {
1127    if !form.config.idents_valid() {
1128        return render_error();
1129    }
1130    let htmx = is_htmx(headers);
1131    let action = format!("{}/new", form.config.base_path);
1132    let data = gathered(form, data);
1133    let actor = Actor::from(user);
1134    match write(&state.db, form, &data, None, &actor).await {
1135        // The audit entry is written by the framework's audit listener, which
1136        // runs on every entity after the commit.
1137        Ok(_) => {
1138            session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1139            saved_response(htmx, &form.config.base_path)
1140        }
1141        // A refused submission re-renders the form with per-field errors as 422,
1142        // the cross-surface "validation failure" status.
1143        Err(SaveError::Invalid(bag)) => {
1144            invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1145        }
1146        Err(SaveError::Failed(msg)) => {
1147            tracing::error!(error = %msg, "admin create failed");
1148            render(build(
1149                state,
1150                form,
1151                &action,
1152                Some(t!("Could not save. Check the values and try again.")),
1153                &data,
1154                &ErrorBag::default(),
1155                &shell,
1156            ))
1157        }
1158    }
1159}
1160
1161/// Renders a form populated with an existing record.
1162pub(crate) async fn edit_form(
1163    state: &AdminState,
1164    form: &PreparedForm,
1165    id: String,
1166    shell: crate::Shell,
1167) -> Response {
1168    if !form.config.idents_valid() {
1169        return render_error();
1170    }
1171    let values = match load_values(&state.db, form, &id).await {
1172        Ok(Some(values)) => values,
1173        Ok(None) => return not_found(),
1174        Err(_) => return render_error(),
1175    };
1176
1177    render(build(
1178        state,
1179        form,
1180        &format!("{}/{}/edit", form.config.base_path, id),
1181        None,
1182        &values,
1183        &ErrorBag::default(),
1184        &shell,
1185    ))
1186}
1187
1188/// The stored record `id` as control values: each column back through its
1189/// field type, since what a column holds and what its control shows are not
1190/// always the same string. `None` when no record has that id.
1191pub(crate) async fn load_values(
1192    db: &Db,
1193    form: &PreparedForm,
1194    id: &str,
1195) -> Result<Option<HashMap<String, String>>, sqlx::Error> {
1196    // Scope the sea-query builder so it is dropped before the await below: its
1197    // identifiers are reference-counted (not `Send`), and a live builder across
1198    // the await would make the caller's future non-`Send`.
1199    let (sql, values) = {
1200        let cast = text_cast(db.backend);
1201        let mut select = Query::select();
1202        for field in &form.config.fields {
1203            select.expr_as(
1204                Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1205                Alias::new(&field.name),
1206            );
1207        }
1208        select.from(Alias::new(&form.config.entity)).and_where(
1209            Expr::col(Alias::new(&form.config.id_field))
1210                .cast_as(Alias::new(cast))
1211                .eq(id.to_string()),
1212        );
1213        to_sql(db.backend, select)
1214    };
1215    let Some(row) = bind_values(sqlx::query(&sql), values)
1216        .fetch_optional(&db.pool)
1217        .await?
1218    else {
1219        return Ok(None);
1220    };
1221    Ok(Some(
1222        form.config
1223            .fields
1224            .iter()
1225            .zip(&form.fields)
1226            .map(|(f, prepared)| {
1227                let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1228                let value = prepared
1229                    .field_type
1230                    .to_control(stored.as_deref(), &prepared.opts);
1231                (f.name.clone(), value)
1232            })
1233            .collect(),
1234    ))
1235}
1236
1237/// Persists an edited record, then redirects to the list. Re-renders with
1238/// per-field errors when validation fails.
1239// The generic handlers thread the request's pieces explicitly rather than through
1240// a context struct; the list is long but each argument is named at the call site.
1241#[allow(clippy::too_many_arguments)]
1242pub(crate) async fn update(
1243    state: &AdminState,
1244    form: &PreparedForm,
1245    id: String,
1246    data: HashMap<String, String>,
1247    shell: crate::Shell,
1248    user: &laterite_auth::AuthenticatedUser,
1249    session: &crate::session::SessionHandle,
1250    headers: &axum::http::HeaderMap,
1251) -> Response {
1252    if !form.config.idents_valid() {
1253        return render_error();
1254    }
1255    let htmx = is_htmx(headers);
1256    let action = format!("{}/{}/edit", form.config.base_path, id);
1257    let data = gathered(form, data);
1258    let actor = Actor::from(user);
1259    match write(&state.db, form, &data, Some(&id), &actor).await {
1260        Ok(_) => {
1261            session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1262            saved_response(htmx, &form.config.base_path)
1263        }
1264        // A refused submission re-renders the form with per-field errors as 422,
1265        // the cross-surface "validation failure" status.
1266        Err(SaveError::Invalid(bag)) => {
1267            invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1268        }
1269        Err(SaveError::Failed(msg)) => {
1270            tracing::error!(error = %msg, "admin update failed");
1271            render(build(
1272                state,
1273                form,
1274                &action,
1275                Some(t!("Could not save. Check the values and try again.")),
1276                &data,
1277                &ErrorBag::default(),
1278                &shell,
1279            ))
1280        }
1281    }
1282}
1283
1284/// The 422 response for a failed save. An HTMX submit gets the form alone, which
1285/// replaces the form in place; anything else gets the whole page, so the admin
1286/// still works with scripting off.
1287#[allow(clippy::too_many_arguments)]
1288fn invalid_response(
1289    htmx: bool,
1290    state: &AdminState,
1291    form: &PreparedForm,
1292    action: &str,
1293    error: Option<Text>,
1294    values: &HashMap<String, String>,
1295    bag: &ErrorBag,
1296    shell: &crate::Shell,
1297) -> Response {
1298    let page = build(state, form, action, error, values, bag, shell);
1299    let body = if htmx {
1300        render(FormFragment {
1301            shell: page.shell,
1302            action: page.action,
1303            cancel_path: page.cancel_path,
1304            error: page.error,
1305            fields: page.fields,
1306            enter_off: page.enter_off,
1307            refused: page.refused,
1308            leave_off: page.leave_off,
1309            focus: page.focus,
1310        })
1311    } else {
1312        render(page)
1313    };
1314    (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1315}
1316
1317/// Where to send the browser after a successful save. HTMX will not follow a
1318/// 303 usefully (it would swap the redirected page into the form), so it gets
1319/// the header it understands instead.
1320pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1321    if htmx {
1322        ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1323    } else {
1324        Redirect::to(to).into_response()
1325    }
1326}
1327
1328/// Whether this request came from HTMX rather than a plain form post.
1329pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1330    headers.contains_key("hx-request")
1331}
1332
1333fn build(
1334    state: &AdminState,
1335    form: &PreparedForm,
1336    action: &str,
1337    error: Option<Text>,
1338    values: &HashMap<String, String>,
1339    bag: &ErrorBag,
1340    shell: &crate::Shell,
1341) -> FormTemplate {
1342    let fields = form
1343        .config
1344        .fields
1345        .iter()
1346        .zip(&form.fields)
1347        .map(|(f, pf)| {
1348            let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1349            let required = has_required(&pf.rules);
1350            // Localize the label once: the field type sees it (aria/inline labels)
1351            // and the framework chrome renders it.
1352            let label = shell.tt(&f.label);
1353            let cx = FieldCx {
1354                name: &f.name,
1355                id: &f.name,
1356                label: &label,
1357                value: &value,
1358                required,
1359                max_length: max_length(&pf.rules),
1360                opts: &pf.opts,
1361                base: &shell.base,
1362                i18n: shell.i18n(),
1363            };
1364            // The type is registered (prepare validated it); the lookup here is
1365            // only to render.
1366            let control = match state.field_types.get(&f.field_type) {
1367                Some(ft) => {
1368                    let scope = OverrideScope {
1369                        surface: Surface::Field,
1370                        view_key: &f.field_type,
1371                        resource: Some(&form.config.base_path),
1372                        field: Some(&f.name),
1373                    };
1374                    render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1375                }
1376                None => String::new(),
1377            };
1378            FieldView {
1379                id: f.name.clone(),
1380                label,
1381                control,
1382                required,
1383                enter: f.enter.attribute(),
1384                layout: layout_classes(f.span, f.break_row),
1385                attrs: behaviour_attrs(f),
1386                // Localize each per-field message through the request translator.
1387                errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1388            }
1389        })
1390        .collect();
1391    // Collect the widget assets the rendered field types declare, deduped and
1392    // resolved to URLs for the head. Most declare none (their widgets ship in
1393    // core laterite.js).
1394    let keys: Vec<&str> = form
1395        .config
1396        .fields
1397        .iter()
1398        .zip(&form.fields)
1399        .filter_map(|(f, pf)| {
1400            state
1401                .field_types
1402                .get(&f.field_type)
1403                .map(|ft| ft.assets(&pf.opts))
1404        })
1405        .flatten()
1406        .collect();
1407    // Localize the title and banner before the shell is moved into the template.
1408    let shell_title = shell.tt(&form.config.title);
1409    let refused = error.is_some() || !bag.is_empty();
1410    let error = error.map(|m| shell.tt(&m));
1411    let mut shell = shell.clone();
1412    shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1413    FormTemplate {
1414        shell,
1415        title: shell_title,
1416        action: action.to_string(),
1417        cancel_path: form.config.base_path.clone(),
1418        error,
1419        fields,
1420        enter_off: form.config.enter == EnterPolicy::Off,
1421        refused,
1422        leave_off: !form.config.confirm_leave,
1423        focus: match form.config.focus {
1424            FormFocus::Off => "off",
1425            // A new record has nothing in it yet, so the first field is where
1426            // the operator starts. An edit opens for reading first.
1427            FormFocus::Auto if action.ends_with("/new") => "first",
1428            FormFocus::Auto => "",
1429        },
1430    }
1431}
1432
1433struct FieldView {
1434    /// DOM id for the control and its label's `for`.
1435    id: String,
1436    label: String,
1437    /// The control HTML, rendered by the field-type registry (override-aware).
1438    /// The framework owns the surrounding chrome (label, required marker, errors).
1439    control: String,
1440    required: bool,
1441    errors: Vec<String>,
1442    /// The field's own Enter rule (`off`, `next`), or empty for the form's.
1443    enter: &'static str,
1444    /// Wrapper classes for the span and a row break, each led by a space.
1445    layout: String,
1446    /// The wrapper's preset and trigger attributes, each led by a space.
1447    attrs: String,
1448}
1449
1450/// Just the form element, for an HTMX submit that failed validation: the
1451/// response replaces the form in place rather than reloading the page.
1452#[derive(Template)]
1453#[template(path = "_form_fields.html")]
1454struct FormFragment {
1455    shell: crate::Shell,
1456    action: String,
1457    cancel_path: String,
1458    error: Option<String>,
1459    fields: Vec<FieldView>,
1460    /// Enter never submits this form.
1461    enter_off: bool,
1462    /// The form holds a submission that was not saved.
1463    refused: bool,
1464    /// Leaving with changes unsaved does not ask.
1465    leave_off: bool,
1466    /// `first`, `off`, or empty for the default.
1467    focus: &'static str,
1468}
1469
1470#[derive(Template)]
1471#[template(path = "form.html")]
1472struct FormTemplate {
1473    shell: crate::Shell,
1474    title: String,
1475    action: String,
1476    cancel_path: String,
1477    error: Option<String>,
1478    fields: Vec<FieldView>,
1479    /// Enter never submits this form.
1480    enter_off: bool,
1481    /// The form holds a submission that was not saved.
1482    refused: bool,
1483    /// Leaving with changes unsaved does not ask.
1484    leave_off: bool,
1485    /// `first`, `off`, or empty for the default.
1486    focus: &'static str,
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491    use super::*;
1492    use axum::http::StatusCode;
1493    use laterite_core::strata::{
1494        async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1495    };
1496    use laterite_core::testing::{connect_test, TestGuard};
1497    use laterite_core::Db;
1498    use laterite_core::SaveCx;
1499
1500    /// A minimal table for exercising the generic insert/update path in isolation,
1501    /// defined as a portable migration so the test runs on any backend.
1502    struct CreateSamples;
1503    #[async_trait(?Send)]
1504    impl Migration for CreateSamples {
1505        fn name(&self) -> &str {
1506            "0001_create_samples"
1507        }
1508        async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1509            s.exec(
1510                Table::create()
1511                    .table(Alias::new("samples"))
1512                    .if_not_exists()
1513                    .col(
1514                        ColumnDef::new(Alias::new("id"))
1515                            .big_integer()
1516                            .not_null()
1517                            .auto_increment()
1518                            .primary_key(),
1519                    )
1520                    .col(ColumnDef::new(Alias::new("code")).text().not_null())
1521                    .col(ColumnDef::new(Alias::new("name")).text().not_null())
1522                    // Nullable, so a form that does not opt into timestamps
1523                    // still inserts.
1524                    .col(ColumnDef::new(Alias::new("created_at")).text())
1525                    .col(ColumnDef::new(Alias::new("updated_at")).text())
1526                    .to_owned(),
1527            )
1528            .await
1529        }
1530    }
1531
1532    fn config() -> PreparedForm {
1533        let config = FormConfig {
1534            enter: Default::default(),
1535            confirm_leave: true,
1536            focus: Default::default(),
1537            entity: "samples".to_string(),
1538            title: "Sample".into(),
1539            base_path: "/admin/samples".to_string(),
1540            id_field: "id".to_string(),
1541            fields: vec![
1542                FormField::text("code", "Code").required().unique(),
1543                FormField::text("name", "Name").required(),
1544            ],
1545            persist: None,
1546            timestamps: false,
1547        };
1548        PreparedForm::prepare(
1549            config,
1550            &crate::field::builtin_registry(),
1551            &PersisterRegistry::new(),
1552            &[],
1553        )
1554        .unwrap()
1555    }
1556
1557    /// The whole delete path against a real table: the row is read, the
1558    /// listeners run, the row goes, and the after-stage sees what went.
1559    #[tokio::test]
1560    async fn a_delete_removes_the_row_through_the_pipeline() {
1561        use crate::persist::{delete, DefaultPersister, DeleteRequest};
1562
1563        let (db, _guard) = test_db().await;
1564        let st = state(db.clone());
1565        let form = config();
1566
1567        let mut data = HashMap::new();
1568        data.insert("code".to_string(), "c1".to_string());
1569        data.insert("name".to_string(), "Chair".to_string());
1570        create(
1571            &st,
1572            &form,
1573            data,
1574            crate::Shell::test(),
1575            &crate::audit::test_actor(),
1576            &crate::session::SessionHandle::from_blob(None),
1577            &axum::http::HeaderMap::new(),
1578        )
1579        .await;
1580        assert_eq!(
1581            fetch_text(&db, "name", "c1").await.as_deref(),
1582            Some("Chair")
1583        );
1584
1585        let id = row_id(&db, "c1").await;
1586        let persister = DefaultPersister::from_config(&form.config);
1587        let rec = delete(DeleteRequest {
1588            db: &db,
1589            listeners: &[],
1590            persister: &persister,
1591            actor: &laterite_core::Actor::system("test"),
1592            entity: "samples",
1593            id: &id,
1594        })
1595        .await
1596        .expect("deleted");
1597
1598        // Gone from the table, and the caller learns what it removed.
1599        assert_eq!(fetch_text(&db, "name", "c1").await, None);
1600        assert_eq!(rec.text("code"), Some("c1"));
1601    }
1602
1603    /// The stored id for a row, as text, the shape the delete path takes.
1604    async fn row_id(db: &Db, code: &str) -> String {
1605        let stmt = Query::select()
1606            .expr_as(
1607                Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1608                Alias::new("v"),
1609            )
1610            .from(Alias::new("samples"))
1611            .and_where(Expr::col(Alias::new("code")).eq(code))
1612            .to_owned();
1613        let (sql, values) = laterite_core::query::build(db.backend, stmt);
1614        let row = bind_values(sqlx::query(&sql), values)
1615            .fetch_one(&db.pool)
1616            .await
1617            .unwrap();
1618        row.get_text("v").unwrap()
1619    }
1620
1621    pub(super) fn state(db: Db) -> AdminState {
1622        AdminState::new(
1623            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1624            db,
1625        )
1626    }
1627
1628    /// A fresh test database holding a minimal `samples` table, on whichever
1629    /// backend the run targets. Hold the returned guard for the test's lifetime.
1630    pub(super) async fn test_db() -> (Db, TestGuard) {
1631        let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1632        connect_test(&[samples]).await
1633    }
1634
1635    /// Reads a single text column from the one row matching `code`, so a test can
1636    /// assert what was persisted without depending on the read path under test.
1637    pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1638        let stmt = Query::select()
1639            .expr_as(
1640                Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1641                Alias::new("v"),
1642            )
1643            .from(Alias::new("samples"))
1644            .and_where(Expr::col(Alias::new("code")).eq(code))
1645            .to_owned();
1646        let (sql, values) = to_sql(db.backend, stmt);
1647        let row = bind_values(sqlx::query(&sql), values)
1648            .fetch_optional(&db.pool)
1649            .await
1650            .unwrap()?;
1651        row.get_text_opt("v").ok().flatten()
1652    }
1653
1654    async fn body_of(resp: Response) -> String {
1655        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1656            .await
1657            .unwrap();
1658        String::from_utf8(bytes.to_vec()).unwrap()
1659    }
1660
1661    async fn count(db: &Db) -> i64 {
1662        sqlx::query_scalar("select count(*) from samples")
1663            .fetch_one(&db.pool)
1664            .await
1665            .unwrap()
1666    }
1667
1668    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1669        pairs
1670            .iter()
1671            .map(|(k, v)| (k.to_string(), v.to_string()))
1672            .collect()
1673    }
1674
1675    /// The header htmx sets on every request it makes.
1676    pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1677        let mut h = axum::http::HeaderMap::new();
1678        h.insert("hx-request", "true".parse().unwrap());
1679        h
1680    }
1681
1682    #[tokio::test]
1683    async fn create_then_fetch() {
1684        let (db, _guard) = test_db().await;
1685        let cfg = config();
1686        let st = state(db.clone());
1687
1688        let resp = create(
1689            &st,
1690            &cfg,
1691            data(&[("code", "editor"), ("name", "Content Editor")]),
1692            crate::Shell::test(),
1693            &crate::audit::test_actor(),
1694            &crate::session::SessionHandle::from_blob(None),
1695            &axum::http::HeaderMap::new(),
1696        )
1697        .await;
1698        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1699
1700        assert_eq!(
1701            fetch_text(&db, "name", "editor").await.as_deref(),
1702            Some("Content Editor")
1703        );
1704    }
1705
1706    #[tokio::test]
1707    async fn update_changes_the_row() {
1708        let (db, _guard) = test_db().await;
1709        let cfg = config();
1710        let st = state(db.clone());
1711        create(
1712            &st,
1713            &cfg,
1714            data(&[("code", "editor"), ("name", "Editor")]),
1715            crate::Shell::test(),
1716            &crate::audit::test_actor(),
1717            &crate::session::SessionHandle::from_blob(None),
1718            &axum::http::HeaderMap::new(),
1719        )
1720        .await;
1721
1722        let id = fetch_text(&db, "id", "editor")
1723            .await
1724            .expect("row should exist after create");
1725
1726        // The code is unchanged, so its unique rule must ignore the edited row.
1727        let resp = update(
1728            &st,
1729            &cfg,
1730            id,
1731            data(&[("code", "editor"), ("name", "Senior Editor")]),
1732            crate::Shell::test(),
1733            &crate::audit::test_actor(),
1734            &crate::session::SessionHandle::from_blob(None),
1735            &axum::http::HeaderMap::new(),
1736        )
1737        .await;
1738        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1739
1740        assert_eq!(
1741            fetch_text(&db, "name", "editor").await.as_deref(),
1742            Some("Senior Editor")
1743        );
1744    }
1745
1746    #[tokio::test]
1747    async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1748        let (db, _guard) = test_db().await;
1749        let cfg = config();
1750        let st = state(db.clone());
1751
1752        let resp = create(
1753            &st,
1754            &cfg,
1755            data(&[("code", ""), ("name", "No Code")]),
1756            crate::Shell::test(),
1757            &crate::audit::test_actor(),
1758            &crate::session::SessionHandle::from_blob(None),
1759            &axum::http::HeaderMap::new(),
1760        )
1761        .await;
1762        // Re-renders the form (200), does not redirect.
1763        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1764        assert_eq!(count(&db).await, 0);
1765        // The per-field message is rendered.
1766        assert!(body_of(resp).await.contains("Code is required."));
1767    }
1768
1769    #[tokio::test]
1770    async fn create_rejects_a_duplicate_unique_value() {
1771        let (db, _guard) = test_db().await;
1772        let cfg = config();
1773        let st = state(db.clone());
1774        create(
1775            &st,
1776            &cfg,
1777            data(&[("code", "editor"), ("name", "Editor")]),
1778            crate::Shell::test(),
1779            &crate::audit::test_actor(),
1780            &crate::session::SessionHandle::from_blob(None),
1781            &axum::http::HeaderMap::new(),
1782        )
1783        .await;
1784
1785        // A second row with the same code is refused by the unique rule.
1786        let resp = create(
1787            &st,
1788            &cfg,
1789            data(&[("code", "editor"), ("name", "Other")]),
1790            crate::Shell::test(),
1791            &crate::audit::test_actor(),
1792            &crate::session::SessionHandle::from_blob(None),
1793            &axum::http::HeaderMap::new(),
1794        )
1795        .await;
1796        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1797        assert_eq!(count(&db).await, 1);
1798        assert!(body_of(resp).await.contains("already taken"));
1799    }
1800
1801    #[tokio::test]
1802    async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1803        let (db, _guard) = test_db().await;
1804        let st = state(db.clone());
1805        // The `name` column is a text field with the `email` input, which
1806        // contributes Rule::Email without the descriptor listing it.
1807        let cfg = PreparedForm::prepare(
1808            FormConfig {
1809                enter: Default::default(),
1810                confirm_leave: true,
1811                focus: Default::default(),
1812                entity: "samples".to_string(),
1813                title: "Sample".into(),
1814                base_path: "/admin/samples".to_string(),
1815                id_field: "id".to_string(),
1816                fields: vec![
1817                    FormField::text("code", "Code").required(),
1818                    FormField::of("name", "Email", "text")
1819                        .options(serde_json::json!({ "input": "email" }))
1820                        .required(),
1821                ],
1822                persist: None,
1823                timestamps: false,
1824            },
1825            &st.field_types,
1826            &PersisterRegistry::new(),
1827            &[],
1828        )
1829        .unwrap();
1830
1831        let resp = create(
1832            &st,
1833            &cfg,
1834            data(&[("code", "c1"), ("name", "nope")]),
1835            crate::Shell::test(),
1836            &crate::audit::test_actor(),
1837            &crate::session::SessionHandle::from_blob(None),
1838            &axum::http::HeaderMap::new(),
1839        )
1840        .await;
1841        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1842        assert_eq!(count(&db).await, 0);
1843
1844        let resp = create(
1845            &st,
1846            &cfg,
1847            data(&[("code", "c1"), ("name", "a@b.test")]),
1848            crate::Shell::test(),
1849            &crate::audit::test_actor(),
1850            &crate::session::SessionHandle::from_blob(None),
1851            &axum::http::HeaderMap::new(),
1852        )
1853        .await;
1854        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1855        assert_eq!(count(&db).await, 1);
1856    }
1857
1858    #[test]
1859    fn prepare_rejects_an_unregistered_field_type() {
1860        let config = FormConfig {
1861            enter: Default::default(),
1862            confirm_leave: true,
1863            focus: Default::default(),
1864            entity: "samples".to_string(),
1865            title: "Sample".into(),
1866            base_path: "/admin/samples".to_string(),
1867            id_field: "id".to_string(),
1868            fields: vec![FormField::of("place", "Place", "no.such.type")],
1869            persist: None,
1870            timestamps: false,
1871        };
1872        let Err(err) = PreparedForm::prepare(
1873            config,
1874            &crate::field::builtin_registry(),
1875            &PersisterRegistry::new(),
1876            &[],
1877        ) else {
1878            panic!("expected prepare to reject an unregistered type");
1879        };
1880        assert!(err.contains("no.such.type"), "{err}");
1881    }
1882
1883    #[test]
1884    fn prepare_rejects_a_malformed_option() {
1885        // The text field's `input` option is a string; a wrong JSON type for it
1886        // aborts prepare naming the field.
1887        let config = FormConfig {
1888            enter: Default::default(),
1889            confirm_leave: true,
1890            focus: Default::default(),
1891            entity: "samples".to_string(),
1892            title: "Sample".into(),
1893            base_path: "/admin/samples".to_string(),
1894            id_field: "id".to_string(),
1895            fields: vec![
1896                FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1897            ],
1898            persist: None,
1899            timestamps: false,
1900        };
1901        let Err(err) = PreparedForm::prepare(
1902            config,
1903            &crate::field::builtin_registry(),
1904            &PersisterRegistry::new(),
1905            &[],
1906        ) else {
1907            panic!("expected prepare to reject a malformed option");
1908        };
1909        assert!(err.contains("`email`"), "{err}");
1910    }
1911
1912    /// A persister that inserts a row then fails. The pipeline owns the
1913    /// transaction, so the insert must roll back with it.
1914    struct RollbackPersister;
1915    #[laterite_core::strata::async_trait]
1916    impl Persister for RollbackPersister {
1917        async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1918            let backend = cx.backend();
1919            let insert = Query::insert()
1920                .into_table(Alias::new("samples"))
1921                .columns([Alias::new("code"), Alias::new("name")])
1922                .values_panic(["rolled".into(), "back".into()])
1923                .to_owned();
1924            let (sql, values) = to_sql(backend, insert);
1925            bind_values(sqlx::query(&sql), values)
1926                .execute(cx.conn())
1927                .await
1928                .map_err(|e| SaveError::Failed(e.to_string()))?;
1929            Err(SaveError::Failed("deliberate".to_string()))
1930        }
1931        async fn update(
1932            &self,
1933            _cx: &mut SaveCx<'_>,
1934            _id: &str,
1935            _rec: &Record,
1936        ) -> Result<(), SaveError> {
1937            Ok(())
1938        }
1939    }
1940
1941    /// A persister that always rejects create with a per-field validation error.
1942    struct RejectingPersister;
1943    #[laterite_core::strata::async_trait]
1944    impl Persister for RejectingPersister {
1945        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1946            let mut bag = ErrorBag::default();
1947            bag.add("code", t!("Code is not allowed here."));
1948            Err(SaveError::Invalid(bag))
1949        }
1950        async fn update(
1951            &self,
1952            _cx: &mut SaveCx<'_>,
1953            _id: &str,
1954            _rec: &Record,
1955        ) -> Result<(), SaveError> {
1956            Ok(())
1957        }
1958    }
1959
1960    fn config_with(persister: &str) -> FormConfig {
1961        FormConfig {
1962            enter: Default::default(),
1963            confirm_leave: true,
1964            focus: Default::default(),
1965            entity: "samples".to_string(),
1966            title: "Sample".into(),
1967            base_path: "/admin/samples".to_string(),
1968            id_field: "id".to_string(),
1969            fields: vec![
1970                FormField::text("code", "Code").required(),
1971                FormField::text("name", "Name").required(),
1972            ],
1973            persist: Some(persister.to_string()),
1974            timestamps: false,
1975        }
1976    }
1977
1978    #[test]
1979    fn prepare_rejects_an_unregistered_persister() {
1980        let Err(err) = PreparedForm::prepare(
1981            config_with("no.such.persister"),
1982            &crate::field::builtin_registry(),
1983            &PersisterRegistry::new(),
1984            &[],
1985        ) else {
1986            panic!("expected prepare to reject an unregistered persister");
1987        };
1988        assert!(err.contains("no.such.persister"), "{err}");
1989    }
1990
1991    #[tokio::test]
1992    async fn a_persister_transaction_rolls_back_on_error() {
1993        let (db, _guard) = test_db().await;
1994        let st = state(db.clone());
1995        let mut persisters = PersisterRegistry::new();
1996        persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1997        let form = PreparedForm::prepare(
1998            config_with("test.rollback"),
1999            &st.field_types,
2000            &persisters,
2001            &[],
2002        )
2003        .unwrap();
2004
2005        let resp = create(
2006            &st,
2007            &form,
2008            data(&[("code", "rolled"), ("name", "back")]),
2009            crate::Shell::test(),
2010            &crate::audit::test_actor(),
2011            &crate::session::SessionHandle::from_blob(None),
2012            &axum::http::HeaderMap::new(),
2013        )
2014        .await;
2015        // The persister failed, so the form re-renders (200) and its in-tx insert
2016        // rolled back: no row landed.
2017        assert_eq!(resp.status(), StatusCode::OK);
2018        assert_eq!(count(&db).await, 0);
2019    }
2020
2021    #[tokio::test]
2022    async fn a_persister_invalid_error_re_renders_422() {
2023        let (db, _guard) = test_db().await;
2024        let st = state(db.clone());
2025        let mut persisters = PersisterRegistry::new();
2026        persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
2027        let form = PreparedForm::prepare(
2028            config_with("test.reject"),
2029            &st.field_types,
2030            &persisters,
2031            &[],
2032        )
2033        .unwrap();
2034
2035        let resp = create(
2036            &st,
2037            &form,
2038            data(&[("code", "editor"), ("name", "Editor")]),
2039            crate::Shell::test(),
2040            &crate::audit::test_actor(),
2041            &crate::session::SessionHandle::from_blob(None),
2042            &axum::http::HeaderMap::new(),
2043        )
2044        .await;
2045        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2046        assert!(body_of(resp).await.contains("Code is not allowed here."));
2047    }
2048}
2049
2050#[cfg(test)]
2051mod timestamp_tests {
2052    use super::tests::*;
2053    use super::*;
2054    use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
2055    use laterite_core::Actor;
2056
2057    fn timestamped(on: bool) -> FormConfig {
2058        FormConfig {
2059            enter: Default::default(),
2060            confirm_leave: true,
2061            focus: Default::default(),
2062            entity: "samples".to_string(),
2063            title: "Sample".into(),
2064            base_path: "/admin/samples".to_string(),
2065            id_field: "id".to_string(),
2066            fields: vec![
2067                FormField::text("code", "Code").required(),
2068                FormField::text("name", "Name").required(),
2069            ],
2070            persist: None,
2071            timestamps: on,
2072        }
2073    }
2074
2075    async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
2076        let form = PreparedForm::prepare(
2077            timestamped(on),
2078            &crate::field::builtin_registry(),
2079            &PersisterRegistry::new(),
2080            &[],
2081        )
2082        .unwrap();
2083        let mut data = HashMap::new();
2084        data.insert("code".to_string(), "c1".to_string());
2085        data.insert("name".to_string(), "Chair".to_string());
2086        let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
2087        let actor = Actor::system("test");
2088        persist::save(
2089            persist::SaveRequest {
2090                db,
2091                listeners: &form.listeners,
2092                persister: form.persister.as_ref(),
2093                actor: &actor,
2094                op: Op::Create,
2095                id: None,
2096            },
2097            &mut rec,
2098        )
2099        .await
2100        .expect("the insert should succeed");
2101        rec
2102    }
2103
2104    #[tokio::test]
2105    async fn opting_in_stamps_the_row() {
2106        let (db, _guard) = test_db().await;
2107        let rec = create_through_pipeline(&db, true).await;
2108        assert!(rec.datetime(CREATED_AT).is_some());
2109        assert!(rec.datetime(UPDATED_AT).is_some());
2110        // The stamp reached the table, not just the record.
2111        assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2112    }
2113
2114    #[tokio::test]
2115    async fn leaving_it_off_writes_no_timestamps() {
2116        let (db, _guard) = test_db().await;
2117        let rec = create_through_pipeline(&db, false).await;
2118        assert!(rec.get(CREATED_AT).is_none());
2119        assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2120    }
2121}
2122
2123#[cfg(test)]
2124mod htmx_tests {
2125    use super::tests::*;
2126    use super::*;
2127    use axum::http::StatusCode;
2128
2129    /// A submission missing a required field, so validation refuses it.
2130    fn invalid() -> HashMap<String, String> {
2131        let mut d = HashMap::new();
2132        d.insert("code".to_string(), String::new());
2133        d.insert("name".to_string(), "No code".to_string());
2134        d
2135    }
2136
2137    fn valid() -> HashMap<String, String> {
2138        let mut d = HashMap::new();
2139        d.insert("code".to_string(), "c1".to_string());
2140        d.insert("name".to_string(), "Chair".to_string());
2141        d
2142    }
2143
2144    async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2145        let (db, _guard) = test_db().await;
2146        let st = state(db);
2147        let form = PreparedForm::prepare(
2148            FormConfig {
2149                enter: Default::default(),
2150                confirm_leave: true,
2151                focus: Default::default(),
2152                entity: "samples".to_string(),
2153                title: "Sample".into(),
2154                base_path: "/admin/samples".to_string(),
2155                id_field: "id".to_string(),
2156                fields: vec![
2157                    FormField::text("code", "Code").required(),
2158                    FormField::text("name", "Name").required(),
2159                ],
2160                persist: None,
2161                timestamps: false,
2162            },
2163            &st.field_types,
2164            &PersisterRegistry::new(),
2165            &[],
2166        )
2167        .unwrap();
2168        create(
2169            &st,
2170            &form,
2171            data,
2172            crate::Shell::test(),
2173            &crate::audit::test_actor(),
2174            &crate::session::SessionHandle::from_blob(None),
2175            &headers,
2176        )
2177        .await
2178    }
2179
2180    async fn body_of(resp: Response) -> String {
2181        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2182            .await
2183            .unwrap();
2184        String::from_utf8(bytes.to_vec()).unwrap()
2185    }
2186
2187    #[tokio::test]
2188    async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2189        let resp = post(invalid(), htmx_headers()).await;
2190        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2191        let html = body_of(resp).await;
2192        assert!(html.contains("<form"), "the form comes back");
2193        assert!(html.contains("lat-field__error"), "with its errors");
2194        // A fragment, not a page: swapping a whole document into the form would
2195        // nest the admin inside itself.
2196        assert!(!html.contains("<body"), "no page chrome");
2197    }
2198
2199    #[tokio::test]
2200    async fn a_plain_submit_that_fails_returns_the_whole_page() {
2201        let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2202        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2203        let html = body_of(resp).await;
2204        assert!(html.contains("<body"), "the admin still works without htmx");
2205        assert!(html.contains("lat-field__error"));
2206    }
2207
2208    #[tokio::test]
2209    async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2210        let resp = post(valid(), htmx_headers()).await;
2211        // htmx cannot follow a 303 usefully; it reads this header instead.
2212        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2213        assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2214    }
2215
2216    #[tokio::test]
2217    async fn a_plain_submit_that_succeeds_still_redirects() {
2218        let resp = post(valid(), axum::http::HeaderMap::new()).await;
2219        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2220    }
2221}