Skip to main content

laterite_admin/
field.rs

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