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