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, Db, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
31};
32use sea_query::{Alias, Expr, Query};
33use serde::{Deserialize, Serialize};
34
35use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
36use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
37use crate::sql::valid_ident;
38use crate::{not_found, render, render_error, AdminState};
39
40/// One editable field: the column, its label, its field-type key (resolved
41/// through the field-type registry), typed options for that type, the validation
42/// rules it carries, and whether it holds translatable content. A serde
43/// descriptor, so it is authorable as data (later YAML) as well as by builder.
44#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct FormField {
47    pub name: String,
48    /// The field's label, localized at render. Serde stays a plain string.
49    pub label: Text,
50    /// The field-type registry key (`text`, `textarea`, or a plugin's
51    /// `vendor.name`), resolved to behaviour at render time (see [`crate::field`]).
52    pub field_type: String,
53    /// The keys that belong to the field's type, as an object: in a file they
54    /// are written on the field itself, beside its own. Null when there are none.
55    pub options: serde_json::Value,
56    /// Validation rules run on submit (see [`laterite_core::validation`]).
57    pub rules: Vec<Rule>,
58    /// Marks a field whose value is translatable content. A reserved seam: the
59    /// framework stores the value verbatim; a content-translation plugin reads
60    /// the flag to manage per-locale values.
61    pub translatable: bool,
62    /// Help text shown beneath the control, localized at render.
63    pub help: Option<Text>,
64    /// What Enter does in this field: `submit` (the form's rule), `off`, or
65    /// `next` (moves to the next field).
66    pub enter: FieldEnter,
67    /// The share of the row the field takes, on a twelve-column grid.
68    pub span: Span,
69    /// Starts a new row, whatever room the current one has left.
70    pub break_row: bool,
71    /// Follows another field as it is typed, until this one is edited.
72    pub preset: Option<Preset>,
73    /// Submitted but never shown: a plain hidden input carrying its value. The
74    /// foreign key of a related record's form is one.
75    pub hidden: bool,
76    /// Changes when another field's state meets a condition.
77    pub trigger: Option<Trigger>,
78}
79
80/// How a preset shapes the text it copies.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum PresetShape {
84    /// As typed.
85    Exact,
86    /// Lowercase words joined by hyphens: `hello-world`.
87    #[default]
88    Slug,
89    /// A slug with a leading slash: `/hello-world`.
90    Url,
91    /// A file name: spaces to hyphens, the rest kept.
92    File,
93}
94
95impl PresetShape {
96    /// The word the page carries, for the island.
97    pub fn as_str(self) -> &'static str {
98        match self {
99            PresetShape::Exact => "exact",
100            PresetShape::Slug => "slug",
101            PresetShape::Url => "url",
102            PresetShape::File => "file",
103        }
104    }
105}
106
107/// Fills a field from another as it is typed, until the field is edited.
108///
109/// In YAML, the followed field's name alone (`preset: title`, a slug), or a
110/// map: `preset: { field: title, type: url }`.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112pub struct Preset {
113    pub field: String,
114    pub shape: PresetShape,
115}
116
117impl Preset {
118    pub fn new(field: impl Into<String>, shape: PresetShape) -> Self {
119        Self {
120            field: field.into(),
121            shape,
122        }
123    }
124}
125
126impl<'de> Deserialize<'de> for Preset {
127    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
128        #[derive(Deserialize)]
129        #[serde(deny_unknown_fields)]
130        struct Full {
131            field: String,
132            #[serde(rename = "type", default)]
133            shape: PresetShape,
134        }
135        #[derive(Deserialize)]
136        #[serde(untagged)]
137        enum Raw {
138            Field(String),
139            Full(Full),
140        }
141        Ok(match Raw::deserialize(de)? {
142            Raw::Field(field) => Preset::new(field, PresetShape::Slug),
143            Raw::Full(full) => Preset::new(full.field, full.shape),
144        })
145    }
146}
147
148/// Changes a field when another field's state meets a condition.
149///
150/// ```yaml
151/// send_at:
152///   type: date
153///   trigger: { action: show, field: is_delayed, condition: checked }
154/// ```
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct Trigger {
158    /// What happens when the condition holds: `show`, `hide`, `enable`,
159    /// `disable`, `empty`, `fill[value]`, or several joined by `|`.
160    pub action: String,
161    /// The field watched. `name[]` watches every value of a checklist.
162    pub field: String,
163    /// `checked`, `unchecked`, `value[x]`, `value[x][y]` (either), `value[]`
164    /// (empty), `value[*]` (anything), `value[foo*]` (a prefix).
165    pub condition: String,
166}
167
168impl Trigger {
169    pub fn new(
170        action: impl Into<String>,
171        field: impl Into<String>,
172        condition: impl Into<String>,
173    ) -> Self {
174        Self {
175            action: action.into(),
176            field: field.into(),
177            condition: condition.into(),
178        }
179    }
180
181    /// Refuses an action or condition the island does not know.
182    pub fn check(&self) -> Result<(), String> {
183        for action in self.action.split('|') {
184            let known = matches!(action, "show" | "hide" | "enable" | "disable" | "empty")
185                || (action.starts_with("fill[") && action.ends_with(']'));
186            if !known {
187                return Err(format!(
188                    "unknown trigger action `{action}`: show, hide, enable, disable, empty or fill[value]"
189                ));
190            }
191        }
192        let known = matches!(self.condition.as_str(), "checked" | "unchecked")
193            || (self.condition.starts_with("value[") && self.condition.ends_with(']'));
194        if !known {
195            return Err(format!(
196                "unknown trigger condition `{}`: checked, unchecked or value[..]",
197                self.condition
198            ));
199        }
200        Ok(())
201    }
202
203    /// The name of the field watched, without a `[]` suffix.
204    pub fn watched(&self) -> &str {
205        self.field.trim_end_matches("[]")
206    }
207}
208
209/// The attributes a field's wrapper carries for its preset and trigger, each
210/// led by a space, empty when it has neither.
211pub(crate) fn behaviour_attrs(field: &FormField) -> String {
212    fn escaped(text: &str) -> String {
213        text.replace('&', "&amp;")
214            .replace('<', "&lt;")
215            .replace('>', "&gt;")
216            .replace('"', "&quot;")
217    }
218    let mut attrs = String::new();
219    if let Some(preset) = &field.preset {
220        attrs.push_str(&format!(
221            r#" data-lat-preset="{}" data-lat-preset-type="{}""#,
222            escaped(&preset.field),
223            preset.shape.as_str()
224        ));
225    }
226    if let Some(trigger) = &field.trigger {
227        attrs.push_str(&format!(
228            r#" data-lat-trigger-action="{}" data-lat-trigger-field="{}" data-lat-trigger-condition="{}""#,
229            escaped(&trigger.action),
230            escaped(&trigger.field),
231            escaped(&trigger.condition)
232        ));
233    }
234    attrs
235}
236
237/// The share of a row a field takes, in twelfths. Written in YAML as a
238/// fraction (`1/2`, `1/3`, `2/3`, `1/4`, `3/4`), `full`, a count of columns
239/// (`1` to `12`), or the words `half`, `third`, `quarter`, `left`, `right` and
240/// `auto` (each a half). Fields flow left to right and wrap when a row is full.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct Span(u8);
243
244impl Span {
245    /// The whole row.
246    pub const FULL: Span = Span(12);
247
248    /// `columns` twelfths of the row, clamped to 1 to 12.
249    pub fn columns(columns: u8) -> Self {
250        Span(columns.clamp(1, 12))
251    }
252
253    /// The twelfths this span covers.
254    pub fn width(self) -> u8 {
255        self.0
256    }
257
258    /// Reads the YAML spellings.
259    pub fn parse(text: &str) -> Result<Self, String> {
260        let columns = match text.trim() {
261            "full" | "12/12" => 12,
262            "1/2" | "half" | "left" | "right" | "auto" => 6,
263            "1/3" | "third" => 4,
264            "2/3" => 8,
265            "1/4" | "quarter" => 3,
266            "3/4" => 9,
267            other => match other.parse::<u8>() {
268                Ok(n) if (1..=12).contains(&n) => n,
269                _ => {
270                    return Err(format!(
271                        "unknown span `{other}`, expected full, 1/2, 1/3, 2/3, 1/4, 3/4 or 1 to 12"
272                    ))
273                }
274            },
275        };
276        Ok(Span(columns))
277    }
278
279    /// The CSS class the field wrapper carries; none for a full row.
280    fn class(self) -> String {
281        if self.0 == 12 {
282            String::new()
283        } else {
284            format!(" lat-field--{}", self.0)
285        }
286    }
287}
288
289impl Default for Span {
290    fn default() -> Self {
291        Span::FULL
292    }
293}
294
295impl Serialize for Span {
296    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
297        match self.0 {
298            12 => serializer.serialize_str("full"),
299            6 => serializer.serialize_str("1/2"),
300            4 => serializer.serialize_str("1/3"),
301            8 => serializer.serialize_str("2/3"),
302            3 => serializer.serialize_str("1/4"),
303            9 => serializer.serialize_str("3/4"),
304            n => serializer.serialize_u8(n),
305        }
306    }
307}
308
309impl<'de> Deserialize<'de> for Span {
310    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
311        #[derive(Deserialize)]
312        #[serde(untagged)]
313        enum Raw {
314            Columns(u8),
315            Text(String),
316        }
317        match Raw::deserialize(deserializer)? {
318            Raw::Columns(n) if (1..=12).contains(&n) => Ok(Span(n)),
319            Raw::Columns(n) => Err(serde::de::Error::custom(format!(
320                "unknown span `{n}`, expected 1 to 12"
321            ))),
322            Raw::Text(text) => Span::parse(&text).map_err(serde::de::Error::custom),
323        }
324    }
325}
326
327/// The wrapper classes a field's layout adds: its span, and a row break.
328pub(crate) fn layout_classes(span: Span, break_row: bool) -> String {
329    let mut classes = span.class();
330    if break_row {
331        classes.push_str(" lat-field--break");
332    }
333    classes
334}
335
336/// What Enter does in a form's single-line fields.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
338#[serde(rename_all = "lowercase")]
339pub enum EnterPolicy {
340    /// Submits the form. Cmd/Ctrl+Enter submits from a textarea as well.
341    #[default]
342    Submit,
343    /// Enter never submits; Cmd/Ctrl+Enter still does.
344    Off,
345}
346
347/// Where the cursor goes when a form opens.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
349#[serde(rename_all = "lowercase")]
350pub enum FormFocus {
351    /// A new record opens with the cursor in its first field. A refused save
352    /// comes back with the cursor in the first field it refused.
353    #[default]
354    Auto,
355    /// The cursor is left where the browser puts it.
356    Off,
357}
358
359/// What Enter does in one field, over the form's [`EnterPolicy`].
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
361#[serde(rename_all = "lowercase")]
362pub enum FieldEnter {
363    /// The form's rule.
364    #[default]
365    Submit,
366    /// Nothing.
367    Off,
368    /// Focus moves to the next field.
369    Next,
370}
371
372impl FieldEnter {
373    /// The `data-lat-enter` value the field wrapper carries; empty for the
374    /// form's rule.
375    fn attribute(self) -> &'static str {
376        match self {
377            FieldEnter::Submit => "",
378            FieldEnter::Off => "off",
379            FieldEnter::Next => "next",
380        }
381    }
382}
383
384impl Default for FormConfig {
385    /// Everything a descriptor file leaves to the resource level: the entity,
386    /// the path and the id column are filled in when the file is read.
387    fn default() -> Self {
388        Self {
389            entity: String::new(),
390            title: Text::new(""),
391            base_path: String::new(),
392            id_field: "id".to_string(),
393            fields: Vec::new(),
394            enter: EnterPolicy::default(),
395            confirm_leave: true,
396            focus: FormFocus::default(),
397            persist: None,
398            timestamps: false,
399            done_path: None,
400        }
401    }
402}
403
404impl Default for FormField {
405    /// A text field, named by the key it is written under.
406    fn default() -> Self {
407        Self::of("", Text::new(""), "text")
408    }
409}
410
411impl<'de> Deserialize<'de> for FormField {
412    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
413        struct Entry;
414        impl<'de> serde::de::Visitor<'de> for Entry {
415            type Value = FormField;
416
417            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
418                f.write_str("a field: its own keys and its type's")
419            }
420
421            fn visit_map<A: serde::de::MapAccess<'de>>(
422                self,
423                map: A,
424            ) -> Result<FormField, A::Error> {
425                let mut field = FormField::default();
426                let theirs = crate::keyed::split_entry(map, |key, map| {
427                    match key {
428                        "name" => field.name = map.next_value()?,
429                        "label" => field.label = map.next_value()?,
430                        "type" => field.field_type = map.next_value()?,
431                        "rules" => field.rules = map.next_value()?,
432                        "translatable" => field.translatable = map.next_value()?,
433                        "help" => field.help = map.next_value()?,
434                        "enter" => field.enter = map.next_value()?,
435                        "span" => field.span = map.next_value()?,
436                        "break" => field.break_row = map.next_value()?,
437                        "preset" => field.preset = map.next_value()?,
438                        "trigger" => field.trigger = map.next_value()?,
439                        "hidden" => field.hidden = map.next_value()?,
440                        _ => return Ok(false),
441                    }
442                    Ok(true)
443                })?;
444                field.options = theirs;
445                if let Some(trigger) = &field.trigger {
446                    trigger.check().map_err(serde::de::Error::custom)?;
447                }
448                Ok(field)
449            }
450
451            /// A key written bare (`title:`) is a field with every default.
452            fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
453                Ok(FormField::default())
454            }
455        }
456        de.deserialize_map(Entry)
457    }
458}
459
460impl Serialize for FormField {
461    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
462        use serde::ser::SerializeMap;
463        let mut map = ser.serialize_map(None)?;
464        map.serialize_entry("name", &self.name)?;
465        map.serialize_entry("label", &self.label)?;
466        map.serialize_entry("type", &self.field_type)?;
467        map.serialize_entry("rules", &self.rules)?;
468        map.serialize_entry("translatable", &self.translatable)?;
469        map.serialize_entry("help", &self.help)?;
470        map.serialize_entry("enter", &self.enter)?;
471        map.serialize_entry("span", &self.span)?;
472        map.serialize_entry("break", &self.break_row)?;
473        if let Some(preset) = &self.preset {
474            map.serialize_entry("preset", preset)?;
475        }
476        if self.hidden {
477            map.serialize_entry("hidden", &true)?;
478        }
479        if let Some(trigger) = &self.trigger {
480            map.serialize_entry("trigger", trigger)?;
481        }
482        crate::keyed::serialize_theirs(&mut map, &self.options)?;
483        map.end()
484    }
485}
486
487impl crate::keyed::Keyed for FormField {
488    fn key(&self) -> &str {
489        &self.name
490    }
491    fn set_key(&mut self, key: String) {
492        self.name = key;
493    }
494}
495
496impl FormField {
497    /// A field of a registered type by key.
498    pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
499        Self {
500            name: name.to_string(),
501            label: label.into(),
502            field_type: field_type.to_string(),
503            options: serde_json::Value::Null,
504            rules: Vec::new(),
505            translatable: false,
506            help: None,
507            enter: FieldEnter::default(),
508            span: Span::default(),
509            break_row: false,
510            preset: None,
511            trigger: None,
512            hidden: false,
513        }
514    }
515
516    /// Follows `field` as it is typed, shaped as `shape`, until this field is
517    /// edited.
518    pub fn preset(mut self, field: &str, shape: PresetShape) -> Self {
519        self.preset = Some(Preset::new(field, shape));
520        self
521    }
522
523    /// Changes when `trigger`'s condition holds on the field it watches.
524    pub fn trigger(mut self, trigger: Trigger) -> Self {
525        self.trigger = Some(trigger);
526        self
527    }
528
529    pub fn text(name: &str, label: impl Into<Text>) -> Self {
530        Self::of(name, label, "text")
531    }
532
533    pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
534        Self::of(name, label, "textarea")
535    }
536
537    /// A reference field: a picker over a registered picker source (a `vendor.name`
538    /// key), which the field searches and resolves against. Builds the `source`
539    /// option so a descriptor needs no raw JSON.
540    pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
541        Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
542    }
543
544    /// A dropdown over a fixed set of `(value, label)` choices.
545    pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
546        Self::of(name, label, "select").options(Self::choices(options))
547    }
548
549    /// The same choices rendered as radio buttons.
550    pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
551        Self::of(name, label, "radio").options(Self::choices(options))
552    }
553
554    /// A checkbox over a boolean column.
555    pub fn switch(name: &str, label: impl Into<Text>) -> Self {
556        Self::of(name, label, "switch")
557    }
558
559    /// A calendar date, stored as `YYYY-MM-DD`.
560    pub fn date(name: &str, label: impl Into<Text>) -> Self {
561        Self::of(name, label, "date")
562    }
563
564    /// A password: hashed on save, never rendered back, and left alone when the
565    /// field is submitted blank on an edit.
566    pub fn password(name: &str, label: impl Into<Text>) -> Self {
567        Self::of(name, label, "password")
568    }
569
570    /// A list of rows, each holding `fields`, stored as an array of objects.
571    pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
572        Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
573    }
574
575    /// A repeater whose rows collapse to the line that names them, opening one
576    /// at a time. `summary_field` names the sub-field that titles a row; unset,
577    /// the first one does.
578    ///
579    /// Use it when a row holds more than one or two fields: rendered inline
580    /// those become columns, and a row of four is already unreadable. A single
581    /// narrow column is better left inline, where collapsing would hide the one
582    /// thing worth seeing.
583    pub fn repeater_list(
584        name: &str,
585        label: impl Into<Text>,
586        fields: Vec<FormField>,
587        summary_field: Option<&str>,
588    ) -> Self {
589        Self::of(name, label, "repeater").options(serde_json::json!({
590            "fields": fields,
591            "display": "list",
592            "summary_field": summary_field,
593        }))
594    }
595
596    /// The options blob the choice-shaped types read.
597    fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
598        let options: Vec<serde_json::Value> = options
599            .into_iter()
600            .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
601            .collect();
602        serde_json::json!({ "options": options })
603    }
604
605    /// Sets the field type's typed options (the type validates them at render).
606    pub fn options(mut self, options: serde_json::Value) -> Self {
607        self.options = options;
608        self
609    }
610
611    /// Requires a non-empty value in every mode.
612    pub fn required(mut self) -> Self {
613        self.rules.push(Rule::Required);
614        self
615    }
616
617    /// Requires a non-empty value in one mode only (for example a password set on
618    /// create but left unchanged on edit).
619    pub fn required_on(mut self, mode: Mode) -> Self {
620        self.rules.push(Rule::RequiredOn(mode));
621        self
622    }
623
624    /// Requires the value to be unique in this field's column (a DB probe that
625    /// ignores the edited row on update).
626    pub fn unique(mut self) -> Self {
627        self.rules.push(Rule::Unique);
628        self
629    }
630
631    /// Caps the value's length.
632    pub fn max_length(mut self, n: usize) -> Self {
633        self.rules.push(Rule::MaxLength(n));
634        self
635    }
636
637    /// Requires at least `n` characters when the value is non-empty.
638    pub fn min_length(mut self, n: usize) -> Self {
639        self.rules.push(Rule::MinLength(n));
640        self
641    }
642
643    /// Requires a syntactically valid email address.
644    pub fn email(mut self) -> Self {
645        self.rules.push(Rule::Email);
646        self
647    }
648
649    /// Marks this field as translatable content (see [`FormField::translatable`]).
650    pub fn translatable(mut self) -> Self {
651        self.translatable = true;
652        self
653    }
654
655    /// Help text shown beneath the control.
656    pub fn help(mut self, text: impl Into<Text>) -> Self {
657        self.help = Some(text.into());
658        self
659    }
660
661    /// The share of the row this field takes.
662    pub fn span(mut self, span: Span) -> Self {
663        self.span = span;
664        self
665    }
666
667    /// Starts a new row.
668    pub fn break_row(mut self) -> Self {
669        self.break_row = true;
670        self
671    }
672}
673
674/// A form descriptor: which table, its editable fields, the id column, and the
675/// base path the form lives under (`{base_path}/new`, `{base_path}/{id}/edit`).
676///
677/// Built with [`FormConfig::new`] plus its builder methods. Non-exhaustive so the
678/// framework can learn something new about a form after 1.0 without a major bump.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680#[serde(deny_unknown_fields, default)]
681#[non_exhaustive]
682pub struct FormConfig {
683    pub entity: String,
684    /// The screen title, localized at render. Serde stays a plain string.
685    pub title: Text,
686    pub base_path: String,
687    pub id_field: String,
688    #[serde(
689        deserialize_with = "crate::keyed::deserialize",
690        serialize_with = "crate::keyed::serialize"
691    )]
692    pub fields: Vec<FormField>,
693    /// What Enter does in the form's single-line fields. Default `submit`.
694    #[serde(default)]
695    pub enter: EnterPolicy,
696    /// Ask before leaving the form with changes unsaved. Default `true`.
697    pub confirm_leave: bool,
698    /// Where the cursor goes when the form opens. Default `auto`.
699    pub focus: FormFocus,
700    /// The registered persister that writes this form (a dotted `vendor.name`),
701    /// or `None` for the built-in descriptor insert/update. See [`crate::persist`].
702    pub persist: Option<String>,
703    /// Stamp `created_at` and `updated_at` on write. Opt in per form, because
704    /// the columns have to exist on this entity's table.
705    #[serde(default)]
706    pub timestamps: bool,
707    /// Where a save and a cancel return to, when it is not the list under
708    /// `base_path`: a related record's form returns to its parent. Set by the
709    /// router, never by a descriptor.
710    #[serde(skip)]
711    pub(crate) done_path: Option<String>,
712}
713
714impl FormConfig {
715    /// A form over `entity`, mounted under `base_path`, keyed by `id_field`.
716    ///
717    /// The four arguments are the ones with no sensible default; everything else
718    /// is opt-in through a builder method, so a later field costs nothing.
719    pub fn new(
720        entity: impl Into<String>,
721        title: impl Into<Text>,
722        base_path: impl Into<String>,
723        id_field: impl Into<String>,
724        fields: Vec<FormField>,
725    ) -> Self {
726        Self {
727            enter: Default::default(),
728            confirm_leave: true,
729            focus: FormFocus::default(),
730            entity: entity.into(),
731            title: title.into(),
732            base_path: base_path.into(),
733            id_field: id_field.into(),
734            fields,
735            persist: None,
736            timestamps: false,
737            done_path: None,
738        }
739    }
740
741    /// Writes this form through a registered persister instead of the built-in
742    /// descriptor insert/update.
743    pub fn persist(mut self, name: impl Into<String>) -> Self {
744        self.persist = Some(name.into());
745        self
746    }
747
748    /// Whether leaving the form with changes unsaved asks first. Default `true`.
749    pub fn confirm_leave(mut self, ask: bool) -> Self {
750        self.confirm_leave = ask;
751        self
752    }
753
754    /// Where the cursor goes when the form opens.
755    pub fn focus(mut self, focus: FormFocus) -> Self {
756        self.focus = focus;
757        self
758    }
759
760    /// Stamps `created_at` and `updated_at` on write. Opt in, because the
761    /// columns have to exist on this entity's table.
762    pub fn timestamps(mut self) -> Self {
763        self.timestamps = true;
764        self
765    }
766
767    fn idents_valid(&self) -> bool {
768        valid_ident(&self.entity)
769            && valid_ident(&self.id_field)
770            && self.fields.iter().all(|f| valid_ident(&f.name))
771    }
772}
773
774/// A [`FormConfig`] with each field's options resolved once, at router build.
775/// Handlers carry this so option resolution and intrinsic-rule merging happen a
776/// single time (not per render), and a malformed option or unregistered type
777/// aborts boot rather than degrading silently to plain text.
778#[derive(Clone)]
779pub(crate) struct PreparedForm {
780    pub(crate) config: FormConfig,
781    /// Parallel to `config.fields`.
782    fields: Vec<PreparedField>,
783    /// The write handler: the named persister, or the built-in default.
784    persister: Arc<dyn Persister>,
785    /// Listeners for this form's entity, in registration order.
786    listeners: Vec<Arc<dyn ModelListener>>,
787    /// Offer "Save and continue editing" beside Save: a resource with relation
788    /// sections wants to land on the edit page, where the sections are.
789    pub(crate) stay: bool,
790}
791
792/// One field's boot-resolved state: its typed options and its merged rules (the
793/// type's intrinsic rules ahead of the descriptor's own).
794#[derive(Clone)]
795struct PreparedField {
796    /// Resolved once at boot, so the save and load hooks need no lookup.
797    field_type: Arc<dyn crate::field::FieldType>,
798    opts: ResolvedOptions,
799    rules: Vec<Rule>,
800}
801
802impl PreparedForm {
803    /// The write handler this form resolved to.
804    pub(crate) fn persister(&self) -> Arc<dyn Persister> {
805        self.persister.clone()
806    }
807
808    /// Whether the form declares `field`.
809    pub(crate) fn has_field(&self, field: &str) -> bool {
810        self.config.fields.iter().any(|f| f.name == field)
811    }
812
813    /// The widget assets `field`'s type declares.
814    pub(crate) fn field_assets(&self, field: &str) -> Vec<&'static str> {
815        self.config
816            .fields
817            .iter()
818            .zip(&self.fields)
819            .find(|(f, _)| f.name == field)
820            .map(|(_, pf)| pf.field_type.assets(&pf.opts))
821            .unwrap_or_default()
822    }
823
824    /// One field's bare control holding `value`, as the form would render it,
825    /// for a place outside the form such as an editable cell. `None` when the
826    /// form has no such field.
827    pub(crate) fn control(
828        &self,
829        state: &AdminState,
830        field: &str,
831        dom_id: &str,
832        value: &str,
833        shell: &crate::Shell,
834    ) -> Option<String> {
835        let (f, pf) = self
836            .config
837            .fields
838            .iter()
839            .zip(&self.fields)
840            .find(|(f, _)| f.name == field)?;
841        let value = FieldValue::Text(value.to_string());
842        let label = shell.tt(&f.label);
843        let cx = FieldCx {
844            name: &f.name,
845            id: dom_id,
846            label: &label,
847            value: &value,
848            required: has_required(&pf.rules),
849            max_length: max_length(&pf.rules),
850            opts: &pf.opts,
851            base: &shell.base,
852            i18n: shell.i18n(),
853            icons: crate::icons::Icons::new(&shell.icon_set, &shell.sprite_url),
854        };
855        let ft = state.field_types.get(&f.field_type)?;
856        let scope = OverrideScope {
857            surface: Surface::Field,
858            view_key: &f.field_type,
859            resource: Some(&self.config.base_path),
860            field: Some(&f.name),
861        };
862        Some(render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string())
863    }
864
865    /// Resolves every field against the registry. Returns the offending field
866    /// and reason (for a boot abort) when a type is unregistered or rejects its
867    /// options.
868    pub(crate) fn prepare(
869        config: FormConfig,
870        field_types: &crate::field::FieldRegistry,
871        persisters: &PersisterRegistry,
872        listeners: &[ModelListenerReg],
873    ) -> Result<Self, String> {
874        let mut fields = Vec::with_capacity(config.fields.len());
875        for f in &config.fields {
876            let names = || config.fields.iter().map(|other| other.name.as_str());
877            if let Some(preset) = &f.preset {
878                if !names().any(|n| n == preset.field) {
879                    return Err(format!(
880                        "field `{}` presets from `{}`, which is not in this form",
881                        f.name, preset.field
882                    ));
883                }
884            }
885            if let Some(trigger) = &f.trigger {
886                trigger
887                    .check()
888                    .map_err(|e| format!("field `{}`: {e}", f.name))?;
889                if !names().any(|n| n == trigger.watched()) {
890                    return Err(format!(
891                        "field `{}` watches `{}`, which is not in this form",
892                        f.name,
893                        trigger.watched()
894                    ));
895                }
896            }
897            let ft = field_types.get(&f.field_type).ok_or_else(|| {
898                format!(
899                    "field `{}` uses unregistered type `{}`",
900                    f.name, f.field_type
901                )
902            })?;
903            crate::field::check_field_keys(f, ft.as_ref())
904                .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
905            let opts = ft
906                .resolve_options(&f.options, field_types)
907                .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
908            let mut rules = ft.intrinsic_rules(&opts);
909            rules.extend(f.rules.clone());
910            fields.push(PreparedField {
911                field_type: ft.clone(),
912                opts,
913                rules,
914            });
915        }
916        let persister: Arc<dyn Persister> = match &config.persist {
917            Some(key) => persisters
918                .get(key)
919                .cloned()
920                .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
921            None => Arc::new(DefaultPersister::from_config(&config)),
922        };
923        // The framework's own listeners run first, so a contributed one can see
924        // or override what they set.
925        let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
926        if config.timestamps {
927            resolved.push(Arc::new(laterite_core::Timestamps));
928        }
929        resolved.extend(
930            listeners
931                .iter()
932                .filter(|reg| reg.matches(&config.entity))
933                .map(|reg| reg.listener.clone()),
934        );
935        let listeners = resolved;
936        Ok(Self {
937            config,
938            fields,
939            persister,
940            listeners,
941            stay: false,
942        })
943    }
944}
945
946/// The submission as a record, carrying only the fields the descriptor declares.
947/// A request cannot introduce an attribute this way, so the persister may widen
948/// its write to whatever the record holds; only registered listeners add keys.
949///
950/// Each value passes through its field type, which decides what gets stored and
951/// may leave the attribute out entirely (a blank password on an edit).
952fn declared_record(
953    form: &PreparedForm,
954    data: &HashMap<String, String>,
955    mode: Mode,
956) -> Result<Record, String> {
957    let mut rec = Record::new(&form.config.entity);
958    for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
959        let submitted = crate::field::SubmittedField::new(&field.name, data);
960        if let Some(value) = prepared
961            .field_type
962            .to_attr(&submitted, &prepared.opts, mode)?
963        {
964            rec.set(field.name.clone(), value);
965        }
966    }
967    Ok(rec)
968}
969
970/// The submission with every field present under its own name.
971///
972/// A field of several controls submits keys of its own and none under its name.
973/// Its type gathers them into one value, so the rules see the field and a
974/// refused save is re-rendered from what the operator entered. A key already
975/// present is left as submitted.
976pub(crate) fn gathered(
977    form: &PreparedForm,
978    mut data: HashMap<String, String>,
979) -> HashMap<String, String> {
980    for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
981        if data.contains_key(&field.name) {
982            continue;
983        }
984        let value = prepared.field_type.submitted(
985            &crate::field::SubmittedField::new(&field.name, &data),
986            &prepared.opts,
987        );
988        if let Some(value) = value {
989            data.insert(field.name.clone(), value);
990        }
991    }
992    data
993}
994
995/// The merged validation rules for every field, in order.
996fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
997    form.config
998        .fields
999        .iter()
1000        .zip(&form.fields)
1001        // The rules carry the label's source string; the validation message re-wraps
1002        // it as a nested Text, so it localizes at render like any other label.
1003        .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
1004        .collect()
1005}
1006
1007/// The write a submit and a [`FormHandle`] share: `data` (already gathered)
1008/// validated against the merged rules, shaped into the declared record and saved
1009/// through the listeners and the persister. `id` names the row an edit updates;
1010/// `None` creates. Returns the record as saved, its id set on a create.
1011pub(crate) async fn write(
1012    db: &Db,
1013    cache: Option<&laterite_core::Cache>,
1014    form: &PreparedForm,
1015    data: &HashMap<String, String>,
1016    id: Option<&str>,
1017    actor: &Actor,
1018) -> Result<Record, SaveError> {
1019    let (mut rec, op) = prepare(db, form, data, id).await?;
1020    persist::save(
1021        persist::SaveRequest {
1022            db,
1023            cache,
1024            listeners: &form.listeners,
1025            persister: form.persister.as_ref(),
1026            actor,
1027            op,
1028            id,
1029        },
1030        &mut rec,
1031    )
1032    .await?;
1033    link_media(
1034        db,
1035        form,
1036        data,
1037        rec.id()
1038            .map(|i| i.to_string())
1039            .or_else(|| id.map(str::to_string)),
1040    )
1041    .await;
1042    Ok(rec)
1043}
1044
1045/// Writes the links a saved record's media fields hold, under each field's
1046/// name, so the library knows who uses a file. A type that holds no media
1047/// says so and is skipped; a failure is logged, never a failed save.
1048async fn link_media(
1049    db: &Db,
1050    form: &PreparedForm,
1051    data: &HashMap<String, String>,
1052    owner_id: Option<String>,
1053) {
1054    let Some(owner_id) = owner_id else {
1055        return;
1056    };
1057    for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
1058        let submitted = data.get(&field.name).map(String::as_str);
1059        let Some(ids) = prepared.field_type.linked_media(submitted, &prepared.opts) else {
1060            continue;
1061        };
1062        let owner = laterite_media::Owner {
1063            owner_type: &form.config.entity,
1064            owner_id: &owner_id,
1065            field: &field.name,
1066        };
1067        if let Err(e) = laterite_media::link::set(db, owner, &ids).await {
1068            tracing::warn!(error = %e, entity = %form.config.entity, field = %field.name, "media links could not be written");
1069        }
1070    }
1071}
1072
1073/// The write up to the point of writing: the data validated and shaped into
1074/// the record, with the operation it would be. What a check runs, and what a
1075/// write runs first.
1076async fn prepare(
1077    db: &Db,
1078    form: &PreparedForm,
1079    data: &HashMap<String, String>,
1080    id: Option<&str>,
1081) -> Result<(Record, Op), SaveError> {
1082    if !form.config.idents_valid() {
1083        return Err(SaveError::Failed(format!(
1084            "form `{}` names an invalid entity or field identifier",
1085            form.config.entity
1086        )));
1087    }
1088    let (mode, op) = match id {
1089        None => (Mode::Create, Op::Create),
1090        Some(_) => (Mode::Update, Op::Update),
1091    };
1092    let bag = validate(
1093        db,
1094        &form.config.entity,
1095        &form.config.id_field,
1096        &merged_field_rules(form),
1097        data,
1098        mode,
1099        id,
1100    )
1101    .await
1102    .map_err(|e| SaveError::Failed(e.to_string()))?;
1103    if !bag.is_empty() {
1104        return Err(SaveError::Invalid(bag));
1105    }
1106    let mut rec = declared_record(form, data, mode).map_err(|e| {
1107        tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
1108        SaveError::Failed(e)
1109    })?;
1110    if let Some(n) = id.and_then(|id| id.parse::<i64>().ok()) {
1111        rec.set_id(n);
1112    }
1113    Ok((rec, op))
1114}
1115
1116/// The forms the resources mounted, keyed by each resource's path as its
1117/// descriptor declares it, relative to the admin root.
1118pub(crate) type Forms = Arc<HashMap<String, Arc<PreparedForm>>>;
1119
1120/// A resource's form, written through from code.
1121///
1122/// Runs what the form's own submit runs, with no request in hand: the values
1123/// are validated against the form's rules, shaped by its field types and saved
1124/// through its listeners and persister, so an importer or a sync writes exactly
1125/// what an operator could have typed, audited the same way. A screen reaches one
1126/// through [`crate::routes::RouteCtx::form`].
1127#[derive(Clone)]
1128pub struct FormHandle {
1129    db: Db,
1130    cache: laterite_core::Cache,
1131    form: Arc<PreparedForm>,
1132}
1133
1134impl FormHandle {
1135    pub(crate) fn new(db: Db, cache: laterite_core::Cache, form: Arc<PreparedForm>) -> Self {
1136        Self { db, cache, form }
1137    }
1138
1139    /// The table the form writes.
1140    pub fn entity(&self) -> &str {
1141        &self.form.config.entity
1142    }
1143
1144    /// The primary key column.
1145    pub fn id_field(&self) -> &str {
1146        &self.form.config.id_field
1147    }
1148
1149    /// The fields the form declares, in order. `data` is keyed by their names.
1150    pub fn fields(&self) -> &[FormField] {
1151        &self.form.config.fields
1152    }
1153
1154    /// Validates `data` as a submission and saves a new record, returning its id.
1155    ///
1156    /// `data` holds each field's value under the field's name, as the form's
1157    /// control submits it; a field left out is blank. `Invalid` carries the
1158    /// per-field messages a refused submit would show.
1159    pub async fn create(
1160        &self,
1161        data: HashMap<String, String>,
1162        actor: &Actor,
1163    ) -> Result<i64, SaveError> {
1164        let data = gathered(&self.form, data);
1165        let rec = write(&self.db, Some(&self.cache), &self.form, &data, None, actor).await?;
1166        rec.id()
1167            .ok_or_else(|| SaveError::Failed("the persister returned no id".to_string()))
1168    }
1169
1170    /// The record `id` as the edit form shows it: each stored value back through
1171    /// its field type, keyed by field name. `None` when no record has that id.
1172    /// Lay a partial `data` over it before [`update`](Self::update) to change
1173    /// some fields and keep the rest.
1174    pub async fn load(&self, id: i64) -> Result<Option<HashMap<String, String>>, SaveError> {
1175        load_values(&self.db, &self.form, &id.to_string())
1176            .await
1177            .map_err(|e| SaveError::Failed(e.to_string()))
1178    }
1179
1180    /// What [`create`](Self::create) or [`update`](Self::update) would do with
1181    /// `data`, with nothing written: the rules and the field conversions run,
1182    /// the listeners and the persister do not. `id` names the record an edit
1183    /// would change; `None` checks as a create.
1184    pub async fn check(
1185        &self,
1186        id: Option<i64>,
1187        data: HashMap<String, String>,
1188    ) -> Result<(), SaveError> {
1189        let data = gathered(&self.form, data);
1190        let id = id.map(|id| id.to_string());
1191        prepare(&self.db, &self.form, &data, id.as_deref())
1192            .await
1193            .map(|_| ())
1194    }
1195
1196    /// Validates `data` as an edit of the record `id` and saves it. Every field
1197    /// is written, so a field left out of `data` is stored blank.
1198    pub async fn update(
1199        &self,
1200        id: i64,
1201        data: HashMap<String, String>,
1202        actor: &Actor,
1203    ) -> Result<(), SaveError> {
1204        let data = gathered(&self.form, data);
1205        write(
1206            &self.db,
1207            Some(&self.cache),
1208            &self.form,
1209            &data,
1210            Some(&id.to_string()),
1211            actor,
1212        )
1213        .await
1214        .map(|_| ())
1215    }
1216}
1217
1218/// The longest value the rules accept, when they set one.
1219pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
1220    rules
1221        .iter()
1222        .filter_map(|r| match r {
1223            Rule::MaxLength(n) => Some(*n),
1224            _ => None,
1225        })
1226        .min()
1227}
1228
1229/// Whether any rule marks the field required.
1230fn has_required(rules: &[Rule]) -> bool {
1231    rules
1232        .iter()
1233        .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
1234}
1235
1236/// Renders an empty create form.
1237pub(crate) fn new_form(
1238    state: &AdminState,
1239    form: &PreparedForm,
1240    shell: crate::Shell,
1241    pending: Vec<String>,
1242) -> Response {
1243    let mut page = build(
1244        state,
1245        form,
1246        &format!("{}/new", form.config.base_path),
1247        None,
1248        &HashMap::new(),
1249        &ErrorBag::default(),
1250        &shell,
1251    );
1252    page.pending = pending;
1253    render(page)
1254}
1255
1256/// Where a save goes: the edit page when the operator asked to stay, else the
1257/// form's done path or its list.
1258fn after_save(form: &PreparedForm, data: &HashMap<String, String>, id: Option<i64>) -> String {
1259    let stay = data.get("then").map(String::as_str) == Some("stay");
1260    match (stay, id) {
1261        (true, Some(id)) => format!("{}/{}/edit", form.config.base_path, id),
1262        _ => form
1263            .config
1264            .done_path
1265            .clone()
1266            .unwrap_or_else(|| form.config.base_path.clone()),
1267    }
1268}
1269
1270/// Persists a new record, then redirects to the list. Re-renders with per-field
1271/// errors when validation fails.
1272pub(crate) async fn create(
1273    state: &AdminState,
1274    form: &PreparedForm,
1275    data: HashMap<String, String>,
1276    shell: crate::Shell,
1277    user: &laterite_auth::AuthenticatedUser,
1278    session: &crate::session::SessionHandle,
1279    headers: &axum::http::HeaderMap,
1280) -> Response {
1281    if !form.config.idents_valid() {
1282        return render_error();
1283    }
1284    let htmx = is_htmx(headers);
1285    let action = format!("{}/new", form.config.base_path);
1286    let mut data = gathered(form, data);
1287    let then = data.remove("then");
1288    let actor = Actor::from(user);
1289    match write(&state.db, Some(&state.cache), form, &data, None, &actor).await {
1290        // The audit entry is written by the framework's audit listener, which
1291        // runs on every entity after the commit.
1292        Ok(rec) => {
1293            session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1294            let mut choice = HashMap::new();
1295            if let Some(then) = then {
1296                choice.insert("then".to_string(), then);
1297            }
1298            saved_response(htmx, &after_save(form, &choice, rec.id()))
1299        }
1300        // A refused submission re-renders the form with per-field errors as 422,
1301        // the cross-surface "validation failure" status.
1302        Err(SaveError::Invalid(bag)) => {
1303            invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1304        }
1305        Err(SaveError::Failed(msg)) => {
1306            tracing::error!(error = %msg, "admin create failed");
1307            render(build(
1308                state,
1309                form,
1310                &action,
1311                Some(t!("Could not save. Check the values and try again.")),
1312                &data,
1313                &ErrorBag::default(),
1314                &shell,
1315            ))
1316        }
1317    }
1318}
1319
1320/// Renders a form populated with an existing record.
1321pub(crate) async fn edit_form(
1322    state: &AdminState,
1323    form: &PreparedForm,
1324    id: String,
1325    shell: crate::Shell,
1326    sections: Vec<RelationSection>,
1327) -> Response {
1328    if !form.config.idents_valid() {
1329        return render_error();
1330    }
1331    let values = match load_values(&state.db, form, &id).await {
1332        Ok(Some(values)) => values,
1333        Ok(None) => return not_found(),
1334        Err(_) => return render_error(),
1335    };
1336
1337    let mut page = build(
1338        state,
1339        form,
1340        &format!("{}/{}/edit", form.config.base_path, id),
1341        None,
1342        &values,
1343        &ErrorBag::default(),
1344        &shell,
1345    );
1346    page.sections = sections;
1347    render(page)
1348}
1349
1350/// The stored record `id` as control values: each column back through its
1351/// field type, since what a column holds and what its control shows are not
1352/// always the same string. `None` when no record has that id.
1353/// `GET {base}/{id}/reveal/{field}`: the stored value of a sensitive field,
1354/// opened, as plain text for the field's Reveal button. Recorded in the audit
1355/// log as `<entity>.reveal` with the field as the detail.
1356pub(crate) async fn reveal(
1357    state: &AdminState,
1358    form: &PreparedForm,
1359    id: &str,
1360    field: &str,
1361    user: &laterite_auth::AuthenticatedUser,
1362) -> Response {
1363    if !form
1364        .config
1365        .fields
1366        .iter()
1367        .any(|f| f.name == field && f.field_type == "sensitive")
1368    {
1369        return crate::AdminError::NotFound.into_response();
1370    }
1371    // The column as stored, not as the control shows it (`load_values` masks it).
1372    let (sql, values) = {
1373        let cast = text_cast(state.db.backend);
1374        let mut select = Query::select();
1375        select
1376            .expr_as(
1377                Expr::col(Alias::new(field)).cast_as(Alias::new(cast)),
1378                Alias::new("sealed"),
1379            )
1380            .from(Alias::new(&form.config.entity))
1381            .and_where(
1382                Expr::col(Alias::new(&form.config.id_field))
1383                    .cast_as(Alias::new(cast))
1384                    .eq(id.to_string()),
1385            );
1386        to_sql(state.db.backend, select)
1387    };
1388    let sealed = match bind_values(sqlx::query(&sql), values)
1389        .fetch_optional(&state.db.pool)
1390        .await
1391    {
1392        Ok(Some(row)) => row
1393            .get_text_opt("sealed")
1394            .unwrap_or_default()
1395            .unwrap_or_default(),
1396        Ok(None) => return crate::AdminError::NotFound.into_response(),
1397        Err(e) => return crate::AdminError::Internal(anyhow::anyhow!(e)).into_response(),
1398    };
1399    let Some(plain) = crate::field::SensitiveField::open(&state.crypt, field, &sealed) else {
1400        return crate::AdminError::Internal(anyhow::anyhow!(
1401            "the value does not open under the application key"
1402        ))
1403        .into_response();
1404    };
1405    crate::audit::record(
1406        state,
1407        user,
1408        &format!("{}.reveal", form.config.entity),
1409        Some(&form.config.entity),
1410        Some(id),
1411        None,
1412        Some(field),
1413    )
1414    .await;
1415    (
1416        StatusCode::OK,
1417        [
1418            (
1419                axum::http::header::CONTENT_TYPE,
1420                "text/plain; charset=utf-8",
1421            ),
1422            (axum::http::header::CACHE_CONTROL, "private, no-store"),
1423        ],
1424        plain,
1425    )
1426        .into_response()
1427}
1428
1429pub(crate) async fn load_values(
1430    db: &Db,
1431    form: &PreparedForm,
1432    id: &str,
1433) -> Result<Option<HashMap<String, String>>, sqlx::Error> {
1434    // Scope the sea-query builder so it is dropped before the await below: its
1435    // identifiers are reference-counted (not `Send`), and a live builder across
1436    // the await would make the caller's future non-`Send`.
1437    let (sql, values) = {
1438        let cast = text_cast(db.backend);
1439        let mut select = Query::select();
1440        for field in &form.config.fields {
1441            select.expr_as(
1442                Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1443                Alias::new(&field.name),
1444            );
1445        }
1446        select.from(Alias::new(&form.config.entity)).and_where(
1447            Expr::col(Alias::new(&form.config.id_field))
1448                .cast_as(Alias::new(cast))
1449                .eq(id.to_string()),
1450        );
1451        to_sql(db.backend, select)
1452    };
1453    let Some(row) = bind_values(sqlx::query(&sql), values)
1454        .fetch_optional(&db.pool)
1455        .await?
1456    else {
1457        return Ok(None);
1458    };
1459    Ok(Some(
1460        form.config
1461            .fields
1462            .iter()
1463            .zip(&form.fields)
1464            .map(|(f, prepared)| {
1465                let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1466                let value = prepared
1467                    .field_type
1468                    .to_control(stored.as_deref(), &prepared.opts);
1469                (f.name.clone(), value)
1470            })
1471            .collect(),
1472    ))
1473}
1474
1475/// Persists an edited record, then redirects to the list. Re-renders with
1476/// per-field errors when validation fails.
1477// The generic handlers thread the request's pieces explicitly rather than through
1478// a context struct; the list is long but each argument is named at the call site.
1479#[allow(clippy::too_many_arguments)]
1480pub(crate) async fn update(
1481    state: &AdminState,
1482    form: &PreparedForm,
1483    id: String,
1484    data: HashMap<String, String>,
1485    shell: crate::Shell,
1486    user: &laterite_auth::AuthenticatedUser,
1487    session: &crate::session::SessionHandle,
1488    headers: &axum::http::HeaderMap,
1489) -> Response {
1490    if !form.config.idents_valid() {
1491        return render_error();
1492    }
1493    let htmx = is_htmx(headers);
1494    let action = format!("{}/{}/edit", form.config.base_path, id);
1495    let mut data = gathered(form, data);
1496    let then = data.remove("then");
1497    let actor = Actor::from(user);
1498    match write(
1499        &state.db,
1500        Some(&state.cache),
1501        form,
1502        &data,
1503        Some(&id),
1504        &actor,
1505    )
1506    .await
1507    {
1508        Ok(_) => {
1509            session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1510            let mut choice = HashMap::new();
1511            if let Some(then) = then {
1512                choice.insert("then".to_string(), then);
1513            }
1514            saved_response(htmx, &after_save(form, &choice, id.parse().ok()))
1515        }
1516        // A refused submission re-renders the form with per-field errors as 422,
1517        // the cross-surface "validation failure" status.
1518        Err(SaveError::Invalid(bag)) => {
1519            invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1520        }
1521        Err(SaveError::Failed(msg)) => {
1522            tracing::error!(error = %msg, "admin update failed");
1523            render(build(
1524                state,
1525                form,
1526                &action,
1527                Some(t!("Could not save. Check the values and try again.")),
1528                &data,
1529                &ErrorBag::default(),
1530                &shell,
1531            ))
1532        }
1533    }
1534}
1535
1536/// The 422 response for a failed save. An HTMX submit gets the form alone, which
1537/// replaces the form in place; anything else gets the whole page, so the admin
1538/// still works with scripting off.
1539#[allow(clippy::too_many_arguments)]
1540/// A form page posting to `action`, holding `values`: the shape another screen
1541/// borrows when it edits one thing a record owns, such as its tree parent.
1542pub(crate) fn page(
1543    state: &AdminState,
1544    form: &PreparedForm,
1545    action: &str,
1546    values: &HashMap<String, String>,
1547    shell: &crate::Shell,
1548) -> Response {
1549    render(build(
1550        state,
1551        form,
1552        action,
1553        None,
1554        values,
1555        &ErrorBag::default(),
1556        shell,
1557    ))
1558}
1559
1560/// The 422 for a submission the list's own checks refused, shaped like a
1561/// failed save: the form again with the messages against their fields.
1562#[allow(clippy::too_many_arguments)]
1563pub(crate) fn refused(
1564    htmx: bool,
1565    state: &AdminState,
1566    form: &PreparedForm,
1567    action: &str,
1568    error: Option<Text>,
1569    values: &HashMap<String, String>,
1570    bag: &ErrorBag,
1571    shell: &crate::Shell,
1572) -> Response {
1573    invalid_response(htmx, state, form, action, error, values, bag, shell)
1574}
1575
1576#[allow(clippy::too_many_arguments)]
1577fn invalid_response(
1578    htmx: bool,
1579    state: &AdminState,
1580    form: &PreparedForm,
1581    action: &str,
1582    error: Option<Text>,
1583    values: &HashMap<String, String>,
1584    bag: &ErrorBag,
1585    shell: &crate::Shell,
1586) -> Response {
1587    let page = build(state, form, action, error, values, bag, shell);
1588    let body = if htmx {
1589        render(FormFragment {
1590            shell: page.shell,
1591            action: page.action,
1592            cancel_path: page.cancel_path,
1593            error: page.error,
1594            fields: page.fields,
1595            stay: page.stay,
1596            enter_off: page.enter_off,
1597            refused: page.refused,
1598            leave_off: page.leave_off,
1599            focus: page.focus,
1600        })
1601    } else {
1602        render(page)
1603    };
1604    (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1605}
1606
1607/// Where to send the browser after a successful save. HTMX will not follow a
1608/// 303 usefully (it would swap the redirected page into the form), so it gets
1609/// the header it understands instead.
1610pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1611    if htmx {
1612        ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1613    } else {
1614        Redirect::to(to).into_response()
1615    }
1616}
1617
1618/// Whether this request came from HTMX rather than a plain form post.
1619pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1620    headers.contains_key("hx-request")
1621}
1622
1623fn build(
1624    state: &AdminState,
1625    form: &PreparedForm,
1626    action: &str,
1627    error: Option<Text>,
1628    values: &HashMap<String, String>,
1629    bag: &ErrorBag,
1630    shell: &crate::Shell,
1631) -> FormTemplate {
1632    let fields = form
1633        .config
1634        .fields
1635        .iter()
1636        .zip(&form.fields)
1637        .map(|(f, pf)| {
1638            let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1639            let required = has_required(&pf.rules);
1640            // Localize the label once: the field type sees it (aria/inline labels)
1641            // and the framework chrome renders it.
1642            let label = shell.tt(&f.label);
1643            let cx = FieldCx {
1644                name: &f.name,
1645                id: &f.name,
1646                label: &label,
1647                value: &value,
1648                required,
1649                max_length: max_length(&pf.rules),
1650                opts: &pf.opts,
1651                base: &shell.base,
1652                i18n: shell.i18n(),
1653                icons: crate::icons::Icons::new(&shell.icon_set, &shell.sprite_url),
1654            };
1655            // The type is registered (prepare validated it); the lookup here is
1656            // only to render.
1657            let control = if f.hidden {
1658                format!(
1659                    r#"<input type="hidden" name="{}" value="{}">"#,
1660                    escape_attr(&f.name),
1661                    escape_attr(values.get(&f.name).map(String::as_str).unwrap_or_default())
1662                )
1663            } else {
1664                match state.field_types.get(&f.field_type) {
1665                    Some(ft) => {
1666                        let scope = OverrideScope {
1667                            surface: Surface::Field,
1668                            view_key: &f.field_type,
1669                            resource: Some(&form.config.base_path),
1670                            field: Some(&f.name),
1671                        };
1672                        render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx)
1673                            .into_string()
1674                    }
1675                    None => String::new(),
1676                }
1677            };
1678            FieldView {
1679                id: f.name.clone(),
1680                label,
1681                control,
1682                required,
1683                enter: f.enter.attribute(),
1684                layout: layout_classes(f.span, f.break_row),
1685                attrs: behaviour_attrs(f),
1686                hidden: f.hidden,
1687                // Localize each per-field message through the request translator.
1688                errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1689            }
1690        })
1691        .collect();
1692    // Collect the widget assets the rendered field types declare, deduped and
1693    // resolved to URLs for the head. Most declare none (their widgets ship in
1694    // core laterite.js).
1695    let keys: Vec<&str> = form
1696        .config
1697        .fields
1698        .iter()
1699        .zip(&form.fields)
1700        .filter_map(|(f, pf)| {
1701            state
1702                .field_types
1703                .get(&f.field_type)
1704                .map(|ft| ft.assets(&pf.opts))
1705        })
1706        .flatten()
1707        .collect();
1708    // Localize the title and banner before the shell is moved into the template.
1709    let shell_title = shell.tt(&form.config.title);
1710    let refused = error.is_some() || !bag.is_empty();
1711    let error = error.map(|m| shell.tt(&m));
1712    let mut shell = shell.clone();
1713    shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1714    FormTemplate {
1715        shell,
1716        title: shell_title,
1717        action: action.to_string(),
1718        cancel_path: form
1719            .config
1720            .done_path
1721            .clone()
1722            .unwrap_or_else(|| form.config.base_path.clone()),
1723        error,
1724        fields,
1725        stay: form.stay,
1726        sections: Vec::new(),
1727        pending: Vec::new(),
1728        enter_off: form.config.enter == EnterPolicy::Off,
1729        refused,
1730        leave_off: !form.config.confirm_leave,
1731        focus: match form.config.focus {
1732            FormFocus::Off => "off",
1733            // A new record has nothing in it yet, so the first field is where
1734            // the operator starts. An edit opens for reading first.
1735            FormFocus::Auto if action.ends_with("/new") => "first",
1736            FormFocus::Auto => "",
1737        },
1738    }
1739}
1740
1741struct FieldView {
1742    /// DOM id for the control and its label's `for`.
1743    id: String,
1744    label: String,
1745    /// The control HTML, rendered by the field-type registry (override-aware).
1746    /// The framework owns the surrounding chrome (label, required marker, errors).
1747    control: String,
1748    required: bool,
1749    errors: Vec<String>,
1750    /// The field's own Enter rule (`off`, `next`), or empty for the form's.
1751    enter: &'static str,
1752    /// Wrapper classes for the span and a row break, each led by a space.
1753    layout: String,
1754    /// The wrapper's preset and trigger attributes, each led by a space.
1755    attrs: String,
1756    /// A hidden input with no chrome around it.
1757    hidden: bool,
1758}
1759
1760/// One relation on an edit page: its heading, its New link and the list region.
1761pub(crate) struct RelationSection {
1762    /// The section's toolbar: its title and the New or Attach button.
1763    pub toolbar: crate::html::Markup,
1764    pub html: crate::html::Markup,
1765}
1766
1767pub(crate) fn escape_attr(s: &str) -> String {
1768    s.replace('&', "&amp;")
1769        .replace('"', "&quot;")
1770        .replace('<', "&lt;")
1771        .replace('>', "&gt;")
1772}
1773
1774/// Just the form element, for an HTMX submit that failed validation: the
1775/// response replaces the form in place rather than reloading the page.
1776#[derive(Template)]
1777#[template(path = "_form_fields.html")]
1778struct FormFragment {
1779    shell: crate::Shell,
1780    action: String,
1781    cancel_path: String,
1782    error: Option<String>,
1783    fields: Vec<FieldView>,
1784    stay: bool,
1785    /// Enter never submits this form.
1786    enter_off: bool,
1787    /// The form holds a submission that was not saved.
1788    refused: bool,
1789    /// Leaving with changes unsaved does not ask.
1790    leave_off: bool,
1791    /// `first`, `off`, or empty for the default.
1792    focus: &'static str,
1793}
1794
1795#[derive(Template)]
1796#[template(path = "form.html")]
1797struct FormTemplate {
1798    shell: crate::Shell,
1799    title: String,
1800    action: String,
1801    cancel_path: String,
1802    error: Option<String>,
1803    fields: Vec<FieldView>,
1804    /// Offer "Save and continue editing".
1805    stay: bool,
1806    /// The related records, one section each, on an edit page.
1807    sections: Vec<RelationSection>,
1808    /// The relations a create page cannot show yet, by section title.
1809    pending: Vec<String>,
1810    /// Enter never submits this form.
1811    enter_off: bool,
1812    /// The form holds a submission that was not saved.
1813    refused: bool,
1814    /// Leaving with changes unsaved does not ask.
1815    leave_off: bool,
1816    /// `first`, `off`, or empty for the default.
1817    focus: &'static str,
1818}
1819
1820/// A field as a value form renders it: the label, the control its type drew,
1821/// the help, and the wrapper's layout classes and behaviour attributes.
1822pub struct ValueFieldView {
1823    pub label: String,
1824    /// The control's DOM id, which the label points at.
1825    pub id: String,
1826    pub help: Option<String>,
1827    /// The control, rendered by the field's own type.
1828    pub control: String,
1829    /// Wrapper classes for the span and a row break, each led by a space.
1830    pub layout: String,
1831    /// The wrapper's preset and trigger attributes, each led by a space.
1832    pub attrs: String,
1833}
1834
1835#[derive(Template)]
1836#[template(path = "_value_fields.html")]
1837struct ValueFieldsTemplate {
1838    fields: Vec<ValueFieldView>,
1839}
1840
1841/// A form over a JSON object: the declared fields rendered with their stored
1842/// values, and a submission collected back through the field types into an
1843/// object with every declared key. The settings screens are one, a theme's
1844/// settings another. Built from a [`RouteCtx`](crate::routes::RouteCtx) with
1845/// [`value_form`](crate::routes::RouteCtx::value_form).
1846pub struct ValueForm {
1847    fields: Vec<FormField>,
1848    types: Arc<crate::field::FieldRegistry>,
1849    overrides: Arc<dyn crate::field::OverrideResolver>,
1850    /// What an override is scoped to (a settings code, a theme code).
1851    scope: String,
1852}
1853
1854impl ValueForm {
1855    /// Checks every field's type is registered and its options resolve. The
1856    /// error names the field and the type, for a form declared at runtime.
1857    pub(crate) fn new(
1858        fields: &[FormField],
1859        types: Arc<crate::field::FieldRegistry>,
1860        overrides: Arc<dyn crate::field::OverrideResolver>,
1861        scope: &str,
1862    ) -> Result<Self, Text> {
1863        for field in fields {
1864            let Some(ft) = types.get(&field.field_type) else {
1865                return Err(Text::dynamic(format!(
1866                    "field {:?} uses unregistered type {:?}",
1867                    field.name, field.field_type
1868                )));
1869            };
1870            if ft.resolve_options(&field.options, &types).is_err() {
1871                return Err(Text::dynamic(format!(
1872                    "field {:?} has malformed options",
1873                    field.name
1874                )));
1875            }
1876        }
1877        Ok(Self {
1878            fields: fields.to_vec(),
1879            types,
1880            overrides,
1881            scope: scope.to_string(),
1882        })
1883    }
1884
1885    /// The fields, in order.
1886    pub fn fields(&self) -> &[FormField] {
1887        &self.fields
1888    }
1889
1890    /// Each field over `stored`, ready for a template.
1891    pub fn views(&self, stored: &serde_json::Value, shell: &crate::Shell) -> Vec<ValueFieldView> {
1892        value_views(
1893            &self.fields,
1894            &self.types,
1895            self.overrides.as_ref(),
1896            stored,
1897            shell,
1898            &self.scope,
1899        )
1900    }
1901
1902    /// The fields over `stored` as one `.lat-fields` block, for a form a screen
1903    /// builds by hand around it.
1904    pub fn render(&self, stored: &serde_json::Value, shell: &crate::Shell) -> crate::html::Markup {
1905        let template = ValueFieldsTemplate {
1906            fields: self.views(stored, shell),
1907        };
1908        crate::html::Markup::from_template(&template).unwrap_or_default()
1909    }
1910
1911    /// A submission as the stored object: every declared key, each value
1912    /// through its field type. A type that refuses its input refuses the whole.
1913    pub fn collect(&self, data: &HashMap<String, String>) -> Result<serde_json::Value, Text> {
1914        value_collect(&self.fields, &self.types, data)
1915    }
1916}
1917
1918/// Each of `fields` over `stored`, rendered by its type with overrides scoped
1919/// to `scope`.
1920pub(crate) fn value_views(
1921    fields: &[FormField],
1922    types: &crate::field::FieldRegistry,
1923    overrides: &dyn crate::field::OverrideResolver,
1924    stored: &serde_json::Value,
1925    shell: &crate::Shell,
1926    scope: &str,
1927) -> Vec<ValueFieldView> {
1928    fields
1929        .iter()
1930        .map(|f| {
1931            let label = shell.tt(&f.label);
1932            let control = match types.get(&f.field_type) {
1933                Some(ft) => {
1934                    let opts = ft
1935                        .resolve_options(&f.options, types)
1936                        .unwrap_or_else(|_| ResolvedOptions::none());
1937                    let value = match stored.get(&f.name) {
1938                        Some(serde_json::Value::Array(rows)) => {
1939                            FieldValue::Json(serde_json::Value::Array(rows.clone()))
1940                        }
1941                        Some(serde_json::Value::String(text)) => {
1942                            FieldValue::Text(ft.to_control(Some(text), &opts))
1943                        }
1944                        Some(serde_json::Value::Null) | None => {
1945                            FieldValue::Text(ft.to_control(None, &opts))
1946                        }
1947                        Some(other) => {
1948                            FieldValue::Text(ft.to_control(Some(&other.to_string()), &opts))
1949                        }
1950                    };
1951                    let cx = FieldCx {
1952                        name: &f.name,
1953                        id: &f.name,
1954                        label: &label,
1955                        value: &value,
1956                        required: false,
1957                        max_length: max_length(&f.rules),
1958                        opts: &opts,
1959                        base: &shell.base,
1960                        i18n: shell.i18n(),
1961                        icons: crate::icons::Icons::new(&shell.icon_set, &shell.sprite_url),
1962                    };
1963                    let scope = OverrideScope {
1964                        surface: Surface::Field,
1965                        view_key: &f.field_type,
1966                        resource: Some(scope),
1967                        field: Some(&f.name),
1968                    };
1969                    render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
1970                }
1971                None => String::new(),
1972            };
1973            ValueFieldView {
1974                label,
1975                id: f.name.clone(),
1976                help: f.help.as_ref().map(|h| shell.tt(h)),
1977                control,
1978                layout: layout_classes(f.span, f.break_row),
1979                attrs: behaviour_attrs(f),
1980            }
1981        })
1982        .collect()
1983}
1984
1985/// A submission over `fields` as a JSON object with every declared key.
1986pub(crate) fn value_collect(
1987    fields: &[FormField],
1988    types: &crate::field::FieldRegistry,
1989    data: &HashMap<String, String>,
1990) -> Result<serde_json::Value, Text> {
1991    let mut object = serde_json::Map::new();
1992    for field in fields {
1993        let Some(field_type) = types.get(&field.field_type) else {
1994            return Err(t!("This screen uses a field type that is not registered."));
1995        };
1996        let opts = field_type
1997            .resolve_options(&field.options, types)
1998            .map_err(|_| t!("This screen has a malformed field."))?;
1999        let submitted = crate::field::SubmittedField::new(&field.name, data);
2000        let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
2001            Ok(Some(value)) => attr_to_json(value),
2002            Ok(None) => serde_json::Value::String(String::new()),
2003            Err(_) => return Err(t!("Check the values and try again.")),
2004        };
2005        object.insert(field.name.clone(), value);
2006    }
2007    Ok(serde_json::Value::Object(object))
2008}
2009
2010/// A field type's attribute value as settings JSON stores it.
2011pub(crate) fn attr_to_json(value: laterite_core::AttrValue) -> serde_json::Value {
2012    use laterite_core::AttrValue;
2013    match value {
2014        AttrValue::Null => serde_json::Value::String(String::new()),
2015        AttrValue::Bool(b) => serde_json::Value::Bool(b),
2016        AttrValue::Int(i) => serde_json::Value::from(i),
2017        AttrValue::Float(f) => serde_json::Value::from(f),
2018        AttrValue::Json(j) => j,
2019        other => serde_json::Value::String(other.to_text().unwrap_or_default()),
2020    }
2021}
2022
2023#[cfg(test)]
2024mod tests {
2025    use super::*;
2026    use axum::http::StatusCode;
2027    use laterite_core::strata::{
2028        async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
2029    };
2030    use laterite_core::testing::{connect_test, TestGuard};
2031    use laterite_core::Db;
2032    use laterite_core::SaveCx;
2033
2034    /// A minimal table for exercising the generic insert/update path in isolation,
2035    /// defined as a portable migration so the test runs on any backend.
2036    struct CreateSamples;
2037    #[async_trait(?Send)]
2038    impl Migration for CreateSamples {
2039        fn name(&self) -> &str {
2040            "0001_create_samples"
2041        }
2042        async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
2043            s.exec(
2044                Table::create()
2045                    .table(Alias::new("samples"))
2046                    .if_not_exists()
2047                    .col(
2048                        ColumnDef::new(Alias::new("id"))
2049                            .big_integer()
2050                            .not_null()
2051                            .auto_increment()
2052                            .primary_key(),
2053                    )
2054                    .col(ColumnDef::new(Alias::new("code")).text().not_null())
2055                    .col(ColumnDef::new(Alias::new("name")).text().not_null())
2056                    // Nullable, so a form that does not opt into timestamps
2057                    // still inserts.
2058                    .col(ColumnDef::new(Alias::new("created_at")).text())
2059                    .col(ColumnDef::new(Alias::new("updated_at")).text())
2060                    .to_owned(),
2061            )
2062            .await
2063        }
2064    }
2065
2066    fn config() -> PreparedForm {
2067        let config = FormConfig {
2068            enter: Default::default(),
2069            confirm_leave: true,
2070            focus: Default::default(),
2071            entity: "samples".to_string(),
2072            title: "Sample".into(),
2073            base_path: "/admin/samples".to_string(),
2074            id_field: "id".to_string(),
2075            fields: vec![
2076                FormField::text("code", "Code").required().unique(),
2077                FormField::text("name", "Name").required(),
2078            ],
2079            persist: None,
2080            timestamps: false,
2081            done_path: None,
2082        };
2083        PreparedForm::prepare(
2084            config,
2085            &crate::field::builtin_registry(),
2086            &PersisterRegistry::new(),
2087            &[],
2088        )
2089        .unwrap()
2090    }
2091
2092    /// The whole delete path against a real table: the row is read, the
2093    /// listeners run, the row goes, and the after-stage sees what went.
2094    #[tokio::test]
2095    async fn a_delete_removes_the_row_through_the_pipeline() {
2096        use crate::persist::{delete, DefaultPersister, DeleteRequest};
2097
2098        let (db, _guard) = test_db().await;
2099        let st = state(db.clone());
2100        let form = config();
2101
2102        let mut data = HashMap::new();
2103        data.insert("code".to_string(), "c1".to_string());
2104        data.insert("name".to_string(), "Chair".to_string());
2105        create(
2106            &st,
2107            &form,
2108            data,
2109            crate::Shell::test(),
2110            &crate::audit::test_actor(),
2111            &crate::session::SessionHandle::from_blob(None),
2112            &axum::http::HeaderMap::new(),
2113        )
2114        .await;
2115        assert_eq!(
2116            fetch_text(&db, "name", "c1").await.as_deref(),
2117            Some("Chair")
2118        );
2119
2120        let id = row_id(&db, "c1").await;
2121        let persister = DefaultPersister::from_config(&form.config);
2122        let rec = delete(DeleteRequest {
2123            db: &db,
2124            cache: None,
2125            listeners: &[],
2126            persister: &persister,
2127            actor: &laterite_core::Actor::system("test"),
2128            entity: "samples",
2129            id: &id,
2130        })
2131        .await
2132        .expect("deleted");
2133
2134        // Gone from the table, and the caller learns what it removed.
2135        assert_eq!(fetch_text(&db, "name", "c1").await, None);
2136        assert_eq!(rec.text("code"), Some("c1"));
2137    }
2138
2139    /// The stored id for a row, as text, the shape the delete path takes.
2140    async fn row_id(db: &Db, code: &str) -> String {
2141        let stmt = Query::select()
2142            .expr_as(
2143                Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
2144                Alias::new("v"),
2145            )
2146            .from(Alias::new("samples"))
2147            .and_where(Expr::col(Alias::new("code")).eq(code))
2148            .to_owned();
2149        let (sql, values) = laterite_core::query::build(db.backend, stmt);
2150        let row = bind_values(sqlx::query(&sql), values)
2151            .fetch_one(&db.pool)
2152            .await
2153            .unwrap();
2154        row.get_text("v").unwrap()
2155    }
2156
2157    pub(super) fn state(db: Db) -> AdminState {
2158        AdminState::new(
2159            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2160            db,
2161        )
2162    }
2163
2164    /// A fresh test database holding a minimal `samples` table, on whichever
2165    /// backend the run targets. Hold the returned guard for the test's lifetime.
2166    pub(super) async fn test_db() -> (Db, TestGuard) {
2167        let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
2168        connect_test(&[samples]).await
2169    }
2170
2171    /// Reads a single text column from the one row matching `code`, so a test can
2172    /// assert what was persisted without depending on the read path under test.
2173    pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
2174        let stmt = Query::select()
2175            .expr_as(
2176                Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
2177                Alias::new("v"),
2178            )
2179            .from(Alias::new("samples"))
2180            .and_where(Expr::col(Alias::new("code")).eq(code))
2181            .to_owned();
2182        let (sql, values) = to_sql(db.backend, stmt);
2183        let row = bind_values(sqlx::query(&sql), values)
2184            .fetch_optional(&db.pool)
2185            .await
2186            .unwrap()?;
2187        row.get_text_opt("v").ok().flatten()
2188    }
2189
2190    async fn body_of(resp: Response) -> String {
2191        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2192            .await
2193            .unwrap();
2194        String::from_utf8(bytes.to_vec()).unwrap()
2195    }
2196
2197    async fn count(db: &Db) -> i64 {
2198        sqlx::query_scalar("select count(*) from samples")
2199            .fetch_one(&db.pool)
2200            .await
2201            .unwrap()
2202    }
2203
2204    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2205        pairs
2206            .iter()
2207            .map(|(k, v)| (k.to_string(), v.to_string()))
2208            .collect()
2209    }
2210
2211    /// The header htmx sets on every request it makes.
2212    pub(super) fn htmx_headers() -> axum::http::HeaderMap {
2213        let mut h = axum::http::HeaderMap::new();
2214        h.insert("hx-request", "true".parse().unwrap());
2215        h
2216    }
2217
2218    #[tokio::test]
2219    async fn create_then_fetch() {
2220        let (db, _guard) = test_db().await;
2221        let cfg = config();
2222        let st = state(db.clone());
2223
2224        let resp = create(
2225            &st,
2226            &cfg,
2227            data(&[("code", "editor"), ("name", "Content Editor")]),
2228            crate::Shell::test(),
2229            &crate::audit::test_actor(),
2230            &crate::session::SessionHandle::from_blob(None),
2231            &axum::http::HeaderMap::new(),
2232        )
2233        .await;
2234        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2235
2236        assert_eq!(
2237            fetch_text(&db, "name", "editor").await.as_deref(),
2238            Some("Content Editor")
2239        );
2240    }
2241
2242    #[tokio::test]
2243    async fn update_changes_the_row() {
2244        let (db, _guard) = test_db().await;
2245        let cfg = config();
2246        let st = state(db.clone());
2247        create(
2248            &st,
2249            &cfg,
2250            data(&[("code", "editor"), ("name", "Editor")]),
2251            crate::Shell::test(),
2252            &crate::audit::test_actor(),
2253            &crate::session::SessionHandle::from_blob(None),
2254            &axum::http::HeaderMap::new(),
2255        )
2256        .await;
2257
2258        let id = fetch_text(&db, "id", "editor")
2259            .await
2260            .expect("row should exist after create");
2261
2262        // The code is unchanged, so its unique rule must ignore the edited row.
2263        let resp = update(
2264            &st,
2265            &cfg,
2266            id,
2267            data(&[("code", "editor"), ("name", "Senior Editor")]),
2268            crate::Shell::test(),
2269            &crate::audit::test_actor(),
2270            &crate::session::SessionHandle::from_blob(None),
2271            &axum::http::HeaderMap::new(),
2272        )
2273        .await;
2274        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2275
2276        assert_eq!(
2277            fetch_text(&db, "name", "editor").await.as_deref(),
2278            Some("Senior Editor")
2279        );
2280    }
2281
2282    #[tokio::test]
2283    async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
2284        let (db, _guard) = test_db().await;
2285        let cfg = config();
2286        let st = state(db.clone());
2287
2288        let resp = create(
2289            &st,
2290            &cfg,
2291            data(&[("code", ""), ("name", "No Code")]),
2292            crate::Shell::test(),
2293            &crate::audit::test_actor(),
2294            &crate::session::SessionHandle::from_blob(None),
2295            &axum::http::HeaderMap::new(),
2296        )
2297        .await;
2298        // Re-renders the form (200), does not redirect.
2299        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2300        assert_eq!(count(&db).await, 0);
2301        // The per-field message is rendered.
2302        assert!(body_of(resp).await.contains("Code is required."));
2303    }
2304
2305    #[tokio::test]
2306    async fn create_rejects_a_duplicate_unique_value() {
2307        let (db, _guard) = test_db().await;
2308        let cfg = config();
2309        let st = state(db.clone());
2310        create(
2311            &st,
2312            &cfg,
2313            data(&[("code", "editor"), ("name", "Editor")]),
2314            crate::Shell::test(),
2315            &crate::audit::test_actor(),
2316            &crate::session::SessionHandle::from_blob(None),
2317            &axum::http::HeaderMap::new(),
2318        )
2319        .await;
2320
2321        // A second row with the same code is refused by the unique rule.
2322        let resp = create(
2323            &st,
2324            &cfg,
2325            data(&[("code", "editor"), ("name", "Other")]),
2326            crate::Shell::test(),
2327            &crate::audit::test_actor(),
2328            &crate::session::SessionHandle::from_blob(None),
2329            &axum::http::HeaderMap::new(),
2330        )
2331        .await;
2332        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2333        assert_eq!(count(&db).await, 1);
2334        assert!(body_of(resp).await.contains("already taken"));
2335    }
2336
2337    #[tokio::test]
2338    async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
2339        let (db, _guard) = test_db().await;
2340        let st = state(db.clone());
2341        // The `name` column is a text field with the `email` input, which
2342        // contributes Rule::Email without the descriptor listing it.
2343        let cfg = PreparedForm::prepare(
2344            FormConfig {
2345                enter: Default::default(),
2346                confirm_leave: true,
2347                focus: Default::default(),
2348                entity: "samples".to_string(),
2349                title: "Sample".into(),
2350                base_path: "/admin/samples".to_string(),
2351                id_field: "id".to_string(),
2352                fields: vec![
2353                    FormField::text("code", "Code").required(),
2354                    FormField::of("name", "Email", "text")
2355                        .options(serde_json::json!({ "input": "email" }))
2356                        .required(),
2357                ],
2358                persist: None,
2359                timestamps: false,
2360                done_path: None,
2361            },
2362            &st.field_types,
2363            &PersisterRegistry::new(),
2364            &[],
2365        )
2366        .unwrap();
2367
2368        let resp = create(
2369            &st,
2370            &cfg,
2371            data(&[("code", "c1"), ("name", "nope")]),
2372            crate::Shell::test(),
2373            &crate::audit::test_actor(),
2374            &crate::session::SessionHandle::from_blob(None),
2375            &axum::http::HeaderMap::new(),
2376        )
2377        .await;
2378        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2379        assert_eq!(count(&db).await, 0);
2380
2381        let resp = create(
2382            &st,
2383            &cfg,
2384            data(&[("code", "c1"), ("name", "a@b.test")]),
2385            crate::Shell::test(),
2386            &crate::audit::test_actor(),
2387            &crate::session::SessionHandle::from_blob(None),
2388            &axum::http::HeaderMap::new(),
2389        )
2390        .await;
2391        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2392        assert_eq!(count(&db).await, 1);
2393    }
2394
2395    #[test]
2396    fn prepare_rejects_an_unregistered_field_type() {
2397        let config = FormConfig {
2398            enter: Default::default(),
2399            confirm_leave: true,
2400            focus: Default::default(),
2401            entity: "samples".to_string(),
2402            title: "Sample".into(),
2403            base_path: "/admin/samples".to_string(),
2404            id_field: "id".to_string(),
2405            fields: vec![FormField::of("place", "Place", "no.such.type")],
2406            persist: None,
2407            timestamps: false,
2408            done_path: None,
2409        };
2410        let Err(err) = PreparedForm::prepare(
2411            config,
2412            &crate::field::builtin_registry(),
2413            &PersisterRegistry::new(),
2414            &[],
2415        ) else {
2416            panic!("expected prepare to reject an unregistered type");
2417        };
2418        assert!(err.contains("no.such.type"), "{err}");
2419    }
2420
2421    #[test]
2422    fn prepare_rejects_a_malformed_option() {
2423        // The text field's `input` option is a string; a wrong JSON type for it
2424        // aborts prepare naming the field.
2425        let config = FormConfig {
2426            enter: Default::default(),
2427            confirm_leave: true,
2428            focus: Default::default(),
2429            entity: "samples".to_string(),
2430            title: "Sample".into(),
2431            base_path: "/admin/samples".to_string(),
2432            id_field: "id".to_string(),
2433            fields: vec![
2434                FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
2435            ],
2436            persist: None,
2437            timestamps: false,
2438            done_path: None,
2439        };
2440        let Err(err) = PreparedForm::prepare(
2441            config,
2442            &crate::field::builtin_registry(),
2443            &PersisterRegistry::new(),
2444            &[],
2445        ) else {
2446            panic!("expected prepare to reject a malformed option");
2447        };
2448        assert!(err.contains("`email`"), "{err}");
2449    }
2450
2451    /// A persister that inserts a row then fails. The pipeline owns the
2452    /// transaction, so the insert must roll back with it.
2453    struct RollbackPersister;
2454    #[laterite_core::strata::async_trait]
2455    impl Persister for RollbackPersister {
2456        async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
2457            let backend = cx.backend();
2458            let insert = Query::insert()
2459                .into_table(Alias::new("samples"))
2460                .columns([Alias::new("code"), Alias::new("name")])
2461                .values_panic(["rolled".into(), "back".into()])
2462                .to_owned();
2463            let (sql, values) = to_sql(backend, insert);
2464            bind_values(sqlx::query(&sql), values)
2465                .execute(cx.conn())
2466                .await
2467                .map_err(|e| SaveError::Failed(e.to_string()))?;
2468            Err(SaveError::Failed("deliberate".to_string()))
2469        }
2470        async fn update(
2471            &self,
2472            _cx: &mut SaveCx<'_>,
2473            _id: &str,
2474            _rec: &Record,
2475        ) -> Result<(), SaveError> {
2476            Ok(())
2477        }
2478    }
2479
2480    /// A persister that always rejects create with a per-field validation error.
2481    struct RejectingPersister;
2482    #[laterite_core::strata::async_trait]
2483    impl Persister for RejectingPersister {
2484        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
2485            let mut bag = ErrorBag::default();
2486            bag.add("code", t!("Code is not allowed here."));
2487            Err(SaveError::Invalid(bag))
2488        }
2489        async fn update(
2490            &self,
2491            _cx: &mut SaveCx<'_>,
2492            _id: &str,
2493            _rec: &Record,
2494        ) -> Result<(), SaveError> {
2495            Ok(())
2496        }
2497    }
2498
2499    fn config_with(persister: &str) -> FormConfig {
2500        FormConfig {
2501            enter: Default::default(),
2502            confirm_leave: true,
2503            focus: Default::default(),
2504            entity: "samples".to_string(),
2505            title: "Sample".into(),
2506            base_path: "/admin/samples".to_string(),
2507            id_field: "id".to_string(),
2508            fields: vec![
2509                FormField::text("code", "Code").required(),
2510                FormField::text("name", "Name").required(),
2511            ],
2512            persist: Some(persister.to_string()),
2513            timestamps: false,
2514            done_path: None,
2515        }
2516    }
2517
2518    #[test]
2519    fn prepare_rejects_an_unregistered_persister() {
2520        let Err(err) = PreparedForm::prepare(
2521            config_with("no.such.persister"),
2522            &crate::field::builtin_registry(),
2523            &PersisterRegistry::new(),
2524            &[],
2525        ) else {
2526            panic!("expected prepare to reject an unregistered persister");
2527        };
2528        assert!(err.contains("no.such.persister"), "{err}");
2529    }
2530
2531    #[tokio::test]
2532    async fn a_persister_transaction_rolls_back_on_error() {
2533        let (db, _guard) = test_db().await;
2534        let st = state(db.clone());
2535        let mut persisters = PersisterRegistry::new();
2536        persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
2537        let form = PreparedForm::prepare(
2538            config_with("test.rollback"),
2539            &st.field_types,
2540            &persisters,
2541            &[],
2542        )
2543        .unwrap();
2544
2545        let resp = create(
2546            &st,
2547            &form,
2548            data(&[("code", "rolled"), ("name", "back")]),
2549            crate::Shell::test(),
2550            &crate::audit::test_actor(),
2551            &crate::session::SessionHandle::from_blob(None),
2552            &axum::http::HeaderMap::new(),
2553        )
2554        .await;
2555        // The persister failed, so the form re-renders (200) and its in-tx insert
2556        // rolled back: no row landed.
2557        assert_eq!(resp.status(), StatusCode::OK);
2558        assert_eq!(count(&db).await, 0);
2559    }
2560
2561    #[tokio::test]
2562    async fn a_persister_invalid_error_re_renders_422() {
2563        let (db, _guard) = test_db().await;
2564        let st = state(db.clone());
2565        let mut persisters = PersisterRegistry::new();
2566        persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
2567        let form = PreparedForm::prepare(
2568            config_with("test.reject"),
2569            &st.field_types,
2570            &persisters,
2571            &[],
2572        )
2573        .unwrap();
2574
2575        let resp = create(
2576            &st,
2577            &form,
2578            data(&[("code", "editor"), ("name", "Editor")]),
2579            crate::Shell::test(),
2580            &crate::audit::test_actor(),
2581            &crate::session::SessionHandle::from_blob(None),
2582            &axum::http::HeaderMap::new(),
2583        )
2584        .await;
2585        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2586        assert!(body_of(resp).await.contains("Code is not allowed here."));
2587    }
2588}
2589
2590#[cfg(test)]
2591mod timestamp_tests {
2592    use super::tests::*;
2593    use super::*;
2594    use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
2595    use laterite_core::Actor;
2596
2597    fn timestamped(on: bool) -> FormConfig {
2598        FormConfig {
2599            enter: Default::default(),
2600            confirm_leave: true,
2601            focus: Default::default(),
2602            entity: "samples".to_string(),
2603            title: "Sample".into(),
2604            base_path: "/admin/samples".to_string(),
2605            id_field: "id".to_string(),
2606            fields: vec![
2607                FormField::text("code", "Code").required(),
2608                FormField::text("name", "Name").required(),
2609            ],
2610            persist: None,
2611            timestamps: on,
2612            done_path: None,
2613        }
2614    }
2615
2616    async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
2617        let form = PreparedForm::prepare(
2618            timestamped(on),
2619            &crate::field::builtin_registry(),
2620            &PersisterRegistry::new(),
2621            &[],
2622        )
2623        .unwrap();
2624        let mut data = HashMap::new();
2625        data.insert("code".to_string(), "c1".to_string());
2626        data.insert("name".to_string(), "Chair".to_string());
2627        let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
2628        let actor = Actor::system("test");
2629        persist::save(
2630            persist::SaveRequest {
2631                db,
2632                cache: None,
2633                listeners: &form.listeners,
2634                persister: form.persister.as_ref(),
2635                actor: &actor,
2636                op: Op::Create,
2637                id: None,
2638            },
2639            &mut rec,
2640        )
2641        .await
2642        .expect("the insert should succeed");
2643        rec
2644    }
2645
2646    #[tokio::test]
2647    async fn opting_in_stamps_the_row() {
2648        let (db, _guard) = test_db().await;
2649        let rec = create_through_pipeline(&db, true).await;
2650        assert!(rec.datetime(CREATED_AT).is_some());
2651        assert!(rec.datetime(UPDATED_AT).is_some());
2652        // The stamp reached the table, not just the record.
2653        assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2654    }
2655
2656    #[tokio::test]
2657    async fn leaving_it_off_writes_no_timestamps() {
2658        let (db, _guard) = test_db().await;
2659        let rec = create_through_pipeline(&db, false).await;
2660        assert!(rec.get(CREATED_AT).is_none());
2661        assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2662    }
2663}
2664
2665#[cfg(test)]
2666mod htmx_tests {
2667    use super::tests::*;
2668    use super::*;
2669    use axum::http::StatusCode;
2670
2671    /// A submission missing a required field, so validation refuses it.
2672    fn invalid() -> HashMap<String, String> {
2673        let mut d = HashMap::new();
2674        d.insert("code".to_string(), String::new());
2675        d.insert("name".to_string(), "No code".to_string());
2676        d
2677    }
2678
2679    fn valid() -> HashMap<String, String> {
2680        let mut d = HashMap::new();
2681        d.insert("code".to_string(), "c1".to_string());
2682        d.insert("name".to_string(), "Chair".to_string());
2683        d
2684    }
2685
2686    async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2687        let (db, _guard) = test_db().await;
2688        let st = state(db);
2689        let form = PreparedForm::prepare(
2690            FormConfig {
2691                enter: Default::default(),
2692                confirm_leave: true,
2693                focus: Default::default(),
2694                entity: "samples".to_string(),
2695                title: "Sample".into(),
2696                base_path: "/admin/samples".to_string(),
2697                id_field: "id".to_string(),
2698                fields: vec![
2699                    FormField::text("code", "Code").required(),
2700                    FormField::text("name", "Name").required(),
2701                ],
2702                persist: None,
2703                timestamps: false,
2704                done_path: None,
2705            },
2706            &st.field_types,
2707            &PersisterRegistry::new(),
2708            &[],
2709        )
2710        .unwrap();
2711        create(
2712            &st,
2713            &form,
2714            data,
2715            crate::Shell::test(),
2716            &crate::audit::test_actor(),
2717            &crate::session::SessionHandle::from_blob(None),
2718            &headers,
2719        )
2720        .await
2721    }
2722
2723    async fn body_of(resp: Response) -> String {
2724        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2725            .await
2726            .unwrap();
2727        String::from_utf8(bytes.to_vec()).unwrap()
2728    }
2729
2730    #[tokio::test]
2731    async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2732        let resp = post(invalid(), htmx_headers()).await;
2733        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2734        let html = body_of(resp).await;
2735        assert!(html.contains("<form"), "the form comes back");
2736        assert!(html.contains("lat-field__error"), "with its errors");
2737        // A fragment, not a page: swapping a whole document into the form would
2738        // nest the admin inside itself.
2739        assert!(!html.contains("<body"), "no page chrome");
2740    }
2741
2742    #[tokio::test]
2743    async fn a_plain_submit_that_fails_returns_the_whole_page() {
2744        let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2745        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2746        let html = body_of(resp).await;
2747        assert!(html.contains("<body"), "the admin still works without htmx");
2748        assert!(html.contains("lat-field__error"));
2749    }
2750
2751    #[tokio::test]
2752    async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2753        let resp = post(valid(), htmx_headers()).await;
2754        // htmx cannot follow a 303 usefully; it reads this header instead.
2755        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2756        assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2757    }
2758
2759    #[tokio::test]
2760    async fn a_plain_submit_that_succeeds_still_redirects() {
2761        let resp = post(valid(), axum::http::HeaderMap::new()).await;
2762        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2763    }
2764}