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