Skip to main content

laterite_admin/
field.rs

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