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