Skip to main content

laterite_admin/
field.rs

1//! The open field-type registry: the framework and plugins add form field types.
2//!
3//! A field descriptor ([`crate::form::FormField`]) is serde data (name, label,
4//! type key, options, rules); a field type is behaviour keyed by that type.
5//! Rendering splits so an override can present the same data the built-in does:
6//! [`FieldType::view_model`] builds a serde [`FieldVm`], [`FieldType::render_default`]
7//! is the Askama presenter over it, and an [`OverrideResolver`] (default
8//! [`NoOverrides`]) may swap in a runtime template. Load/parse/save/assets/action
9//! join as defaulted methods when a type first needs them (backward-compatible).
10
11use std::any::Any;
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use askama::Template;
16use laterite_core::validation::{Mode, Rule};
17use laterite_core::{AttrValue, Translator};
18use serde::{Deserialize, Serialize};
19
20use crate::html::Markup;
21use crate::picker::PickerRegistry;
22
23/// A field's value, general enough for scalar and (later) structured types.
24/// Serde so it rides in the view-model and a failed-validation re-render.
25#[derive(Debug, Clone, Serialize)]
26#[serde(untagged)]
27pub enum FieldValue {
28    Null,
29    Text(String),
30    Json(serde_json::Value),
31}
32
33impl FieldValue {
34    /// The value as text for a scalar field (empty for null or non-text).
35    pub fn as_text(&self) -> &str {
36        match self {
37            FieldValue::Text(s) => s,
38            _ => "",
39        }
40    }
41}
42
43/// Per-type options, resolved once at boot from the descriptor's options blob
44/// into a typed value cached for rendering (the text field resolves its input
45/// variant, select its option list; textarea carries none).
46#[derive(Default)]
47pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
48
49impl ResolvedOptions {
50    /// No options (the default for optionless types).
51    pub fn none() -> Self {
52        Self(None)
53    }
54    /// Wraps a resolved typed options value.
55    pub fn new<T: Any + Send + Sync>(value: T) -> Self {
56        Self(Some(Arc::new(value)))
57    }
58    /// Borrows the typed options, if present and of type `T`.
59    pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
60        self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
61    }
62}
63
64/// Whether the framework wraps a field in standard chrome (label, required
65/// marker, error list) or the type renders bare (a hidden field, a heading).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Chrome {
68    Wrapped,
69    Bare,
70}
71
72/// The resolved per-field state a field type renders from.
73#[non_exhaustive]
74pub struct FieldCx<'a> {
75    /// The submitted/DOM field name (becomes a path with repeaters).
76    pub name: &'a str,
77    /// A DOM-safe id for the control and its label's `for`.
78    pub id: &'a str,
79    pub label: &'a str,
80    pub value: &'a FieldValue,
81    /// Derived from the merged (intrinsic + descriptor) rules.
82    pub required: bool,
83    pub opts: &'a ResolvedOptions,
84    /// The admin mount path (e.g. `/admin`), so a type that calls an endpoint
85    /// (the reference picker) builds its URL without knowing routing conventions.
86    pub base: &'a str,
87    /// The request's translator, for the strings a field type localizes itself.
88    ///
89    /// `label` above is already localized by the caller, because the framework
90    /// chrome renders it too. A composite type is the case this exists for: a
91    /// repeater's sub-field labels are declared inside the type and never pass
92    /// through the caller, so without this they could only render from their
93    /// source string, untranslated on every surface.
94    pub i18n: &'a Translator,
95}
96
97/// The serialisable payload both the built-in template and an override render,
98/// so an override never re-derives data. `data` is per-type data, never HTML.
99#[derive(Debug, Clone, Serialize)]
100pub struct FieldVm {
101    pub view_key: String,
102    pub name: String,
103    pub id: String,
104    pub label: String,
105    pub required: bool,
106    pub value: FieldValue,
107    pub data: serde_json::Value,
108}
109
110/// A field type: behaviour registered once per string key.
111/// What a field sees of the submission when it produces its stored value.
112///
113/// A scalar field wants [`value`](Self::value), the single entry under its own
114/// name. A field made of more than one control (a repeater, a checkbox list)
115/// reads its own keys with [`nested`](Self::nested): the whole submission is
116/// here because a field's shape is the field's business, not the form's.
117pub struct SubmittedField<'a> {
118    name: &'a str,
119    data: &'a HashMap<String, String>,
120}
121
122impl<'a> SubmittedField<'a> {
123    pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
124        Self { name, data }
125    }
126
127    /// This field's submitted name, which a multi-value field uses as the prefix
128    /// of its own keys.
129    pub fn name(&self) -> &str {
130        self.name
131    }
132
133    /// The value submitted under this field's own name.
134    ///
135    /// `None` when the submission omits the field, which is how an unchecked
136    /// checkbox arrives.
137    pub fn value(&self) -> Option<&str> {
138        self.data.get(self.name).map(String::as_str)
139    }
140
141    /// Every submitted key beginning `name[`, as its remainder and value.
142    ///
143    /// `rules[0][path]` on a field named `rules` yields `("0][path]", ..)`; the
144    /// field parses the remainder however its own encoding says.
145    pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
146        let prefix = format!("{}[", self.name);
147        self.data.iter().filter_map(move |(key, value)| {
148            key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
149        })
150    }
151
152    /// The whole submission, for a field whose encoding the helpers above do not
153    /// cover.
154    pub fn all(&self) -> &'a HashMap<String, String> {
155        self.data
156    }
157}
158
159pub trait FieldType: Send + Sync + 'static {
160    /// The stable key for override resolution and default-template selection
161    /// (bare like `text` for core; dotted `vendor.name` for plugins).
162    fn view_key(&self) -> &'static str;
163
164    /// Types the descriptor's raw options blob once at boot. A failure aborts
165    /// boot naming the resource + field. Default: no options.
166    ///
167    /// `types` is the field registry, for a composite field (a repeater) that
168    /// holds fields of its own and has to resolve their types and options here,
169    /// while the registry is in hand.
170    fn resolve_options(
171        &self,
172        _raw: &serde_json::Value,
173        _types: &FieldRegistry,
174    ) -> Result<ResolvedOptions, OptionsError> {
175        Ok(ResolvedOptions::none())
176    }
177    /// The keys this type reads from a field's entry, beyond the ones every
178    /// field has (`rows` for a textarea, `options` for a select). A key outside
179    /// the list is refused when the form is prepared, naming the field and the
180    /// keys accepted, so a typo never passes silently. `None` accepts any key
181    /// unchecked; declare yours.
182    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
183        None
184    }
185
186    /// Rules this type always contributes, merged before the descriptor's own
187    /// (e.g. the text field's `email` input contributes [`Rule::Email`]).
188    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
189        Vec::new()
190    }
191
192    /// Builds the serialisable presentation payload. Pure, no IO.
193    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
194
195    /// The built-in presenter over the view-model (an Askama partial). Not
196    /// called when an override wins.
197    fn render_default(&self, vm: &FieldVm) -> Markup;
198
199    /// How a submitted value becomes the attribute that gets stored.
200    ///
201    /// `raw` is `None` when the submission omits the field entirely, which is
202    /// how an unchecked checkbox arrives. Returning `None` leaves the attribute
203    /// out of the write, so the column keeps whatever it held: that is how a
204    /// blank password on an edit means "unchanged" rather than "erase".
205    ///
206    /// Validation has already run, so a type that needs its input to parse
207    /// should guarantee that with an [`intrinsic_rules`](FieldType::intrinsic_rules)
208    /// entry and treat the value here as sound. The error is for a genuine
209    /// failure (hashing), not a bad value, and surfaces as a failed save.
210    ///
211    /// Default: the submitted text, unchanged.
212    fn to_attr(
213        &self,
214        field: &SubmittedField<'_>,
215        opts: &ResolvedOptions,
216        mode: Mode,
217    ) -> Result<Option<AttrValue>, String> {
218        let _ = (opts, mode);
219        Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
220    }
221
222    /// How a stored value is presented back in the control on an edit.
223    ///
224    /// The counterpart of [`to_attr`](FieldType::to_attr): storage and
225    /// presentation differ whenever a type stores something other than the text
226    /// the browser sent, and a type that never shows its value (a password)
227    /// returns the empty string here.
228    ///
229    /// Default: the stored text, unchanged.
230    fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
231        let _ = opts;
232        stored.unwrap_or_default().to_string()
233    }
234
235    /// What a field of several controls submitted, as the one value its control
236    /// is rebuilt from.
237    ///
238    /// A form holds one value a field, and a checklist submits a key for every
239    /// box ticked and none under its own name. Gathered here into one value, the
240    /// rules see the field, so `required` means a box is ticked, and a refused
241    /// save comes back with the operator's ticks in place. `None` when the
242    /// submission holds nothing of this field.
243    ///
244    /// Default: the value under the field's own name.
245    fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
246        let _ = opts;
247        field.value().map(str::to_string)
248    }
249
250    /// Whether the framework wraps this field in standard chrome.
251    fn chrome(&self) -> Chrome {
252        Chrome::Wrapped
253    }
254
255    /// Asset-registry keys this type's control needs on any page that renders it
256    /// (a heavy widget's script/style). The page shell collects, dedupes, and
257    /// emits them; keys must exist in the asset registry. Default: none, so a
258    /// type whose widget ships in core `laterite.js` declares nothing.
259    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
260        Vec::new()
261    }
262}
263
264/// A field type failed to type its options blob at boot.
265#[derive(Debug, thiserror::Error)]
266#[error("invalid field options: {0}")]
267pub struct OptionsError(pub String);
268
269/// The surface being rendered, for override resolution.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum Surface {
272    Field,
273    Column,
274}
275
276/// Where a render is happening, for override resolution (most-specific first:
277/// `resource.field` then `view_key` then the compiled default).
278pub struct OverrideScope<'a> {
279    pub surface: Surface,
280    pub view_key: &'a str,
281    pub resource: Option<&'a str>,
282    pub field: Option<&'a str>,
283}
284
285/// A runtime override failed to render.
286#[derive(Debug, thiserror::Error)]
287#[error("override render failed: {0}")]
288pub struct OverrideError(pub String);
289
290/// The seam a theme/CMS layer backs with a runtime template engine (MiniJinja)
291/// to let users override a field's presentation from outside the plugin.
292/// `laterite-admin` stays engine-agnostic; the default is [`NoOverrides`].
293pub trait OverrideResolver: Send + Sync {
294    /// An override for this view-model if one is registered for the scope, else
295    /// `None` (use the default). The `String` is engine-autoescaped HTML.
296    fn render_override(
297        &self,
298        scope: &OverrideScope<'_>,
299        vm: &serde_json::Value,
300    ) -> Option<Result<String, OverrideError>>;
301}
302
303/// The default resolver: no overrides, so the compiled path always runs at zero
304/// cost. The theme layer injects a real resolver when it is built.
305pub struct NoOverrides;
306
307impl OverrideResolver for NoOverrides {
308    fn render_override(
309        &self,
310        _scope: &OverrideScope<'_>,
311        _vm: &serde_json::Value,
312    ) -> Option<Result<String, OverrideError>> {
313        None
314    }
315}
316
317/// The field-type registry: type key to behaviour, built once at boot.
318pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
319
320/// A field type a module contributes, so a form can offer an input the framework
321/// has no built-in for (a colour picker, a money amount, a map point).
322///
323/// The key is the type's own [`FieldType::view_key`], so a registration cannot
324/// disagree with the type it registers. Use a dotted `vendor.name` to stay clear
325/// of the built-ins and of other modules.
326pub struct FieldTypeReg {
327    pub field_type: Arc<dyn FieldType>,
328}
329
330impl FieldTypeReg {
331    pub fn new(field_type: Arc<dyn FieldType>) -> Self {
332        Self { field_type }
333    }
334
335    /// The key this type registers under.
336    pub fn name(&self) -> &'static str {
337        self.field_type.view_key()
338    }
339}
340
341/// Renders a field: an override if the resolver supplies one, else the compiled
342/// default. The one place the two paths meet.
343pub(crate) fn render_field(
344    ft: &dyn FieldType,
345    resolver: &dyn OverrideResolver,
346    scope: &OverrideScope<'_>,
347    cx: &FieldCx<'_>,
348) -> Markup {
349    let vm = ft.view_model(cx);
350    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
351        Some(Ok(html)) => Markup::from_override(html),
352        // A failing override falls back to the default rather than blank the field.
353        Some(Err(_)) | None => ft.render_default(&vm),
354    }
355}
356
357/// A repeater's typed options: the fields one row holds, each resolved to its
358/// type and its own options at boot.
359pub(crate) struct RepeaterOptions {
360    rows: Vec<RepeaterSub>,
361    min_items: usize,
362    max_items: Option<usize>,
363    display: RepeaterDisplay,
364    /// Which sub-field names a collapsed row. Defaults to the first.
365    summary_field: Option<String>,
366}
367
368/// How a repeater's rows are laid out.
369///
370/// A row of several fields rendered inline is a row of columns, and four of
371/// them is already too many to read. `List` collapses each row to a line naming
372/// it, opening one at a time, which is the shape an operator expects of a list
373/// with an Add button.
374#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
375#[serde(rename_all = "snake_case")]
376pub enum RepeaterDisplay {
377    /// Every row expanded, all fields visible. Right for a single narrow
378    /// column, where collapsing would hide the only thing there is to see.
379    #[default]
380    Inline,
381    /// Rows collapsed to a summary line, expanded one at a time. Adding a row
382    /// opens it.
383    List,
384}
385
386/// One sub-field of a repeater row, resolved once.
387pub(crate) struct RepeaterSub {
388    field: crate::form::FormField,
389    field_type: Arc<dyn FieldType>,
390    opts: ResolvedOptions,
391}
392
393/// The descriptor shape a repeater's `options` blob takes.
394#[derive(Deserialize)]
395struct RepeaterOptionsRaw {
396    /// Written as a map of named fields in a file, a list by the builders.
397    #[serde(deserialize_with = "named_or_listed")]
398    fields: Vec<crate::form::FormField>,
399    #[serde(default)]
400    min_items: usize,
401    #[serde(default)]
402    max_items: Option<usize>,
403    #[serde(default)]
404    display: RepeaterDisplay,
405    #[serde(default)]
406    summary_field: Option<String>,
407}
408
409/// Refuses a key the type does not read, naming the ones it does.
410pub(crate) fn check_option_keys(
411    raw: &serde_json::Value,
412    accepted: &[String],
413) -> Result<(), OptionsError> {
414    let serde_json::Value::Object(entries) = raw else {
415        return Ok(());
416    };
417    for key in entries.keys() {
418        if !accepted.iter().any(|k| k == key) {
419            return Err(OptionsError(if accepted.is_empty() {
420                format!("unknown key `{key}`: this type takes no keys of its own")
421            } else {
422                format!(
423                    "unknown key `{key}`, expected one of: {}",
424                    accepted.join(", ")
425                )
426            }));
427        }
428    }
429    Ok(())
430}
431
432/// The keys a field's type declares, checked against what the field carries.
433/// A type that declares none is left unchecked.
434pub(crate) fn check_field_keys(
435    field: &crate::form::FormField,
436    field_type: &dyn FieldType,
437) -> Result<(), OptionsError> {
438    match field_type.option_keys(&field.options) {
439        Some(keys) => check_option_keys(&field.options, &keys),
440        None => Ok(()),
441    }
442}
443
444fn keys(names: &[&str]) -> Option<Vec<String>> {
445    Some(names.iter().map(|k| k.to_string()).collect())
446}
447
448/// Reads a repeater's sub-fields from either shape: `{ label: {}, url: {} }`,
449/// each told its name by its key, or a list whose entries carry `name`.
450fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
451where
452    D: serde::Deserializer<'de>,
453{
454    use crate::keyed::Keyed;
455    #[derive(Deserialize)]
456    #[serde(untagged)]
457    enum Shape {
458        Listed(Vec<crate::form::FormField>),
459        Named(serde_json::Map<String, serde_json::Value>),
460    }
461    match Shape::deserialize(de)? {
462        Shape::Listed(fields) => Ok(fields),
463        Shape::Named(entries) => entries
464            .into_iter()
465            .map(|(name, entry)| {
466                let mut field: crate::form::FormField =
467                    serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
468                field.set_key(name);
469                Ok(field)
470            })
471            .collect(),
472    }
473}
474
475/// A list of rows, stored as a JSON array of objects.
476///
477/// Row controls are named `field[index][subfield]`. The indices are read back
478/// out of the submitted keys and sorted, so a row removed in the browser leaves
479/// no hole and the page renumbers only for tidiness, never for correctness.
480///
481/// A row's sub-fields are ordinary field types, so a switch inside a row stores
482/// a bool and a date inside a row is a date, exactly as at the top level.
483pub(crate) struct RepeaterField;
484
485impl RepeaterField {
486    /// The placeholder index in the blank row the browser clones.
487    const BLANK: &'static str = "__index__";
488}
489
490impl FieldType for RepeaterField {
491    fn view_key(&self) -> &'static str {
492        "repeater"
493    }
494
495    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
496        keys(&[
497            "fields",
498            "min_items",
499            "max_items",
500            "display",
501            "summary_field",
502        ])
503    }
504
505    fn resolve_options(
506        &self,
507        raw: &serde_json::Value,
508        types: &FieldRegistry,
509    ) -> Result<ResolvedOptions, OptionsError> {
510        let parsed: RepeaterOptionsRaw =
511            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
512        let mut rows = Vec::new();
513        for field in parsed.fields {
514            if field.field_type == "repeater" {
515                return Err(OptionsError(
516                    "a repeater cannot hold a repeater".to_string(),
517                ));
518            }
519            let field_type = types
520                .get(&field.field_type)
521                .cloned()
522                .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
523            check_field_keys(&field, field_type.as_ref())
524                .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
525            let opts = field_type.resolve_options(&field.options, types)?;
526            rows.push(RepeaterSub {
527                field,
528                field_type,
529                opts,
530            });
531        }
532        Ok(ResolvedOptions::new(RepeaterOptions {
533            rows,
534            min_items: parsed.min_items,
535            max_items: parsed.max_items,
536            display: parsed.display,
537            summary_field: parsed.summary_field,
538        }))
539    }
540
541    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
542        let opts = cx.opts.get::<RepeaterOptions>();
543        let stored = match cx.value {
544            FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
545            _ => Vec::new(),
546        };
547        // The cell that names a row: the one the descriptor nominated, else the
548        // first, which is the field an operator reads as the row's identity.
549        let summary_index = opts
550            .and_then(|o| {
551                o.summary_field
552                    .as_ref()
553                    .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
554            })
555            .unwrap_or(0);
556        let summaries = stored
557            .iter()
558            .map(|row| {
559                opts.and_then(|o| o.rows.get(summary_index))
560                    .and_then(|sub| row.get(&sub.field.name))
561                    .map(summary_text)
562                    .unwrap_or_default()
563            })
564            .collect();
565        let data = RepeaterData {
566            rows: opts
567                .map(|o| {
568                    stored
569                        .iter()
570                        .enumerate()
571                        .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
572                        .collect()
573                })
574                .unwrap_or_default(),
575            blank: opts
576                .map(|o| render_row(o, cx, Self::BLANK, None))
577                .unwrap_or_default(),
578            list: opts.is_some_and(|o| o.display == RepeaterDisplay::List),
579            summary_index,
580            summaries,
581        };
582
583        FieldVm {
584            view_key: self.view_key().to_string(),
585            name: cx.name.to_string(),
586            id: cx.id.to_string(),
587            label: cx.label.to_string(),
588            required: cx.required,
589            value: cx.value.clone(),
590            data: serde_json::to_value(data).unwrap_or_default(),
591        }
592    }
593
594    fn render_default(&self, vm: &FieldVm) -> Markup {
595        let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
596        Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
597    }
598
599    fn to_attr(
600        &self,
601        field: &SubmittedField<'_>,
602        opts: &ResolvedOptions,
603        mode: Mode,
604    ) -> Result<Option<AttrValue>, String> {
605        let Some(options) = opts.get::<RepeaterOptions>() else {
606            return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
607        };
608
609        // Indices come from the keys, sorted, so a gap left by a removed row
610        // costs nothing and the client never has to renumber for correctness.
611        let mut indices: Vec<usize> = field
612            .nested()
613            .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
614            .collect();
615        indices.sort_unstable();
616        indices.dedup();
617
618        let mut rows = Vec::new();
619        for index in indices {
620            let mut object = serde_json::Map::new();
621            for sub in &options.rows {
622                let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
623                let scoped = SubmittedField::new(&key, field.all());
624                if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
625                    object.insert(sub.field.name.clone(), attr_to_json(value));
626                }
627            }
628            let row = serde_json::Value::Object(object);
629            // A row the operator added and left empty is not a row.
630            if !is_blank_row(&row) {
631                rows.push(row);
632            }
633        }
634
635        if rows.len() < options.min_items {
636            return Err(format!("at least {} required", options.min_items));
637        }
638        if let Some(max) = options.max_items {
639            if rows.len() > max {
640                return Err(format!("at most {max} allowed"));
641            }
642        }
643        Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
644    }
645
646    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
647        stored.unwrap_or("[]").to_string()
648    }
649}
650
651/// One row's sub-fields, rendered. `row` is `None` for the blank row.
652/// A stored cell rendered as the one line that names its row. Multi-line text
653/// collapses to its first line: a summary that wraps is not a summary.
654fn summary_text(value: &serde_json::Value) -> String {
655    let raw = match value {
656        serde_json::Value::String(s) => s.clone(),
657        serde_json::Value::Null => String::new(),
658        other => other.to_string(),
659    };
660    raw.lines().next().unwrap_or_default().trim().to_string()
661}
662
663fn render_row(
664    options: &RepeaterOptions,
665    cx: &FieldCx<'_>,
666    index: &str,
667    row: Option<&serde_json::Value>,
668) -> Vec<RepeaterCell> {
669    options
670        .rows
671        .iter()
672        .map(|sub| {
673            let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
674            let stored = row.and_then(|r| r.get(&sub.field.name));
675            let value = match stored {
676                Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
677                Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
678                Some(other) => FieldValue::Text(
679                    sub.field_type
680                        .to_control(Some(&other.to_string()), &sub.opts),
681                ),
682            };
683            let sub_cx = FieldCx {
684                name: &name,
685                id: &name,
686                label: cx.label,
687                value: &value,
688                required: false,
689                opts: &sub.opts,
690                base: cx.base,
691                i18n: cx.i18n,
692            };
693            let vm = sub.field_type.view_model(&sub_cx);
694            RepeaterCell {
695                label: cx.i18n.t(&sub.field.label),
696                control: sub.field_type.render_default(&vm).into_string(),
697                help: sub
698                    .field
699                    .help
700                    .as_ref()
701                    .map(|h| cx.i18n.t(h))
702                    .unwrap_or_default(),
703            }
704        })
705        .collect()
706}
707
708/// A stored attribute as the JSON a repeater row holds.
709fn attr_to_json(value: AttrValue) -> serde_json::Value {
710    match value {
711        AttrValue::Null => serde_json::Value::Null,
712        AttrValue::Bool(b) => serde_json::Value::Bool(b),
713        AttrValue::Int(i) => serde_json::Value::from(i),
714        AttrValue::Float(f) => serde_json::Value::from(f),
715        AttrValue::Json(j) => j,
716        other => serde_json::Value::String(other.to_text().unwrap_or_default()),
717    }
718}
719
720/// Whether every value in a row is empty or false.
721fn is_blank_row(row: &serde_json::Value) -> bool {
722    row.as_object().is_none_or(|object| {
723        object.values().all(|v| match v {
724            serde_json::Value::String(s) => s.trim().is_empty(),
725            serde_json::Value::Bool(b) => !b,
726            serde_json::Value::Array(items) => items.is_empty(),
727            serde_json::Value::Null => true,
728            _ => false,
729        })
730    })
731}
732
733/// One rendered cell of a repeater row.
734#[derive(Serialize, Deserialize)]
735struct RepeaterCell {
736    label: String,
737    control: String,
738    /// The sub-field's own help text. A collapsed row shows less context than an
739    /// expanded form, so the line explaining a field earns its place more here,
740    /// not less.
741    help: String,
742}
743
744/// A repeater's presentation payload, typed on the way out and back so the
745/// template reads fields rather than poking at JSON.
746#[derive(Serialize, Deserialize, Default)]
747struct RepeaterData {
748    rows: Vec<Vec<RepeaterCell>>,
749    blank: Vec<RepeaterCell>,
750    /// True when rows collapse to a summary line.
751    list: bool,
752    /// Index of the cell whose value names a collapsed row.
753    summary_index: usize,
754    /// Each row's summary text, parallel to `rows`. Empty when a row's naming
755    /// cell has no value yet, which the template shows as a placeholder.
756    summaries: Vec<String>,
757}
758
759#[derive(Template)]
760#[template(path = "fields/repeater.html")]
761struct RepeaterTmpl {
762    data: RepeaterData,
763}
764
765/// The framework's built-in field types. The text field is constructed with the
766/// built-in input-type registry it delegates to.
767pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
768    let inputs = Arc::new(builtin_input_registry());
769    vec![
770        Arc::new(TextField::new(inputs)),
771        Arc::new(TextareaField),
772        Arc::new(RepeaterField),
773        Arc::new(SelectField),
774        Arc::new(ChecklistField),
775        Arc::new(SwitchField),
776        Arc::new(DateField),
777        Arc::new(PasswordField),
778        Arc::new(RadioField),
779    ]
780}
781
782/// The field registry seeded with the built-in types, keyed by [`FieldType::view_key`].
783pub(crate) fn builtin_registry() -> FieldRegistry {
784    builtin_field_types()
785        .into_iter()
786        .map(|ft| (ft.view_key().to_string(), ft))
787        .collect()
788}
789
790/// Where an adornment sits relative to the input.
791#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum Placement {
794    Leading,
795    Trailing,
796}
797
798/// A control placed beside the input (a copy button, a currency symbol): data in
799/// the view-model, never HTML, so an override re-presents it. The text template
800/// renders it into the input group.
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct Adornment {
803    pub placement: Placement,
804    /// `true` renders a `<button>` (an action); `false` an inert affix `<span>`.
805    pub button: bool,
806    /// Visible text and accessible label.
807    pub label: String,
808    /// The `data-lat-widget` island wiring a button (bare like `copy` for core,
809    /// dotted `vendor.name` for a plugin). `None` for an inert affix.
810    pub widget: Option<String>,
811}
812
813/// An input variant of the text field: its HTML `type`, the rules it contributes,
814/// and how it parameterises the one text control (extra attributes, adjacent
815/// adornments). The extension point a plugin builds on (a currency input, say)
816/// instead of reimplementing a field. An input parameterises the single text
817/// control; anything needing different control markup is a [`FieldType`].
818pub trait InputType: Send + Sync + 'static {
819    /// The registry key naming this input (`text`, `email`, `url`, `currency`).
820    fn key(&self) -> &'static str;
821    /// The HTML `type` attribute.
822    fn html_type(&self) -> &'static str {
823        "text"
824    }
825    /// Types this input's own keys from the field's options blob (the same blob
826    /// the field type reads) once at boot. Default: no options.
827    fn resolve_options(
828        &self,
829        _raw: &serde_json::Value,
830        _types: &FieldRegistry,
831    ) -> Result<ResolvedOptions, OptionsError> {
832        Ok(ResolvedOptions::none())
833    }
834    /// The keys this input reads from the field's entry (`min`, `max`, `step`
835    /// for a number). A key no part of the field reads is refused.
836    fn option_keys(&self) -> Vec<&'static str> {
837        Vec::new()
838    }
839    /// Rules this input contributes (an `email` input adds [`Rule::Email`]).
840    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
841        Vec::new()
842    }
843    /// Extra `<input>` attributes (a number input's `min`/`max`/`step`). Names are
844    /// validated at boot and may not collide with the ones the template owns.
845    fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
846        BTreeMap::new()
847    }
848    /// Controls placed beside the input (a copy button, a currency symbol).
849    fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
850        Vec::new()
851    }
852    /// Asset-registry keys this input needs (a heavy widget's script/style). The
853    /// copy button needs none, since its island ships in core `laterite.js`.
854    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
855        Vec::new()
856    }
857}
858
859/// The registry the text field resolves its `input` option against.
860pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
861
862struct TextInput;
863impl InputType for TextInput {
864    fn key(&self) -> &'static str {
865        "text"
866    }
867}
868
869struct EmailInput;
870impl InputType for EmailInput {
871    fn key(&self) -> &'static str {
872        "email"
873    }
874    fn html_type(&self) -> &'static str {
875        "email"
876    }
877    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
878        vec![Rule::Email]
879    }
880}
881
882struct TelInput;
883impl InputType for TelInput {
884    fn key(&self) -> &'static str {
885        "tel"
886    }
887    fn html_type(&self) -> &'static str {
888        "tel"
889    }
890}
891
892/// A number input's optional bounds and step, emitted as `<input>` attributes.
893#[derive(Default, Deserialize)]
894struct NumberOptions {
895    #[serde(default)]
896    min: Option<f64>,
897    #[serde(default)]
898    max: Option<f64>,
899    #[serde(default)]
900    step: Option<f64>,
901}
902
903struct NumberInput;
904impl InputType for NumberInput {
905    fn key(&self) -> &'static str {
906        "number"
907    }
908    fn html_type(&self) -> &'static str {
909        "number"
910    }
911    fn option_keys(&self) -> Vec<&'static str> {
912        vec!["min", "max", "step"]
913    }
914    fn resolve_options(
915        &self,
916        raw: &serde_json::Value,
917        _types: &FieldRegistry,
918    ) -> Result<ResolvedOptions, OptionsError> {
919        let opts: NumberOptions = if raw.is_null() {
920            NumberOptions::default()
921        } else {
922            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
923        };
924        Ok(ResolvedOptions::new(opts))
925    }
926    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
927        vec![Rule::Numeric]
928    }
929    fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
930        let mut attrs = BTreeMap::new();
931        if let Some(o) = opts.get::<NumberOptions>() {
932            if let Some(min) = o.min {
933                attrs.insert("min".to_string(), min.to_string());
934            }
935            if let Some(max) = o.max {
936                attrs.insert("max".to_string(), max.to_string());
937            }
938            if let Some(step) = o.step {
939                attrs.insert("step".to_string(), step.to_string());
940            }
941        }
942        attrs
943    }
944}
945
946/// A url input's option: whether to show the copy-to-clipboard button.
947#[derive(Deserialize)]
948struct UrlOptions {
949    #[serde(default = "default_true")]
950    copy: bool,
951}
952
953impl Default for UrlOptions {
954    fn default() -> Self {
955        Self { copy: true }
956    }
957}
958
959fn default_true() -> bool {
960    true
961}
962
963struct UrlInput;
964impl InputType for UrlInput {
965    fn key(&self) -> &'static str {
966        "url"
967    }
968    fn html_type(&self) -> &'static str {
969        "url"
970    }
971    fn option_keys(&self) -> Vec<&'static str> {
972        vec!["copy"]
973    }
974    fn resolve_options(
975        &self,
976        raw: &serde_json::Value,
977        _types: &FieldRegistry,
978    ) -> Result<ResolvedOptions, OptionsError> {
979        let opts: UrlOptions = if raw.is_null() {
980            UrlOptions::default()
981        } else {
982            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
983        };
984        Ok(ResolvedOptions::new(opts))
985    }
986    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
987        vec![Rule::Url]
988    }
989    fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
990        if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
991            vec![Adornment {
992                placement: Placement::Trailing,
993                button: true,
994                label: "Copy".to_string(),
995                widget: Some("copy".to_string()),
996            }]
997        } else {
998            Vec::new()
999        }
1000    }
1001}
1002
1003pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1004    vec![
1005        Arc::new(TextInput),
1006        Arc::new(EmailInput),
1007        Arc::new(TelInput),
1008        Arc::new(NumberInput),
1009        Arc::new(UrlInput),
1010    ]
1011}
1012
1013/// The input-type registry seeded with the built-in inputs.
1014pub(crate) fn builtin_input_registry() -> InputRegistry {
1015    builtin_input_types()
1016        .into_iter()
1017        .map(|i| (i.key().to_string(), i))
1018        .collect()
1019}
1020
1021/// Attribute names the text template emits itself; an input may not re-declare
1022/// them (HTML resolves a duplicate to the first, silently dropping the input's).
1023const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1024
1025/// Validates an input's contributed attribute and widget names at boot. Names
1026/// come from plugin code, so a bad one is a wiring bug caught at boot, not user
1027/// input: an attribute name is lowercase kebab and unreserved; a widget name may
1028/// carry a dotted plugin namespace.
1029fn validate_contributions(
1030    attrs: &BTreeMap<String, String>,
1031    adornments: &[Adornment],
1032) -> Result<(), OptionsError> {
1033    for name in attrs.keys() {
1034        if !is_name(name, false) {
1035            return Err(OptionsError(format!("invalid attribute name `{name}`")));
1036        }
1037        if RESERVED_ATTRS.contains(&name.as_str()) {
1038            return Err(OptionsError(format!("attribute `{name}` is reserved")));
1039        }
1040    }
1041    for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1042        if !is_name(widget, true) {
1043            return Err(OptionsError(format!("invalid widget name `{widget}`")));
1044        }
1045    }
1046    Ok(())
1047}
1048
1049/// `^[a-z][a-z0-9-]*$`, plus `.` when `dotted` (for a widget's `vendor.name`).
1050pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1051    let mut chars = s.chars();
1052    matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1053        && chars.all(|c| {
1054            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1055        })
1056}
1057
1058/// The text field's typed options: which input variant to render.
1059#[derive(Deserialize)]
1060struct TextOptions {
1061    #[serde(default = "default_input")]
1062    input: String,
1063    /// Shown in the empty input.
1064    #[serde(default)]
1065    placeholder: Option<String>,
1066}
1067
1068fn default_input() -> String {
1069    "text".to_string()
1070}
1071
1072/// The text field's resolved options: everything needed to render and validate,
1073/// computed (and name-checked) once at boot from the selected input.
1074struct TextResolved {
1075    html_type: &'static str,
1076    attrs: BTreeMap<String, String>,
1077    adornments: Vec<Adornment>,
1078    rules: Vec<Rule>,
1079    assets: Vec<&'static str>,
1080}
1081
1082/// The text field's view-model payload. Additive over the prior `{input_type}`,
1083/// so existing overrides keep working; this shape is the override contract.
1084#[derive(Default, Serialize, Deserialize)]
1085struct TextData {
1086    input_type: String,
1087    #[serde(default)]
1088    attrs: BTreeMap<String, String>,
1089    #[serde(default)]
1090    adornments: Vec<Adornment>,
1091}
1092
1093#[derive(Template)]
1094#[template(path = "fields/text.html")]
1095struct TextTmpl<'a> {
1096    name: &'a str,
1097    id: &'a str,
1098    value: &'a str,
1099    required: bool,
1100    input_type: &'a str,
1101    attrs: &'a BTreeMap<String, String>,
1102    leading: &'a [Adornment],
1103    trailing: &'a [Adornment],
1104}
1105
1106/// A single-line text input. Its `input` option selects an [`InputType`] from
1107/// the registry (text, email, ...; plugins add more) that drives the HTML type
1108/// and intrinsic rules.
1109pub(crate) struct TextField {
1110    inputs: Arc<InputRegistry>,
1111}
1112
1113impl TextField {
1114    pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1115        Self { inputs }
1116    }
1117}
1118
1119impl FieldType for TextField {
1120    fn view_key(&self) -> &'static str {
1121        "text"
1122    }
1123    fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1124        let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1125        // An input this registry does not hold is refused by name when the
1126        // options resolve; its keys are not this check's to judge.
1127        let input = self.inputs.get(input)?;
1128        let mut all = vec!["input".to_string(), "placeholder".to_string()];
1129        all.extend(input.option_keys().into_iter().map(str::to_string));
1130        Some(all)
1131    }
1132    fn resolve_options(
1133        &self,
1134        raw: &serde_json::Value,
1135        _types: &FieldRegistry,
1136    ) -> Result<ResolvedOptions, OptionsError> {
1137        let opts: TextOptions = if raw.is_null() {
1138            TextOptions {
1139                input: default_input(),
1140                placeholder: None,
1141            }
1142        } else {
1143            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1144        };
1145        let input = self
1146            .inputs
1147            .get(&opts.input)
1148            .cloned()
1149            .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1150        // The input reads its own keys from the same blob, then contributes its
1151        // control shape; the contributed names are validated before caching.
1152        let input_opts = input.resolve_options(raw, _types)?;
1153        let mut attrs = input.attributes(&input_opts);
1154        let adornments = input.adornments(&input_opts);
1155        validate_contributions(&attrs, &adornments)?;
1156        if let Some(placeholder) = opts.placeholder {
1157            attrs.insert("placeholder".to_string(), placeholder);
1158        }
1159        Ok(ResolvedOptions::new(TextResolved {
1160            html_type: input.html_type(),
1161            rules: input.rules(&input_opts),
1162            assets: input.assets(&input_opts),
1163            attrs,
1164            adornments,
1165        }))
1166    }
1167    fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1168        opts.get::<TextResolved>()
1169            .map(|r| r.rules.clone())
1170            .unwrap_or_default()
1171    }
1172    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1173        opts.get::<TextResolved>()
1174            .map(|r| r.assets.clone())
1175            .unwrap_or_default()
1176    }
1177    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1178        let data = match cx.opts.get::<TextResolved>() {
1179            Some(r) => TextData {
1180                input_type: r.html_type.to_string(),
1181                attrs: r.attrs.clone(),
1182                adornments: r.adornments.clone(),
1183            },
1184            None => TextData {
1185                input_type: "text".to_string(),
1186                ..TextData::default()
1187            },
1188        };
1189        FieldVm {
1190            view_key: "text".to_string(),
1191            name: cx.name.to_string(),
1192            id: cx.id.to_string(),
1193            label: cx.label.to_string(),
1194            required: cx.required,
1195            value: cx.value.clone(),
1196            data: serde_json::to_value(data).unwrap_or_default(),
1197        }
1198    }
1199    fn render_default(&self, vm: &FieldVm) -> Markup {
1200        let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1201        let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1202            .adornments
1203            .into_iter()
1204            .partition(|a| matches!(a.placement, Placement::Leading));
1205        Markup::from_template(&TextTmpl {
1206            name: &vm.name,
1207            id: &vm.id,
1208            value: vm.value.as_text(),
1209            required: vm.required,
1210            input_type: &data.input_type,
1211            attrs: &data.attrs,
1212            leading: &leading,
1213            trailing: &trailing,
1214        })
1215        .unwrap_or_default()
1216    }
1217}
1218
1219#[derive(Template)]
1220#[template(path = "fields/textarea.html")]
1221struct TextareaTmpl<'a> {
1222    name: &'a str,
1223    id: &'a str,
1224    value: &'a str,
1225    required: bool,
1226    rows: Option<u16>,
1227    placeholder: Option<&'a str>,
1228}
1229
1230/// A textarea's own keys: its height in rows, and what an empty one shows.
1231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1232struct TextareaOptions {
1233    #[serde(default)]
1234    rows: Option<u16>,
1235    #[serde(default)]
1236    placeholder: Option<String>,
1237}
1238
1239/// A multi-line text input.
1240pub(crate) struct TextareaField;
1241
1242impl FieldType for TextareaField {
1243    fn view_key(&self) -> &'static str {
1244        "textarea"
1245    }
1246    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1247        keys(&["rows", "placeholder"])
1248    }
1249    fn resolve_options(
1250        &self,
1251        raw: &serde_json::Value,
1252        _types: &FieldRegistry,
1253    ) -> Result<ResolvedOptions, OptionsError> {
1254        let opts: TextareaOptions = if raw.is_null() {
1255            TextareaOptions::default()
1256        } else {
1257            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1258        };
1259        Ok(ResolvedOptions::new(opts))
1260    }
1261    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1262        let mut vm = scalar_vm("textarea", cx);
1263        if let Some(opts) = cx.opts.get::<TextareaOptions>() {
1264            vm.data = serde_json::to_value(opts).unwrap_or_default();
1265        }
1266        vm
1267    }
1268    fn render_default(&self, vm: &FieldVm) -> Markup {
1269        let opts: TextareaOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1270        Markup::from_template(&TextareaTmpl {
1271            name: &vm.name,
1272            id: &vm.id,
1273            value: vm.value.as_text(),
1274            required: vm.required,
1275            rows: opts.rows,
1276            placeholder: opts.placeholder.as_deref(),
1277        })
1278        .unwrap_or_default()
1279    }
1280}
1281
1282/// A `select` field's options: a list of value/label pairs.
1283#[derive(Debug, Default, Deserialize)]
1284struct SelectOptions {
1285    #[serde(default)]
1286    options: Vec<SelectOption>,
1287}
1288
1289#[derive(Debug, Deserialize)]
1290struct SelectOption {
1291    value: String,
1292    /// Display text; defaults to the value when omitted.
1293    #[serde(default)]
1294    label: Option<String>,
1295}
1296
1297/// One rendered option: label resolved, `selected` computed against the current
1298/// value. Carried in the view-model so an override presents the same list.
1299#[derive(Debug, Serialize, Deserialize)]
1300struct OptionView {
1301    value: String,
1302    label: String,
1303    selected: bool,
1304}
1305
1306#[derive(Template)]
1307#[template(path = "fields/select.html")]
1308struct SelectTmpl<'a> {
1309    name: &'a str,
1310    id: &'a str,
1311    required: bool,
1312    options: &'a [OptionView],
1313}
1314
1315/// A dropdown over a fixed option list.
1316pub(crate) struct SelectField;
1317
1318impl FieldType for SelectField {
1319    fn view_key(&self) -> &'static str {
1320        "select"
1321    }
1322    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1323        keys(&["options"])
1324    }
1325    fn resolve_options(
1326        &self,
1327        raw: &serde_json::Value,
1328        _types: &FieldRegistry,
1329    ) -> Result<ResolvedOptions, OptionsError> {
1330        let opts: SelectOptions =
1331            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1332        Ok(ResolvedOptions::new(opts))
1333    }
1334    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1335        let current = cx.value.as_text();
1336        let views: Vec<OptionView> = cx
1337            .opts
1338            .get::<SelectOptions>()
1339            .map(|o| {
1340                o.options
1341                    .iter()
1342                    .map(|so| OptionView {
1343                        value: so.value.clone(),
1344                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1345                        selected: so.value == current,
1346                    })
1347                    .collect()
1348            })
1349            .unwrap_or_default();
1350        FieldVm {
1351            view_key: "select".to_string(),
1352            name: cx.name.to_string(),
1353            id: cx.id.to_string(),
1354            label: cx.label.to_string(),
1355            required: cx.required,
1356            value: cx.value.clone(),
1357            data: serde_json::to_value(&views).unwrap_or_default(),
1358        }
1359    }
1360    fn render_default(&self, vm: &FieldVm) -> Markup {
1361        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1362        Markup::from_template(&SelectTmpl {
1363            name: &vm.name,
1364            id: &vm.id,
1365            required: vm.required,
1366            options: &options,
1367        })
1368        .unwrap_or_default()
1369    }
1370}
1371
1372/// A `checklist` field's keys.
1373#[derive(Debug, Default, Deserialize)]
1374struct ChecklistOptions {
1375    #[serde(default)]
1376    options: Vec<ChecklistEntry>,
1377    #[serde(default)]
1378    select_all: crate::checklist::Offer,
1379    #[serde(default)]
1380    search: crate::checklist::Offer,
1381    #[serde(default)]
1382    expand: crate::checklist::Expand,
1383}
1384
1385/// One entry under `options`: a choice, or a group holding entries of its own.
1386#[derive(Debug, Deserialize)]
1387#[serde(untagged)]
1388enum ChecklistEntry {
1389    Group {
1390        group: String,
1391        #[serde(default)]
1392        options: Vec<ChecklistEntry>,
1393    },
1394    Choice {
1395        value: String,
1396        /// Display text; defaults to the value when omitted.
1397        #[serde(default)]
1398        label: Option<String>,
1399        /// A second line under the label.
1400        #[serde(default)]
1401        note: Option<String>,
1402    },
1403}
1404
1405impl ChecklistOptions {
1406    /// Every value a box can submit, in the order declared.
1407    fn values(&self) -> Vec<&str> {
1408        fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1409            for entry in entries {
1410                match entry {
1411                    ChecklistEntry::Choice { value, .. } => out.push(value),
1412                    ChecklistEntry::Group { options, .. } => walk(options, out),
1413                }
1414            }
1415        }
1416        let mut out = Vec::new();
1417        walk(&self.options, &mut out);
1418        out
1419    }
1420
1421    /// The ticked values among those declared, in the order declared. A value
1422    /// the descriptor does not offer is dropped, so a forged box stores nothing.
1423    fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1424        self.values()
1425            .into_iter()
1426            .filter(|value| submitted.contains(value))
1427            .collect()
1428    }
1429}
1430
1431/// The values held by a stored or gathered checklist: a JSON array of strings.
1432fn checklist_values(text: &str) -> Vec<String> {
1433    serde_json::from_str(text).unwrap_or_default()
1434}
1435
1436/// Boxes in groups, storing the ticked values as a JSON array.
1437///
1438/// Each box submits under `name[position]`, so a form's one-value-a-key
1439/// submission carries them all.
1440pub(crate) struct ChecklistField;
1441
1442impl ChecklistField {
1443    fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1444        let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1445        match opts.get::<ChecklistOptions>() {
1446            Some(options) => options
1447                .ticked(&submitted)
1448                .into_iter()
1449                .map(str::to_string)
1450                .collect(),
1451            None => Vec::new(),
1452        }
1453    }
1454}
1455
1456impl FieldType for ChecklistField {
1457    fn view_key(&self) -> &'static str {
1458        "checklist"
1459    }
1460    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1461        keys(&["options", "select_all", "search", "expand"])
1462    }
1463    fn resolve_options(
1464        &self,
1465        raw: &serde_json::Value,
1466        _types: &FieldRegistry,
1467    ) -> Result<ResolvedOptions, OptionsError> {
1468        let opts: ChecklistOptions = if raw.is_null() {
1469            ChecklistOptions::default()
1470        } else {
1471            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1472        };
1473        let mut seen = std::collections::HashSet::new();
1474        for value in opts.values() {
1475            if !seen.insert(value) {
1476                return Err(OptionsError(format!("option `{value}` is listed twice")));
1477            }
1478        }
1479        Ok(ResolvedOptions::new(opts))
1480    }
1481
1482    fn to_attr(
1483        &self,
1484        field: &SubmittedField<'_>,
1485        opts: &ResolvedOptions,
1486        _mode: Mode,
1487    ) -> Result<Option<AttrValue>, String> {
1488        // No box ticked submits nothing, which has to store an empty list:
1489        // leaving the attribute out would keep the ticks that were just cleared.
1490        let values = Self::gathered(field, opts)
1491            .into_iter()
1492            .map(serde_json::Value::String)
1493            .collect();
1494        Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1495    }
1496
1497    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1498        stored.unwrap_or("[]").to_string()
1499    }
1500
1501    fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1502        let values = Self::gathered(field, opts);
1503        if values.is_empty() {
1504            return None;
1505        }
1506        serde_json::to_string(&values).ok()
1507    }
1508
1509    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1510        use crate::checklist::{Checklist, Choice, Entry, Group};
1511
1512        let ticked: Vec<String> = match cx.value {
1513            FieldValue::Json(serde_json::Value::Array(values)) => values
1514                .iter()
1515                .filter_map(|v| v.as_str().map(str::to_string))
1516                .collect(),
1517            FieldValue::Text(text) => checklist_values(text),
1518            _ => Vec::new(),
1519        };
1520
1521        fn build(
1522            entries: &[ChecklistEntry],
1523            name: &str,
1524            ticked: &[String],
1525            position: &mut usize,
1526        ) -> Vec<Entry> {
1527            entries
1528                .iter()
1529                .map(|entry| match entry {
1530                    ChecklistEntry::Choice { value, label, note } => {
1531                        let mut choice = Choice::new(
1532                            format!("{name}[{position}]"),
1533                            value.clone(),
1534                            label.clone().unwrap_or_else(|| value.clone()),
1535                        )
1536                        .checked(ticked.contains(value));
1537                        if let Some(note) = note {
1538                            choice = choice.note(note.clone());
1539                        }
1540                        *position += 1;
1541                        Entry::from(choice)
1542                    }
1543                    ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1544                        group.clone(),
1545                        build(options, name, ticked, position),
1546                    )),
1547                })
1548                .collect()
1549        }
1550
1551        let mut list = Checklist::new(cx.label).id(cx.id);
1552        if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1553            let mut position = 0;
1554            for entry in build(&options.options, cx.name, &ticked, &mut position) {
1555                list = list.entry(entry);
1556            }
1557            list = list
1558                .select_all(options.select_all)
1559                .search(options.search)
1560                .expand(options.expand);
1561        }
1562        FieldVm {
1563            view_key: "checklist".to_string(),
1564            name: cx.name.to_string(),
1565            id: cx.id.to_string(),
1566            label: cx.label.to_string(),
1567            required: cx.required,
1568            value: cx.value.clone(),
1569            data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1570        }
1571    }
1572
1573    fn render_default(&self, vm: &FieldVm) -> Markup {
1574        serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1575            .map(|view| view.render_markup())
1576            .unwrap_or_default()
1577    }
1578}
1579
1580#[derive(Template)]
1581#[template(path = "fields/switch.html")]
1582struct SwitchTmpl<'a> {
1583    name: &'a str,
1584    id: &'a str,
1585    on: bool,
1586}
1587
1588/// A boolean toggle over a bool column.
1589///
1590/// The only field whose submission can be absent rather than empty: a browser
1591/// sends nothing for an unchecked box, which is why absence has to mean `false`
1592/// here rather than "leave it alone".
1593pub(crate) struct SwitchField;
1594
1595/// Whether a stored value reads as true. Accepts what each backend gives back
1596/// for a boolean column, since `sqlx::Any` reads them all as text.
1597fn stored_is_on(stored: Option<&str>) -> bool {
1598    matches!(
1599        stored.map(str::trim).unwrap_or_default(),
1600        "1" | "true" | "TRUE" | "t" | "yes" | "on"
1601    )
1602}
1603
1604impl FieldType for SwitchField {
1605    fn view_key(&self) -> &'static str {
1606        "switch"
1607    }
1608    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1609        keys(&[])
1610    }
1611
1612    fn to_attr(
1613        &self,
1614        field: &SubmittedField<'_>,
1615        _opts: &ResolvedOptions,
1616        _mode: Mode,
1617    ) -> Result<Option<AttrValue>, String> {
1618        // Absent means unchecked, so this always writes a value: leaving the
1619        // attribute out would keep the old one and the box would never clear.
1620        Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1621    }
1622
1623    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1624        if stored_is_on(stored) {
1625            "1".to_string()
1626        } else {
1627            String::new()
1628        }
1629    }
1630
1631    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1632        scalar_vm("switch", cx)
1633    }
1634
1635    fn render_default(&self, vm: &FieldVm) -> Markup {
1636        Markup::from_template(&SwitchTmpl {
1637            name: &vm.name,
1638            id: &vm.id,
1639            on: stored_is_on(Some(vm.value.as_text())),
1640        })
1641        .unwrap_or_default()
1642    }
1643}
1644
1645#[derive(Template)]
1646#[template(path = "fields/date.html")]
1647struct DateTmpl<'a> {
1648    name: &'a str,
1649    id: &'a str,
1650    value: &'a str,
1651    required: bool,
1652}
1653
1654/// A calendar date.
1655///
1656/// Stores `YYYY-MM-DD`, the format a date control sends. Presenting is where the
1657/// work is: the column may hold a full timestamp, which a date input rejects, so
1658/// a stored value is trimmed back to its date.
1659pub(crate) struct DateField;
1660
1661impl FieldType for DateField {
1662    fn view_key(&self) -> &'static str {
1663        "date"
1664    }
1665    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1666        keys(&[])
1667    }
1668
1669    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1670        vec![Rule::Date]
1671    }
1672
1673    fn to_attr(
1674        &self,
1675        field: &SubmittedField<'_>,
1676        _opts: &ResolvedOptions,
1677        _mode: Mode,
1678    ) -> Result<Option<AttrValue>, String> {
1679        Ok(field.value().map(|v| match v.trim() {
1680            "" => AttrValue::Null,
1681            date => AttrValue::Text(date.to_string()),
1682        }))
1683    }
1684
1685    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1686        let stored = stored.unwrap_or_default().trim();
1687        // A timestamp column hands back the whole instant; the control wants the
1688        // date, and everything the framework stores starts with one.
1689        stored
1690            .split(['T', ' '])
1691            .next()
1692            .unwrap_or_default()
1693            .to_string()
1694    }
1695
1696    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1697        scalar_vm("date", cx)
1698    }
1699
1700    fn render_default(&self, vm: &FieldVm) -> Markup {
1701        Markup::from_template(&DateTmpl {
1702            name: &vm.name,
1703            id: &vm.id,
1704            value: vm.value.as_text(),
1705            required: vm.required,
1706        })
1707        .unwrap_or_default()
1708    }
1709}
1710
1711#[derive(Template)]
1712#[template(path = "fields/password.html")]
1713struct PasswordTmpl<'a> {
1714    name: &'a str,
1715    id: &'a str,
1716    required: bool,
1717}
1718
1719/// A password: hashed on the way in, never shown on the way out.
1720///
1721/// Both halves matter. The control renders with no value, so a stored hash
1722/// cannot leak into the page, and a blank submission omits the attribute, so
1723/// editing a record without touching its password leaves that password alone.
1724/// Require it on create (`required_on(Mode::Create)`) if a blank one is not
1725/// acceptable there.
1726pub(crate) struct PasswordField;
1727
1728impl FieldType for PasswordField {
1729    fn view_key(&self) -> &'static str {
1730        "password"
1731    }
1732    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1733        keys(&[])
1734    }
1735
1736    fn to_attr(
1737        &self,
1738        field: &SubmittedField<'_>,
1739        _opts: &ResolvedOptions,
1740        _mode: Mode,
1741    ) -> Result<Option<AttrValue>, String> {
1742        match field.value().map(str::trim).unwrap_or_default() {
1743            // Absent or blank: leave the stored password as it is.
1744            "" => Ok(None),
1745            plain => laterite_auth::password::hash_password(plain)
1746                .map(|hash| Some(AttrValue::Text(hash)))
1747                .map_err(|e| e.to_string()),
1748        }
1749    }
1750
1751    /// Never echoes: a hash must not reach the page, and a browser must not be
1752    /// invited to refill it.
1753    fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1754        String::new()
1755    }
1756
1757    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1758        scalar_vm("password", cx)
1759    }
1760
1761    fn render_default(&self, vm: &FieldVm) -> Markup {
1762        Markup::from_template(&PasswordTmpl {
1763            name: &vm.name,
1764            id: &vm.id,
1765            required: vm.required,
1766        })
1767        .unwrap_or_default()
1768    }
1769}
1770
1771#[derive(Template)]
1772#[template(path = "fields/radio.html")]
1773struct RadioTmpl<'a> {
1774    name: &'a str,
1775    options: &'a [OptionView],
1776    required: bool,
1777}
1778
1779/// One choice from a fixed list, shown as radios rather than a dropdown.
1780///
1781/// The same options as `select`, so a descriptor swaps between them by changing
1782/// the type alone. Suits a short list where seeing every choice matters.
1783pub(crate) struct RadioField;
1784
1785impl FieldType for RadioField {
1786    fn view_key(&self) -> &'static str {
1787        "radio"
1788    }
1789    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1790        keys(&["options"])
1791    }
1792
1793    fn resolve_options(
1794        &self,
1795        raw: &serde_json::Value,
1796        _types: &FieldRegistry,
1797    ) -> Result<ResolvedOptions, OptionsError> {
1798        let opts: SelectOptions =
1799            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1800        Ok(ResolvedOptions::new(opts))
1801    }
1802
1803    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1804        let current = cx.value.as_text();
1805        let views: Vec<OptionView> = cx
1806            .opts
1807            .get::<SelectOptions>()
1808            .map(|o| {
1809                o.options
1810                    .iter()
1811                    .map(|so| OptionView {
1812                        value: so.value.clone(),
1813                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1814                        selected: so.value == current,
1815                    })
1816                    .collect()
1817            })
1818            .unwrap_or_default();
1819        FieldVm {
1820            view_key: "radio".to_string(),
1821            name: cx.name.to_string(),
1822            id: cx.id.to_string(),
1823            label: cx.label.to_string(),
1824            required: cx.required,
1825            value: cx.value.clone(),
1826            data: serde_json::to_value(&views).unwrap_or_default(),
1827        }
1828    }
1829
1830    fn render_default(&self, vm: &FieldVm) -> Markup {
1831        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1832        Markup::from_template(&RadioTmpl {
1833            name: &vm.name,
1834            options: &options,
1835            required: vm.required,
1836        })
1837        .unwrap_or_default()
1838    }
1839}
1840
1841/// The view-model common to scalar text-like fields (no per-type `data`).
1842fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1843    FieldVm {
1844        view_key: view_key.to_string(),
1845        name: cx.name.to_string(),
1846        id: cx.id.to_string(),
1847        label: cx.label.to_string(),
1848        required: cx.required,
1849        value: cx.value.clone(),
1850        data: serde_json::Value::Null,
1851    }
1852}
1853
1854/// The reference-picker field's option: which registered source to pick from.
1855#[derive(Deserialize)]
1856struct RefOptions {
1857    source: String,
1858}
1859
1860/// The reference-picker's resolved options: the source name, validated at boot.
1861struct RefResolved {
1862    source: String,
1863}
1864
1865/// The reference-picker's view-model payload: the source and the endpoint URLs
1866/// its widget calls, plus the stored id (which the hidden input submits).
1867#[derive(Default, Serialize, Deserialize)]
1868struct RefData {
1869    source: String,
1870    search_url: String,
1871    resolve_url: String,
1872    value: String,
1873    placeholder: String,
1874}
1875
1876#[derive(Template)]
1877#[template(path = "fields/ref_picker.html")]
1878struct RefPickerTmpl<'a> {
1879    name: &'a str,
1880    id: &'a str,
1881    value: &'a str,
1882    search_url: &'a str,
1883    resolve_url: &'a str,
1884    placeholder: &'a str,
1885}
1886
1887/// A picker over a reference to another record: stores that record's id, shows
1888/// its label, and offers typeahead candidates from a registered [`PickerSource`]
1889/// (selected by the `source` option). The label and candidates are fetched by
1890/// its widget from the source's endpoints; rendering stays pure.
1891pub(crate) struct RefPickerField {
1892    pickers: Arc<PickerRegistry>,
1893}
1894
1895impl RefPickerField {
1896    pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1897        Self { pickers }
1898    }
1899}
1900
1901impl FieldType for RefPickerField {
1902    fn view_key(&self) -> &'static str {
1903        "reference"
1904    }
1905    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1906        keys(&["source"])
1907    }
1908    fn resolve_options(
1909        &self,
1910        raw: &serde_json::Value,
1911        _types: &FieldRegistry,
1912    ) -> Result<ResolvedOptions, OptionsError> {
1913        let opts: RefOptions =
1914            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1915        if !self.pickers.contains_key(&opts.source) {
1916            return Err(OptionsError(format!(
1917                "unknown picker source `{}`",
1918                opts.source
1919            )));
1920        }
1921        Ok(ResolvedOptions::new(RefResolved {
1922            source: opts.source,
1923        }))
1924    }
1925    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1926        let source = cx
1927            .opts
1928            .get::<RefResolved>()
1929            .map(|r| r.source.as_str())
1930            .unwrap_or_default();
1931        let data = RefData {
1932            search_url: format!("{}/pickers/{}/search", cx.base, source),
1933            resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1934            source: source.to_string(),
1935            value: cx.value.as_text().to_string(),
1936            placeholder: "Search…".to_string(),
1937        };
1938        FieldVm {
1939            view_key: "reference".to_string(),
1940            name: cx.name.to_string(),
1941            id: cx.id.to_string(),
1942            label: cx.label.to_string(),
1943            required: cx.required,
1944            value: cx.value.clone(),
1945            data: serde_json::to_value(data).unwrap_or_default(),
1946        }
1947    }
1948    fn render_default(&self, vm: &FieldVm) -> Markup {
1949        let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1950        Markup::from_template(&RefPickerTmpl {
1951            name: &vm.name,
1952            id: &vm.id,
1953            value: vm.value.as_text(),
1954            search_url: &data.search_url,
1955            resolve_url: &data.resolve_url,
1956            placeholder: &data.placeholder,
1957        })
1958        .unwrap_or_default()
1959    }
1960    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1961        vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1962    }
1963}
1964
1965#[cfg(test)]
1966mod tests {
1967    use super::*;
1968
1969    /// A translator with no catalogs, so every string resolves to its source:
1970    /// what a test wants unless it is testing translation itself.
1971    fn untranslated() -> Translator {
1972        Translator::new("en")
1973    }
1974
1975    fn cx_with<'a>(
1976        name: &'a str,
1977        value: &'a FieldValue,
1978        opts: &'a ResolvedOptions,
1979        i18n: &'a Translator,
1980    ) -> FieldCx<'a> {
1981        FieldCx {
1982            name,
1983            id: name,
1984            label: "Label",
1985            value,
1986            required: true,
1987            opts,
1988            base: "/admin",
1989            i18n,
1990        }
1991    }
1992
1993    fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1994        // Leaked so the borrow outlives the call; a test process is short-lived
1995        // and this keeps every existing call site unchanged.
1996        cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
1997    }
1998
1999    fn text_field() -> TextField {
2000        TextField::new(Arc::new(builtin_input_registry()))
2001    }
2002
2003    /// An input that contributes a reserved attribute name, to prove boot rejects it.
2004    struct BadAttrInput;
2005    impl InputType for BadAttrInput {
2006        fn key(&self) -> &'static str {
2007            "bad"
2008        }
2009        fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2010            BTreeMap::from([("class".to_string(), "x".to_string())])
2011        }
2012    }
2013
2014    #[test]
2015    fn text_renders_its_value_escaped() {
2016        let opts = ResolvedOptions::none();
2017        let value = FieldValue::Text("a<b>&\"c".to_string());
2018        let markup = render_field(
2019            &text_field(),
2020            &NoOverrides,
2021            &scope(),
2022            &cx("title", &value, &opts),
2023        );
2024        let html = markup.as_str();
2025        assert!(html.contains(r#"name="title""#));
2026        assert!(html.contains("required"));
2027        // The value is HTML-escaped by the Askama partial: no raw tag injected,
2028        // and the angle/ampersand are escaped (named or numeric entity, Askama's
2029        // choice, so accept either).
2030        assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2031        assert!(
2032            html.contains("&#60;") || html.contains("&lt;"),
2033            "angle escaped: {html}"
2034        );
2035        assert!(
2036            html.contains("&#38;") || html.contains("&amp;"),
2037            "ampersand escaped: {html}"
2038        );
2039    }
2040
2041    #[test]
2042    fn textarea_uses_its_own_control() {
2043        let opts = ResolvedOptions::none();
2044        let value = FieldValue::Text("body".to_string());
2045        let markup = render_field(
2046            &TextareaField,
2047            &NoOverrides,
2048            &scope(),
2049            &cx("body", &value, &opts),
2050        );
2051        assert!(markup.as_str().contains("<textarea"));
2052    }
2053
2054    #[test]
2055    fn no_overrides_uses_the_compiled_default() {
2056        // A resolver that never overrides yields the built-in markup unchanged.
2057        let opts = ResolvedOptions::none();
2058        let value = FieldValue::Text("x".to_string());
2059        let ft = text_field();
2060        let vm = ft.view_model(&cx("t", &value, &opts));
2061        let direct = ft.render_default(&vm);
2062        let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2063        assert_eq!(direct.as_str(), routed.as_str());
2064    }
2065
2066    #[test]
2067    fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2068        let field = text_field();
2069        let opts = field
2070            .resolve_options(
2071                &serde_json::json!({ "input": "email" }),
2072                &builtin_registry(),
2073            )
2074            .unwrap();
2075        // The email input variant contributes the Email rule.
2076        assert!(matches!(
2077            field.intrinsic_rules(&opts).as_slice(),
2078            [Rule::Email]
2079        ));
2080        let value = FieldValue::Text("a@b.test".to_string());
2081        let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2082        assert!(markup.as_str().contains(r#"type="email""#));
2083    }
2084
2085    #[test]
2086    fn text_default_input_is_plain_text_with_no_extra_rule() {
2087        let field = text_field();
2088        let opts = field
2089            .resolve_options(&serde_json::Value::Null, &builtin_registry())
2090            .unwrap();
2091        assert!(field.intrinsic_rules(&opts).is_empty());
2092        let value = FieldValue::Text("hi".to_string());
2093        let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2094        assert!(markup.as_str().contains(r#"type="text""#));
2095    }
2096
2097    #[test]
2098    fn text_number_input_sets_the_type_and_numeric_rule() {
2099        let field = text_field();
2100        let opts = field
2101            .resolve_options(
2102                &serde_json::json!({ "input": "number" }),
2103                &builtin_registry(),
2104            )
2105            .unwrap();
2106        assert!(matches!(
2107            field.intrinsic_rules(&opts).as_slice(),
2108            [Rule::Numeric]
2109        ));
2110        let value = FieldValue::Text("42".to_string());
2111        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2112        assert!(markup.as_str().contains(r#"type="number""#));
2113    }
2114
2115    #[test]
2116    fn text_tel_input_sets_the_type_with_no_rule() {
2117        let field = text_field();
2118        let opts = field
2119            .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2120            .unwrap();
2121        assert!(field.intrinsic_rules(&opts).is_empty());
2122        let value = FieldValue::Text(String::new());
2123        let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2124        assert!(markup.as_str().contains(r#"type="tel""#));
2125    }
2126
2127    #[test]
2128    fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2129        let field = text_field();
2130        let opts = field
2131            .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2132            .unwrap();
2133        assert!(matches!(
2134            field.intrinsic_rules(&opts).as_slice(),
2135            [Rule::Url]
2136        ));
2137        let value = FieldValue::Text("https://example.com".to_string());
2138        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2139        let html = markup.as_str();
2140        assert!(html.contains(r#"type="url""#), "{html}");
2141        assert!(html.contains("lat-input-group"), "{html}");
2142        assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2143    }
2144
2145    #[test]
2146    fn text_url_input_copy_false_omits_the_button() {
2147        let field = text_field();
2148        let opts = field
2149            .resolve_options(
2150                &serde_json::json!({ "input": "url", "copy": false }),
2151                &builtin_registry(),
2152            )
2153            .unwrap();
2154        let value = FieldValue::Text("https://example.com".to_string());
2155        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2156        let html = markup.as_str();
2157        assert!(html.contains(r#"type="url""#), "{html}");
2158        // No adornment, so a plain input with no group and no widget hook.
2159        assert!(!html.contains("lat-input-group"), "{html}");
2160        assert!(!html.contains("data-lat-widget"), "{html}");
2161    }
2162
2163    #[test]
2164    fn text_number_input_emits_min_max_step_attributes() {
2165        let field = text_field();
2166        let opts = field
2167            .resolve_options(
2168                &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2169                &builtin_registry(),
2170            )
2171            .unwrap();
2172        // Rendering runs vm -> data -> render_default, so attributes surviving into
2173        // the markup also proves the view-model serialize round-trip.
2174        let value = FieldValue::Text("5".to_string());
2175        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2176        let html = markup.as_str();
2177        assert!(html.contains(r#"min="0""#), "{html}");
2178        assert!(html.contains(r#"max="10""#), "{html}");
2179        assert!(html.contains(r#"step="2""#), "{html}");
2180    }
2181
2182    #[test]
2183    fn text_input_reserved_attribute_name_aborts_resolve() {
2184        let mut inputs = builtin_input_registry();
2185        inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2186        let field = TextField::new(Arc::new(inputs));
2187        let Err(e) =
2188            field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2189        else {
2190            panic!("expected a reserved-attribute rejection");
2191        };
2192        assert!(e.0.contains("class"), "{e}");
2193    }
2194
2195    #[test]
2196    fn attribute_and_widget_name_rules() {
2197        assert!(is_name("min", false));
2198        assert!(is_name("data-x", false));
2199        assert!(!is_name("Min", false));
2200        assert!(!is_name("min.x", false)); // a dot is not allowed in an attribute name
2201        assert!(is_name("vendor.copy", true)); // but is in a widget name
2202        assert!(!is_name("", false));
2203    }
2204
2205    #[test]
2206    fn select_renders_options_with_the_current_value_selected() {
2207        let raw = serde_json::json!({
2208            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2209        });
2210        let opts = SelectField
2211            .resolve_options(&raw, &builtin_registry())
2212            .unwrap();
2213        let value = FieldValue::Text("closed".to_string());
2214        let markup = render_field(
2215            &SelectField,
2216            &NoOverrides,
2217            &scope(),
2218            &cx("status", &value, &opts),
2219        );
2220        let html = markup.as_str();
2221        assert!(html.contains(r#"<option value="open">Open</option>"#));
2222        // The current value is selected; a missing label falls back to the value.
2223        assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2224    }
2225
2226    #[test]
2227    fn radio_renders_the_same_options_with_the_current_one_checked() {
2228        let raw = serde_json::json!({
2229            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2230        });
2231        let opts = RadioField
2232            .resolve_options(&raw, &builtin_registry())
2233            .unwrap();
2234        let value = FieldValue::Text("closed".to_string());
2235        let markup = render_field(
2236            &RadioField,
2237            &NoOverrides,
2238            &scope(),
2239            &cx("status", &value, &opts),
2240        );
2241        let html = markup.as_str();
2242        assert!(html.contains(r#"value="open""#));
2243        assert!(
2244            html.contains(r#"value="closed" checked"#),
2245            "current is checked"
2246        );
2247        // Every radio shares the field's name, or the browser treats them as
2248        // separate controls and lets more than one be chosen.
2249        assert_eq!(html.matches(r#"name="status""#).count(), 2);
2250        // A missing label falls back to the value, as select does.
2251        assert!(html.contains("closed</label>"));
2252    }
2253
2254    fn scope<'a>() -> OverrideScope<'a> {
2255        OverrideScope {
2256            surface: Surface::Field,
2257            view_key: "text",
2258            resource: None,
2259            field: None,
2260        }
2261    }
2262
2263    fn ref_field() -> RefPickerField {
2264        let mut pickers = crate::picker::PickerRegistry::new();
2265        let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2266        pickers.insert(
2267            "acme.place".to_string(),
2268            crate::picker::PickerSourceReg::new("acme.place", source),
2269        );
2270        RefPickerField::new(Arc::new(pickers))
2271    }
2272
2273    #[test]
2274    fn reference_rejects_an_unknown_source() {
2275        let field = ref_field();
2276        let Err(e) = field.resolve_options(
2277            &serde_json::json!({ "source": "no.such" }),
2278            &builtin_registry(),
2279        ) else {
2280            panic!("expected an unknown-source rejection");
2281        };
2282        assert!(e.0.contains("no.such"), "{e}");
2283    }
2284
2285    #[test]
2286    fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2287        let field = ref_field();
2288        let opts = field
2289            .resolve_options(
2290                &serde_json::json!({ "source": "acme.place" }),
2291                &builtin_registry(),
2292            )
2293            .unwrap();
2294        let value = FieldValue::Text("42".to_string());
2295        let markup = render_field(
2296            &field,
2297            &NoOverrides,
2298            &scope(),
2299            &cx("place_id", &value, &opts),
2300        );
2301        let html = markup.as_str();
2302        // The stored id rides in the hidden input the form submits, so an untouched
2303        // form (or one with JS off) preserves the reference.
2304        assert!(html.contains(r#"type="hidden""#), "{html}");
2305        assert!(html.contains(r#"name="place_id""#), "{html}");
2306        assert!(html.contains(r#"value="42""#), "{html}");
2307        // The widget hook and its endpoint URLs (built from the mount path).
2308        assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2309        assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2310        assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2311    }
2312
2313    #[test]
2314    fn reference_declares_its_widget_assets() {
2315        let field = ref_field();
2316        let opts = field
2317            .resolve_options(
2318                &serde_json::json!({ "source": "acme.place" }),
2319                &builtin_registry(),
2320            )
2321            .unwrap();
2322        assert_eq!(
2323            field.assets(&opts),
2324            vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2325        );
2326    }
2327}
2328
2329#[cfg(test)]
2330mod repeater_tests {
2331    use super::*;
2332
2333    fn options() -> ResolvedOptions {
2334        RepeaterField
2335            .resolve_options(
2336                &serde_json::json!({
2337                    "fields": [
2338                        { "name": "path", "label": "Path", "type": "text" },
2339                        { "name": "allow", "label": "Allow", "type": "switch" },
2340                    ]
2341                }),
2342                &builtin_registry(),
2343            )
2344            .unwrap()
2345    }
2346
2347    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2348        pairs
2349            .iter()
2350            .map(|(k, v)| (k.to_string(), v.to_string()))
2351            .collect()
2352    }
2353
2354    fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2355        let field = SubmittedField::new("rules", data);
2356        match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2357            Some(AttrValue::Json(value)) => value,
2358            other => panic!("expected a JSON array, got {other:?}"),
2359        }
2360    }
2361
2362    /// A row's sub-fields go through their own field types, so a switch inside a
2363    /// row stores a bool rather than the string "on". This is the whole reason
2364    /// the repeater belongs in the field system.
2365    #[test]
2366    fn a_row_is_typed_by_its_sub_field_types() {
2367        let rows = stored(
2368            &submitted(&[
2369                ("rules[0][path]", "/private"),
2370                ("rules[1][path]", "/public"),
2371                ("rules[1][allow]", "on"),
2372            ]),
2373            &options(),
2374        );
2375
2376        assert_eq!(rows[0]["path"], "/private");
2377        assert_eq!(rows[0]["allow"], serde_json::json!(false));
2378        assert_eq!(rows[1]["allow"], serde_json::json!(true));
2379    }
2380
2381    /// Removing a row in the browser can leave a gap. Indices are read from the
2382    /// keys and sorted, so a gap costs nothing.
2383    #[test]
2384    fn rows_are_ordered_by_index_not_submission_order() {
2385        let rows = stored(
2386            &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2387            &options(),
2388        );
2389        assert_eq!(rows.as_array().unwrap().len(), 2);
2390        assert_eq!(rows[0]["path"], "/first");
2391        assert_eq!(rows[1]["path"], "/last");
2392    }
2393
2394    #[test]
2395    fn a_row_left_blank_is_dropped() {
2396        let rows = stored(
2397            &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", "  ")]),
2398            &options(),
2399        );
2400        assert_eq!(rows.as_array().unwrap().len(), 1);
2401    }
2402
2403    #[test]
2404    fn nothing_submitted_stores_an_empty_list() {
2405        assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2406    }
2407
2408    #[test]
2409    fn a_repeater_refuses_to_hold_a_repeater() {
2410        let err = RepeaterField
2411            .resolve_options(
2412                &serde_json::json!({
2413                    "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2414                }),
2415                &builtin_registry(),
2416            )
2417            .err()
2418            .expect("a nested repeater is refused");
2419        assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2420    }
2421
2422    #[test]
2423    fn an_unregistered_sub_field_type_is_refused_at_boot() {
2424        let err = RepeaterField
2425            .resolve_options(
2426                &serde_json::json!({
2427                    "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2428                }),
2429                &builtin_registry(),
2430            )
2431            .err()
2432            .expect("an unregistered type is refused");
2433        assert!(err.0.contains("acme.nope"), "{}", err.0);
2434    }
2435
2436    #[test]
2437    fn row_counts_are_bounded_when_asked() {
2438        let opts = RepeaterField
2439            .resolve_options(
2440                &serde_json::json!({
2441                    "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2442                    "min_items": 1,
2443                    "max_items": 2
2444                }),
2445                &builtin_registry(),
2446            )
2447            .unwrap();
2448
2449        let empty = HashMap::new();
2450        let too_few = SubmittedField::new("rules", &empty);
2451        assert!(RepeaterField
2452            .to_attr(&too_few, &opts, Mode::Create)
2453            .is_err());
2454
2455        let three = submitted(&[
2456            ("rules[0][path]", "/a"),
2457            ("rules[1][path]", "/b"),
2458            ("rules[2][path]", "/c"),
2459        ]);
2460        let field = SubmittedField::new("rules", &three);
2461        assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2462    }
2463
2464    #[test]
2465    fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2466        let value = FieldValue::Json(serde_json::json!([
2467            { "path": "/private", "allow": false },
2468        ]));
2469        let cx = FieldCx {
2470            name: "rules",
2471            id: "rules",
2472            label: "Rules",
2473            value: &value,
2474            required: false,
2475            opts: &options(),
2476            base: "/admin",
2477            i18n: &Translator::new("en"),
2478        };
2479        let html = RepeaterField
2480            .render_default(&RepeaterField.view_model(&cx))
2481            .into_string();
2482
2483        assert!(html.contains(r#"name="rules[0][path]""#));
2484        assert!(html.contains(r#"value="/private""#));
2485        // The blank row is inert inside a <template> until the browser clones it.
2486        assert!(html.contains("lat-repeater__blank"));
2487        assert!(html.contains(r#"name="rules[__index__][path]""#));
2488    }
2489
2490    /// Resolved options for a list-mode repeater naming rows by `agent`.
2491    fn list_options() -> ResolvedOptions {
2492        RepeaterField
2493            .resolve_options(
2494                &serde_json::json!({
2495                    "fields": [
2496                        { "name": "agent", "label": "Agent", "type": "text" },
2497                        { "name": "disallow", "label": "Disallow", "type": "textarea" },
2498                    ],
2499                    "display": "list",
2500                    "summary_field": "agent",
2501                }),
2502                &builtin_registry(),
2503            )
2504            .unwrap()
2505    }
2506
2507    fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2508        let cx = FieldCx {
2509            name: "rules",
2510            id: "rules",
2511            label: "Rules",
2512            value,
2513            required: false,
2514            opts,
2515            base: "/admin",
2516            i18n: &Translator::new("en"),
2517        };
2518        RepeaterField
2519            .render_default(&RepeaterField.view_model(&cx))
2520            .into_string()
2521    }
2522
2523    #[test]
2524    fn a_list_repeater_collapses_its_rows_and_names_them() {
2525        let value = FieldValue::Json(serde_json::json!([
2526            { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2527        ]));
2528        let html = render_with(&list_options(), &value);
2529        assert!(html.contains("lat-repeater--list"), "list mode is declared");
2530        assert!(html.contains("<details"), "a row collapses");
2531        assert!(
2532            html.contains("Googlebot"),
2533            "the naming field titles the row"
2534        );
2535        // The fields are still there, just behind the summary.
2536        assert!(html.contains(r#"name="rules[0][disallow]""#));
2537    }
2538
2539    #[test]
2540    fn a_row_with_no_name_yet_reads_as_untitled() {
2541        let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2542        let html = render_with(&list_options(), &value);
2543        assert!(html.contains("lat-repeater__untitled"));
2544    }
2545
2546    #[test]
2547    fn the_default_layout_stays_inline() {
2548        // A single narrow column is better left expanded, so the mode is opt-in.
2549        let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2550        let html = render_with(&options(), &value);
2551        assert!(!html.contains("lat-repeater--list"));
2552        assert!(!html.contains("<details"));
2553    }
2554
2555    #[test]
2556    fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2557        // A summary that wraps is not a summary.
2558        assert_eq!(
2559            summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2560            "/private"
2561        );
2562        assert_eq!(summary_text(&serde_json::json!("")), "");
2563    }
2564
2565    /// A repeater's sub-field labels are declared inside the field type and never
2566    /// pass through the caller that localizes an ordinary label, so before the
2567    /// context carried a translator they could only render from their source
2568    /// string: English on every screen, whatever the operator's locale.
2569    /// A module's own field type is offered to every form and settings screen,
2570    /// which is what keeps a custom input from meaning a framework change.
2571    #[test]
2572    fn a_contributed_field_type_names_itself_and_converts() {
2573        struct Money;
2574        impl FieldType for Money {
2575            fn view_key(&self) -> &'static str {
2576                "acme.money"
2577            }
2578            fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2579                scalar_vm("acme.money", cx)
2580            }
2581            fn render_default(&self, _vm: &FieldVm) -> Markup {
2582                Markup::empty()
2583            }
2584            fn to_attr(
2585                &self,
2586                field: &SubmittedField<'_>,
2587                _opts: &ResolvedOptions,
2588                _mode: Mode,
2589            ) -> Result<Option<AttrValue>, String> {
2590                // Minor units, so the stored value never carries a float.
2591                let raw = field.value().unwrap_or("0");
2592                let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2593                Ok(Some(AttrValue::Int(minor as i64)))
2594            }
2595        }
2596
2597        let reg = FieldTypeReg::new(Arc::new(Money));
2598        // The key is the type's own, so a registration cannot disagree with what
2599        // it registers.
2600        assert_eq!(reg.name(), "acme.money");
2601
2602        let mut registry = builtin_registry();
2603        let name = reg.name().to_string();
2604        assert!(
2605            registry.insert(name.clone(), reg.field_type).is_none(),
2606            "a dotted key does not collide with a built-in"
2607        );
2608
2609        let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2610        let submitted = SubmittedField::new("price", &data);
2611        let converted = registry[&name]
2612            .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2613            .unwrap();
2614        assert!(
2615            matches!(converted, Some(AttrValue::Int(1250))),
2616            "{converted:?}"
2617        );
2618    }
2619
2620    #[test]
2621    fn a_sub_field_label_is_localized_like_any_other() {
2622        use laterite_core::i18n::CatalogStore;
2623        use std::sync::Arc;
2624
2625        let po = concat!(
2626            "msgid \"\"\n",
2627            "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2628            "\n",
2629            "msgid \"Path\"\n",
2630            "msgstr \"Path-kn\"\n",
2631        );
2632        let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2633        let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2634
2635        let value = FieldValue::Text(String::new());
2636        let cx = FieldCx {
2637            name: "rules",
2638            id: "rules",
2639            label: "Rules",
2640            value: &value,
2641            required: false,
2642            opts: &options(),
2643            base: "/admin",
2644            i18n: &i18n,
2645        };
2646        let html = RepeaterField
2647            .render_default(&RepeaterField.view_model(&cx))
2648            .into_string();
2649
2650        assert!(
2651            html.contains("Path-kn"),
2652            "sub-label was not localized: {html}"
2653        );
2654        assert!(
2655            !html.contains(">Path<"),
2656            "the source string still leaked: {html}"
2657        );
2658    }
2659}
2660
2661#[cfg(test)]
2662mod save_contract_tests {
2663    use super::*;
2664
2665    /// One scalar submission, the way the form builds it for a field.
2666    fn one(raw: Option<&str>) -> HashMap<String, String> {
2667        raw.into_iter()
2668            .map(|v| ("f".to_string(), v.to_string()))
2669            .collect()
2670    }
2671
2672    fn none() -> ResolvedOptions {
2673        ResolvedOptions::none()
2674    }
2675
2676    #[test]
2677    fn a_switch_stores_a_bool_and_absence_means_off() {
2678        let f = SwitchField;
2679        // A browser sends nothing for an unchecked box, so absence must write
2680        // false rather than leave the column as it was.
2681        assert_eq!(
2682            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2683                .unwrap(),
2684            Some(AttrValue::Bool(false))
2685        );
2686        assert_eq!(
2687            f.to_attr(
2688                &SubmittedField::new("f", &one(Some("on"))),
2689                &none(),
2690                Mode::Create
2691            )
2692            .unwrap(),
2693            Some(AttrValue::Bool(true))
2694        );
2695    }
2696
2697    #[test]
2698    fn a_switch_reads_back_whatever_the_backend_gave() {
2699        let f = SwitchField;
2700        // sqlx::Any hands every backend's boolean back as text, and they differ.
2701        for on in ["1", "true", "t", "on"] {
2702            assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2703        }
2704        for off in ["0", "false", "f", ""] {
2705            assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2706        }
2707        assert!(f.to_control(None, &none()).is_empty());
2708    }
2709
2710    #[test]
2711    fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2712        let f = DateField;
2713        // A timestamp column hands back the whole instant; a date input rejects it.
2714        assert_eq!(
2715            f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2716            "2026-09-09"
2717        );
2718        assert_eq!(
2719            f.to_control(Some("2026-09-09 12:30:45"), &none()),
2720            "2026-09-09"
2721        );
2722        assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2723        assert_eq!(f.to_control(None, &none()), "");
2724    }
2725
2726    #[test]
2727    fn a_cleared_date_stores_null_not_an_empty_string() {
2728        let f = DateField;
2729        assert_eq!(
2730            f.to_attr(
2731                &SubmittedField::new("f", &one(Some(""))),
2732                &none(),
2733                Mode::Update
2734            )
2735            .unwrap(),
2736            Some(AttrValue::Null)
2737        );
2738        assert_eq!(
2739            f.to_attr(
2740                &SubmittedField::new("f", &one(Some("2026-09-09"))),
2741                &none(),
2742                Mode::Create
2743            )
2744            .unwrap(),
2745            Some(AttrValue::Text("2026-09-09".into()))
2746        );
2747        assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2748    }
2749
2750    #[test]
2751    fn a_password_hashes_and_never_echoes() {
2752        let f = PasswordField;
2753        let stored = f
2754            .to_attr(
2755                &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2756                &none(),
2757                Mode::Create,
2758            )
2759            .unwrap()
2760            .unwrap();
2761        let hash = stored.as_str().unwrap();
2762        assert!(
2763            hash.starts_with("$argon2"),
2764            "stored as a hash, not plaintext"
2765        );
2766        assert_ne!(hash, "hunter2hunter2");
2767        // The hash must never reach the page, whatever is stored.
2768        assert_eq!(f.to_control(Some(hash), &none()), "");
2769    }
2770
2771    #[test]
2772    fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2773        let f = PasswordField;
2774        // Omitted, so the write does not touch the column.
2775        assert_eq!(
2776            f.to_attr(
2777                &SubmittedField::new("f", &one(Some(""))),
2778                &none(),
2779                Mode::Update
2780            )
2781            .unwrap(),
2782            None
2783        );
2784        assert_eq!(
2785            f.to_attr(
2786                &SubmittedField::new("f", &one(Some("   "))),
2787                &none(),
2788                Mode::Update
2789            )
2790            .unwrap(),
2791            None
2792        );
2793        assert_eq!(
2794            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2795                .unwrap(),
2796            None
2797        );
2798    }
2799
2800    #[test]
2801    fn a_plain_field_stores_and_shows_the_text_unchanged() {
2802        let f = TextareaField;
2803        assert_eq!(
2804            f.to_attr(
2805                &SubmittedField::new("f", &one(Some("hello"))),
2806                &none(),
2807                Mode::Create
2808            )
2809            .unwrap(),
2810            Some(AttrValue::Text("hello".into()))
2811        );
2812        // Absent stays absent, so the write leaves that column alone.
2813        assert_eq!(
2814            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2815                .unwrap(),
2816            None
2817        );
2818        assert_eq!(f.to_control(Some("hello"), &none()), "hello");
2819    }
2820}