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