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