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