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