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