Skip to main content

laterite_admin/
field.rs

1//! The open field-type registry: the framework and plugins add form field types.
2//!
3//! A field descriptor ([`crate::form::FormField`]) is serde data (name, label,
4//! type key, options, rules); a field type is behaviour keyed by that type.
5//! Rendering splits so an override can present the same data the built-in does:
6//! [`FieldType::view_model`] builds a serde [`FieldVm`], [`FieldType::render_default`]
7//! is the Askama presenter over it, and an [`OverrideResolver`] (default
8//! [`NoOverrides`]) may swap in a runtime template. Load/parse/save/assets/action
9//! join as defaulted methods when a type first needs them (backward-compatible).
10
11use std::any::Any;
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use askama::Template;
16use laterite_core::validation::{Mode, Rule};
17use laterite_core::{AttrValue, Translator};
18use serde::{Deserialize, Serialize};
19
20use crate::html::Markup;
21use crate::picker::PickerRegistry;
22
23/// A field's value, general enough for scalar and (later) structured types.
24/// Serde so it rides in the view-model and a failed-validation re-render.
25#[derive(Debug, Clone, Serialize)]
26#[serde(untagged)]
27pub enum FieldValue {
28    Null,
29    Text(String),
30    Json(serde_json::Value),
31}
32
33impl FieldValue {
34    /// The value as text for a scalar field (empty for null or non-text).
35    pub fn as_text(&self) -> &str {
36        match self {
37            FieldValue::Text(s) => s,
38            _ => "",
39        }
40    }
41}
42
43/// Per-type options, resolved once at boot from the descriptor's options blob
44/// into a typed value cached for rendering (the text field resolves its input
45/// variant, select its option list; textarea carries none).
46#[derive(Default, Clone)]
47pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
48
49impl ResolvedOptions {
50    /// No options (the default for optionless types).
51    pub fn none() -> Self {
52        Self(None)
53    }
54    /// Wraps a resolved typed options value.
55    pub fn new<T: Any + Send + Sync>(value: T) -> Self {
56        Self(Some(Arc::new(value)))
57    }
58    /// Borrows the typed options, if present and of type `T`.
59    pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
60        self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
61    }
62}
63
64/// Whether the framework wraps a field in standard chrome (label, required
65/// marker, error list) or the type renders bare (a hidden field, a heading).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Chrome {
68    Wrapped,
69    Bare,
70}
71
72/// The resolved per-field state a field type renders from.
73#[non_exhaustive]
74pub struct FieldCx<'a> {
75    /// The submitted/DOM field name (becomes a path with repeaters).
76    pub name: &'a str,
77    /// A DOM-safe id for the control and its label's `for`.
78    pub id: &'a str,
79    pub label: &'a str,
80    pub value: &'a FieldValue,
81    /// Derived from the merged (intrinsic + descriptor) rules.
82    pub required: bool,
83    /// The longest value the rules accept, from a `max_length` rule.
84    pub max_length: Option<usize>,
85    pub opts: &'a ResolvedOptions,
86    /// The admin mount path (e.g. `/admin`), so a type that calls an endpoint
87    /// (the reference picker) builds its URL without knowing routing conventions.
88    pub base: &'a str,
89    /// The request's translator, for the strings a field type localizes itself.
90    ///
91    /// `label` above is already localized by the caller, because the framework
92    /// chrome renders it too. A composite type is the case this exists for: a
93    /// repeater's sub-field labels are declared inside the type and never pass
94    /// through the caller, so without this they could only render from their
95    /// source string, untranslated on every surface.
96    pub i18n: &'a Translator,
97    /// The icon set and its sprite, behind [`FieldCx::icon`].
98    pub(crate) icons: crate::icons::Icons<'a>,
99}
100
101impl FieldCx<'_> {
102    /// Markup for a named icon from the framework's set, for a control with
103    /// iconed buttons (the rich editor's toolbar). Decorative: label the button.
104    pub fn icon(&self, name: &str) -> String {
105        self.icons.render(Some(name))
106    }
107}
108
109/// The serialisable payload both the built-in template and an override render,
110/// so an override never re-derives data. `data` is per-type data, never HTML.
111#[derive(Debug, Clone, Serialize)]
112pub struct FieldVm {
113    pub view_key: String,
114    pub name: String,
115    pub id: String,
116    pub label: String,
117    pub required: bool,
118    pub value: FieldValue,
119    pub data: serde_json::Value,
120}
121
122/// A field type: behaviour registered once per string key.
123/// What a field sees of the submission when it produces its stored value.
124///
125/// A scalar field wants [`value`](Self::value), the single entry under its own
126/// name. A field made of more than one control (a repeater, a checkbox list)
127/// reads its own keys with [`nested`](Self::nested): the whole submission is
128/// here because a field's shape is the field's business, not the form's.
129pub struct SubmittedField<'a> {
130    name: &'a str,
131    data: &'a HashMap<String, String>,
132}
133
134impl<'a> SubmittedField<'a> {
135    pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
136        Self { name, data }
137    }
138
139    /// This field's submitted name, which a multi-value field uses as the prefix
140    /// of its own keys.
141    pub fn name(&self) -> &str {
142        self.name
143    }
144
145    /// The value submitted under this field's own name.
146    ///
147    /// `None` when the submission omits the field, which is how an unchecked
148    /// checkbox arrives.
149    pub fn value(&self) -> Option<&str> {
150        self.data.get(self.name).map(String::as_str)
151    }
152
153    /// Every submitted key beginning `name[`, as its remainder and value.
154    ///
155    /// `rules[0][path]` on a field named `rules` yields `("0][path]", ..)`; the
156    /// field parses the remainder however its own encoding says.
157    pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
158        let prefix = format!("{}[", self.name);
159        self.data.iter().filter_map(move |(key, value)| {
160            key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
161        })
162    }
163
164    /// The whole submission, for a field whose encoding the helpers above do not
165    /// cover.
166    pub fn all(&self) -> &'a HashMap<String, String> {
167        self.data
168    }
169}
170
171pub trait FieldType: Send + Sync + 'static {
172    /// The stable key for override resolution and default-template selection
173    /// (bare like `text` for core; dotted `vendor.name` for plugins).
174    fn view_key(&self) -> &'static str;
175
176    /// Types the descriptor's raw options blob once at boot. A failure aborts
177    /// boot naming the resource + field. Default: no options.
178    ///
179    /// `types` is the field registry, for a composite field (a repeater) that
180    /// holds fields of its own and has to resolve their types and options here,
181    /// while the registry is in hand.
182    fn resolve_options(
183        &self,
184        _raw: &serde_json::Value,
185        _types: &FieldRegistry,
186    ) -> Result<ResolvedOptions, OptionsError> {
187        Ok(ResolvedOptions::none())
188    }
189    /// The keys this type reads from a field's entry, beyond the ones every
190    /// field has (`rows` for a textarea, `options` for a select). A key outside
191    /// the list is refused when the form is prepared, naming the field and the
192    /// keys accepted, so a typo never passes silently. `None` accepts any key
193    /// unchecked; declare yours.
194    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
195        None
196    }
197
198    /// Rules this type always contributes, merged before the descriptor's own
199    /// (e.g. the text field's `email` input contributes [`Rule::Email`]).
200    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
201        Vec::new()
202    }
203
204    /// Builds the serialisable presentation payload. Pure, no IO.
205    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
206
207    /// The built-in presenter over the view-model (an Askama partial). Not
208    /// called when an override wins.
209    fn render_default(&self, vm: &FieldVm) -> Markup;
210
211    /// How a submitted value becomes the attribute that gets stored.
212    ///
213    /// `raw` is `None` when the submission omits the field entirely, which is
214    /// how an unchecked checkbox arrives. Returning `None` leaves the attribute
215    /// out of the write, so the column keeps whatever it held: that is how a
216    /// blank password on an edit means "unchanged" rather than "erase".
217    ///
218    /// Validation has already run, so a type that needs its input to parse
219    /// should guarantee that with an [`intrinsic_rules`](FieldType::intrinsic_rules)
220    /// entry and treat the value here as sound. The error is for a genuine
221    /// failure (hashing), not a bad value, and surfaces as a failed save.
222    ///
223    /// Default: the submitted text, unchanged.
224    fn to_attr(
225        &self,
226        field: &SubmittedField<'_>,
227        opts: &ResolvedOptions,
228        mode: Mode,
229    ) -> Result<Option<AttrValue>, String> {
230        let _ = (opts, mode);
231        Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
232    }
233
234    /// How a stored value is presented back in the control on an edit.
235    ///
236    /// The counterpart of [`to_attr`](FieldType::to_attr): storage and
237    /// presentation differ whenever a type stores something other than the text
238    /// the browser sent, and a type that never shows its value (a password)
239    /// returns the empty string here.
240    ///
241    /// Default: the stored text, unchanged.
242    fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
243        let _ = opts;
244        stored.unwrap_or_default().to_string()
245    }
246
247    /// What a field of several controls submitted, as the one value its control
248    /// is rebuilt from.
249    ///
250    /// A form holds one value a field, and a checklist submits a key for every
251    /// box ticked and none under its own name. Gathered here into one value, the
252    /// rules see the field, so `required` means a box is ticked, and a refused
253    /// save comes back with the operator's ticks in place. `None` when the
254    /// submission holds nothing of this field.
255    ///
256    /// Default: the value under the field's own name.
257    fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
258        let _ = opts;
259        field.value().map(str::to_string)
260    }
261
262    /// Whether the framework wraps this field in standard chrome.
263    fn chrome(&self) -> Chrome {
264        Chrome::Wrapped
265    }
266
267    /// The media records a submitted value refers to, for a type that holds
268    /// files from the library: the form writes them as the record's links
269    /// after a save, under the field's name, and an empty list clears them.
270    /// `None` (the default) for a type that holds no media.
271    fn linked_media(&self, submitted: Option<&str>, opts: &ResolvedOptions) -> Option<Vec<i64>> {
272        let _ = (submitted, opts);
273        None
274    }
275
276    /// Asset-registry keys this type's control needs on any page that renders it
277    /// (a heavy widget's script/style). The page shell collects, dedupes, and
278    /// emits them; keys must exist in the asset registry. Default: none, so a
279    /// type whose widget ships in core `laterite.js` declares nothing.
280    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
281        Vec::new()
282    }
283}
284
285/// A field type failed to type its options blob at boot.
286#[derive(Debug, thiserror::Error)]
287#[error("invalid field options: {0}")]
288pub struct OptionsError(pub String);
289
290/// The surface being rendered, for override resolution.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum Surface {
293    Field,
294    Column,
295}
296
297/// Where a render is happening, for override resolution (most-specific first:
298/// `resource.field` then `view_key` then the compiled default).
299pub struct OverrideScope<'a> {
300    pub surface: Surface,
301    pub view_key: &'a str,
302    pub resource: Option<&'a str>,
303    pub field: Option<&'a str>,
304}
305
306/// A runtime override failed to render.
307#[derive(Debug, thiserror::Error)]
308#[error("override render failed: {0}")]
309pub struct OverrideError(pub String);
310
311/// The seam a theme/CMS layer backs with a runtime template engine (MiniJinja)
312/// to let users override a field's presentation from outside the plugin.
313/// `laterite-admin` stays engine-agnostic; the default is [`NoOverrides`].
314pub trait OverrideResolver: Send + Sync {
315    /// An override for this view-model if one is registered for the scope, else
316    /// `None` (use the default). The `String` is engine-autoescaped HTML.
317    fn render_override(
318        &self,
319        scope: &OverrideScope<'_>,
320        vm: &serde_json::Value,
321    ) -> Option<Result<String, OverrideError>>;
322}
323
324/// The default resolver: no overrides, so the compiled path always runs at zero
325/// cost. The theme layer injects a real resolver when it is built.
326pub struct NoOverrides;
327
328impl OverrideResolver for NoOverrides {
329    fn render_override(
330        &self,
331        _scope: &OverrideScope<'_>,
332        _vm: &serde_json::Value,
333    ) -> Option<Result<String, OverrideError>> {
334        None
335    }
336}
337
338/// The field-type registry: type key to behaviour, built once at boot.
339pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
340
341/// A field type a module contributes, so a form can offer an input the framework
342/// has no built-in for (a colour picker, a money amount, a map point).
343///
344/// The key is the type's own [`FieldType::view_key`], so a registration cannot
345/// disagree with the type it registers. Use a dotted `vendor.name` to stay clear
346/// of the built-ins and of other modules.
347pub struct FieldTypeReg {
348    pub field_type: Arc<dyn FieldType>,
349}
350
351impl FieldTypeReg {
352    pub fn new(field_type: Arc<dyn FieldType>) -> Self {
353        Self { field_type }
354    }
355
356    /// The key this type registers under.
357    pub fn name(&self) -> &'static str {
358        self.field_type.view_key()
359    }
360}
361
362/// Renders a field: an override if the resolver supplies one, else the compiled
363/// default. The one place the two paths meet.
364pub(crate) fn render_field(
365    ft: &dyn FieldType,
366    resolver: &dyn OverrideResolver,
367    scope: &OverrideScope<'_>,
368    cx: &FieldCx<'_>,
369) -> Markup {
370    let vm = ft.view_model(cx);
371    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
372        Some(Ok(html)) => Markup::from_override(html),
373        // A failing override falls back to the default rather than blank the field.
374        Some(Err(_)) | None => ft.render_default(&vm),
375    }
376}
377
378/// A repeater's typed options: the fields one row holds, each resolved to its
379/// type and its own options at boot.
380pub(crate) struct RepeaterOptions {
381    rows: Vec<RepeaterSub>,
382    min_items: usize,
383    max_items: Option<usize>,
384    /// Stated, or `None` for the display chosen from the row: past
385    /// [`REPEATER_LIST_PAST`] fields a row, rows collapse to a list.
386    display: Option<RepeaterDisplay>,
387    /// Which sub-field names a collapsed row. Defaults to the first.
388    summary_field: Option<String>,
389    /// Rows can be moved up and down.
390    reorder: bool,
391    /// A row can be copied into a new one beneath it.
392    duplicate: bool,
393}
394
395/// Past this many fields a row, a repeater with no stated `display` is a list.
396pub const REPEATER_LIST_PAST: usize = 3;
397
398impl RepeaterOptions {
399    fn list(&self) -> bool {
400        match self.display {
401            Some(RepeaterDisplay::List) => true,
402            Some(RepeaterDisplay::Inline) => false,
403            None => self.rows.len() > REPEATER_LIST_PAST,
404        }
405    }
406}
407
408/// Reads `display`: a stated layout, or `auto` for the one chosen from the row.
409fn auto_or_display<'de, D: serde::Deserializer<'de>>(
410    de: D,
411) -> Result<Option<RepeaterDisplay>, D::Error> {
412    #[derive(Deserialize)]
413    #[serde(rename_all = "snake_case")]
414    enum Raw {
415        Auto,
416        Inline,
417        List,
418    }
419    Ok(match Raw::deserialize(de)? {
420        Raw::Auto => None,
421        Raw::Inline => Some(RepeaterDisplay::Inline),
422        Raw::List => Some(RepeaterDisplay::List),
423    })
424}
425
426fn on() -> bool {
427    true
428}
429
430/// How a repeater's rows are laid out.
431///
432/// A row of several fields rendered inline is a row of columns, and four of
433/// them is already too many to read. `List` collapses each row to a line naming
434/// it, opening one at a time, which is the shape an operator expects of a list
435/// with an Add button.
436#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
437#[serde(rename_all = "snake_case")]
438pub enum RepeaterDisplay {
439    /// Every row expanded, all fields visible. Right for a single narrow
440    /// column, where collapsing would hide the only thing there is to see.
441    #[default]
442    Inline,
443    /// Rows collapsed to a summary line, expanded one at a time. Adding a row
444    /// opens it.
445    List,
446}
447
448/// One sub-field of a repeater row, resolved once.
449pub(crate) struct RepeaterSub {
450    field: crate::form::FormField,
451    field_type: Arc<dyn FieldType>,
452    opts: ResolvedOptions,
453}
454
455/// The descriptor shape a repeater's `options` blob takes.
456#[derive(Deserialize)]
457struct RepeaterOptionsRaw {
458    /// Written as a map of named fields in a file, a list by the builders.
459    #[serde(deserialize_with = "named_or_listed")]
460    fields: Vec<crate::form::FormField>,
461    #[serde(default)]
462    min_items: usize,
463    #[serde(default)]
464    max_items: Option<usize>,
465    #[serde(default, deserialize_with = "auto_or_display")]
466    display: Option<RepeaterDisplay>,
467    #[serde(default)]
468    summary_field: Option<String>,
469    #[serde(default = "on")]
470    reorder: bool,
471    #[serde(default = "on")]
472    duplicate: bool,
473}
474
475/// Refuses a key the type does not read, naming the ones it does.
476pub(crate) fn check_option_keys(
477    raw: &serde_json::Value,
478    accepted: &[String],
479) -> Result<(), OptionsError> {
480    let serde_json::Value::Object(entries) = raw else {
481        return Ok(());
482    };
483    for key in entries.keys() {
484        if !accepted.iter().any(|k| k == key) {
485            return Err(OptionsError(if accepted.is_empty() {
486                format!("unknown key `{key}`: this type takes no keys of its own")
487            } else {
488                format!(
489                    "unknown key `{key}`, expected one of: {}",
490                    accepted.join(", ")
491                )
492            }));
493        }
494    }
495    Ok(())
496}
497
498/// The keys a field's type declares, checked against what the field carries.
499/// A type that declares none is left unchecked.
500pub(crate) fn check_field_keys(
501    field: &crate::form::FormField,
502    field_type: &dyn FieldType,
503) -> Result<(), OptionsError> {
504    match field_type.option_keys(&field.options) {
505        Some(keys) => check_option_keys(&field.options, &keys),
506        None => Ok(()),
507    }
508}
509
510fn keys(names: &[&str]) -> Option<Vec<String>> {
511    Some(names.iter().map(|k| k.to_string()).collect())
512}
513
514/// Reads a repeater's sub-fields from either shape: `{ label: {}, url: {} }`,
515/// each told its name by its key, or a list whose entries carry `name`.
516fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
517where
518    D: serde::Deserializer<'de>,
519{
520    use crate::keyed::Keyed;
521    #[derive(Deserialize)]
522    #[serde(untagged)]
523    enum Shape {
524        Listed(Vec<crate::form::FormField>),
525        Named(serde_json::Map<String, serde_json::Value>),
526    }
527    match Shape::deserialize(de)? {
528        Shape::Listed(fields) => Ok(fields),
529        Shape::Named(entries) => entries
530            .into_iter()
531            .map(|(name, entry)| {
532                let mut field: crate::form::FormField =
533                    serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
534                field.set_key(name);
535                Ok(field)
536            })
537            .collect(),
538    }
539}
540
541/// A list of rows, stored as a JSON array of objects.
542///
543/// Row controls are named `field[index][subfield]`. The indices are read back
544/// out of the submitted keys and sorted, so a row removed in the browser leaves
545/// no hole and the page renumbers only for tidiness, never for correctness.
546///
547/// A row's sub-fields are ordinary field types, so a switch inside a row stores
548/// a bool and a date inside a row is a date, exactly as at the top level.
549pub(crate) struct RepeaterField;
550
551impl RepeaterField {
552    /// The placeholder index in the blank row the browser clones.
553    const BLANK: &'static str = "__index__";
554}
555
556impl FieldType for RepeaterField {
557    fn view_key(&self) -> &'static str {
558        "repeater"
559    }
560
561    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
562        keys(&[
563            "fields",
564            "min_items",
565            "max_items",
566            "display",
567            "summary_field",
568            "reorder",
569            "duplicate",
570        ])
571    }
572
573    fn resolve_options(
574        &self,
575        raw: &serde_json::Value,
576        types: &FieldRegistry,
577    ) -> Result<ResolvedOptions, OptionsError> {
578        let parsed: RepeaterOptionsRaw =
579            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
580        let mut rows = Vec::new();
581        for field in parsed.fields {
582            if field.field_type == "repeater" {
583                return Err(OptionsError(
584                    "a repeater cannot hold a repeater".to_string(),
585                ));
586            }
587            let field_type = types
588                .get(&field.field_type)
589                .cloned()
590                .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
591            check_field_keys(&field, field_type.as_ref())
592                .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
593            let opts = field_type.resolve_options(&field.options, types)?;
594            rows.push(RepeaterSub {
595                field,
596                field_type,
597                opts,
598            });
599        }
600        Ok(ResolvedOptions::new(RepeaterOptions {
601            rows,
602            min_items: parsed.min_items,
603            max_items: parsed.max_items,
604            display: parsed.display,
605            summary_field: parsed.summary_field,
606            reorder: parsed.reorder,
607            duplicate: parsed.duplicate,
608        }))
609    }
610
611    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
612        let opts = cx.opts.get::<RepeaterOptions>();
613        // A settings screen hands the rows over typed; a descriptor form hands
614        // over the column's text, or what a refused save gathered.
615        let stored = match cx.value {
616            FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
617            FieldValue::Text(text) => serde_json::from_str::<serde_json::Value>(text)
618                .ok()
619                .and_then(|v| v.as_array().cloned())
620                .unwrap_or_default(),
621            _ => Vec::new(),
622        };
623        // The cell that names a row: the one the descriptor nominated, else the
624        // first, which is the field an operator reads as the row's identity.
625        let summary_index = opts
626            .and_then(|o| {
627                o.summary_field
628                    .as_ref()
629                    .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
630            })
631            .unwrap_or(0);
632        let summaries = stored
633            .iter()
634            .map(|row| {
635                opts.and_then(|o| o.rows.get(summary_index))
636                    .and_then(|sub| row.get(&sub.field.name))
637                    .map(summary_text)
638                    .unwrap_or_default()
639            })
640            .collect();
641        let data = RepeaterData {
642            rows: opts
643                .map(|o| {
644                    stored
645                        .iter()
646                        .enumerate()
647                        .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
648                        .collect()
649                })
650                .unwrap_or_default(),
651            blank: opts
652                .map(|o| render_row(o, cx, Self::BLANK, None))
653                .unwrap_or_default(),
654            list: opts.is_some_and(RepeaterOptions::list),
655            summary_index,
656            summaries,
657            reorder: opts.is_none_or(|o| o.reorder),
658            duplicate: opts.is_none_or(|o| o.duplicate),
659            text: RepeaterText {
660                add: cx.i18n.t(&laterite_core::t!("Add")),
661                remove: cx.i18n.t(&laterite_core::t!("Remove")),
662                duplicate: cx.i18n.t(&laterite_core::t!("Duplicate")),
663                up: cx.i18n.t(&laterite_core::t!("Move up")),
664                down: cx.i18n.t(&laterite_core::t!("Move down")),
665                untitled: cx.i18n.t(&laterite_core::t!("Untitled")),
666            },
667        };
668
669        FieldVm {
670            view_key: self.view_key().to_string(),
671            name: cx.name.to_string(),
672            id: cx.id.to_string(),
673            label: cx.label.to_string(),
674            required: cx.required,
675            value: cx.value.clone(),
676            data: serde_json::to_value(data).unwrap_or_default(),
677        }
678    }
679
680    fn render_default(&self, vm: &FieldVm) -> Markup {
681        let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
682        Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
683    }
684
685    fn to_attr(
686        &self,
687        field: &SubmittedField<'_>,
688        opts: &ResolvedOptions,
689        mode: Mode,
690    ) -> Result<Option<AttrValue>, String> {
691        let Some(options) = opts.get::<RepeaterOptions>() else {
692            return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
693        };
694
695        // Indices come from the keys, sorted, so a gap left by a removed row
696        // costs nothing and the client never has to renumber for correctness.
697        let mut indices: Vec<usize> = field
698            .nested()
699            .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
700            .collect();
701        indices.sort_unstable();
702        indices.dedup();
703
704        let mut rows = Vec::new();
705        for index in indices {
706            let mut object = serde_json::Map::new();
707            for sub in &options.rows {
708                let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
709                let scoped = SubmittedField::new(&key, field.all());
710                if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
711                    object.insert(sub.field.name.clone(), attr_to_json(value));
712                }
713            }
714            let row = serde_json::Value::Object(object);
715            // A row the operator added and left empty is not a row.
716            if !is_blank_row(&row) {
717                rows.push(row);
718            }
719        }
720
721        if rows.len() < options.min_items {
722            return Err(format!("at least {} required", options.min_items));
723        }
724        if let Some(max) = options.max_items {
725            if rows.len() > max {
726                return Err(format!("at most {max} allowed"));
727            }
728        }
729        Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
730    }
731
732    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
733        stored.unwrap_or("[]").to_string()
734    }
735
736    /// The rows as submitted, each value the text it arrived as, so a refused
737    /// save shows them again and `required` means a row is there.
738    fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
739        let options = opts.get::<RepeaterOptions>()?;
740        let mut indices: Vec<usize> = field
741            .nested()
742            .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
743            .collect();
744        indices.sort_unstable();
745        indices.dedup();
746        let rows: Vec<serde_json::Value> = indices
747            .into_iter()
748            .map(|index| {
749                let mut object = serde_json::Map::new();
750                for sub in &options.rows {
751                    let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
752                    if let Some(value) = field.all().get(&key) {
753                        object.insert(
754                            sub.field.name.clone(),
755                            serde_json::Value::String(value.clone()),
756                        );
757                    }
758                }
759                serde_json::Value::Object(object)
760            })
761            .filter(|row| !is_blank_row(row))
762            .collect();
763        if rows.is_empty() {
764            return None;
765        }
766        serde_json::to_string(&rows).ok()
767    }
768}
769
770/// One row's sub-fields, rendered. `row` is `None` for the blank row.
771/// A stored cell rendered as the one line that names its row. Multi-line text
772/// collapses to its first line: a summary that wraps is not a summary.
773fn summary_text(value: &serde_json::Value) -> String {
774    let raw = match value {
775        serde_json::Value::String(s) => s.clone(),
776        serde_json::Value::Null => String::new(),
777        other => other.to_string(),
778    };
779    raw.lines().next().unwrap_or_default().trim().to_string()
780}
781
782fn render_row(
783    options: &RepeaterOptions,
784    cx: &FieldCx<'_>,
785    index: &str,
786    row: Option<&serde_json::Value>,
787) -> Vec<RepeaterCell> {
788    options
789        .rows
790        .iter()
791        .map(|sub| {
792            let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
793            let stored = row.and_then(|r| r.get(&sub.field.name));
794            let value = match stored {
795                Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
796                Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
797                Some(other) => FieldValue::Text(
798                    sub.field_type
799                        .to_control(Some(&other.to_string()), &sub.opts),
800                ),
801            };
802            let sub_cx = FieldCx {
803                name: &name,
804                id: &name,
805                label: cx.label,
806                value: &value,
807                required: false,
808                max_length: crate::form::max_length(&sub.field.rules),
809                opts: &sub.opts,
810                base: cx.base,
811                i18n: cx.i18n,
812                icons: cx.icons,
813            };
814            let vm = sub.field_type.view_model(&sub_cx);
815            RepeaterCell {
816                label: cx.i18n.t(&sub.field.label),
817                control: sub.field_type.render_default(&vm).into_string(),
818                help: sub
819                    .field
820                    .help
821                    .as_ref()
822                    .map(|h| cx.i18n.t(h))
823                    .unwrap_or_default(),
824            }
825        })
826        .collect()
827}
828
829/// A stored attribute as the JSON a repeater row holds.
830fn attr_to_json(value: AttrValue) -> serde_json::Value {
831    match value {
832        AttrValue::Null => serde_json::Value::Null,
833        AttrValue::Bool(b) => serde_json::Value::Bool(b),
834        AttrValue::Int(i) => serde_json::Value::from(i),
835        AttrValue::Float(f) => serde_json::Value::from(f),
836        AttrValue::Json(j) => j,
837        other => serde_json::Value::String(other.to_text().unwrap_or_default()),
838    }
839}
840
841/// Whether every value in a row is empty or false.
842fn is_blank_row(row: &serde_json::Value) -> bool {
843    row.as_object().is_none_or(|object| {
844        object.values().all(|v| match v {
845            serde_json::Value::String(s) => s.trim().is_empty(),
846            serde_json::Value::Bool(b) => !b,
847            serde_json::Value::Array(items) => items.is_empty(),
848            serde_json::Value::Null => true,
849            _ => false,
850        })
851    })
852}
853
854/// One rendered cell of a repeater row.
855#[derive(Serialize, Deserialize)]
856struct RepeaterCell {
857    label: String,
858    control: String,
859    /// The sub-field's own help text. A collapsed row shows less context than an
860    /// expanded form, so the line explaining a field earns its place more here,
861    /// not less.
862    help: String,
863}
864
865/// A repeater's presentation payload, typed on the way out and back so the
866/// template reads fields rather than poking at JSON.
867#[derive(Serialize, Deserialize, Default)]
868struct RepeaterData {
869    rows: Vec<Vec<RepeaterCell>>,
870    blank: Vec<RepeaterCell>,
871    /// True when rows collapse to a summary line.
872    list: bool,
873    /// Index of the cell whose value names a collapsed row.
874    summary_index: usize,
875    /// Each row's summary text, parallel to `rows`. Empty when a row's naming
876    /// cell has no value yet, which the template shows as a placeholder.
877    summaries: Vec<String>,
878    /// Rows carry move controls.
879    #[serde(default = "on")]
880    reorder: bool,
881    /// Rows carry a duplicate control.
882    #[serde(default = "on")]
883    duplicate: bool,
884    /// The control labels, localized.
885    #[serde(default)]
886    text: RepeaterText,
887}
888
889/// The repeater's control labels, localized once for the render.
890#[derive(Debug, Clone, Default, Serialize, Deserialize)]
891struct RepeaterText {
892    add: String,
893    remove: String,
894    duplicate: String,
895    up: String,
896    down: String,
897    untitled: String,
898}
899
900#[derive(Template)]
901#[template(path = "fields/repeater.html")]
902struct RepeaterTmpl {
903    data: RepeaterData,
904}
905
906/// The languages the code editor highlights. `text` is plain.
907#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
908#[serde(rename_all = "lowercase")]
909pub enum CodeLanguage {
910    #[default]
911    Text,
912    Html,
913    Css,
914    Javascript,
915    Json,
916    Yaml,
917    Markdown,
918    Jinja,
919}
920
921impl CodeLanguage {
922    fn as_str(self) -> &'static str {
923        match self {
924            Self::Text => "text",
925            Self::Html => "html",
926            Self::Css => "css",
927            Self::Javascript => "javascript",
928            Self::Json => "json",
929            Self::Yaml => "yaml",
930            Self::Markdown => "markdown",
931            Self::Jinja => "jinja",
932        }
933    }
934}
935
936/// A code editor's own keys: the language it highlights, its height in rows,
937/// the gutter, soft wrapping, and what an empty one shows.
938#[derive(Debug, Clone, Serialize, Deserialize)]
939struct CodeOptions {
940    #[serde(default)]
941    language: CodeLanguage,
942    #[serde(default)]
943    rows: Option<u16>,
944    #[serde(default = "yes")]
945    line_numbers: bool,
946    #[serde(default)]
947    wrap: bool,
948    #[serde(default)]
949    placeholder: Option<String>,
950}
951
952impl Default for CodeOptions {
953    fn default() -> Self {
954        Self {
955            language: CodeLanguage::Text,
956            rows: None,
957            line_numbers: true,
958            wrap: false,
959            placeholder: None,
960        }
961    }
962}
963
964#[derive(Template)]
965#[template(path = "fields/codeeditor.html")]
966struct CodeTmpl<'a> {
967    name: &'a str,
968    id: &'a str,
969    value: &'a str,
970    required: bool,
971    language: &'a str,
972    rows: Option<u16>,
973    line_numbers: bool,
974    wrap: bool,
975    placeholder: Option<&'a str>,
976}
977
978/// A code editor over a textarea: the textarea is the control the form
979/// submits, and the editor engine (loaded through [`FieldType::assets`])
980/// replaces it on the page.
981pub(crate) struct CodeEditorField;
982
983impl FieldType for CodeEditorField {
984    fn view_key(&self) -> &'static str {
985        "codeeditor"
986    }
987    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
988        keys(&["language", "rows", "line_numbers", "wrap", "placeholder"])
989    }
990    fn resolve_options(
991        &self,
992        raw: &serde_json::Value,
993        _types: &FieldRegistry,
994    ) -> Result<ResolvedOptions, OptionsError> {
995        let opts: CodeOptions = if raw.is_null() {
996            CodeOptions::default()
997        } else {
998            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
999        };
1000        Ok(ResolvedOptions::new(opts))
1001    }
1002    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1003        let mut vm = scalar_vm("codeeditor", cx);
1004        let opts = cx.opts.get::<CodeOptions>().cloned().unwrap_or_default();
1005        vm.data = serde_json::to_value(opts).unwrap_or_default();
1006        vm
1007    }
1008    fn render_default(&self, vm: &FieldVm) -> Markup {
1009        let opts: CodeOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1010        Markup::from_template(&CodeTmpl {
1011            name: &vm.name,
1012            id: &vm.id,
1013            value: vm.value.as_text(),
1014            required: vm.required,
1015            language: opts.language.as_str(),
1016            rows: opts.rows,
1017            line_numbers: opts.line_numbers,
1018            wrap: opts.wrap,
1019            placeholder: opts.placeholder.as_deref(),
1020        })
1021        .unwrap_or_default()
1022    }
1023    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1024        crate::CODE_EDITOR_ASSETS.to_vec()
1025    }
1026}
1027
1028/// The toolbar entries a rich editor offers, named in YAML through `toolbar`.
1029const RICH_TOOLBAR_DEFAULT: &[&str] = &[
1030    "format",
1031    "bold",
1032    "italic",
1033    "underline",
1034    "strike",
1035    "|",
1036    "bullet_list",
1037    "ordered_list",
1038    "|",
1039    "link",
1040    "image",
1041    "table",
1042    "rule",
1043    "insert",
1044    "|",
1045    "undo",
1046    "redo",
1047];
1048
1049/// A rich editor's own keys: its height in rows, what an empty one shows, and
1050/// which toolbar entries it offers (any of `format`, `bold`, `italic`,
1051/// `underline`, `strike`, `bullet_list`, `ordered_list`, `link`, `image`,
1052/// `table`, `rule`, `insert`, `undo`, `redo`, with `|` a separator).
1053#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1054struct RichOptions {
1055    #[serde(default)]
1056    rows: Option<u16>,
1057    #[serde(default)]
1058    placeholder: Option<String>,
1059    #[serde(default)]
1060    toolbar: Option<Vec<String>>,
1061}
1062
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064struct RichButton {
1065    /// `button`, `format` (the block-format select) or `sep`.
1066    kind: String,
1067    cmd: String,
1068    label: String,
1069    icon: String,
1070}
1071
1072/// The rich editor's view-model payload: the picker endpoint, the toolbar with
1073/// its icons and words already localized, and the words its dialogs use.
1074#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1075struct RichData {
1076    pick_url: String,
1077    menu_url: String,
1078    rows: Option<u16>,
1079    placeholder: Option<String>,
1080    toolbar_label: String,
1081    buttons: Vec<RichButton>,
1082    formats: Vec<(String, String)>,
1083    table_actions: Vec<(String, String)>,
1084    words: Vec<(String, String)>,
1085}
1086
1087#[derive(Template)]
1088#[template(path = "fields/richeditor.html")]
1089struct RichTmpl<'a> {
1090    name: &'a str,
1091    id: &'a str,
1092    value: &'a str,
1093    required: bool,
1094    data: &'a RichData,
1095}
1096
1097/// A rich-text editor over a textarea that holds the document as HTML. The
1098/// server keeps the HTML to the editor's schema on save ([`crate::richtext`])
1099/// and links the library files the document refers to.
1100pub(crate) struct RichEditorField;
1101
1102impl RichEditorField {
1103    fn button(cx: &FieldCx<'_>, name: &str) -> Option<RichButton> {
1104        let (label, icon) = match name {
1105            "bold" => (laterite_core::t!("Bold"), "bold"),
1106            "italic" => (laterite_core::t!("Italic"), "italic"),
1107            "underline" => (laterite_core::t!("Underline"), "underline"),
1108            "strike" => (laterite_core::t!("Strikethrough"), "strikethrough"),
1109            "bullet_list" => (laterite_core::t!("Bulleted list"), "list"),
1110            "ordered_list" => (laterite_core::t!("Numbered list"), "list-ordered"),
1111            "link" => (laterite_core::t!("Link"), "link"),
1112            "image" => (laterite_core::t!("Image"), "image"),
1113            "table" => (laterite_core::t!("Table"), "table"),
1114            "rule" => (laterite_core::t!("Horizontal rule"), "minus"),
1115            "insert" => (laterite_core::t!("Insert"), "blocks"),
1116            "undo" => (laterite_core::t!("Undo"), "undo-2"),
1117            "redo" => (laterite_core::t!("Redo"), "redo-2"),
1118            "format" => {
1119                return Some(RichButton {
1120                    kind: "format".into(),
1121                    cmd: "format".into(),
1122                    label: cx.i18n.t(&laterite_core::t!("Format")),
1123                    icon: String::new(),
1124                })
1125            }
1126            "|" => {
1127                return Some(RichButton {
1128                    kind: "sep".into(),
1129                    cmd: String::new(),
1130                    label: String::new(),
1131                    icon: String::new(),
1132                })
1133            }
1134            _ => return None,
1135        };
1136        Some(RichButton {
1137            kind: "button".into(),
1138            cmd: name.to_string(),
1139            label: cx.i18n.t(&label),
1140            icon: cx.icon(icon),
1141        })
1142    }
1143}
1144
1145impl FieldType for RichEditorField {
1146    fn view_key(&self) -> &'static str {
1147        "richeditor"
1148    }
1149    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1150        keys(&["rows", "placeholder", "toolbar"])
1151    }
1152    fn resolve_options(
1153        &self,
1154        raw: &serde_json::Value,
1155        _types: &FieldRegistry,
1156    ) -> Result<ResolvedOptions, OptionsError> {
1157        let opts: RichOptions = if raw.is_null() {
1158            RichOptions::default()
1159        } else {
1160            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1161        };
1162        if let Some(names) = &opts.toolbar {
1163            if let Some(bad) = names
1164                .iter()
1165                .find(|n| !RICH_TOOLBAR_DEFAULT.contains(&n.as_str()))
1166            {
1167                return Err(OptionsError(format!(
1168                    "unknown toolbar entry `{bad}`; expected one of {}",
1169                    RICH_TOOLBAR_DEFAULT.join(", ")
1170                )));
1171            }
1172        }
1173        Ok(ResolvedOptions::new(opts))
1174    }
1175    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1176        let mut vm = scalar_vm("richeditor", cx);
1177        let opts = cx.opts.get::<RichOptions>().cloned().unwrap_or_default();
1178        let t = |s: &str| cx.i18n.t(&laterite_core::Text::dynamic(s));
1179        let names: Vec<String> = opts
1180            .toolbar
1181            .clone()
1182            .unwrap_or_else(|| RICH_TOOLBAR_DEFAULT.iter().map(|s| s.to_string()).collect());
1183        let data = RichData {
1184            pick_url: format!("{}/media/pick", cx.base),
1185            menu_url: format!("{}/editor/menu?surface=richeditor", cx.base),
1186            rows: opts.rows,
1187            placeholder: opts.placeholder,
1188            toolbar_label: cx.i18n.t(&laterite_core::t!("Formatting")),
1189            buttons: names.iter().filter_map(|n| Self::button(cx, n)).collect(),
1190            formats: vec![
1191                (
1192                    "paragraph".into(),
1193                    cx.i18n.t(&laterite_core::t!("Paragraph")),
1194                ),
1195                ("h1".into(), cx.i18n.t(&laterite_core::t!("Heading 1"))),
1196                ("h2".into(), cx.i18n.t(&laterite_core::t!("Heading 2"))),
1197                ("h3".into(), cx.i18n.t(&laterite_core::t!("Heading 3"))),
1198                ("h4".into(), cx.i18n.t(&laterite_core::t!("Heading 4"))),
1199                ("blockquote".into(), cx.i18n.t(&laterite_core::t!("Quote"))),
1200                (
1201                    "code_block".into(),
1202                    cx.i18n.t(&laterite_core::t!("Code block")),
1203                ),
1204            ],
1205            table_actions: vec![
1206                ("row_after".into(), cx.i18n.t(&laterite_core::t!("Add row"))),
1207                (
1208                    "col_after".into(),
1209                    cx.i18n.t(&laterite_core::t!("Add column")),
1210                ),
1211                (
1212                    "row_delete".into(),
1213                    cx.i18n.t(&laterite_core::t!("Delete row")),
1214                ),
1215                (
1216                    "col_delete".into(),
1217                    cx.i18n.t(&laterite_core::t!("Delete column")),
1218                ),
1219                (
1220                    "header_row".into(),
1221                    cx.i18n.t(&laterite_core::t!("Header row")),
1222                ),
1223                (
1224                    "table_delete".into(),
1225                    cx.i18n.t(&laterite_core::t!("Delete table")),
1226                ),
1227            ],
1228            words: vec![
1229                (
1230                    "insert-title".into(),
1231                    cx.i18n.t(&laterite_core::t!("Insert")),
1232                ),
1233                ("snippets".into(), cx.i18n.t(&laterite_core::t!("Snippets"))),
1234                (
1235                    "nothing-to-insert".into(),
1236                    cx.i18n.t(&laterite_core::t!("Nothing to insert.")),
1237                ),
1238                ("edit".into(), cx.i18n.t(&laterite_core::t!("Edit"))),
1239                ("link-title".into(), cx.i18n.t(&laterite_core::t!("Link"))),
1240                ("url".into(), cx.i18n.t(&laterite_core::t!("URL"))),
1241                (
1242                    "new-tab".into(),
1243                    cx.i18n.t(&laterite_core::t!("Open in a new tab")),
1244                ),
1245                (
1246                    "remove-link".into(),
1247                    cx.i18n.t(&laterite_core::t!("Remove link")),
1248                ),
1249                ("apply".into(), cx.i18n.t(&laterite_core::t!("Apply"))),
1250                ("cancel".into(), cx.i18n.t(&laterite_core::t!("Cancel"))),
1251                (
1252                    "library-title".into(),
1253                    cx.i18n.t(&laterite_core::t!("Choose from the library")),
1254                ),
1255                (
1256                    "library-use".into(),
1257                    cx.i18n.t(&laterite_core::t!("Use selected")),
1258                ),
1259            ],
1260        };
1261        let _ = t;
1262        vm.data = serde_json::to_value(data).unwrap_or_default();
1263        vm
1264    }
1265    fn render_default(&self, vm: &FieldVm) -> Markup {
1266        let data: RichData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1267        Markup::from_template(&RichTmpl {
1268            name: &vm.name,
1269            id: &vm.id,
1270            value: vm.value.as_text(),
1271            required: vm.required,
1272            data: &data,
1273        })
1274        .unwrap_or_default()
1275    }
1276    /// An empty editor submits `<p></p>`; the rules see it as empty.
1277    fn submitted(&self, field: &SubmittedField<'_>, _opts: &ResolvedOptions) -> Option<String> {
1278        field.value().map(|v| {
1279            if crate::richtext::is_blank(v) {
1280                String::new()
1281            } else {
1282                v.to_string()
1283            }
1284        })
1285    }
1286    /// Stores the document cleaned to the editor's schema; a blank one as empty.
1287    fn to_attr(
1288        &self,
1289        field: &SubmittedField<'_>,
1290        _opts: &ResolvedOptions,
1291        _mode: Mode,
1292    ) -> Result<Option<AttrValue>, String> {
1293        Ok(field.value().map(|v| {
1294            let clean = crate::richtext::clean(v);
1295            AttrValue::Text(if crate::richtext::is_blank(&clean) {
1296                String::new()
1297            } else {
1298                clean
1299            })
1300        }))
1301    }
1302    fn linked_media(&self, submitted: Option<&str>, _opts: &ResolvedOptions) -> Option<Vec<i64>> {
1303        submitted.map(crate::richtext::media_ids)
1304    }
1305    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1306        crate::RICH_EDITOR_ASSETS.to_vec()
1307    }
1308}
1309
1310/// A markdown editor's own keys: its height in rows, what an empty one shows,
1311/// and whether the Preview tab is offered.
1312#[derive(Debug, Clone, Serialize, Deserialize)]
1313struct MarkdownOptions {
1314    #[serde(default)]
1315    rows: Option<u16>,
1316    #[serde(default)]
1317    placeholder: Option<String>,
1318    #[serde(default = "yes")]
1319    preview: bool,
1320}
1321
1322impl Default for MarkdownOptions {
1323    fn default() -> Self {
1324        Self {
1325            rows: None,
1326            placeholder: None,
1327            preview: true,
1328        }
1329    }
1330}
1331
1332/// The markdown editor's view-model payload: its endpoints, the toolbar with
1333/// icons and words localized, and the words its island uses.
1334#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1335struct MarkdownData {
1336    preview_url: Option<String>,
1337    pick_url: String,
1338    menu_url: String,
1339    rows: Option<u16>,
1340    placeholder: Option<String>,
1341    toolbar_label: String,
1342    write: String,
1343    preview: String,
1344    buttons: Vec<RichButton>,
1345    words: Vec<(String, String)>,
1346}
1347
1348#[derive(Template)]
1349#[template(path = "fields/markdown.html")]
1350struct MarkdownTmpl<'a> {
1351    name: &'a str,
1352    id: &'a str,
1353    value: &'a str,
1354    required: bool,
1355    data: &'a MarkdownData,
1356}
1357
1358/// A markdown editor over a textarea that holds the text as written; the site
1359/// renders it with [`laterite_core::markdown`], and so does the Preview tab.
1360pub(crate) struct MarkdownField;
1361
1362impl FieldType for MarkdownField {
1363    fn view_key(&self) -> &'static str {
1364        "markdown"
1365    }
1366    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1367        keys(&["rows", "placeholder", "preview"])
1368    }
1369    fn resolve_options(
1370        &self,
1371        raw: &serde_json::Value,
1372        _types: &FieldRegistry,
1373    ) -> Result<ResolvedOptions, OptionsError> {
1374        let opts: MarkdownOptions = if raw.is_null() {
1375            MarkdownOptions::default()
1376        } else {
1377            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1378        };
1379        Ok(ResolvedOptions::new(opts))
1380    }
1381    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1382        let mut vm = scalar_vm("markdown", cx);
1383        let opts = cx
1384            .opts
1385            .get::<MarkdownOptions>()
1386            .cloned()
1387            .unwrap_or_default();
1388        let button = |cmd: &str, label: laterite_core::Text, icon: &str| RichButton {
1389            kind: "button".into(),
1390            cmd: cmd.into(),
1391            label: cx.i18n.t(&label),
1392            icon: cx.icon(icon),
1393        };
1394        let data = MarkdownData {
1395            preview_url: opts
1396                .preview
1397                .then(|| format!("{}/markdown/preview", cx.base)),
1398            pick_url: format!("{}/media/pick", cx.base),
1399            menu_url: format!("{}/editor/menu?surface=markdown", cx.base),
1400            rows: opts.rows,
1401            placeholder: opts.placeholder,
1402            toolbar_label: cx.i18n.t(&laterite_core::t!("Formatting")),
1403            write: cx.i18n.t(&laterite_core::t!("Write")),
1404            preview: cx.i18n.t(&laterite_core::t!("Preview")),
1405            buttons: vec![
1406                button("heading", laterite_core::t!("Heading"), "heading"),
1407                button("bold", laterite_core::t!("Bold"), "bold"),
1408                button("italic", laterite_core::t!("Italic"), "italic"),
1409                button("quote", laterite_core::t!("Quote"), "quote"),
1410                button("code", laterite_core::t!("Code"), "code"),
1411                button("link", laterite_core::t!("Link"), "link"),
1412                button("image", laterite_core::t!("Image"), "image"),
1413                button("bullet_list", laterite_core::t!("Bulleted list"), "list"),
1414                button(
1415                    "ordered_list",
1416                    laterite_core::t!("Numbered list"),
1417                    "list-ordered",
1418                ),
1419                button("table", laterite_core::t!("Table"), "table"),
1420                button("insert", laterite_core::t!("Insert"), "blocks"),
1421            ],
1422            words: vec![
1423                (
1424                    "insert-title".into(),
1425                    cx.i18n.t(&laterite_core::t!("Insert")),
1426                ),
1427                ("snippets".into(), cx.i18n.t(&laterite_core::t!("Snippets"))),
1428                (
1429                    "nothing-to-insert".into(),
1430                    cx.i18n.t(&laterite_core::t!("Nothing to insert.")),
1431                ),
1432                (
1433                    "bold-text".into(),
1434                    cx.i18n.t(&laterite_core::t!("bold text")),
1435                ),
1436                (
1437                    "italic-text".into(),
1438                    cx.i18n.t(&laterite_core::t!("italic text")),
1439                ),
1440                (
1441                    "link-text".into(),
1442                    cx.i18n.t(&laterite_core::t!("link text")),
1443                ),
1444                ("column".into(), cx.i18n.t(&laterite_core::t!("Column"))),
1445                (
1446                    "nothing".into(),
1447                    cx.i18n.t(&laterite_core::t!("Nothing to preview.")),
1448                ),
1449                (
1450                    "preview-failed".into(),
1451                    cx.i18n
1452                        .t(&laterite_core::t!("The preview could not be rendered.")),
1453                ),
1454                (
1455                    "library-title".into(),
1456                    cx.i18n.t(&laterite_core::t!("Choose from the library")),
1457                ),
1458                (
1459                    "library-use".into(),
1460                    cx.i18n.t(&laterite_core::t!("Use selected")),
1461                ),
1462                ("cancel".into(), cx.i18n.t(&laterite_core::t!("Cancel"))),
1463            ],
1464        };
1465        vm.data = serde_json::to_value(data).unwrap_or_default();
1466        vm
1467    }
1468    fn render_default(&self, vm: &FieldVm) -> Markup {
1469        let data: MarkdownData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1470        Markup::from_template(&MarkdownTmpl {
1471            name: &vm.name,
1472            id: &vm.id,
1473            value: vm.value.as_text(),
1474            required: vm.required,
1475            data: &data,
1476        })
1477        .unwrap_or_default()
1478    }
1479    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1480        crate::MARKDOWN_ASSETS.to_vec()
1481    }
1482}
1483
1484/// The framework's built-in field types. The text field is constructed with the
1485/// built-in input-type registry it delegates to.
1486pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
1487    let inputs = Arc::new(builtin_input_registry());
1488    vec![
1489        Arc::new(TextField::new(inputs)),
1490        Arc::new(TextareaField),
1491        Arc::new(CodeEditorField),
1492        Arc::new(RichEditorField),
1493        Arc::new(MarkdownField),
1494        Arc::new(IdField),
1495        Arc::new(RepeaterField),
1496        Arc::new(SelectField),
1497        Arc::new(ChecklistField),
1498        Arc::new(SwitchField),
1499        Arc::new(DateField),
1500        Arc::new(PasswordField),
1501        Arc::new(RadioField),
1502        Arc::new(crate::medialib::field::MediaField),
1503    ]
1504}
1505
1506/// The field registry seeded with the built-in types, keyed by [`FieldType::view_key`].
1507pub(crate) fn builtin_registry() -> FieldRegistry {
1508    builtin_field_types()
1509        .into_iter()
1510        .map(|ft| (ft.view_key().to_string(), ft))
1511        .collect()
1512}
1513
1514/// Where an adornment sits relative to the input.
1515#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1516#[serde(rename_all = "snake_case")]
1517pub enum Placement {
1518    Leading,
1519    Trailing,
1520}
1521
1522/// A control placed beside the input (a copy button, a currency symbol): data in
1523/// the view-model, never HTML, so an override re-presents it. The text template
1524/// renders it into the input group.
1525#[derive(Debug, Clone, Serialize, Deserialize)]
1526pub struct Adornment {
1527    pub placement: Placement,
1528    /// `true` renders a `<button>` (an action); `false` an inert affix `<span>`.
1529    pub button: bool,
1530    /// Visible text and accessible label.
1531    pub label: String,
1532    /// The `data-lat-widget` island wiring a button (bare like `copy` for core,
1533    /// dotted `vendor.name` for a plugin). `None` for an inert affix.
1534    pub widget: Option<String>,
1535}
1536
1537/// An input variant of the text field: its HTML `type`, the rules it contributes,
1538/// and how it parameterises the one text control (extra attributes, adjacent
1539/// adornments). The extension point a plugin builds on (a currency input, say)
1540/// instead of reimplementing a field. An input parameterises the single text
1541/// control; anything needing different control markup is a [`FieldType`].
1542pub trait InputType: Send + Sync + 'static {
1543    /// The registry key naming this input (`text`, `email`, `url`, `currency`).
1544    fn key(&self) -> &'static str;
1545    /// The HTML `type` attribute.
1546    fn html_type(&self) -> &'static str {
1547        "text"
1548    }
1549    /// Types this input's own keys from the field's options blob (the same blob
1550    /// the field type reads) once at boot. Default: no options.
1551    fn resolve_options(
1552        &self,
1553        _raw: &serde_json::Value,
1554        _types: &FieldRegistry,
1555    ) -> Result<ResolvedOptions, OptionsError> {
1556        Ok(ResolvedOptions::none())
1557    }
1558    /// The keys this input reads from the field's entry (`min`, `max`, `step`
1559    /// for a number). A key no part of the field reads is refused.
1560    fn option_keys(&self) -> Vec<&'static str> {
1561        Vec::new()
1562    }
1563    /// Rules this input contributes (an `email` input adds [`Rule::Email`]).
1564    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1565        Vec::new()
1566    }
1567    /// Extra `<input>` attributes (a number input's `min`/`max`/`step`). Names are
1568    /// validated at boot and may not collide with the ones the template owns.
1569    fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1570        BTreeMap::new()
1571    }
1572    /// Controls placed beside the input (a copy button, a currency symbol).
1573    fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
1574        Vec::new()
1575    }
1576    /// Asset-registry keys this input needs (a heavy widget's script/style). The
1577    /// copy button needs none, since its island ships in core `laterite.js`.
1578    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1579        Vec::new()
1580    }
1581}
1582
1583/// The registry the text field resolves its `input` option against.
1584pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
1585
1586struct TextInput;
1587impl InputType for TextInput {
1588    fn key(&self) -> &'static str {
1589        "text"
1590    }
1591}
1592
1593struct EmailInput;
1594impl InputType for EmailInput {
1595    fn key(&self) -> &'static str {
1596        "email"
1597    }
1598    fn html_type(&self) -> &'static str {
1599        "email"
1600    }
1601    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1602        vec![Rule::Email]
1603    }
1604}
1605
1606struct TelInput;
1607impl InputType for TelInput {
1608    fn key(&self) -> &'static str {
1609        "tel"
1610    }
1611    fn html_type(&self) -> &'static str {
1612        "tel"
1613    }
1614}
1615
1616/// A number input's optional bounds and step, emitted as `<input>` attributes.
1617#[derive(Default, Deserialize)]
1618struct NumberOptions {
1619    #[serde(default)]
1620    min: Option<f64>,
1621    #[serde(default)]
1622    max: Option<f64>,
1623    #[serde(default)]
1624    step: Option<f64>,
1625}
1626
1627struct NumberInput;
1628impl InputType for NumberInput {
1629    fn key(&self) -> &'static str {
1630        "number"
1631    }
1632    fn html_type(&self) -> &'static str {
1633        "number"
1634    }
1635    fn option_keys(&self) -> Vec<&'static str> {
1636        vec!["min", "max", "step"]
1637    }
1638    fn resolve_options(
1639        &self,
1640        raw: &serde_json::Value,
1641        _types: &FieldRegistry,
1642    ) -> Result<ResolvedOptions, OptionsError> {
1643        let opts: NumberOptions = if raw.is_null() {
1644            NumberOptions::default()
1645        } else {
1646            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1647        };
1648        Ok(ResolvedOptions::new(opts))
1649    }
1650    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1651        vec![Rule::Numeric]
1652    }
1653    fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
1654        let mut attrs = BTreeMap::new();
1655        if let Some(o) = opts.get::<NumberOptions>() {
1656            if let Some(min) = o.min {
1657                attrs.insert("min".to_string(), min.to_string());
1658            }
1659            if let Some(max) = o.max {
1660                attrs.insert("max".to_string(), max.to_string());
1661            }
1662            if let Some(step) = o.step {
1663                attrs.insert("step".to_string(), step.to_string());
1664            }
1665        }
1666        attrs
1667    }
1668}
1669
1670/// A url input's option: whether to show the copy-to-clipboard button.
1671#[derive(Deserialize)]
1672struct UrlOptions {
1673    #[serde(default = "default_true")]
1674    copy: bool,
1675}
1676
1677impl Default for UrlOptions {
1678    fn default() -> Self {
1679        Self { copy: true }
1680    }
1681}
1682
1683fn default_true() -> bool {
1684    true
1685}
1686
1687struct UrlInput;
1688impl InputType for UrlInput {
1689    fn key(&self) -> &'static str {
1690        "url"
1691    }
1692    fn html_type(&self) -> &'static str {
1693        "url"
1694    }
1695    fn option_keys(&self) -> Vec<&'static str> {
1696        vec!["copy"]
1697    }
1698    fn resolve_options(
1699        &self,
1700        raw: &serde_json::Value,
1701        _types: &FieldRegistry,
1702    ) -> Result<ResolvedOptions, OptionsError> {
1703        let opts: UrlOptions = if raw.is_null() {
1704            UrlOptions::default()
1705        } else {
1706            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1707        };
1708        Ok(ResolvedOptions::new(opts))
1709    }
1710    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1711        vec![Rule::Url]
1712    }
1713    fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
1714        if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
1715            vec![Adornment {
1716                placement: Placement::Trailing,
1717                button: true,
1718                label: "Copy".to_string(),
1719                widget: Some("copy".to_string()),
1720            }]
1721        } else {
1722            Vec::new()
1723        }
1724    }
1725}
1726
1727/// A colour, picked with the browser's own control and stored as `#rrggbb`.
1728struct ColorInput;
1729impl InputType for ColorInput {
1730    fn key(&self) -> &'static str {
1731        "color"
1732    }
1733    fn html_type(&self) -> &'static str {
1734        "color"
1735    }
1736}
1737
1738pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1739    vec![
1740        Arc::new(TextInput),
1741        Arc::new(EmailInput),
1742        Arc::new(TelInput),
1743        Arc::new(NumberInput),
1744        Arc::new(UrlInput),
1745        Arc::new(ColorInput),
1746    ]
1747}
1748
1749/// The input-type registry seeded with the built-in inputs.
1750pub(crate) fn builtin_input_registry() -> InputRegistry {
1751    builtin_input_types()
1752        .into_iter()
1753        .map(|i| (i.key().to_string(), i))
1754        .collect()
1755}
1756
1757/// Attribute names the text template emits itself; an input may not re-declare
1758/// them (HTML resolves a duplicate to the first, silently dropping the input's).
1759const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1760
1761/// Validates an input's contributed attribute and widget names at boot. Names
1762/// come from plugin code, so a bad one is a wiring bug caught at boot, not user
1763/// input: an attribute name is lowercase kebab and unreserved; a widget name may
1764/// carry a dotted plugin namespace.
1765fn validate_contributions(
1766    attrs: &BTreeMap<String, String>,
1767    adornments: &[Adornment],
1768) -> Result<(), OptionsError> {
1769    for name in attrs.keys() {
1770        if !is_name(name, false) {
1771            return Err(OptionsError(format!("invalid attribute name `{name}`")));
1772        }
1773        if RESERVED_ATTRS.contains(&name.as_str()) {
1774            return Err(OptionsError(format!("attribute `{name}` is reserved")));
1775        }
1776    }
1777    for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1778        if !is_name(widget, true) {
1779            return Err(OptionsError(format!("invalid widget name `{widget}`")));
1780        }
1781    }
1782    Ok(())
1783}
1784
1785/// `^[a-z][a-z0-9-]*$`, plus `.` when `dotted` (for a widget's `vendor.name`).
1786pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1787    let mut chars = s.chars();
1788    matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1789        && chars.all(|c| {
1790            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1791        })
1792}
1793
1794/// The text field's typed options: which input variant to render.
1795#[derive(Deserialize)]
1796struct TextOptions {
1797    #[serde(default = "default_input")]
1798    input: String,
1799    /// Shown in the empty input.
1800    #[serde(default)]
1801    placeholder: Option<String>,
1802    /// The count of characters against the limit a `max_length` rule sets.
1803    #[serde(default)]
1804    counter: crate::checklist::Offer,
1805}
1806
1807fn default_input() -> String {
1808    "text".to_string()
1809}
1810
1811/// The text field's resolved options: everything needed to render and validate,
1812/// computed (and name-checked) once at boot from the selected input.
1813struct TextResolved {
1814    html_type: &'static str,
1815    attrs: BTreeMap<String, String>,
1816    adornments: Vec<Adornment>,
1817    rules: Vec<Rule>,
1818    assets: Vec<&'static str>,
1819    counter: crate::checklist::Offer,
1820}
1821
1822/// The attributes a length limit puts on a control: `maxlength`, so the browser
1823/// holds the limit, and the counter's setting for the island that draws it.
1824fn length_attrs(
1825    attrs: &mut BTreeMap<String, String>,
1826    max_length: Option<usize>,
1827    counter: crate::checklist::Offer,
1828) {
1829    use crate::checklist::Offer;
1830    let Some(max) = max_length else {
1831        return;
1832    };
1833    attrs.insert("maxlength".to_string(), max.to_string());
1834    match counter {
1835        Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1836        Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1837        Offer::Never => None,
1838    };
1839}
1840
1841/// The text field's view-model payload. Additive over the prior `{input_type}`,
1842/// so existing overrides keep working; this shape is the override contract.
1843#[derive(Default, Serialize, Deserialize)]
1844struct TextData {
1845    input_type: String,
1846    #[serde(default)]
1847    attrs: BTreeMap<String, String>,
1848    #[serde(default)]
1849    adornments: Vec<Adornment>,
1850}
1851
1852#[derive(Template)]
1853#[template(path = "fields/text.html")]
1854struct TextTmpl<'a> {
1855    name: &'a str,
1856    id: &'a str,
1857    value: &'a str,
1858    required: bool,
1859    input_type: &'a str,
1860    attrs: &'a BTreeMap<String, String>,
1861    leading: &'a [Adornment],
1862    trailing: &'a [Adornment],
1863}
1864
1865/// A single-line text input. Its `input` option selects an [`InputType`] from
1866/// the registry (text, email, ...; plugins add more) that drives the HTML type
1867/// and intrinsic rules.
1868pub(crate) struct TextField {
1869    inputs: Arc<InputRegistry>,
1870}
1871
1872impl TextField {
1873    pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1874        Self { inputs }
1875    }
1876}
1877
1878impl FieldType for TextField {
1879    fn view_key(&self) -> &'static str {
1880        "text"
1881    }
1882    fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1883        let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1884        // An input this registry does not hold is refused by name when the
1885        // options resolve; its keys are not this check's to judge.
1886        let input = self.inputs.get(input)?;
1887        let mut all = vec![
1888            "input".to_string(),
1889            "placeholder".to_string(),
1890            "counter".to_string(),
1891        ];
1892        all.extend(input.option_keys().into_iter().map(str::to_string));
1893        Some(all)
1894    }
1895    fn resolve_options(
1896        &self,
1897        raw: &serde_json::Value,
1898        _types: &FieldRegistry,
1899    ) -> Result<ResolvedOptions, OptionsError> {
1900        let opts: TextOptions = if raw.is_null() {
1901            TextOptions {
1902                input: default_input(),
1903                placeholder: None,
1904                counter: Default::default(),
1905            }
1906        } else {
1907            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1908        };
1909        let input = self
1910            .inputs
1911            .get(&opts.input)
1912            .cloned()
1913            .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1914        // The input reads its own keys from the same blob, then contributes its
1915        // control shape; the contributed names are validated before caching.
1916        let input_opts = input.resolve_options(raw, _types)?;
1917        let mut attrs = input.attributes(&input_opts);
1918        let adornments = input.adornments(&input_opts);
1919        validate_contributions(&attrs, &adornments)?;
1920        if let Some(placeholder) = opts.placeholder {
1921            attrs.insert("placeholder".to_string(), placeholder);
1922        }
1923        Ok(ResolvedOptions::new(TextResolved {
1924            html_type: input.html_type(),
1925            rules: input.rules(&input_opts),
1926            assets: input.assets(&input_opts),
1927            attrs,
1928            adornments,
1929            counter: opts.counter,
1930        }))
1931    }
1932    fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1933        opts.get::<TextResolved>()
1934            .map(|r| r.rules.clone())
1935            .unwrap_or_default()
1936    }
1937    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1938        opts.get::<TextResolved>()
1939            .map(|r| r.assets.clone())
1940            .unwrap_or_default()
1941    }
1942    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1943        let mut data = match cx.opts.get::<TextResolved>() {
1944            Some(r) => TextData {
1945                input_type: r.html_type.to_string(),
1946                attrs: r.attrs.clone(),
1947                adornments: r.adornments.clone(),
1948            },
1949            None => TextData {
1950                input_type: "text".to_string(),
1951                ..TextData::default()
1952            },
1953        };
1954        // A number or a date has no length to count.
1955        if matches!(
1956            data.input_type.as_str(),
1957            "text" | "email" | "tel" | "url" | "search"
1958        ) {
1959            let counter = cx
1960                .opts
1961                .get::<TextResolved>()
1962                .map(|r| r.counter)
1963                .unwrap_or_default();
1964            length_attrs(&mut data.attrs, cx.max_length, counter);
1965        }
1966        FieldVm {
1967            view_key: "text".to_string(),
1968            name: cx.name.to_string(),
1969            id: cx.id.to_string(),
1970            label: cx.label.to_string(),
1971            required: cx.required,
1972            value: cx.value.clone(),
1973            data: serde_json::to_value(data).unwrap_or_default(),
1974        }
1975    }
1976    fn render_default(&self, vm: &FieldVm) -> Markup {
1977        let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1978        let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1979            .adornments
1980            .into_iter()
1981            .partition(|a| matches!(a.placement, Placement::Leading));
1982        Markup::from_template(&TextTmpl {
1983            name: &vm.name,
1984            id: &vm.id,
1985            value: vm.value.as_text(),
1986            required: vm.required,
1987            input_type: &data.input_type,
1988            attrs: &data.attrs,
1989            leading: &leading,
1990            trailing: &trailing,
1991        })
1992        .unwrap_or_default()
1993    }
1994}
1995
1996#[derive(Template)]
1997#[template(path = "fields/textarea.html")]
1998struct TextareaTmpl<'a> {
1999    name: &'a str,
2000    id: &'a str,
2001    value: &'a str,
2002    required: bool,
2003    rows: Option<u16>,
2004    placeholder: Option<&'a str>,
2005    grow: bool,
2006    attrs: &'a BTreeMap<String, String>,
2007}
2008
2009fn yes() -> bool {
2010    true
2011}
2012
2013/// A textarea's own keys: its height in rows, what an empty one shows, whether
2014/// it grows with what is typed, and the count against a `max_length` rule.
2015#[derive(Debug, Clone, Serialize, Deserialize)]
2016struct TextareaOptions {
2017    #[serde(default)]
2018    rows: Option<u16>,
2019    #[serde(default)]
2020    placeholder: Option<String>,
2021    #[serde(default = "yes")]
2022    grow: bool,
2023    #[serde(default)]
2024    counter: crate::checklist::Offer,
2025}
2026
2027impl Default for TextareaOptions {
2028    fn default() -> Self {
2029        Self {
2030            rows: None,
2031            placeholder: None,
2032            grow: true,
2033            counter: Default::default(),
2034        }
2035    }
2036}
2037
2038/// The textarea's view-model payload: its keys as resolved for this render.
2039#[derive(Debug, Clone, Serialize, Deserialize)]
2040struct TextareaData {
2041    #[serde(default)]
2042    rows: Option<u16>,
2043    #[serde(default)]
2044    placeholder: Option<String>,
2045    #[serde(default = "yes")]
2046    grow: bool,
2047    /// `maxlength` and the counter's setting, when a rule limits the length.
2048    #[serde(default)]
2049    attrs: BTreeMap<String, String>,
2050}
2051
2052impl Default for TextareaData {
2053    fn default() -> Self {
2054        Self {
2055            rows: None,
2056            placeholder: None,
2057            grow: true,
2058            attrs: BTreeMap::new(),
2059        }
2060    }
2061}
2062
2063/// A multi-line text input.
2064pub(crate) struct TextareaField;
2065
2066impl FieldType for TextareaField {
2067    fn view_key(&self) -> &'static str {
2068        "textarea"
2069    }
2070    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2071        keys(&["rows", "placeholder", "grow", "counter"])
2072    }
2073    fn resolve_options(
2074        &self,
2075        raw: &serde_json::Value,
2076        _types: &FieldRegistry,
2077    ) -> Result<ResolvedOptions, OptionsError> {
2078        let opts: TextareaOptions = if raw.is_null() {
2079            TextareaOptions::default()
2080        } else {
2081            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2082        };
2083        Ok(ResolvedOptions::new(opts))
2084    }
2085    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2086        let mut vm = scalar_vm("textarea", cx);
2087        let opts = cx
2088            .opts
2089            .get::<TextareaOptions>()
2090            .cloned()
2091            .unwrap_or_default();
2092        let mut data = TextareaData {
2093            rows: opts.rows,
2094            placeholder: opts.placeholder,
2095            grow: opts.grow,
2096            attrs: BTreeMap::new(),
2097        };
2098        length_attrs(&mut data.attrs, cx.max_length, opts.counter);
2099        vm.data = serde_json::to_value(data).unwrap_or_default();
2100        vm
2101    }
2102    fn render_default(&self, vm: &FieldVm) -> Markup {
2103        let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2104        Markup::from_template(&TextareaTmpl {
2105            name: &vm.name,
2106            id: &vm.id,
2107            value: vm.value.as_text(),
2108            required: vm.required,
2109            rows: data.rows,
2110            placeholder: data.placeholder.as_deref(),
2111            grow: data.grow,
2112            attrs: &data.attrs,
2113        })
2114        .unwrap_or_default()
2115    }
2116}
2117
2118/// A dropdown turns searchable past this many choices.
2119pub const SELECT_SEARCH_PAST: usize = 10;
2120
2121/// A `select` field's options: a list of value/label pairs, and whether the
2122/// dropdown is searched rather than scrolled.
2123#[derive(Debug, Default, Deserialize)]
2124struct SelectOptions {
2125    #[serde(default)]
2126    options: Vec<SelectOption>,
2127    #[serde(default)]
2128    search: crate::checklist::Offer,
2129}
2130
2131impl SelectOptions {
2132    fn searchable(&self) -> bool {
2133        use crate::checklist::Offer;
2134        match self.search {
2135            Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
2136            Offer::Always => true,
2137            Offer::Never => false,
2138        }
2139    }
2140}
2141
2142/// The select's view-model payload: its choices, and whether it is searched.
2143#[derive(Debug, Default, Serialize, Deserialize)]
2144struct SelectData {
2145    options: Vec<OptionView>,
2146    #[serde(default)]
2147    search: bool,
2148}
2149
2150#[derive(Debug, Deserialize)]
2151struct SelectOption {
2152    value: String,
2153    /// Display text; defaults to the value when omitted.
2154    #[serde(default)]
2155    label: Option<String>,
2156}
2157
2158/// One rendered option: label resolved, `selected` computed against the current
2159/// value. Carried in the view-model so an override presents the same list.
2160#[derive(Debug, Serialize, Deserialize)]
2161struct OptionView {
2162    value: String,
2163    label: String,
2164    selected: bool,
2165}
2166
2167#[derive(Template)]
2168#[template(path = "fields/select.html")]
2169struct SelectTmpl<'a> {
2170    name: &'a str,
2171    id: &'a str,
2172    required: bool,
2173    options: &'a [OptionView],
2174    search: bool,
2175}
2176
2177/// A dropdown over a fixed option list.
2178pub(crate) struct SelectField;
2179
2180impl FieldType for SelectField {
2181    fn view_key(&self) -> &'static str {
2182        "select"
2183    }
2184    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2185        keys(&["options", "search"])
2186    }
2187    /// The typeahead ships with the record picker; a searched dropdown needs it
2188    /// on the page too.
2189    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
2190        match opts.get::<SelectOptions>() {
2191            Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
2192            _ => Vec::new(),
2193        }
2194    }
2195    fn resolve_options(
2196        &self,
2197        raw: &serde_json::Value,
2198        _types: &FieldRegistry,
2199    ) -> Result<ResolvedOptions, OptionsError> {
2200        let opts: SelectOptions =
2201            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2202        Ok(ResolvedOptions::new(opts))
2203    }
2204    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2205        let current = cx.value.as_text();
2206        let opts = cx.opts.get::<SelectOptions>();
2207        let data = SelectData {
2208            options: opts
2209                .map(|o| {
2210                    o.options
2211                        .iter()
2212                        .map(|so| OptionView {
2213                            value: so.value.clone(),
2214                            label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2215                            selected: so.value == current,
2216                        })
2217                        .collect()
2218                })
2219                .unwrap_or_default(),
2220            search: opts.is_some_and(SelectOptions::searchable),
2221        };
2222        FieldVm {
2223            view_key: "select".to_string(),
2224            name: cx.name.to_string(),
2225            id: cx.id.to_string(),
2226            label: cx.label.to_string(),
2227            required: cx.required,
2228            value: cx.value.clone(),
2229            data: serde_json::to_value(&data).unwrap_or_default(),
2230        }
2231    }
2232    fn render_default(&self, vm: &FieldVm) -> Markup {
2233        let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2234        Markup::from_template(&SelectTmpl {
2235            name: &vm.name,
2236            id: &vm.id,
2237            required: vm.required,
2238            options: &data.options,
2239            search: data.search,
2240        })
2241        .unwrap_or_default()
2242    }
2243}
2244
2245/// A `checklist` field's keys.
2246#[derive(Debug, Default, Deserialize)]
2247struct ChecklistOptions {
2248    #[serde(default)]
2249    options: Vec<ChecklistEntry>,
2250    #[serde(default)]
2251    select_all: crate::checklist::Offer,
2252    #[serde(default)]
2253    search: crate::checklist::Offer,
2254    #[serde(default)]
2255    expand: crate::checklist::Expand,
2256}
2257
2258/// One entry under `options`: a choice, or a group holding entries of its own.
2259#[derive(Debug, Deserialize)]
2260#[serde(untagged)]
2261enum ChecklistEntry {
2262    Group {
2263        group: String,
2264        #[serde(default)]
2265        options: Vec<ChecklistEntry>,
2266    },
2267    Choice {
2268        value: String,
2269        /// Display text; defaults to the value when omitted.
2270        #[serde(default)]
2271        label: Option<String>,
2272        /// A second line under the label.
2273        #[serde(default)]
2274        note: Option<String>,
2275    },
2276}
2277
2278impl ChecklistOptions {
2279    /// Every value a box can submit, in the order declared.
2280    fn values(&self) -> Vec<&str> {
2281        fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
2282            for entry in entries {
2283                match entry {
2284                    ChecklistEntry::Choice { value, .. } => out.push(value),
2285                    ChecklistEntry::Group { options, .. } => walk(options, out),
2286                }
2287            }
2288        }
2289        let mut out = Vec::new();
2290        walk(&self.options, &mut out);
2291        out
2292    }
2293
2294    /// The ticked values among those declared, in the order declared. A value
2295    /// the descriptor does not offer is dropped, so a forged box stores nothing.
2296    fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
2297        self.values()
2298            .into_iter()
2299            .filter(|value| submitted.contains(value))
2300            .collect()
2301    }
2302}
2303
2304/// The values held by a stored or gathered checklist: a JSON array of strings.
2305fn checklist_values(text: &str) -> Vec<String> {
2306    serde_json::from_str(text).unwrap_or_default()
2307}
2308
2309/// Boxes in groups, storing the ticked values as a JSON array.
2310///
2311/// Each box submits under `name[position]`, so a form's one-value-a-key
2312/// submission carries them all.
2313pub(crate) struct ChecklistField;
2314
2315impl ChecklistField {
2316    fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
2317        let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
2318        match opts.get::<ChecklistOptions>() {
2319            Some(options) => options
2320                .ticked(&submitted)
2321                .into_iter()
2322                .map(str::to_string)
2323                .collect(),
2324            None => Vec::new(),
2325        }
2326    }
2327}
2328
2329impl FieldType for ChecklistField {
2330    fn view_key(&self) -> &'static str {
2331        "checklist"
2332    }
2333    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2334        keys(&["options", "select_all", "search", "expand"])
2335    }
2336    fn resolve_options(
2337        &self,
2338        raw: &serde_json::Value,
2339        _types: &FieldRegistry,
2340    ) -> Result<ResolvedOptions, OptionsError> {
2341        let opts: ChecklistOptions = if raw.is_null() {
2342            ChecklistOptions::default()
2343        } else {
2344            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2345        };
2346        let mut seen = std::collections::HashSet::new();
2347        for value in opts.values() {
2348            if !seen.insert(value) {
2349                return Err(OptionsError(format!("option `{value}` is listed twice")));
2350            }
2351        }
2352        Ok(ResolvedOptions::new(opts))
2353    }
2354
2355    fn to_attr(
2356        &self,
2357        field: &SubmittedField<'_>,
2358        opts: &ResolvedOptions,
2359        _mode: Mode,
2360    ) -> Result<Option<AttrValue>, String> {
2361        // No box ticked submits nothing, which has to store an empty list:
2362        // leaving the attribute out would keep the ticks that were just cleared.
2363        let values = Self::gathered(field, opts)
2364            .into_iter()
2365            .map(serde_json::Value::String)
2366            .collect();
2367        Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
2368    }
2369
2370    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2371        stored.unwrap_or("[]").to_string()
2372    }
2373
2374    fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
2375        let values = Self::gathered(field, opts);
2376        if values.is_empty() {
2377            return None;
2378        }
2379        serde_json::to_string(&values).ok()
2380    }
2381
2382    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2383        use crate::checklist::{Checklist, Choice, Entry, Group};
2384
2385        let ticked: Vec<String> = match cx.value {
2386            FieldValue::Json(serde_json::Value::Array(values)) => values
2387                .iter()
2388                .filter_map(|v| v.as_str().map(str::to_string))
2389                .collect(),
2390            FieldValue::Text(text) => checklist_values(text),
2391            _ => Vec::new(),
2392        };
2393
2394        fn build(
2395            entries: &[ChecklistEntry],
2396            name: &str,
2397            ticked: &[String],
2398            position: &mut usize,
2399        ) -> Vec<Entry> {
2400            entries
2401                .iter()
2402                .map(|entry| match entry {
2403                    ChecklistEntry::Choice { value, label, note } => {
2404                        let mut choice = Choice::new(
2405                            format!("{name}[{position}]"),
2406                            value.clone(),
2407                            label.clone().unwrap_or_else(|| value.clone()),
2408                        )
2409                        .checked(ticked.contains(value));
2410                        if let Some(note) = note {
2411                            choice = choice.note(note.clone());
2412                        }
2413                        *position += 1;
2414                        Entry::from(choice)
2415                    }
2416                    ChecklistEntry::Group { group, options } => Entry::from(Group::new(
2417                        group.clone(),
2418                        build(options, name, ticked, position),
2419                    )),
2420                })
2421                .collect()
2422        }
2423
2424        let mut list = Checklist::new(cx.label).id(cx.id);
2425        if let Some(options) = cx.opts.get::<ChecklistOptions>() {
2426            let mut position = 0;
2427            for entry in build(&options.options, cx.name, &ticked, &mut position) {
2428                list = list.entry(entry);
2429            }
2430            list = list
2431                .select_all(options.select_all)
2432                .search(options.search)
2433                .expand(options.expand);
2434        }
2435        FieldVm {
2436            view_key: "checklist".to_string(),
2437            name: cx.name.to_string(),
2438            id: cx.id.to_string(),
2439            label: cx.label.to_string(),
2440            required: cx.required,
2441            value: cx.value.clone(),
2442            data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
2443        }
2444    }
2445
2446    fn render_default(&self, vm: &FieldVm) -> Markup {
2447        serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
2448            .map(|view| view.render_markup())
2449            .unwrap_or_default()
2450    }
2451}
2452
2453#[derive(Template)]
2454#[template(path = "fields/switch.html")]
2455struct SwitchTmpl<'a> {
2456    name: &'a str,
2457    id: &'a str,
2458    on: bool,
2459}
2460
2461/// A boolean toggle over a bool column.
2462///
2463/// The only field whose submission can be absent rather than empty: a browser
2464/// sends nothing for an unchecked box, which is why absence has to mean `false`
2465/// here rather than "leave it alone".
2466pub(crate) struct SwitchField;
2467
2468/// Whether a stored value reads as true. Accepts what each backend gives back
2469/// for a boolean column, since `sqlx::Any` reads them all as text.
2470fn stored_is_on(stored: Option<&str>) -> bool {
2471    matches!(
2472        stored.map(str::trim).unwrap_or_default(),
2473        "1" | "true" | "TRUE" | "t" | "yes" | "on"
2474    )
2475}
2476
2477impl FieldType for SwitchField {
2478    fn view_key(&self) -> &'static str {
2479        "switch"
2480    }
2481    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2482        keys(&[])
2483    }
2484
2485    fn to_attr(
2486        &self,
2487        field: &SubmittedField<'_>,
2488        _opts: &ResolvedOptions,
2489        _mode: Mode,
2490    ) -> Result<Option<AttrValue>, String> {
2491        // Absent means unchecked, so this always writes a value: leaving the
2492        // attribute out would keep the old one and the box would never clear.
2493        Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
2494    }
2495
2496    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2497        if stored_is_on(stored) {
2498            "1".to_string()
2499        } else {
2500            String::new()
2501        }
2502    }
2503
2504    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2505        scalar_vm("switch", cx)
2506    }
2507
2508    fn render_default(&self, vm: &FieldVm) -> Markup {
2509        Markup::from_template(&SwitchTmpl {
2510            name: &vm.name,
2511            id: &vm.id,
2512            on: stored_is_on(Some(vm.value.as_text())),
2513        })
2514        .unwrap_or_default()
2515    }
2516}
2517
2518#[derive(Template)]
2519#[template(path = "fields/date.html")]
2520struct DateTmpl<'a> {
2521    name: &'a str,
2522    id: &'a str,
2523    value: &'a str,
2524    required: bool,
2525}
2526
2527/// A calendar date.
2528///
2529/// Stores `YYYY-MM-DD`, the format a date control sends. Presenting is where the
2530/// work is: the column may hold a full timestamp, which a date input rejects, so
2531/// a stored value is trimmed back to its date.
2532pub(crate) struct DateField;
2533
2534impl FieldType for DateField {
2535    fn view_key(&self) -> &'static str {
2536        "date"
2537    }
2538    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2539        keys(&[])
2540    }
2541
2542    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
2543        vec![Rule::Date]
2544    }
2545
2546    fn to_attr(
2547        &self,
2548        field: &SubmittedField<'_>,
2549        _opts: &ResolvedOptions,
2550        _mode: Mode,
2551    ) -> Result<Option<AttrValue>, String> {
2552        Ok(field.value().map(|v| match v.trim() {
2553            "" => AttrValue::Null,
2554            date => AttrValue::Text(date.to_string()),
2555        }))
2556    }
2557
2558    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2559        let stored = stored.unwrap_or_default().trim();
2560        // A timestamp column hands back the whole instant; the control wants the
2561        // date, and everything the framework stores starts with one.
2562        stored
2563            .split(['T', ' '])
2564            .next()
2565            .unwrap_or_default()
2566            .to_string()
2567    }
2568
2569    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2570        scalar_vm("date", cx)
2571    }
2572
2573    fn render_default(&self, vm: &FieldVm) -> Markup {
2574        Markup::from_template(&DateTmpl {
2575            name: &vm.name,
2576            id: &vm.id,
2577            value: vm.value.as_text(),
2578            required: vm.required,
2579        })
2580        .unwrap_or_default()
2581    }
2582}
2583
2584#[derive(Template)]
2585#[template(path = "fields/password.html")]
2586struct PasswordTmpl<'a> {
2587    name: &'a str,
2588    id: &'a str,
2589    required: bool,
2590    reveal: bool,
2591    show: &'a str,
2592}
2593
2594/// A password field's own key: whether it offers to show what was typed.
2595#[derive(Debug, Clone, Serialize, Deserialize)]
2596struct PasswordOptions {
2597    #[serde(default = "yes")]
2598    reveal: bool,
2599    /// The reveal control's name, localized when the field is rendered.
2600    #[serde(default)]
2601    show: String,
2602}
2603
2604impl Default for PasswordOptions {
2605    fn default() -> Self {
2606        Self {
2607            reveal: true,
2608            show: String::new(),
2609        }
2610    }
2611}
2612
2613/// A password: hashed on the way in, never shown on the way out.
2614///
2615/// Both halves matter. The control renders with no value, so a stored hash
2616/// cannot leak into the page, and a blank submission omits the attribute, so
2617/// editing a record without touching its password leaves that password alone.
2618/// Require it on create (`required_on(Mode::Create)`) if a blank one is not
2619/// acceptable there.
2620pub(crate) struct PasswordField;
2621
2622impl FieldType for PasswordField {
2623    fn view_key(&self) -> &'static str {
2624        "password"
2625    }
2626    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2627        keys(&["reveal"])
2628    }
2629    fn resolve_options(
2630        &self,
2631        raw: &serde_json::Value,
2632        _types: &FieldRegistry,
2633    ) -> Result<ResolvedOptions, OptionsError> {
2634        let opts: PasswordOptions = if raw.is_null() {
2635            PasswordOptions::default()
2636        } else {
2637            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2638        };
2639        Ok(ResolvedOptions::new(opts))
2640    }
2641
2642    fn to_attr(
2643        &self,
2644        field: &SubmittedField<'_>,
2645        _opts: &ResolvedOptions,
2646        _mode: Mode,
2647    ) -> Result<Option<AttrValue>, String> {
2648        match field.value().map(str::trim).unwrap_or_default() {
2649            // Absent or blank: leave the stored password as it is.
2650            "" => Ok(None),
2651            plain => laterite_auth::password::hash_password(plain)
2652                .map(|hash| Some(AttrValue::Text(hash)))
2653                .map_err(|e| e.to_string()),
2654        }
2655    }
2656
2657    /// Never echoes: a hash must not reach the page, and a browser must not be
2658    /// invited to refill it.
2659    fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2660        String::new()
2661    }
2662
2663    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2664        let mut vm = scalar_vm("password", cx);
2665        let mut opts = cx
2666            .opts
2667            .get::<PasswordOptions>()
2668            .cloned()
2669            .unwrap_or_default();
2670        opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2671        vm.data = serde_json::to_value(opts).unwrap_or_default();
2672        vm
2673    }
2674
2675    fn render_default(&self, vm: &FieldVm) -> Markup {
2676        let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2677        Markup::from_template(&PasswordTmpl {
2678            name: &vm.name,
2679            id: &vm.id,
2680            required: vm.required,
2681            reveal: opts.reveal,
2682            show: &opts.show,
2683        })
2684        .unwrap_or_default()
2685    }
2686}
2687
2688#[derive(Template)]
2689#[template(path = "fields/radio.html")]
2690struct RadioTmpl<'a> {
2691    name: &'a str,
2692    options: &'a [OptionView],
2693    required: bool,
2694}
2695
2696/// One choice from a fixed list, shown as radios rather than a dropdown.
2697///
2698/// The same options as `select`, so a descriptor swaps between them by changing
2699/// the type alone. Suits a short list where seeing every choice matters.
2700pub(crate) struct RadioField;
2701
2702impl FieldType for RadioField {
2703    fn view_key(&self) -> &'static str {
2704        "radio"
2705    }
2706    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2707        keys(&["options"])
2708    }
2709
2710    fn resolve_options(
2711        &self,
2712        raw: &serde_json::Value,
2713        _types: &FieldRegistry,
2714    ) -> Result<ResolvedOptions, OptionsError> {
2715        let opts: SelectOptions =
2716            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2717        Ok(ResolvedOptions::new(opts))
2718    }
2719
2720    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2721        let current = cx.value.as_text();
2722        let views: Vec<OptionView> = cx
2723            .opts
2724            .get::<SelectOptions>()
2725            .map(|o| {
2726                o.options
2727                    .iter()
2728                    .map(|so| OptionView {
2729                        value: so.value.clone(),
2730                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2731                        selected: so.value == current,
2732                    })
2733                    .collect()
2734            })
2735            .unwrap_or_default();
2736        FieldVm {
2737            view_key: "radio".to_string(),
2738            name: cx.name.to_string(),
2739            id: cx.id.to_string(),
2740            label: cx.label.to_string(),
2741            required: cx.required,
2742            value: cx.value.clone(),
2743            data: serde_json::to_value(&views).unwrap_or_default(),
2744        }
2745    }
2746
2747    fn render_default(&self, vm: &FieldVm) -> Markup {
2748        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2749        Markup::from_template(&RadioTmpl {
2750            name: &vm.name,
2751            options: &options,
2752            required: vm.required,
2753        })
2754        .unwrap_or_default()
2755    }
2756}
2757
2758/// The view-model common to scalar text-like fields (no per-type `data`).
2759fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2760    FieldVm {
2761        view_key: view_key.to_string(),
2762        name: cx.name.to_string(),
2763        id: cx.id.to_string(),
2764        label: cx.label.to_string(),
2765        required: cx.required,
2766        value: cx.value.clone(),
2767        data: serde_json::Value::Null,
2768    }
2769}
2770
2771/// The reference-picker field's option: which registered source to pick from.
2772#[derive(Deserialize)]
2773struct RefOptions {
2774    source: String,
2775}
2776
2777/// The reference-picker's resolved options: the source name, validated at boot.
2778struct RefResolved {
2779    source: String,
2780}
2781
2782/// The reference-picker's view-model payload: the source and the endpoint URLs
2783/// its widget calls, plus the stored id (which the hidden input submits).
2784#[derive(Default, Serialize, Deserialize)]
2785struct RefData {
2786    source: String,
2787    search_url: String,
2788    resolve_url: String,
2789    value: String,
2790    placeholder: String,
2791}
2792
2793#[derive(Template)]
2794#[template(path = "fields/ref_picker.html")]
2795struct RefPickerTmpl<'a> {
2796    name: &'a str,
2797    id: &'a str,
2798    value: &'a str,
2799    search_url: &'a str,
2800    resolve_url: &'a str,
2801    placeholder: &'a str,
2802}
2803
2804/// A picker over a reference to another record: stores that record's id, shows
2805/// its label, and offers typeahead candidates from a registered [`PickerSource`]
2806/// (selected by the `source` option). The label and candidates are fetched by
2807/// its widget from the source's endpoints; rendering stays pure.
2808pub(crate) struct RefPickerField {
2809    pickers: Arc<PickerRegistry>,
2810}
2811
2812impl RefPickerField {
2813    pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2814        Self { pickers }
2815    }
2816}
2817
2818/// A record id held as a number: a relation's foreign key, or a plain key
2819/// column with no picker. Hidden on a relation's form, a hidden input
2820/// otherwise; stored as an integer, so a bigint column takes it on every
2821/// backend, and blank stores null.
2822pub(crate) struct IdField;
2823
2824impl FieldType for IdField {
2825    fn view_key(&self) -> &'static str {
2826        "id"
2827    }
2828    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2829        keys(&[])
2830    }
2831    fn resolve_options(
2832        &self,
2833        _raw: &serde_json::Value,
2834        _types: &FieldRegistry,
2835    ) -> Result<ResolvedOptions, OptionsError> {
2836        Ok(ResolvedOptions::new(()))
2837    }
2838    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2839        FieldVm {
2840            view_key: "id".to_string(),
2841            name: cx.name.to_string(),
2842            id: cx.id.to_string(),
2843            label: cx.label.to_string(),
2844            required: cx.required,
2845            value: cx.value.clone(),
2846            data: serde_json::Value::Null,
2847        }
2848    }
2849    fn render_default(&self, vm: &FieldVm) -> Markup {
2850        Markup::from_override(format!(
2851            r#"<input type="hidden" name="{}" value="{}">"#,
2852            crate::form::escape_attr(&vm.name),
2853            crate::form::escape_attr(vm.value.as_text())
2854        ))
2855    }
2856    fn to_attr(
2857        &self,
2858        field: &SubmittedField<'_>,
2859        _opts: &ResolvedOptions,
2860        _mode: Mode,
2861    ) -> Result<Option<AttrValue>, String> {
2862        Ok(field.value().map(id_attr))
2863    }
2864}
2865
2866/// An id as the storage layer wants it: a number, or null for a blank. A
2867/// value that is not a number is kept as text so the database says so.
2868fn id_attr(value: &str) -> AttrValue {
2869    let value = value.trim();
2870    if value.is_empty() {
2871        AttrValue::Null
2872    } else if let Ok(id) = value.parse::<i64>() {
2873        AttrValue::Int(id)
2874    } else {
2875        AttrValue::Text(value.to_string())
2876    }
2877}
2878
2879impl FieldType for RefPickerField {
2880    /// The chosen record's id, as a number: a bigint column takes it on every
2881    /// backend, and a cleared picker stores null.
2882    fn to_attr(
2883        &self,
2884        field: &SubmittedField<'_>,
2885        _opts: &ResolvedOptions,
2886        _mode: Mode,
2887    ) -> Result<Option<AttrValue>, String> {
2888        Ok(field.value().map(id_attr))
2889    }
2890    fn view_key(&self) -> &'static str {
2891        "reference"
2892    }
2893    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2894        keys(&["source"])
2895    }
2896    fn resolve_options(
2897        &self,
2898        raw: &serde_json::Value,
2899        _types: &FieldRegistry,
2900    ) -> Result<ResolvedOptions, OptionsError> {
2901        let opts: RefOptions =
2902            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2903        if !self.pickers.contains_key(&opts.source) {
2904            return Err(OptionsError(format!(
2905                "unknown picker source `{}`",
2906                opts.source
2907            )));
2908        }
2909        Ok(ResolvedOptions::new(RefResolved {
2910            source: opts.source,
2911        }))
2912    }
2913    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2914        let source = cx
2915            .opts
2916            .get::<RefResolved>()
2917            .map(|r| r.source.as_str())
2918            .unwrap_or_default();
2919        let data = RefData {
2920            search_url: format!("{}/pickers/{}/search", cx.base, source),
2921            resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2922            source: source.to_string(),
2923            value: cx.value.as_text().to_string(),
2924            placeholder: "Search…".to_string(),
2925        };
2926        FieldVm {
2927            view_key: "reference".to_string(),
2928            name: cx.name.to_string(),
2929            id: cx.id.to_string(),
2930            label: cx.label.to_string(),
2931            required: cx.required,
2932            value: cx.value.clone(),
2933            data: serde_json::to_value(data).unwrap_or_default(),
2934        }
2935    }
2936    fn render_default(&self, vm: &FieldVm) -> Markup {
2937        let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2938        Markup::from_template(&RefPickerTmpl {
2939            name: &vm.name,
2940            id: &vm.id,
2941            value: vm.value.as_text(),
2942            search_url: &data.search_url,
2943            resolve_url: &data.resolve_url,
2944            placeholder: &data.placeholder,
2945        })
2946        .unwrap_or_default()
2947    }
2948    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2949        vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2950    }
2951}
2952
2953/// The framework's icon set for a test's field context, leaked so the borrow
2954/// outlives the call; a test process is short-lived.
2955#[cfg(test)]
2956fn test_icons() -> crate::icons::Icons<'static> {
2957    crate::icons::Icons::new(
2958        Box::leak(Box::new(laterite_core::icons::IconSet::new())),
2959        "/admin/assets/icons.svg",
2960    )
2961}
2962
2963#[cfg(test)]
2964mod tests {
2965    use super::*;
2966
2967    #[test]
2968    fn an_id_is_stored_as_a_number_or_null() {
2969        assert!(matches!(id_attr("7"), AttrValue::Int(7)));
2970        assert!(matches!(id_attr(" 12 "), AttrValue::Int(12)));
2971        assert!(matches!(id_attr(""), AttrValue::Null));
2972        assert!(matches!(id_attr("x1"), AttrValue::Text(t) if t == "x1"));
2973    }
2974
2975    /// A translator with no catalogs, so every string resolves to its source:
2976    /// what a test wants unless it is testing translation itself.
2977    fn untranslated() -> Translator {
2978        Translator::new("en")
2979    }
2980
2981    fn cx_with<'a>(
2982        name: &'a str,
2983        value: &'a FieldValue,
2984        opts: &'a ResolvedOptions,
2985        i18n: &'a Translator,
2986    ) -> FieldCx<'a> {
2987        FieldCx {
2988            name,
2989            id: name,
2990            label: "Label",
2991            value,
2992            required: true,
2993            max_length: None,
2994            opts,
2995            base: "/admin",
2996            i18n,
2997            icons: test_icons(),
2998        }
2999    }
3000
3001    fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
3002        // Leaked so the borrow outlives the call; a test process is short-lived
3003        // and this keeps every existing call site unchanged.
3004        cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
3005    }
3006
3007    fn text_field() -> TextField {
3008        TextField::new(Arc::new(builtin_input_registry()))
3009    }
3010
3011    /// An input that contributes a reserved attribute name, to prove boot rejects it.
3012    struct BadAttrInput;
3013    impl InputType for BadAttrInput {
3014        fn key(&self) -> &'static str {
3015            "bad"
3016        }
3017        fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
3018            BTreeMap::from([("class".to_string(), "x".to_string())])
3019        }
3020    }
3021
3022    #[test]
3023    fn text_renders_its_value_escaped() {
3024        let opts = ResolvedOptions::none();
3025        let value = FieldValue::Text("a<b>&\"c".to_string());
3026        let markup = render_field(
3027            &text_field(),
3028            &NoOverrides,
3029            &scope(),
3030            &cx("title", &value, &opts),
3031        );
3032        let html = markup.as_str();
3033        assert!(html.contains(r#"name="title""#));
3034        assert!(html.contains("required"));
3035        // The value is HTML-escaped by the Askama partial: no raw tag injected,
3036        // and the angle/ampersand are escaped (named or numeric entity, Askama's
3037        // choice, so accept either).
3038        assert!(!html.contains("<b>"), "no raw tag injected: {html}");
3039        assert!(
3040            html.contains("&#60;") || html.contains("&lt;"),
3041            "angle escaped: {html}"
3042        );
3043        assert!(
3044            html.contains("&#38;") || html.contains("&amp;"),
3045            "ampersand escaped: {html}"
3046        );
3047    }
3048
3049    #[test]
3050    fn textarea_uses_its_own_control() {
3051        let opts = ResolvedOptions::none();
3052        let value = FieldValue::Text("body".to_string());
3053        let markup = render_field(
3054            &TextareaField,
3055            &NoOverrides,
3056            &scope(),
3057            &cx("body", &value, &opts),
3058        );
3059        assert!(markup.as_str().contains("<textarea"));
3060    }
3061
3062    #[test]
3063    fn code_editor_wraps_a_textarea_in_its_island_and_declares_the_engine() {
3064        let types = FieldRegistry::default();
3065        let opts = CodeEditorField
3066            .resolve_options(
3067                &serde_json::json!({ "language": "jinja", "rows": 20 }),
3068                &types,
3069            )
3070            .unwrap();
3071        let value = FieldValue::Text("{{ page.title }}".to_string());
3072        let markup = render_field(
3073            &CodeEditorField,
3074            &NoOverrides,
3075            &scope(),
3076            &cx("markup", &value, &opts),
3077        );
3078        let html = markup.as_str();
3079        assert!(html.contains(r#"data-lat-widget="codeeditor""#), "{html}");
3080        assert!(html.contains(r#"data-language="jinja""#), "{html}");
3081        assert!(html.contains(r#"rows="20""#), "{html}");
3082        assert!(html.contains("<textarea"), "{html}");
3083        assert_eq!(
3084            CodeEditorField.assets(&opts),
3085            crate::CODE_EDITOR_ASSETS.to_vec()
3086        );
3087        // A language the engine does not know is a boot error, not a silent plain editor.
3088        assert!(CodeEditorField
3089            .resolve_options(&serde_json::json!({ "language": "cobol" }), &types)
3090            .is_err());
3091    }
3092
3093    #[test]
3094    fn rich_editor_renders_its_toolbar_and_cleans_what_it_stores() {
3095        let types = FieldRegistry::default();
3096        let opts = RichEditorField
3097            .resolve_options(
3098                &serde_json::json!({ "toolbar": ["bold", "|", "link"] }),
3099                &types,
3100            )
3101            .unwrap();
3102        let value = FieldValue::Text("<p>Hi</p>".to_string());
3103        let markup = render_field(
3104            &RichEditorField,
3105            &NoOverrides,
3106            &scope(),
3107            &cx("body", &value, &opts),
3108        );
3109        let html = markup.as_str();
3110        assert!(html.contains(r#"data-lat-widget="richeditor""#), "{html}");
3111        assert!(html.contains(r#"data-rich-cmd="bold""#), "{html}");
3112        assert!(html.contains(r#"data-rich-cmd="link""#), "{html}");
3113        assert!(!html.contains(r#"data-rich-cmd="image""#), "{html}");
3114        assert!(html.contains("icons.svg#lat-bold"), "{html}");
3115        // The stored HTML travels in the textarea, escaped, never as markup.
3116        assert!(html.contains("<textarea"), "{html}");
3117        assert!(!html.contains("<p>Hi</p>"), "{html}");
3118        assert!(RichEditorField
3119            .resolve_options(&serde_json::json!({ "toolbar": ["blink"] }), &types)
3120            .is_err());
3121    }
3122
3123    #[test]
3124    fn markdown_renders_its_tabs_and_offers_no_preview_when_told() {
3125        let types = FieldRegistry::default();
3126        let value = FieldValue::Text("# Hi".to_string());
3127        let opts = MarkdownField
3128            .resolve_options(&serde_json::Value::Null, &types)
3129            .unwrap();
3130        let html = render_field(
3131            &MarkdownField,
3132            &NoOverrides,
3133            &scope(),
3134            &cx("body", &value, &opts),
3135        );
3136        let html = html.as_str();
3137        assert!(html.contains(r#"data-lat-widget="markdown""#), "{html}");
3138        assert!(
3139            html.contains(r#"data-preview="/admin/markdown/preview""#),
3140            "{html}"
3141        );
3142        assert!(html.contains(r#"data-md-tab="preview""#), "{html}");
3143        assert!(html.contains("icons.svg#lat-heading"), "{html}");
3144        assert_eq!(MarkdownField.assets(&opts), crate::MARKDOWN_ASSETS.to_vec());
3145        let opts = MarkdownField
3146            .resolve_options(&serde_json::json!({ "preview": false }), &types)
3147            .unwrap();
3148        let html = render_field(
3149            &MarkdownField,
3150            &NoOverrides,
3151            &scope(),
3152            &cx("body", &value, &opts),
3153        );
3154        assert!(!html.as_str().contains("data-md-tab"), "{}", html.as_str());
3155    }
3156
3157    #[test]
3158    fn no_overrides_uses_the_compiled_default() {
3159        // A resolver that never overrides yields the built-in markup unchanged.
3160        let opts = ResolvedOptions::none();
3161        let value = FieldValue::Text("x".to_string());
3162        let ft = text_field();
3163        let vm = ft.view_model(&cx("t", &value, &opts));
3164        let direct = ft.render_default(&vm);
3165        let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
3166        assert_eq!(direct.as_str(), routed.as_str());
3167    }
3168
3169    #[test]
3170    fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
3171        let field = text_field();
3172        let opts = field
3173            .resolve_options(
3174                &serde_json::json!({ "input": "email" }),
3175                &builtin_registry(),
3176            )
3177            .unwrap();
3178        // The email input variant contributes the Email rule.
3179        assert!(matches!(
3180            field.intrinsic_rules(&opts).as_slice(),
3181            [Rule::Email]
3182        ));
3183        let value = FieldValue::Text("a@b.test".to_string());
3184        let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
3185        assert!(markup.as_str().contains(r#"type="email""#));
3186    }
3187
3188    #[test]
3189    fn text_default_input_is_plain_text_with_no_extra_rule() {
3190        let field = text_field();
3191        let opts = field
3192            .resolve_options(&serde_json::Value::Null, &builtin_registry())
3193            .unwrap();
3194        assert!(field.intrinsic_rules(&opts).is_empty());
3195        let value = FieldValue::Text("hi".to_string());
3196        let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
3197        assert!(markup.as_str().contains(r#"type="text""#));
3198    }
3199
3200    #[test]
3201    fn text_number_input_sets_the_type_and_numeric_rule() {
3202        let field = text_field();
3203        let opts = field
3204            .resolve_options(
3205                &serde_json::json!({ "input": "number" }),
3206                &builtin_registry(),
3207            )
3208            .unwrap();
3209        assert!(matches!(
3210            field.intrinsic_rules(&opts).as_slice(),
3211            [Rule::Numeric]
3212        ));
3213        let value = FieldValue::Text("42".to_string());
3214        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3215        assert!(markup.as_str().contains(r#"type="number""#));
3216    }
3217
3218    #[test]
3219    fn text_tel_input_sets_the_type_with_no_rule() {
3220        let field = text_field();
3221        let opts = field
3222            .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
3223            .unwrap();
3224        assert!(field.intrinsic_rules(&opts).is_empty());
3225        let value = FieldValue::Text(String::new());
3226        let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
3227        assert!(markup.as_str().contains(r#"type="tel""#));
3228    }
3229
3230    #[test]
3231    fn text_url_input_sets_the_type_url_rule_and_copy_button() {
3232        let field = text_field();
3233        let opts = field
3234            .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
3235            .unwrap();
3236        assert!(matches!(
3237            field.intrinsic_rules(&opts).as_slice(),
3238            [Rule::Url]
3239        ));
3240        let value = FieldValue::Text("https://example.com".to_string());
3241        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3242        let html = markup.as_str();
3243        assert!(html.contains(r#"type="url""#), "{html}");
3244        assert!(html.contains("lat-input-group"), "{html}");
3245        assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
3246    }
3247
3248    #[test]
3249    fn text_url_input_copy_false_omits_the_button() {
3250        let field = text_field();
3251        let opts = field
3252            .resolve_options(
3253                &serde_json::json!({ "input": "url", "copy": false }),
3254                &builtin_registry(),
3255            )
3256            .unwrap();
3257        let value = FieldValue::Text("https://example.com".to_string());
3258        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3259        let html = markup.as_str();
3260        assert!(html.contains(r#"type="url""#), "{html}");
3261        // No adornment, so a plain input with no group and no widget hook.
3262        assert!(!html.contains("lat-input-group"), "{html}");
3263        assert!(!html.contains("data-lat-widget"), "{html}");
3264    }
3265
3266    #[test]
3267    fn text_number_input_emits_min_max_step_attributes() {
3268        let field = text_field();
3269        let opts = field
3270            .resolve_options(
3271                &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
3272                &builtin_registry(),
3273            )
3274            .unwrap();
3275        // Rendering runs vm -> data -> render_default, so attributes surviving into
3276        // the markup also proves the view-model serialize round-trip.
3277        let value = FieldValue::Text("5".to_string());
3278        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3279        let html = markup.as_str();
3280        assert!(html.contains(r#"min="0""#), "{html}");
3281        assert!(html.contains(r#"max="10""#), "{html}");
3282        assert!(html.contains(r#"step="2""#), "{html}");
3283    }
3284
3285    #[test]
3286    fn text_input_reserved_attribute_name_aborts_resolve() {
3287        let mut inputs = builtin_input_registry();
3288        inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
3289        let field = TextField::new(Arc::new(inputs));
3290        let Err(e) =
3291            field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
3292        else {
3293            panic!("expected a reserved-attribute rejection");
3294        };
3295        assert!(e.0.contains("class"), "{e}");
3296    }
3297
3298    #[test]
3299    fn attribute_and_widget_name_rules() {
3300        assert!(is_name("min", false));
3301        assert!(is_name("data-x", false));
3302        assert!(!is_name("Min", false));
3303        assert!(!is_name("min.x", false)); // a dot is not allowed in an attribute name
3304        assert!(is_name("vendor.copy", true)); // but is in a widget name
3305        assert!(!is_name("", false));
3306    }
3307
3308    #[test]
3309    fn select_renders_options_with_the_current_value_selected() {
3310        let raw = serde_json::json!({
3311            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3312        });
3313        let opts = SelectField
3314            .resolve_options(&raw, &builtin_registry())
3315            .unwrap();
3316        let value = FieldValue::Text("closed".to_string());
3317        let markup = render_field(
3318            &SelectField,
3319            &NoOverrides,
3320            &scope(),
3321            &cx("status", &value, &opts),
3322        );
3323        let html = markup.as_str();
3324        assert!(html.contains(r#"<option value="open">Open</option>"#));
3325        // The current value is selected; a missing label falls back to the value.
3326        assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
3327    }
3328
3329    #[test]
3330    fn radio_renders_the_same_options_with_the_current_one_checked() {
3331        let raw = serde_json::json!({
3332            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3333        });
3334        let opts = RadioField
3335            .resolve_options(&raw, &builtin_registry())
3336            .unwrap();
3337        let value = FieldValue::Text("closed".to_string());
3338        let markup = render_field(
3339            &RadioField,
3340            &NoOverrides,
3341            &scope(),
3342            &cx("status", &value, &opts),
3343        );
3344        let html = markup.as_str();
3345        assert!(html.contains(r#"value="open""#));
3346        assert!(
3347            html.contains(r#"value="closed" checked"#),
3348            "current is checked"
3349        );
3350        // Every radio shares the field's name, or the browser treats them as
3351        // separate controls and lets more than one be chosen.
3352        assert_eq!(html.matches(r#"name="status""#).count(), 2);
3353        // A missing label falls back to the value, as select does.
3354        assert!(html.contains("closed</label>"));
3355    }
3356
3357    fn scope<'a>() -> OverrideScope<'a> {
3358        OverrideScope {
3359            surface: Surface::Field,
3360            view_key: "text",
3361            resource: None,
3362            field: None,
3363        }
3364    }
3365
3366    fn ref_field() -> RefPickerField {
3367        let mut pickers = crate::picker::PickerRegistry::new();
3368        let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
3369        pickers.insert(
3370            "acme.place".to_string(),
3371            crate::picker::PickerSourceReg::new("acme.place", source),
3372        );
3373        RefPickerField::new(Arc::new(pickers))
3374    }
3375
3376    #[test]
3377    fn reference_rejects_an_unknown_source() {
3378        let field = ref_field();
3379        let Err(e) = field.resolve_options(
3380            &serde_json::json!({ "source": "no.such" }),
3381            &builtin_registry(),
3382        ) else {
3383            panic!("expected an unknown-source rejection");
3384        };
3385        assert!(e.0.contains("no.such"), "{e}");
3386    }
3387
3388    #[test]
3389    fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
3390        let field = ref_field();
3391        let opts = field
3392            .resolve_options(
3393                &serde_json::json!({ "source": "acme.place" }),
3394                &builtin_registry(),
3395            )
3396            .unwrap();
3397        let value = FieldValue::Text("42".to_string());
3398        let markup = render_field(
3399            &field,
3400            &NoOverrides,
3401            &scope(),
3402            &cx("place_id", &value, &opts),
3403        );
3404        let html = markup.as_str();
3405        // The stored id rides in the hidden input the form submits, so an untouched
3406        // form (or one with JS off) preserves the reference.
3407        assert!(html.contains(r#"type="hidden""#), "{html}");
3408        assert!(html.contains(r#"name="place_id""#), "{html}");
3409        assert!(html.contains(r#"value="42""#), "{html}");
3410        // The widget hook and its endpoint URLs (built from the mount path).
3411        assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
3412        assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
3413        assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
3414    }
3415
3416    #[test]
3417    fn reference_declares_its_widget_assets() {
3418        let field = ref_field();
3419        let opts = field
3420            .resolve_options(
3421                &serde_json::json!({ "source": "acme.place" }),
3422                &builtin_registry(),
3423            )
3424            .unwrap();
3425        assert_eq!(
3426            field.assets(&opts),
3427            vec!["fields/ref-picker.js", "fields/ref-picker.css"]
3428        );
3429    }
3430}
3431
3432#[cfg(test)]
3433mod repeater_tests {
3434    use super::*;
3435
3436    fn options() -> ResolvedOptions {
3437        RepeaterField
3438            .resolve_options(
3439                &serde_json::json!({
3440                    "fields": [
3441                        { "name": "path", "label": "Path", "type": "text" },
3442                        { "name": "allow", "label": "Allow", "type": "switch" },
3443                    ]
3444                }),
3445                &builtin_registry(),
3446            )
3447            .unwrap()
3448    }
3449
3450    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3451        pairs
3452            .iter()
3453            .map(|(k, v)| (k.to_string(), v.to_string()))
3454            .collect()
3455    }
3456
3457    fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
3458        let field = SubmittedField::new("rules", data);
3459        match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
3460            Some(AttrValue::Json(value)) => value,
3461            other => panic!("expected a JSON array, got {other:?}"),
3462        }
3463    }
3464
3465    /// A row's sub-fields go through their own field types, so a switch inside a
3466    /// row stores a bool rather than the string "on". This is the whole reason
3467    /// the repeater belongs in the field system.
3468    #[test]
3469    fn a_row_is_typed_by_its_sub_field_types() {
3470        let rows = stored(
3471            &submitted(&[
3472                ("rules[0][path]", "/private"),
3473                ("rules[1][path]", "/public"),
3474                ("rules[1][allow]", "on"),
3475            ]),
3476            &options(),
3477        );
3478
3479        assert_eq!(rows[0]["path"], "/private");
3480        assert_eq!(rows[0]["allow"], serde_json::json!(false));
3481        assert_eq!(rows[1]["allow"], serde_json::json!(true));
3482    }
3483
3484    /// Removing a row in the browser can leave a gap. Indices are read from the
3485    /// keys and sorted, so a gap costs nothing.
3486    #[test]
3487    fn rows_are_ordered_by_index_not_submission_order() {
3488        let rows = stored(
3489            &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
3490            &options(),
3491        );
3492        assert_eq!(rows.as_array().unwrap().len(), 2);
3493        assert_eq!(rows[0]["path"], "/first");
3494        assert_eq!(rows[1]["path"], "/last");
3495    }
3496
3497    #[test]
3498    fn a_row_left_blank_is_dropped() {
3499        let rows = stored(
3500            &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", "  ")]),
3501            &options(),
3502        );
3503        assert_eq!(rows.as_array().unwrap().len(), 1);
3504    }
3505
3506    #[test]
3507    fn nothing_submitted_stores_an_empty_list() {
3508        assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
3509    }
3510
3511    #[test]
3512    fn a_repeater_refuses_to_hold_a_repeater() {
3513        let err = RepeaterField
3514            .resolve_options(
3515                &serde_json::json!({
3516                    "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
3517                }),
3518                &builtin_registry(),
3519            )
3520            .err()
3521            .expect("a nested repeater is refused");
3522        assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
3523    }
3524
3525    #[test]
3526    fn an_unregistered_sub_field_type_is_refused_at_boot() {
3527        let err = RepeaterField
3528            .resolve_options(
3529                &serde_json::json!({
3530                    "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
3531                }),
3532                &builtin_registry(),
3533            )
3534            .err()
3535            .expect("an unregistered type is refused");
3536        assert!(err.0.contains("acme.nope"), "{}", err.0);
3537    }
3538
3539    #[test]
3540    fn row_counts_are_bounded_when_asked() {
3541        let opts = RepeaterField
3542            .resolve_options(
3543                &serde_json::json!({
3544                    "fields": [{ "name": "path", "label": "Path", "type": "text" }],
3545                    "min_items": 1,
3546                    "max_items": 2
3547                }),
3548                &builtin_registry(),
3549            )
3550            .unwrap();
3551
3552        let empty = HashMap::new();
3553        let too_few = SubmittedField::new("rules", &empty);
3554        assert!(RepeaterField
3555            .to_attr(&too_few, &opts, Mode::Create)
3556            .is_err());
3557
3558        let three = submitted(&[
3559            ("rules[0][path]", "/a"),
3560            ("rules[1][path]", "/b"),
3561            ("rules[2][path]", "/c"),
3562        ]);
3563        let field = SubmittedField::new("rules", &three);
3564        assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
3565    }
3566
3567    #[test]
3568    fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
3569        let value = FieldValue::Json(serde_json::json!([
3570            { "path": "/private", "allow": false },
3571        ]));
3572        let cx = FieldCx {
3573            name: "rules",
3574            id: "rules",
3575            label: "Rules",
3576            value: &value,
3577            required: false,
3578            max_length: None,
3579            opts: &options(),
3580            base: "/admin",
3581            i18n: &Translator::new("en"),
3582            icons: test_icons(),
3583        };
3584        let html = RepeaterField
3585            .render_default(&RepeaterField.view_model(&cx))
3586            .into_string();
3587
3588        assert!(html.contains(r#"name="rules[0][path]""#));
3589        assert!(html.contains(r#"value="/private""#));
3590        // The blank row is inert inside a <template> until the browser clones it.
3591        assert!(html.contains("lat-repeater__blank"));
3592        assert!(html.contains(r#"name="rules[__index__][path]""#));
3593    }
3594
3595    /// Resolved options for a list-mode repeater naming rows by `agent`.
3596    fn list_options() -> ResolvedOptions {
3597        RepeaterField
3598            .resolve_options(
3599                &serde_json::json!({
3600                    "fields": [
3601                        { "name": "agent", "label": "Agent", "type": "text" },
3602                        { "name": "disallow", "label": "Disallow", "type": "textarea" },
3603                    ],
3604                    "display": "list",
3605                    "summary_field": "agent",
3606                }),
3607                &builtin_registry(),
3608            )
3609            .unwrap()
3610    }
3611
3612    fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
3613        let cx = FieldCx {
3614            name: "rules",
3615            id: "rules",
3616            label: "Rules",
3617            value,
3618            required: false,
3619            max_length: None,
3620            opts,
3621            base: "/admin",
3622            i18n: &Translator::new("en"),
3623            icons: test_icons(),
3624        };
3625        RepeaterField
3626            .render_default(&RepeaterField.view_model(&cx))
3627            .into_string()
3628    }
3629
3630    #[test]
3631    fn a_list_repeater_collapses_its_rows_and_names_them() {
3632        let value = FieldValue::Json(serde_json::json!([
3633            { "agent": "Googlebot", "disallow": "/private\n/tmp" },
3634        ]));
3635        let html = render_with(&list_options(), &value);
3636        assert!(html.contains("lat-repeater--list"), "list mode is declared");
3637        assert!(html.contains("<details"), "a row collapses");
3638        assert!(
3639            html.contains("Googlebot"),
3640            "the naming field titles the row"
3641        );
3642        // The fields are still there, just behind the summary.
3643        assert!(html.contains(r#"name="rules[0][disallow]""#));
3644    }
3645
3646    #[test]
3647    fn a_row_with_no_name_yet_reads_as_untitled() {
3648        let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
3649        let html = render_with(&list_options(), &value);
3650        assert!(html.contains("lat-repeater__untitled"));
3651    }
3652
3653    #[test]
3654    fn the_default_layout_stays_inline() {
3655        // A single narrow column is better left expanded, so the mode is opt-in.
3656        let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
3657        let html = render_with(&options(), &value);
3658        assert!(!html.contains("lat-repeater--list"));
3659        assert!(!html.contains("<details"));
3660    }
3661
3662    #[test]
3663    fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
3664        // A summary that wraps is not a summary.
3665        assert_eq!(
3666            summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
3667            "/private"
3668        );
3669        assert_eq!(summary_text(&serde_json::json!("")), "");
3670    }
3671
3672    /// A repeater's sub-field labels are declared inside the field type and never
3673    /// pass through the caller that localizes an ordinary label, so before the
3674    /// context carried a translator they could only render from their source
3675    /// string: English on every screen, whatever the operator's locale.
3676    /// A module's own field type is offered to every form and settings screen,
3677    /// which is what keeps a custom input from meaning a framework change.
3678    #[test]
3679    fn a_contributed_field_type_names_itself_and_converts() {
3680        struct Money;
3681        impl FieldType for Money {
3682            fn view_key(&self) -> &'static str {
3683                "acme.money"
3684            }
3685            fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
3686                scalar_vm("acme.money", cx)
3687            }
3688            fn render_default(&self, _vm: &FieldVm) -> Markup {
3689                Markup::empty()
3690            }
3691            fn to_attr(
3692                &self,
3693                field: &SubmittedField<'_>,
3694                _opts: &ResolvedOptions,
3695                _mode: Mode,
3696            ) -> Result<Option<AttrValue>, String> {
3697                // Minor units, so the stored value never carries a float.
3698                let raw = field.value().unwrap_or("0");
3699                let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
3700                Ok(Some(AttrValue::Int(minor as i64)))
3701            }
3702        }
3703
3704        let reg = FieldTypeReg::new(Arc::new(Money));
3705        // The key is the type's own, so a registration cannot disagree with what
3706        // it registers.
3707        assert_eq!(reg.name(), "acme.money");
3708
3709        let mut registry = builtin_registry();
3710        let name = reg.name().to_string();
3711        assert!(
3712            registry.insert(name.clone(), reg.field_type).is_none(),
3713            "a dotted key does not collide with a built-in"
3714        );
3715
3716        let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
3717        let submitted = SubmittedField::new("price", &data);
3718        let converted = registry[&name]
3719            .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
3720            .unwrap();
3721        assert!(
3722            matches!(converted, Some(AttrValue::Int(1250))),
3723            "{converted:?}"
3724        );
3725    }
3726
3727    #[test]
3728    fn a_sub_field_label_is_localized_like_any_other() {
3729        use laterite_core::i18n::CatalogStore;
3730        use std::sync::Arc;
3731
3732        let po = concat!(
3733            "msgid \"\"\n",
3734            "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
3735            "\n",
3736            "msgid \"Path\"\n",
3737            "msgstr \"Path-kn\"\n",
3738        );
3739        let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
3740        let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
3741
3742        let value = FieldValue::Text(String::new());
3743        let cx = FieldCx {
3744            name: "rules",
3745            id: "rules",
3746            label: "Rules",
3747            value: &value,
3748            required: false,
3749            max_length: None,
3750            opts: &options(),
3751            base: "/admin",
3752            i18n: &i18n,
3753            icons: test_icons(),
3754        };
3755        let html = RepeaterField
3756            .render_default(&RepeaterField.view_model(&cx))
3757            .into_string();
3758
3759        assert!(
3760            html.contains("Path-kn"),
3761            "sub-label was not localized: {html}"
3762        );
3763        assert!(
3764            !html.contains(">Path<"),
3765            "the source string still leaked: {html}"
3766        );
3767    }
3768}
3769
3770#[cfg(test)]
3771mod save_contract_tests {
3772    use super::*;
3773
3774    /// One scalar submission, the way the form builds it for a field.
3775    fn one(raw: Option<&str>) -> HashMap<String, String> {
3776        raw.into_iter()
3777            .map(|v| ("f".to_string(), v.to_string()))
3778            .collect()
3779    }
3780
3781    fn none() -> ResolvedOptions {
3782        ResolvedOptions::none()
3783    }
3784
3785    #[test]
3786    fn a_switch_stores_a_bool_and_absence_means_off() {
3787        let f = SwitchField;
3788        // A browser sends nothing for an unchecked box, so absence must write
3789        // false rather than leave the column as it was.
3790        assert_eq!(
3791            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3792                .unwrap(),
3793            Some(AttrValue::Bool(false))
3794        );
3795        assert_eq!(
3796            f.to_attr(
3797                &SubmittedField::new("f", &one(Some("on"))),
3798                &none(),
3799                Mode::Create
3800            )
3801            .unwrap(),
3802            Some(AttrValue::Bool(true))
3803        );
3804    }
3805
3806    #[test]
3807    fn a_switch_reads_back_whatever_the_backend_gave() {
3808        let f = SwitchField;
3809        // sqlx::Any hands every backend's boolean back as text, and they differ.
3810        for on in ["1", "true", "t", "on"] {
3811            assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3812        }
3813        for off in ["0", "false", "f", ""] {
3814            assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3815        }
3816        assert!(f.to_control(None, &none()).is_empty());
3817    }
3818
3819    #[test]
3820    fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3821        let f = DateField;
3822        // A timestamp column hands back the whole instant; a date input rejects it.
3823        assert_eq!(
3824            f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3825            "2026-09-09"
3826        );
3827        assert_eq!(
3828            f.to_control(Some("2026-09-09 12:30:45"), &none()),
3829            "2026-09-09"
3830        );
3831        assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3832        assert_eq!(f.to_control(None, &none()), "");
3833    }
3834
3835    #[test]
3836    fn a_cleared_date_stores_null_not_an_empty_string() {
3837        let f = DateField;
3838        assert_eq!(
3839            f.to_attr(
3840                &SubmittedField::new("f", &one(Some(""))),
3841                &none(),
3842                Mode::Update
3843            )
3844            .unwrap(),
3845            Some(AttrValue::Null)
3846        );
3847        assert_eq!(
3848            f.to_attr(
3849                &SubmittedField::new("f", &one(Some("2026-09-09"))),
3850                &none(),
3851                Mode::Create
3852            )
3853            .unwrap(),
3854            Some(AttrValue::Text("2026-09-09".into()))
3855        );
3856        assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3857    }
3858
3859    #[test]
3860    fn a_password_hashes_and_never_echoes() {
3861        let f = PasswordField;
3862        let stored = f
3863            .to_attr(
3864                &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3865                &none(),
3866                Mode::Create,
3867            )
3868            .unwrap()
3869            .unwrap();
3870        let hash = stored.as_str().unwrap();
3871        assert!(
3872            hash.starts_with("$argon2"),
3873            "stored as a hash, not plaintext"
3874        );
3875        assert_ne!(hash, "hunter2hunter2");
3876        // The hash must never reach the page, whatever is stored.
3877        assert_eq!(f.to_control(Some(hash), &none()), "");
3878    }
3879
3880    #[test]
3881    fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3882        let f = PasswordField;
3883        // Omitted, so the write does not touch the column.
3884        assert_eq!(
3885            f.to_attr(
3886                &SubmittedField::new("f", &one(Some(""))),
3887                &none(),
3888                Mode::Update
3889            )
3890            .unwrap(),
3891            None
3892        );
3893        assert_eq!(
3894            f.to_attr(
3895                &SubmittedField::new("f", &one(Some("   "))),
3896                &none(),
3897                Mode::Update
3898            )
3899            .unwrap(),
3900            None
3901        );
3902        assert_eq!(
3903            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3904                .unwrap(),
3905            None
3906        );
3907    }
3908
3909    #[test]
3910    fn rich_text_is_cleaned_on_save_and_blank_when_empty() {
3911        let f = RichEditorField;
3912        let dirty = one(Some(
3913            r#"<p onclick="x()">Hi</p><script>1</script><img src="/a.png" data-media="4">"#,
3914        ));
3915        assert_eq!(
3916            f.to_attr(&SubmittedField::new("f", &dirty), &none(), Mode::Create)
3917                .unwrap(),
3918            Some(AttrValue::Text(
3919                r#"<p>Hi</p><img src="/a.png" data-media="4">"#.into()
3920            ))
3921        );
3922        assert_eq!(
3923            f.linked_media(Some(r#"<img data-media="4">"#), &none()),
3924            Some(vec![4])
3925        );
3926        // An empty editor submits a paragraph; the rules and the store see nothing.
3927        let blank = one(Some("<p></p>"));
3928        assert_eq!(
3929            f.submitted(&SubmittedField::new("f", &blank), &none()),
3930            Some(String::new())
3931        );
3932        assert_eq!(
3933            f.to_attr(&SubmittedField::new("f", &blank), &none(), Mode::Create)
3934                .unwrap(),
3935            Some(AttrValue::Text(String::new()))
3936        );
3937    }
3938
3939    #[test]
3940    fn a_plain_field_stores_and_shows_the_text_unchanged() {
3941        let f = TextareaField;
3942        assert_eq!(
3943            f.to_attr(
3944                &SubmittedField::new("f", &one(Some("hello"))),
3945                &none(),
3946                Mode::Create
3947            )
3948            .unwrap(),
3949            Some(AttrValue::Text("hello".into()))
3950        );
3951        // Absent stays absent, so the write leaves that column alone.
3952        assert_eq!(
3953            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3954                .unwrap(),
3955            None
3956        );
3957        assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3958    }
3959}