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