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