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
1727pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1728    vec![
1729        Arc::new(TextInput),
1730        Arc::new(EmailInput),
1731        Arc::new(TelInput),
1732        Arc::new(NumberInput),
1733        Arc::new(UrlInput),
1734    ]
1735}
1736
1737/// The input-type registry seeded with the built-in inputs.
1738pub(crate) fn builtin_input_registry() -> InputRegistry {
1739    builtin_input_types()
1740        .into_iter()
1741        .map(|i| (i.key().to_string(), i))
1742        .collect()
1743}
1744
1745/// Attribute names the text template emits itself; an input may not re-declare
1746/// them (HTML resolves a duplicate to the first, silently dropping the input's).
1747const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1748
1749/// Validates an input's contributed attribute and widget names at boot. Names
1750/// come from plugin code, so a bad one is a wiring bug caught at boot, not user
1751/// input: an attribute name is lowercase kebab and unreserved; a widget name may
1752/// carry a dotted plugin namespace.
1753fn validate_contributions(
1754    attrs: &BTreeMap<String, String>,
1755    adornments: &[Adornment],
1756) -> Result<(), OptionsError> {
1757    for name in attrs.keys() {
1758        if !is_name(name, false) {
1759            return Err(OptionsError(format!("invalid attribute name `{name}`")));
1760        }
1761        if RESERVED_ATTRS.contains(&name.as_str()) {
1762            return Err(OptionsError(format!("attribute `{name}` is reserved")));
1763        }
1764    }
1765    for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1766        if !is_name(widget, true) {
1767            return Err(OptionsError(format!("invalid widget name `{widget}`")));
1768        }
1769    }
1770    Ok(())
1771}
1772
1773/// `^[a-z][a-z0-9-]*$`, plus `.` when `dotted` (for a widget's `vendor.name`).
1774pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1775    let mut chars = s.chars();
1776    matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1777        && chars.all(|c| {
1778            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1779        })
1780}
1781
1782/// The text field's typed options: which input variant to render.
1783#[derive(Deserialize)]
1784struct TextOptions {
1785    #[serde(default = "default_input")]
1786    input: String,
1787    /// Shown in the empty input.
1788    #[serde(default)]
1789    placeholder: Option<String>,
1790    /// The count of characters against the limit a `max_length` rule sets.
1791    #[serde(default)]
1792    counter: crate::checklist::Offer,
1793}
1794
1795fn default_input() -> String {
1796    "text".to_string()
1797}
1798
1799/// The text field's resolved options: everything needed to render and validate,
1800/// computed (and name-checked) once at boot from the selected input.
1801struct TextResolved {
1802    html_type: &'static str,
1803    attrs: BTreeMap<String, String>,
1804    adornments: Vec<Adornment>,
1805    rules: Vec<Rule>,
1806    assets: Vec<&'static str>,
1807    counter: crate::checklist::Offer,
1808}
1809
1810/// The attributes a length limit puts on a control: `maxlength`, so the browser
1811/// holds the limit, and the counter's setting for the island that draws it.
1812fn length_attrs(
1813    attrs: &mut BTreeMap<String, String>,
1814    max_length: Option<usize>,
1815    counter: crate::checklist::Offer,
1816) {
1817    use crate::checklist::Offer;
1818    let Some(max) = max_length else {
1819        return;
1820    };
1821    attrs.insert("maxlength".to_string(), max.to_string());
1822    match counter {
1823        Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1824        Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1825        Offer::Never => None,
1826    };
1827}
1828
1829/// The text field's view-model payload. Additive over the prior `{input_type}`,
1830/// so existing overrides keep working; this shape is the override contract.
1831#[derive(Default, Serialize, Deserialize)]
1832struct TextData {
1833    input_type: String,
1834    #[serde(default)]
1835    attrs: BTreeMap<String, String>,
1836    #[serde(default)]
1837    adornments: Vec<Adornment>,
1838}
1839
1840#[derive(Template)]
1841#[template(path = "fields/text.html")]
1842struct TextTmpl<'a> {
1843    name: &'a str,
1844    id: &'a str,
1845    value: &'a str,
1846    required: bool,
1847    input_type: &'a str,
1848    attrs: &'a BTreeMap<String, String>,
1849    leading: &'a [Adornment],
1850    trailing: &'a [Adornment],
1851}
1852
1853/// A single-line text input. Its `input` option selects an [`InputType`] from
1854/// the registry (text, email, ...; plugins add more) that drives the HTML type
1855/// and intrinsic rules.
1856pub(crate) struct TextField {
1857    inputs: Arc<InputRegistry>,
1858}
1859
1860impl TextField {
1861    pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1862        Self { inputs }
1863    }
1864}
1865
1866impl FieldType for TextField {
1867    fn view_key(&self) -> &'static str {
1868        "text"
1869    }
1870    fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1871        let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1872        // An input this registry does not hold is refused by name when the
1873        // options resolve; its keys are not this check's to judge.
1874        let input = self.inputs.get(input)?;
1875        let mut all = vec![
1876            "input".to_string(),
1877            "placeholder".to_string(),
1878            "counter".to_string(),
1879        ];
1880        all.extend(input.option_keys().into_iter().map(str::to_string));
1881        Some(all)
1882    }
1883    fn resolve_options(
1884        &self,
1885        raw: &serde_json::Value,
1886        _types: &FieldRegistry,
1887    ) -> Result<ResolvedOptions, OptionsError> {
1888        let opts: TextOptions = if raw.is_null() {
1889            TextOptions {
1890                input: default_input(),
1891                placeholder: None,
1892                counter: Default::default(),
1893            }
1894        } else {
1895            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1896        };
1897        let input = self
1898            .inputs
1899            .get(&opts.input)
1900            .cloned()
1901            .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1902        // The input reads its own keys from the same blob, then contributes its
1903        // control shape; the contributed names are validated before caching.
1904        let input_opts = input.resolve_options(raw, _types)?;
1905        let mut attrs = input.attributes(&input_opts);
1906        let adornments = input.adornments(&input_opts);
1907        validate_contributions(&attrs, &adornments)?;
1908        if let Some(placeholder) = opts.placeholder {
1909            attrs.insert("placeholder".to_string(), placeholder);
1910        }
1911        Ok(ResolvedOptions::new(TextResolved {
1912            html_type: input.html_type(),
1913            rules: input.rules(&input_opts),
1914            assets: input.assets(&input_opts),
1915            attrs,
1916            adornments,
1917            counter: opts.counter,
1918        }))
1919    }
1920    fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1921        opts.get::<TextResolved>()
1922            .map(|r| r.rules.clone())
1923            .unwrap_or_default()
1924    }
1925    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1926        opts.get::<TextResolved>()
1927            .map(|r| r.assets.clone())
1928            .unwrap_or_default()
1929    }
1930    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1931        let mut data = match cx.opts.get::<TextResolved>() {
1932            Some(r) => TextData {
1933                input_type: r.html_type.to_string(),
1934                attrs: r.attrs.clone(),
1935                adornments: r.adornments.clone(),
1936            },
1937            None => TextData {
1938                input_type: "text".to_string(),
1939                ..TextData::default()
1940            },
1941        };
1942        // A number or a date has no length to count.
1943        if matches!(
1944            data.input_type.as_str(),
1945            "text" | "email" | "tel" | "url" | "search"
1946        ) {
1947            let counter = cx
1948                .opts
1949                .get::<TextResolved>()
1950                .map(|r| r.counter)
1951                .unwrap_or_default();
1952            length_attrs(&mut data.attrs, cx.max_length, counter);
1953        }
1954        FieldVm {
1955            view_key: "text".to_string(),
1956            name: cx.name.to_string(),
1957            id: cx.id.to_string(),
1958            label: cx.label.to_string(),
1959            required: cx.required,
1960            value: cx.value.clone(),
1961            data: serde_json::to_value(data).unwrap_or_default(),
1962        }
1963    }
1964    fn render_default(&self, vm: &FieldVm) -> Markup {
1965        let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1966        let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1967            .adornments
1968            .into_iter()
1969            .partition(|a| matches!(a.placement, Placement::Leading));
1970        Markup::from_template(&TextTmpl {
1971            name: &vm.name,
1972            id: &vm.id,
1973            value: vm.value.as_text(),
1974            required: vm.required,
1975            input_type: &data.input_type,
1976            attrs: &data.attrs,
1977            leading: &leading,
1978            trailing: &trailing,
1979        })
1980        .unwrap_or_default()
1981    }
1982}
1983
1984#[derive(Template)]
1985#[template(path = "fields/textarea.html")]
1986struct TextareaTmpl<'a> {
1987    name: &'a str,
1988    id: &'a str,
1989    value: &'a str,
1990    required: bool,
1991    rows: Option<u16>,
1992    placeholder: Option<&'a str>,
1993    grow: bool,
1994    attrs: &'a BTreeMap<String, String>,
1995}
1996
1997fn yes() -> bool {
1998    true
1999}
2000
2001/// A textarea's own keys: its height in rows, what an empty one shows, whether
2002/// it grows with what is typed, and the count against a `max_length` rule.
2003#[derive(Debug, Clone, Serialize, Deserialize)]
2004struct TextareaOptions {
2005    #[serde(default)]
2006    rows: Option<u16>,
2007    #[serde(default)]
2008    placeholder: Option<String>,
2009    #[serde(default = "yes")]
2010    grow: bool,
2011    #[serde(default)]
2012    counter: crate::checklist::Offer,
2013}
2014
2015impl Default for TextareaOptions {
2016    fn default() -> Self {
2017        Self {
2018            rows: None,
2019            placeholder: None,
2020            grow: true,
2021            counter: Default::default(),
2022        }
2023    }
2024}
2025
2026/// The textarea's view-model payload: its keys as resolved for this render.
2027#[derive(Debug, Clone, Serialize, Deserialize)]
2028struct TextareaData {
2029    #[serde(default)]
2030    rows: Option<u16>,
2031    #[serde(default)]
2032    placeholder: Option<String>,
2033    #[serde(default = "yes")]
2034    grow: bool,
2035    /// `maxlength` and the counter's setting, when a rule limits the length.
2036    #[serde(default)]
2037    attrs: BTreeMap<String, String>,
2038}
2039
2040impl Default for TextareaData {
2041    fn default() -> Self {
2042        Self {
2043            rows: None,
2044            placeholder: None,
2045            grow: true,
2046            attrs: BTreeMap::new(),
2047        }
2048    }
2049}
2050
2051/// A multi-line text input.
2052pub(crate) struct TextareaField;
2053
2054impl FieldType for TextareaField {
2055    fn view_key(&self) -> &'static str {
2056        "textarea"
2057    }
2058    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2059        keys(&["rows", "placeholder", "grow", "counter"])
2060    }
2061    fn resolve_options(
2062        &self,
2063        raw: &serde_json::Value,
2064        _types: &FieldRegistry,
2065    ) -> Result<ResolvedOptions, OptionsError> {
2066        let opts: TextareaOptions = if raw.is_null() {
2067            TextareaOptions::default()
2068        } else {
2069            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2070        };
2071        Ok(ResolvedOptions::new(opts))
2072    }
2073    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2074        let mut vm = scalar_vm("textarea", cx);
2075        let opts = cx
2076            .opts
2077            .get::<TextareaOptions>()
2078            .cloned()
2079            .unwrap_or_default();
2080        let mut data = TextareaData {
2081            rows: opts.rows,
2082            placeholder: opts.placeholder,
2083            grow: opts.grow,
2084            attrs: BTreeMap::new(),
2085        };
2086        length_attrs(&mut data.attrs, cx.max_length, opts.counter);
2087        vm.data = serde_json::to_value(data).unwrap_or_default();
2088        vm
2089    }
2090    fn render_default(&self, vm: &FieldVm) -> Markup {
2091        let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2092        Markup::from_template(&TextareaTmpl {
2093            name: &vm.name,
2094            id: &vm.id,
2095            value: vm.value.as_text(),
2096            required: vm.required,
2097            rows: data.rows,
2098            placeholder: data.placeholder.as_deref(),
2099            grow: data.grow,
2100            attrs: &data.attrs,
2101        })
2102        .unwrap_or_default()
2103    }
2104}
2105
2106/// A dropdown turns searchable past this many choices.
2107pub const SELECT_SEARCH_PAST: usize = 10;
2108
2109/// A `select` field's options: a list of value/label pairs, and whether the
2110/// dropdown is searched rather than scrolled.
2111#[derive(Debug, Default, Deserialize)]
2112struct SelectOptions {
2113    #[serde(default)]
2114    options: Vec<SelectOption>,
2115    #[serde(default)]
2116    search: crate::checklist::Offer,
2117}
2118
2119impl SelectOptions {
2120    fn searchable(&self) -> bool {
2121        use crate::checklist::Offer;
2122        match self.search {
2123            Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
2124            Offer::Always => true,
2125            Offer::Never => false,
2126        }
2127    }
2128}
2129
2130/// The select's view-model payload: its choices, and whether it is searched.
2131#[derive(Debug, Default, Serialize, Deserialize)]
2132struct SelectData {
2133    options: Vec<OptionView>,
2134    #[serde(default)]
2135    search: bool,
2136}
2137
2138#[derive(Debug, Deserialize)]
2139struct SelectOption {
2140    value: String,
2141    /// Display text; defaults to the value when omitted.
2142    #[serde(default)]
2143    label: Option<String>,
2144}
2145
2146/// One rendered option: label resolved, `selected` computed against the current
2147/// value. Carried in the view-model so an override presents the same list.
2148#[derive(Debug, Serialize, Deserialize)]
2149struct OptionView {
2150    value: String,
2151    label: String,
2152    selected: bool,
2153}
2154
2155#[derive(Template)]
2156#[template(path = "fields/select.html")]
2157struct SelectTmpl<'a> {
2158    name: &'a str,
2159    id: &'a str,
2160    required: bool,
2161    options: &'a [OptionView],
2162    search: bool,
2163}
2164
2165/// A dropdown over a fixed option list.
2166pub(crate) struct SelectField;
2167
2168impl FieldType for SelectField {
2169    fn view_key(&self) -> &'static str {
2170        "select"
2171    }
2172    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2173        keys(&["options", "search"])
2174    }
2175    /// The typeahead ships with the record picker; a searched dropdown needs it
2176    /// on the page too.
2177    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
2178        match opts.get::<SelectOptions>() {
2179            Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
2180            _ => Vec::new(),
2181        }
2182    }
2183    fn resolve_options(
2184        &self,
2185        raw: &serde_json::Value,
2186        _types: &FieldRegistry,
2187    ) -> Result<ResolvedOptions, OptionsError> {
2188        let opts: SelectOptions =
2189            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2190        Ok(ResolvedOptions::new(opts))
2191    }
2192    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2193        let current = cx.value.as_text();
2194        let opts = cx.opts.get::<SelectOptions>();
2195        let data = SelectData {
2196            options: opts
2197                .map(|o| {
2198                    o.options
2199                        .iter()
2200                        .map(|so| OptionView {
2201                            value: so.value.clone(),
2202                            label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2203                            selected: so.value == current,
2204                        })
2205                        .collect()
2206                })
2207                .unwrap_or_default(),
2208            search: opts.is_some_and(SelectOptions::searchable),
2209        };
2210        FieldVm {
2211            view_key: "select".to_string(),
2212            name: cx.name.to_string(),
2213            id: cx.id.to_string(),
2214            label: cx.label.to_string(),
2215            required: cx.required,
2216            value: cx.value.clone(),
2217            data: serde_json::to_value(&data).unwrap_or_default(),
2218        }
2219    }
2220    fn render_default(&self, vm: &FieldVm) -> Markup {
2221        let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2222        Markup::from_template(&SelectTmpl {
2223            name: &vm.name,
2224            id: &vm.id,
2225            required: vm.required,
2226            options: &data.options,
2227            search: data.search,
2228        })
2229        .unwrap_or_default()
2230    }
2231}
2232
2233/// A `checklist` field's keys.
2234#[derive(Debug, Default, Deserialize)]
2235struct ChecklistOptions {
2236    #[serde(default)]
2237    options: Vec<ChecklistEntry>,
2238    #[serde(default)]
2239    select_all: crate::checklist::Offer,
2240    #[serde(default)]
2241    search: crate::checklist::Offer,
2242    #[serde(default)]
2243    expand: crate::checklist::Expand,
2244}
2245
2246/// One entry under `options`: a choice, or a group holding entries of its own.
2247#[derive(Debug, Deserialize)]
2248#[serde(untagged)]
2249enum ChecklistEntry {
2250    Group {
2251        group: String,
2252        #[serde(default)]
2253        options: Vec<ChecklistEntry>,
2254    },
2255    Choice {
2256        value: String,
2257        /// Display text; defaults to the value when omitted.
2258        #[serde(default)]
2259        label: Option<String>,
2260        /// A second line under the label.
2261        #[serde(default)]
2262        note: Option<String>,
2263    },
2264}
2265
2266impl ChecklistOptions {
2267    /// Every value a box can submit, in the order declared.
2268    fn values(&self) -> Vec<&str> {
2269        fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
2270            for entry in entries {
2271                match entry {
2272                    ChecklistEntry::Choice { value, .. } => out.push(value),
2273                    ChecklistEntry::Group { options, .. } => walk(options, out),
2274                }
2275            }
2276        }
2277        let mut out = Vec::new();
2278        walk(&self.options, &mut out);
2279        out
2280    }
2281
2282    /// The ticked values among those declared, in the order declared. A value
2283    /// the descriptor does not offer is dropped, so a forged box stores nothing.
2284    fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
2285        self.values()
2286            .into_iter()
2287            .filter(|value| submitted.contains(value))
2288            .collect()
2289    }
2290}
2291
2292/// The values held by a stored or gathered checklist: a JSON array of strings.
2293fn checklist_values(text: &str) -> Vec<String> {
2294    serde_json::from_str(text).unwrap_or_default()
2295}
2296
2297/// Boxes in groups, storing the ticked values as a JSON array.
2298///
2299/// Each box submits under `name[position]`, so a form's one-value-a-key
2300/// submission carries them all.
2301pub(crate) struct ChecklistField;
2302
2303impl ChecklistField {
2304    fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
2305        let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
2306        match opts.get::<ChecklistOptions>() {
2307            Some(options) => options
2308                .ticked(&submitted)
2309                .into_iter()
2310                .map(str::to_string)
2311                .collect(),
2312            None => Vec::new(),
2313        }
2314    }
2315}
2316
2317impl FieldType for ChecklistField {
2318    fn view_key(&self) -> &'static str {
2319        "checklist"
2320    }
2321    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2322        keys(&["options", "select_all", "search", "expand"])
2323    }
2324    fn resolve_options(
2325        &self,
2326        raw: &serde_json::Value,
2327        _types: &FieldRegistry,
2328    ) -> Result<ResolvedOptions, OptionsError> {
2329        let opts: ChecklistOptions = if raw.is_null() {
2330            ChecklistOptions::default()
2331        } else {
2332            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2333        };
2334        let mut seen = std::collections::HashSet::new();
2335        for value in opts.values() {
2336            if !seen.insert(value) {
2337                return Err(OptionsError(format!("option `{value}` is listed twice")));
2338            }
2339        }
2340        Ok(ResolvedOptions::new(opts))
2341    }
2342
2343    fn to_attr(
2344        &self,
2345        field: &SubmittedField<'_>,
2346        opts: &ResolvedOptions,
2347        _mode: Mode,
2348    ) -> Result<Option<AttrValue>, String> {
2349        // No box ticked submits nothing, which has to store an empty list:
2350        // leaving the attribute out would keep the ticks that were just cleared.
2351        let values = Self::gathered(field, opts)
2352            .into_iter()
2353            .map(serde_json::Value::String)
2354            .collect();
2355        Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
2356    }
2357
2358    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2359        stored.unwrap_or("[]").to_string()
2360    }
2361
2362    fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
2363        let values = Self::gathered(field, opts);
2364        if values.is_empty() {
2365            return None;
2366        }
2367        serde_json::to_string(&values).ok()
2368    }
2369
2370    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2371        use crate::checklist::{Checklist, Choice, Entry, Group};
2372
2373        let ticked: Vec<String> = match cx.value {
2374            FieldValue::Json(serde_json::Value::Array(values)) => values
2375                .iter()
2376                .filter_map(|v| v.as_str().map(str::to_string))
2377                .collect(),
2378            FieldValue::Text(text) => checklist_values(text),
2379            _ => Vec::new(),
2380        };
2381
2382        fn build(
2383            entries: &[ChecklistEntry],
2384            name: &str,
2385            ticked: &[String],
2386            position: &mut usize,
2387        ) -> Vec<Entry> {
2388            entries
2389                .iter()
2390                .map(|entry| match entry {
2391                    ChecklistEntry::Choice { value, label, note } => {
2392                        let mut choice = Choice::new(
2393                            format!("{name}[{position}]"),
2394                            value.clone(),
2395                            label.clone().unwrap_or_else(|| value.clone()),
2396                        )
2397                        .checked(ticked.contains(value));
2398                        if let Some(note) = note {
2399                            choice = choice.note(note.clone());
2400                        }
2401                        *position += 1;
2402                        Entry::from(choice)
2403                    }
2404                    ChecklistEntry::Group { group, options } => Entry::from(Group::new(
2405                        group.clone(),
2406                        build(options, name, ticked, position),
2407                    )),
2408                })
2409                .collect()
2410        }
2411
2412        let mut list = Checklist::new(cx.label).id(cx.id);
2413        if let Some(options) = cx.opts.get::<ChecklistOptions>() {
2414            let mut position = 0;
2415            for entry in build(&options.options, cx.name, &ticked, &mut position) {
2416                list = list.entry(entry);
2417            }
2418            list = list
2419                .select_all(options.select_all)
2420                .search(options.search)
2421                .expand(options.expand);
2422        }
2423        FieldVm {
2424            view_key: "checklist".to_string(),
2425            name: cx.name.to_string(),
2426            id: cx.id.to_string(),
2427            label: cx.label.to_string(),
2428            required: cx.required,
2429            value: cx.value.clone(),
2430            data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
2431        }
2432    }
2433
2434    fn render_default(&self, vm: &FieldVm) -> Markup {
2435        serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
2436            .map(|view| view.render_markup())
2437            .unwrap_or_default()
2438    }
2439}
2440
2441#[derive(Template)]
2442#[template(path = "fields/switch.html")]
2443struct SwitchTmpl<'a> {
2444    name: &'a str,
2445    id: &'a str,
2446    on: bool,
2447}
2448
2449/// A boolean toggle over a bool column.
2450///
2451/// The only field whose submission can be absent rather than empty: a browser
2452/// sends nothing for an unchecked box, which is why absence has to mean `false`
2453/// here rather than "leave it alone".
2454pub(crate) struct SwitchField;
2455
2456/// Whether a stored value reads as true. Accepts what each backend gives back
2457/// for a boolean column, since `sqlx::Any` reads them all as text.
2458fn stored_is_on(stored: Option<&str>) -> bool {
2459    matches!(
2460        stored.map(str::trim).unwrap_or_default(),
2461        "1" | "true" | "TRUE" | "t" | "yes" | "on"
2462    )
2463}
2464
2465impl FieldType for SwitchField {
2466    fn view_key(&self) -> &'static str {
2467        "switch"
2468    }
2469    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2470        keys(&[])
2471    }
2472
2473    fn to_attr(
2474        &self,
2475        field: &SubmittedField<'_>,
2476        _opts: &ResolvedOptions,
2477        _mode: Mode,
2478    ) -> Result<Option<AttrValue>, String> {
2479        // Absent means unchecked, so this always writes a value: leaving the
2480        // attribute out would keep the old one and the box would never clear.
2481        Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
2482    }
2483
2484    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2485        if stored_is_on(stored) {
2486            "1".to_string()
2487        } else {
2488            String::new()
2489        }
2490    }
2491
2492    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2493        scalar_vm("switch", cx)
2494    }
2495
2496    fn render_default(&self, vm: &FieldVm) -> Markup {
2497        Markup::from_template(&SwitchTmpl {
2498            name: &vm.name,
2499            id: &vm.id,
2500            on: stored_is_on(Some(vm.value.as_text())),
2501        })
2502        .unwrap_or_default()
2503    }
2504}
2505
2506#[derive(Template)]
2507#[template(path = "fields/date.html")]
2508struct DateTmpl<'a> {
2509    name: &'a str,
2510    id: &'a str,
2511    value: &'a str,
2512    required: bool,
2513}
2514
2515/// A calendar date.
2516///
2517/// Stores `YYYY-MM-DD`, the format a date control sends. Presenting is where the
2518/// work is: the column may hold a full timestamp, which a date input rejects, so
2519/// a stored value is trimmed back to its date.
2520pub(crate) struct DateField;
2521
2522impl FieldType for DateField {
2523    fn view_key(&self) -> &'static str {
2524        "date"
2525    }
2526    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2527        keys(&[])
2528    }
2529
2530    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
2531        vec![Rule::Date]
2532    }
2533
2534    fn to_attr(
2535        &self,
2536        field: &SubmittedField<'_>,
2537        _opts: &ResolvedOptions,
2538        _mode: Mode,
2539    ) -> Result<Option<AttrValue>, String> {
2540        Ok(field.value().map(|v| match v.trim() {
2541            "" => AttrValue::Null,
2542            date => AttrValue::Text(date.to_string()),
2543        }))
2544    }
2545
2546    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2547        let stored = stored.unwrap_or_default().trim();
2548        // A timestamp column hands back the whole instant; the control wants the
2549        // date, and everything the framework stores starts with one.
2550        stored
2551            .split(['T', ' '])
2552            .next()
2553            .unwrap_or_default()
2554            .to_string()
2555    }
2556
2557    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2558        scalar_vm("date", cx)
2559    }
2560
2561    fn render_default(&self, vm: &FieldVm) -> Markup {
2562        Markup::from_template(&DateTmpl {
2563            name: &vm.name,
2564            id: &vm.id,
2565            value: vm.value.as_text(),
2566            required: vm.required,
2567        })
2568        .unwrap_or_default()
2569    }
2570}
2571
2572#[derive(Template)]
2573#[template(path = "fields/password.html")]
2574struct PasswordTmpl<'a> {
2575    name: &'a str,
2576    id: &'a str,
2577    required: bool,
2578    reveal: bool,
2579    show: &'a str,
2580}
2581
2582/// A password field's own key: whether it offers to show what was typed.
2583#[derive(Debug, Clone, Serialize, Deserialize)]
2584struct PasswordOptions {
2585    #[serde(default = "yes")]
2586    reveal: bool,
2587    /// The reveal control's name, localized when the field is rendered.
2588    #[serde(default)]
2589    show: String,
2590}
2591
2592impl Default for PasswordOptions {
2593    fn default() -> Self {
2594        Self {
2595            reveal: true,
2596            show: String::new(),
2597        }
2598    }
2599}
2600
2601/// A password: hashed on the way in, never shown on the way out.
2602///
2603/// Both halves matter. The control renders with no value, so a stored hash
2604/// cannot leak into the page, and a blank submission omits the attribute, so
2605/// editing a record without touching its password leaves that password alone.
2606/// Require it on create (`required_on(Mode::Create)`) if a blank one is not
2607/// acceptable there.
2608pub(crate) struct PasswordField;
2609
2610impl FieldType for PasswordField {
2611    fn view_key(&self) -> &'static str {
2612        "password"
2613    }
2614    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2615        keys(&["reveal"])
2616    }
2617    fn resolve_options(
2618        &self,
2619        raw: &serde_json::Value,
2620        _types: &FieldRegistry,
2621    ) -> Result<ResolvedOptions, OptionsError> {
2622        let opts: PasswordOptions = if raw.is_null() {
2623            PasswordOptions::default()
2624        } else {
2625            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2626        };
2627        Ok(ResolvedOptions::new(opts))
2628    }
2629
2630    fn to_attr(
2631        &self,
2632        field: &SubmittedField<'_>,
2633        _opts: &ResolvedOptions,
2634        _mode: Mode,
2635    ) -> Result<Option<AttrValue>, String> {
2636        match field.value().map(str::trim).unwrap_or_default() {
2637            // Absent or blank: leave the stored password as it is.
2638            "" => Ok(None),
2639            plain => laterite_auth::password::hash_password(plain)
2640                .map(|hash| Some(AttrValue::Text(hash)))
2641                .map_err(|e| e.to_string()),
2642        }
2643    }
2644
2645    /// Never echoes: a hash must not reach the page, and a browser must not be
2646    /// invited to refill it.
2647    fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2648        String::new()
2649    }
2650
2651    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2652        let mut vm = scalar_vm("password", cx);
2653        let mut opts = cx
2654            .opts
2655            .get::<PasswordOptions>()
2656            .cloned()
2657            .unwrap_or_default();
2658        opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2659        vm.data = serde_json::to_value(opts).unwrap_or_default();
2660        vm
2661    }
2662
2663    fn render_default(&self, vm: &FieldVm) -> Markup {
2664        let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2665        Markup::from_template(&PasswordTmpl {
2666            name: &vm.name,
2667            id: &vm.id,
2668            required: vm.required,
2669            reveal: opts.reveal,
2670            show: &opts.show,
2671        })
2672        .unwrap_or_default()
2673    }
2674}
2675
2676#[derive(Template)]
2677#[template(path = "fields/radio.html")]
2678struct RadioTmpl<'a> {
2679    name: &'a str,
2680    options: &'a [OptionView],
2681    required: bool,
2682}
2683
2684/// One choice from a fixed list, shown as radios rather than a dropdown.
2685///
2686/// The same options as `select`, so a descriptor swaps between them by changing
2687/// the type alone. Suits a short list where seeing every choice matters.
2688pub(crate) struct RadioField;
2689
2690impl FieldType for RadioField {
2691    fn view_key(&self) -> &'static str {
2692        "radio"
2693    }
2694    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2695        keys(&["options"])
2696    }
2697
2698    fn resolve_options(
2699        &self,
2700        raw: &serde_json::Value,
2701        _types: &FieldRegistry,
2702    ) -> Result<ResolvedOptions, OptionsError> {
2703        let opts: SelectOptions =
2704            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2705        Ok(ResolvedOptions::new(opts))
2706    }
2707
2708    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2709        let current = cx.value.as_text();
2710        let views: Vec<OptionView> = cx
2711            .opts
2712            .get::<SelectOptions>()
2713            .map(|o| {
2714                o.options
2715                    .iter()
2716                    .map(|so| OptionView {
2717                        value: so.value.clone(),
2718                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2719                        selected: so.value == current,
2720                    })
2721                    .collect()
2722            })
2723            .unwrap_or_default();
2724        FieldVm {
2725            view_key: "radio".to_string(),
2726            name: cx.name.to_string(),
2727            id: cx.id.to_string(),
2728            label: cx.label.to_string(),
2729            required: cx.required,
2730            value: cx.value.clone(),
2731            data: serde_json::to_value(&views).unwrap_or_default(),
2732        }
2733    }
2734
2735    fn render_default(&self, vm: &FieldVm) -> Markup {
2736        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2737        Markup::from_template(&RadioTmpl {
2738            name: &vm.name,
2739            options: &options,
2740            required: vm.required,
2741        })
2742        .unwrap_or_default()
2743    }
2744}
2745
2746/// The view-model common to scalar text-like fields (no per-type `data`).
2747fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2748    FieldVm {
2749        view_key: view_key.to_string(),
2750        name: cx.name.to_string(),
2751        id: cx.id.to_string(),
2752        label: cx.label.to_string(),
2753        required: cx.required,
2754        value: cx.value.clone(),
2755        data: serde_json::Value::Null,
2756    }
2757}
2758
2759/// The reference-picker field's option: which registered source to pick from.
2760#[derive(Deserialize)]
2761struct RefOptions {
2762    source: String,
2763}
2764
2765/// The reference-picker's resolved options: the source name, validated at boot.
2766struct RefResolved {
2767    source: String,
2768}
2769
2770/// The reference-picker's view-model payload: the source and the endpoint URLs
2771/// its widget calls, plus the stored id (which the hidden input submits).
2772#[derive(Default, Serialize, Deserialize)]
2773struct RefData {
2774    source: String,
2775    search_url: String,
2776    resolve_url: String,
2777    value: String,
2778    placeholder: String,
2779}
2780
2781#[derive(Template)]
2782#[template(path = "fields/ref_picker.html")]
2783struct RefPickerTmpl<'a> {
2784    name: &'a str,
2785    id: &'a str,
2786    value: &'a str,
2787    search_url: &'a str,
2788    resolve_url: &'a str,
2789    placeholder: &'a str,
2790}
2791
2792/// A picker over a reference to another record: stores that record's id, shows
2793/// its label, and offers typeahead candidates from a registered [`PickerSource`]
2794/// (selected by the `source` option). The label and candidates are fetched by
2795/// its widget from the source's endpoints; rendering stays pure.
2796pub(crate) struct RefPickerField {
2797    pickers: Arc<PickerRegistry>,
2798}
2799
2800impl RefPickerField {
2801    pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2802        Self { pickers }
2803    }
2804}
2805
2806/// A record id held as a number: a relation's foreign key, or a plain key
2807/// column with no picker. Hidden on a relation's form, a hidden input
2808/// otherwise; stored as an integer, so a bigint column takes it on every
2809/// backend, and blank stores null.
2810pub(crate) struct IdField;
2811
2812impl FieldType for IdField {
2813    fn view_key(&self) -> &'static str {
2814        "id"
2815    }
2816    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2817        keys(&[])
2818    }
2819    fn resolve_options(
2820        &self,
2821        _raw: &serde_json::Value,
2822        _types: &FieldRegistry,
2823    ) -> Result<ResolvedOptions, OptionsError> {
2824        Ok(ResolvedOptions::new(()))
2825    }
2826    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2827        FieldVm {
2828            view_key: "id".to_string(),
2829            name: cx.name.to_string(),
2830            id: cx.id.to_string(),
2831            label: cx.label.to_string(),
2832            required: cx.required,
2833            value: cx.value.clone(),
2834            data: serde_json::Value::Null,
2835        }
2836    }
2837    fn render_default(&self, vm: &FieldVm) -> Markup {
2838        Markup::from_override(format!(
2839            r#"<input type="hidden" name="{}" value="{}">"#,
2840            crate::form::escape_attr(&vm.name),
2841            crate::form::escape_attr(vm.value.as_text())
2842        ))
2843    }
2844    fn to_attr(
2845        &self,
2846        field: &SubmittedField<'_>,
2847        _opts: &ResolvedOptions,
2848        _mode: Mode,
2849    ) -> Result<Option<AttrValue>, String> {
2850        Ok(field.value().map(id_attr))
2851    }
2852}
2853
2854/// An id as the storage layer wants it: a number, or null for a blank. A
2855/// value that is not a number is kept as text so the database says so.
2856fn id_attr(value: &str) -> AttrValue {
2857    let value = value.trim();
2858    if value.is_empty() {
2859        AttrValue::Null
2860    } else if let Ok(id) = value.parse::<i64>() {
2861        AttrValue::Int(id)
2862    } else {
2863        AttrValue::Text(value.to_string())
2864    }
2865}
2866
2867impl FieldType for RefPickerField {
2868    /// The chosen record's id, as a number: a bigint column takes it on every
2869    /// backend, and a cleared picker stores null.
2870    fn to_attr(
2871        &self,
2872        field: &SubmittedField<'_>,
2873        _opts: &ResolvedOptions,
2874        _mode: Mode,
2875    ) -> Result<Option<AttrValue>, String> {
2876        Ok(field.value().map(id_attr))
2877    }
2878    fn view_key(&self) -> &'static str {
2879        "reference"
2880    }
2881    fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2882        keys(&["source"])
2883    }
2884    fn resolve_options(
2885        &self,
2886        raw: &serde_json::Value,
2887        _types: &FieldRegistry,
2888    ) -> Result<ResolvedOptions, OptionsError> {
2889        let opts: RefOptions =
2890            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2891        if !self.pickers.contains_key(&opts.source) {
2892            return Err(OptionsError(format!(
2893                "unknown picker source `{}`",
2894                opts.source
2895            )));
2896        }
2897        Ok(ResolvedOptions::new(RefResolved {
2898            source: opts.source,
2899        }))
2900    }
2901    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2902        let source = cx
2903            .opts
2904            .get::<RefResolved>()
2905            .map(|r| r.source.as_str())
2906            .unwrap_or_default();
2907        let data = RefData {
2908            search_url: format!("{}/pickers/{}/search", cx.base, source),
2909            resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2910            source: source.to_string(),
2911            value: cx.value.as_text().to_string(),
2912            placeholder: "Search…".to_string(),
2913        };
2914        FieldVm {
2915            view_key: "reference".to_string(),
2916            name: cx.name.to_string(),
2917            id: cx.id.to_string(),
2918            label: cx.label.to_string(),
2919            required: cx.required,
2920            value: cx.value.clone(),
2921            data: serde_json::to_value(data).unwrap_or_default(),
2922        }
2923    }
2924    fn render_default(&self, vm: &FieldVm) -> Markup {
2925        let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2926        Markup::from_template(&RefPickerTmpl {
2927            name: &vm.name,
2928            id: &vm.id,
2929            value: vm.value.as_text(),
2930            search_url: &data.search_url,
2931            resolve_url: &data.resolve_url,
2932            placeholder: &data.placeholder,
2933        })
2934        .unwrap_or_default()
2935    }
2936    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2937        vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2938    }
2939}
2940
2941/// The framework's icon set for a test's field context, leaked so the borrow
2942/// outlives the call; a test process is short-lived.
2943#[cfg(test)]
2944fn test_icons() -> crate::icons::Icons<'static> {
2945    crate::icons::Icons::new(
2946        Box::leak(Box::new(laterite_core::icons::IconSet::new())),
2947        "/admin/assets/icons.svg",
2948    )
2949}
2950
2951#[cfg(test)]
2952mod tests {
2953    use super::*;
2954
2955    #[test]
2956    fn an_id_is_stored_as_a_number_or_null() {
2957        assert!(matches!(id_attr("7"), AttrValue::Int(7)));
2958        assert!(matches!(id_attr(" 12 "), AttrValue::Int(12)));
2959        assert!(matches!(id_attr(""), AttrValue::Null));
2960        assert!(matches!(id_attr("x1"), AttrValue::Text(t) if t == "x1"));
2961    }
2962
2963    /// A translator with no catalogs, so every string resolves to its source:
2964    /// what a test wants unless it is testing translation itself.
2965    fn untranslated() -> Translator {
2966        Translator::new("en")
2967    }
2968
2969    fn cx_with<'a>(
2970        name: &'a str,
2971        value: &'a FieldValue,
2972        opts: &'a ResolvedOptions,
2973        i18n: &'a Translator,
2974    ) -> FieldCx<'a> {
2975        FieldCx {
2976            name,
2977            id: name,
2978            label: "Label",
2979            value,
2980            required: true,
2981            max_length: None,
2982            opts,
2983            base: "/admin",
2984            i18n,
2985            icons: test_icons(),
2986        }
2987    }
2988
2989    fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2990        // Leaked so the borrow outlives the call; a test process is short-lived
2991        // and this keeps every existing call site unchanged.
2992        cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2993    }
2994
2995    fn text_field() -> TextField {
2996        TextField::new(Arc::new(builtin_input_registry()))
2997    }
2998
2999    /// An input that contributes a reserved attribute name, to prove boot rejects it.
3000    struct BadAttrInput;
3001    impl InputType for BadAttrInput {
3002        fn key(&self) -> &'static str {
3003            "bad"
3004        }
3005        fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
3006            BTreeMap::from([("class".to_string(), "x".to_string())])
3007        }
3008    }
3009
3010    #[test]
3011    fn text_renders_its_value_escaped() {
3012        let opts = ResolvedOptions::none();
3013        let value = FieldValue::Text("a<b>&\"c".to_string());
3014        let markup = render_field(
3015            &text_field(),
3016            &NoOverrides,
3017            &scope(),
3018            &cx("title", &value, &opts),
3019        );
3020        let html = markup.as_str();
3021        assert!(html.contains(r#"name="title""#));
3022        assert!(html.contains("required"));
3023        // The value is HTML-escaped by the Askama partial: no raw tag injected,
3024        // and the angle/ampersand are escaped (named or numeric entity, Askama's
3025        // choice, so accept either).
3026        assert!(!html.contains("<b>"), "no raw tag injected: {html}");
3027        assert!(
3028            html.contains("&#60;") || html.contains("&lt;"),
3029            "angle escaped: {html}"
3030        );
3031        assert!(
3032            html.contains("&#38;") || html.contains("&amp;"),
3033            "ampersand escaped: {html}"
3034        );
3035    }
3036
3037    #[test]
3038    fn textarea_uses_its_own_control() {
3039        let opts = ResolvedOptions::none();
3040        let value = FieldValue::Text("body".to_string());
3041        let markup = render_field(
3042            &TextareaField,
3043            &NoOverrides,
3044            &scope(),
3045            &cx("body", &value, &opts),
3046        );
3047        assert!(markup.as_str().contains("<textarea"));
3048    }
3049
3050    #[test]
3051    fn code_editor_wraps_a_textarea_in_its_island_and_declares_the_engine() {
3052        let types = FieldRegistry::default();
3053        let opts = CodeEditorField
3054            .resolve_options(
3055                &serde_json::json!({ "language": "jinja", "rows": 20 }),
3056                &types,
3057            )
3058            .unwrap();
3059        let value = FieldValue::Text("{{ page.title }}".to_string());
3060        let markup = render_field(
3061            &CodeEditorField,
3062            &NoOverrides,
3063            &scope(),
3064            &cx("markup", &value, &opts),
3065        );
3066        let html = markup.as_str();
3067        assert!(html.contains(r#"data-lat-widget="codeeditor""#), "{html}");
3068        assert!(html.contains(r#"data-language="jinja""#), "{html}");
3069        assert!(html.contains(r#"rows="20""#), "{html}");
3070        assert!(html.contains("<textarea"), "{html}");
3071        assert_eq!(
3072            CodeEditorField.assets(&opts),
3073            crate::CODE_EDITOR_ASSETS.to_vec()
3074        );
3075        // A language the engine does not know is a boot error, not a silent plain editor.
3076        assert!(CodeEditorField
3077            .resolve_options(&serde_json::json!({ "language": "cobol" }), &types)
3078            .is_err());
3079    }
3080
3081    #[test]
3082    fn rich_editor_renders_its_toolbar_and_cleans_what_it_stores() {
3083        let types = FieldRegistry::default();
3084        let opts = RichEditorField
3085            .resolve_options(
3086                &serde_json::json!({ "toolbar": ["bold", "|", "link"] }),
3087                &types,
3088            )
3089            .unwrap();
3090        let value = FieldValue::Text("<p>Hi</p>".to_string());
3091        let markup = render_field(
3092            &RichEditorField,
3093            &NoOverrides,
3094            &scope(),
3095            &cx("body", &value, &opts),
3096        );
3097        let html = markup.as_str();
3098        assert!(html.contains(r#"data-lat-widget="richeditor""#), "{html}");
3099        assert!(html.contains(r#"data-rich-cmd="bold""#), "{html}");
3100        assert!(html.contains(r#"data-rich-cmd="link""#), "{html}");
3101        assert!(!html.contains(r#"data-rich-cmd="image""#), "{html}");
3102        assert!(html.contains("icons.svg#lat-bold"), "{html}");
3103        // The stored HTML travels in the textarea, escaped, never as markup.
3104        assert!(html.contains("<textarea"), "{html}");
3105        assert!(!html.contains("<p>Hi</p>"), "{html}");
3106        assert!(RichEditorField
3107            .resolve_options(&serde_json::json!({ "toolbar": ["blink"] }), &types)
3108            .is_err());
3109    }
3110
3111    #[test]
3112    fn markdown_renders_its_tabs_and_offers_no_preview_when_told() {
3113        let types = FieldRegistry::default();
3114        let value = FieldValue::Text("# Hi".to_string());
3115        let opts = MarkdownField
3116            .resolve_options(&serde_json::Value::Null, &types)
3117            .unwrap();
3118        let html = render_field(
3119            &MarkdownField,
3120            &NoOverrides,
3121            &scope(),
3122            &cx("body", &value, &opts),
3123        );
3124        let html = html.as_str();
3125        assert!(html.contains(r#"data-lat-widget="markdown""#), "{html}");
3126        assert!(
3127            html.contains(r#"data-preview="/admin/markdown/preview""#),
3128            "{html}"
3129        );
3130        assert!(html.contains(r#"data-md-tab="preview""#), "{html}");
3131        assert!(html.contains("icons.svg#lat-heading"), "{html}");
3132        assert_eq!(MarkdownField.assets(&opts), crate::MARKDOWN_ASSETS.to_vec());
3133        let opts = MarkdownField
3134            .resolve_options(&serde_json::json!({ "preview": false }), &types)
3135            .unwrap();
3136        let html = render_field(
3137            &MarkdownField,
3138            &NoOverrides,
3139            &scope(),
3140            &cx("body", &value, &opts),
3141        );
3142        assert!(!html.as_str().contains("data-md-tab"), "{}", html.as_str());
3143    }
3144
3145    #[test]
3146    fn no_overrides_uses_the_compiled_default() {
3147        // A resolver that never overrides yields the built-in markup unchanged.
3148        let opts = ResolvedOptions::none();
3149        let value = FieldValue::Text("x".to_string());
3150        let ft = text_field();
3151        let vm = ft.view_model(&cx("t", &value, &opts));
3152        let direct = ft.render_default(&vm);
3153        let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
3154        assert_eq!(direct.as_str(), routed.as_str());
3155    }
3156
3157    #[test]
3158    fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
3159        let field = text_field();
3160        let opts = field
3161            .resolve_options(
3162                &serde_json::json!({ "input": "email" }),
3163                &builtin_registry(),
3164            )
3165            .unwrap();
3166        // The email input variant contributes the Email rule.
3167        assert!(matches!(
3168            field.intrinsic_rules(&opts).as_slice(),
3169            [Rule::Email]
3170        ));
3171        let value = FieldValue::Text("a@b.test".to_string());
3172        let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
3173        assert!(markup.as_str().contains(r#"type="email""#));
3174    }
3175
3176    #[test]
3177    fn text_default_input_is_plain_text_with_no_extra_rule() {
3178        let field = text_field();
3179        let opts = field
3180            .resolve_options(&serde_json::Value::Null, &builtin_registry())
3181            .unwrap();
3182        assert!(field.intrinsic_rules(&opts).is_empty());
3183        let value = FieldValue::Text("hi".to_string());
3184        let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
3185        assert!(markup.as_str().contains(r#"type="text""#));
3186    }
3187
3188    #[test]
3189    fn text_number_input_sets_the_type_and_numeric_rule() {
3190        let field = text_field();
3191        let opts = field
3192            .resolve_options(
3193                &serde_json::json!({ "input": "number" }),
3194                &builtin_registry(),
3195            )
3196            .unwrap();
3197        assert!(matches!(
3198            field.intrinsic_rules(&opts).as_slice(),
3199            [Rule::Numeric]
3200        ));
3201        let value = FieldValue::Text("42".to_string());
3202        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3203        assert!(markup.as_str().contains(r#"type="number""#));
3204    }
3205
3206    #[test]
3207    fn text_tel_input_sets_the_type_with_no_rule() {
3208        let field = text_field();
3209        let opts = field
3210            .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
3211            .unwrap();
3212        assert!(field.intrinsic_rules(&opts).is_empty());
3213        let value = FieldValue::Text(String::new());
3214        let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
3215        assert!(markup.as_str().contains(r#"type="tel""#));
3216    }
3217
3218    #[test]
3219    fn text_url_input_sets_the_type_url_rule_and_copy_button() {
3220        let field = text_field();
3221        let opts = field
3222            .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
3223            .unwrap();
3224        assert!(matches!(
3225            field.intrinsic_rules(&opts).as_slice(),
3226            [Rule::Url]
3227        ));
3228        let value = FieldValue::Text("https://example.com".to_string());
3229        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3230        let html = markup.as_str();
3231        assert!(html.contains(r#"type="url""#), "{html}");
3232        assert!(html.contains("lat-input-group"), "{html}");
3233        assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
3234    }
3235
3236    #[test]
3237    fn text_url_input_copy_false_omits_the_button() {
3238        let field = text_field();
3239        let opts = field
3240            .resolve_options(
3241                &serde_json::json!({ "input": "url", "copy": false }),
3242                &builtin_registry(),
3243            )
3244            .unwrap();
3245        let value = FieldValue::Text("https://example.com".to_string());
3246        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3247        let html = markup.as_str();
3248        assert!(html.contains(r#"type="url""#), "{html}");
3249        // No adornment, so a plain input with no group and no widget hook.
3250        assert!(!html.contains("lat-input-group"), "{html}");
3251        assert!(!html.contains("data-lat-widget"), "{html}");
3252    }
3253
3254    #[test]
3255    fn text_number_input_emits_min_max_step_attributes() {
3256        let field = text_field();
3257        let opts = field
3258            .resolve_options(
3259                &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
3260                &builtin_registry(),
3261            )
3262            .unwrap();
3263        // Rendering runs vm -> data -> render_default, so attributes surviving into
3264        // the markup also proves the view-model serialize round-trip.
3265        let value = FieldValue::Text("5".to_string());
3266        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3267        let html = markup.as_str();
3268        assert!(html.contains(r#"min="0""#), "{html}");
3269        assert!(html.contains(r#"max="10""#), "{html}");
3270        assert!(html.contains(r#"step="2""#), "{html}");
3271    }
3272
3273    #[test]
3274    fn text_input_reserved_attribute_name_aborts_resolve() {
3275        let mut inputs = builtin_input_registry();
3276        inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
3277        let field = TextField::new(Arc::new(inputs));
3278        let Err(e) =
3279            field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
3280        else {
3281            panic!("expected a reserved-attribute rejection");
3282        };
3283        assert!(e.0.contains("class"), "{e}");
3284    }
3285
3286    #[test]
3287    fn attribute_and_widget_name_rules() {
3288        assert!(is_name("min", false));
3289        assert!(is_name("data-x", false));
3290        assert!(!is_name("Min", false));
3291        assert!(!is_name("min.x", false)); // a dot is not allowed in an attribute name
3292        assert!(is_name("vendor.copy", true)); // but is in a widget name
3293        assert!(!is_name("", false));
3294    }
3295
3296    #[test]
3297    fn select_renders_options_with_the_current_value_selected() {
3298        let raw = serde_json::json!({
3299            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3300        });
3301        let opts = SelectField
3302            .resolve_options(&raw, &builtin_registry())
3303            .unwrap();
3304        let value = FieldValue::Text("closed".to_string());
3305        let markup = render_field(
3306            &SelectField,
3307            &NoOverrides,
3308            &scope(),
3309            &cx("status", &value, &opts),
3310        );
3311        let html = markup.as_str();
3312        assert!(html.contains(r#"<option value="open">Open</option>"#));
3313        // The current value is selected; a missing label falls back to the value.
3314        assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
3315    }
3316
3317    #[test]
3318    fn radio_renders_the_same_options_with_the_current_one_checked() {
3319        let raw = serde_json::json!({
3320            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3321        });
3322        let opts = RadioField
3323            .resolve_options(&raw, &builtin_registry())
3324            .unwrap();
3325        let value = FieldValue::Text("closed".to_string());
3326        let markup = render_field(
3327            &RadioField,
3328            &NoOverrides,
3329            &scope(),
3330            &cx("status", &value, &opts),
3331        );
3332        let html = markup.as_str();
3333        assert!(html.contains(r#"value="open""#));
3334        assert!(
3335            html.contains(r#"value="closed" checked"#),
3336            "current is checked"
3337        );
3338        // Every radio shares the field's name, or the browser treats them as
3339        // separate controls and lets more than one be chosen.
3340        assert_eq!(html.matches(r#"name="status""#).count(), 2);
3341        // A missing label falls back to the value, as select does.
3342        assert!(html.contains("closed</label>"));
3343    }
3344
3345    fn scope<'a>() -> OverrideScope<'a> {
3346        OverrideScope {
3347            surface: Surface::Field,
3348            view_key: "text",
3349            resource: None,
3350            field: None,
3351        }
3352    }
3353
3354    fn ref_field() -> RefPickerField {
3355        let mut pickers = crate::picker::PickerRegistry::new();
3356        let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
3357        pickers.insert(
3358            "acme.place".to_string(),
3359            crate::picker::PickerSourceReg::new("acme.place", source),
3360        );
3361        RefPickerField::new(Arc::new(pickers))
3362    }
3363
3364    #[test]
3365    fn reference_rejects_an_unknown_source() {
3366        let field = ref_field();
3367        let Err(e) = field.resolve_options(
3368            &serde_json::json!({ "source": "no.such" }),
3369            &builtin_registry(),
3370        ) else {
3371            panic!("expected an unknown-source rejection");
3372        };
3373        assert!(e.0.contains("no.such"), "{e}");
3374    }
3375
3376    #[test]
3377    fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
3378        let field = ref_field();
3379        let opts = field
3380            .resolve_options(
3381                &serde_json::json!({ "source": "acme.place" }),
3382                &builtin_registry(),
3383            )
3384            .unwrap();
3385        let value = FieldValue::Text("42".to_string());
3386        let markup = render_field(
3387            &field,
3388            &NoOverrides,
3389            &scope(),
3390            &cx("place_id", &value, &opts),
3391        );
3392        let html = markup.as_str();
3393        // The stored id rides in the hidden input the form submits, so an untouched
3394        // form (or one with JS off) preserves the reference.
3395        assert!(html.contains(r#"type="hidden""#), "{html}");
3396        assert!(html.contains(r#"name="place_id""#), "{html}");
3397        assert!(html.contains(r#"value="42""#), "{html}");
3398        // The widget hook and its endpoint URLs (built from the mount path).
3399        assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
3400        assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
3401        assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
3402    }
3403
3404    #[test]
3405    fn reference_declares_its_widget_assets() {
3406        let field = ref_field();
3407        let opts = field
3408            .resolve_options(
3409                &serde_json::json!({ "source": "acme.place" }),
3410                &builtin_registry(),
3411            )
3412            .unwrap();
3413        assert_eq!(
3414            field.assets(&opts),
3415            vec!["fields/ref-picker.js", "fields/ref-picker.css"]
3416        );
3417    }
3418}
3419
3420#[cfg(test)]
3421mod repeater_tests {
3422    use super::*;
3423
3424    fn options() -> ResolvedOptions {
3425        RepeaterField
3426            .resolve_options(
3427                &serde_json::json!({
3428                    "fields": [
3429                        { "name": "path", "label": "Path", "type": "text" },
3430                        { "name": "allow", "label": "Allow", "type": "switch" },
3431                    ]
3432                }),
3433                &builtin_registry(),
3434            )
3435            .unwrap()
3436    }
3437
3438    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3439        pairs
3440            .iter()
3441            .map(|(k, v)| (k.to_string(), v.to_string()))
3442            .collect()
3443    }
3444
3445    fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
3446        let field = SubmittedField::new("rules", data);
3447        match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
3448            Some(AttrValue::Json(value)) => value,
3449            other => panic!("expected a JSON array, got {other:?}"),
3450        }
3451    }
3452
3453    /// A row's sub-fields go through their own field types, so a switch inside a
3454    /// row stores a bool rather than the string "on". This is the whole reason
3455    /// the repeater belongs in the field system.
3456    #[test]
3457    fn a_row_is_typed_by_its_sub_field_types() {
3458        let rows = stored(
3459            &submitted(&[
3460                ("rules[0][path]", "/private"),
3461                ("rules[1][path]", "/public"),
3462                ("rules[1][allow]", "on"),
3463            ]),
3464            &options(),
3465        );
3466
3467        assert_eq!(rows[0]["path"], "/private");
3468        assert_eq!(rows[0]["allow"], serde_json::json!(false));
3469        assert_eq!(rows[1]["allow"], serde_json::json!(true));
3470    }
3471
3472    /// Removing a row in the browser can leave a gap. Indices are read from the
3473    /// keys and sorted, so a gap costs nothing.
3474    #[test]
3475    fn rows_are_ordered_by_index_not_submission_order() {
3476        let rows = stored(
3477            &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
3478            &options(),
3479        );
3480        assert_eq!(rows.as_array().unwrap().len(), 2);
3481        assert_eq!(rows[0]["path"], "/first");
3482        assert_eq!(rows[1]["path"], "/last");
3483    }
3484
3485    #[test]
3486    fn a_row_left_blank_is_dropped() {
3487        let rows = stored(
3488            &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", "  ")]),
3489            &options(),
3490        );
3491        assert_eq!(rows.as_array().unwrap().len(), 1);
3492    }
3493
3494    #[test]
3495    fn nothing_submitted_stores_an_empty_list() {
3496        assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
3497    }
3498
3499    #[test]
3500    fn a_repeater_refuses_to_hold_a_repeater() {
3501        let err = RepeaterField
3502            .resolve_options(
3503                &serde_json::json!({
3504                    "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
3505                }),
3506                &builtin_registry(),
3507            )
3508            .err()
3509            .expect("a nested repeater is refused");
3510        assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
3511    }
3512
3513    #[test]
3514    fn an_unregistered_sub_field_type_is_refused_at_boot() {
3515        let err = RepeaterField
3516            .resolve_options(
3517                &serde_json::json!({
3518                    "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
3519                }),
3520                &builtin_registry(),
3521            )
3522            .err()
3523            .expect("an unregistered type is refused");
3524        assert!(err.0.contains("acme.nope"), "{}", err.0);
3525    }
3526
3527    #[test]
3528    fn row_counts_are_bounded_when_asked() {
3529        let opts = RepeaterField
3530            .resolve_options(
3531                &serde_json::json!({
3532                    "fields": [{ "name": "path", "label": "Path", "type": "text" }],
3533                    "min_items": 1,
3534                    "max_items": 2
3535                }),
3536                &builtin_registry(),
3537            )
3538            .unwrap();
3539
3540        let empty = HashMap::new();
3541        let too_few = SubmittedField::new("rules", &empty);
3542        assert!(RepeaterField
3543            .to_attr(&too_few, &opts, Mode::Create)
3544            .is_err());
3545
3546        let three = submitted(&[
3547            ("rules[0][path]", "/a"),
3548            ("rules[1][path]", "/b"),
3549            ("rules[2][path]", "/c"),
3550        ]);
3551        let field = SubmittedField::new("rules", &three);
3552        assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
3553    }
3554
3555    #[test]
3556    fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
3557        let value = FieldValue::Json(serde_json::json!([
3558            { "path": "/private", "allow": false },
3559        ]));
3560        let cx = FieldCx {
3561            name: "rules",
3562            id: "rules",
3563            label: "Rules",
3564            value: &value,
3565            required: false,
3566            max_length: None,
3567            opts: &options(),
3568            base: "/admin",
3569            i18n: &Translator::new("en"),
3570            icons: test_icons(),
3571        };
3572        let html = RepeaterField
3573            .render_default(&RepeaterField.view_model(&cx))
3574            .into_string();
3575
3576        assert!(html.contains(r#"name="rules[0][path]""#));
3577        assert!(html.contains(r#"value="/private""#));
3578        // The blank row is inert inside a <template> until the browser clones it.
3579        assert!(html.contains("lat-repeater__blank"));
3580        assert!(html.contains(r#"name="rules[__index__][path]""#));
3581    }
3582
3583    /// Resolved options for a list-mode repeater naming rows by `agent`.
3584    fn list_options() -> ResolvedOptions {
3585        RepeaterField
3586            .resolve_options(
3587                &serde_json::json!({
3588                    "fields": [
3589                        { "name": "agent", "label": "Agent", "type": "text" },
3590                        { "name": "disallow", "label": "Disallow", "type": "textarea" },
3591                    ],
3592                    "display": "list",
3593                    "summary_field": "agent",
3594                }),
3595                &builtin_registry(),
3596            )
3597            .unwrap()
3598    }
3599
3600    fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
3601        let cx = FieldCx {
3602            name: "rules",
3603            id: "rules",
3604            label: "Rules",
3605            value,
3606            required: false,
3607            max_length: None,
3608            opts,
3609            base: "/admin",
3610            i18n: &Translator::new("en"),
3611            icons: test_icons(),
3612        };
3613        RepeaterField
3614            .render_default(&RepeaterField.view_model(&cx))
3615            .into_string()
3616    }
3617
3618    #[test]
3619    fn a_list_repeater_collapses_its_rows_and_names_them() {
3620        let value = FieldValue::Json(serde_json::json!([
3621            { "agent": "Googlebot", "disallow": "/private\n/tmp" },
3622        ]));
3623        let html = render_with(&list_options(), &value);
3624        assert!(html.contains("lat-repeater--list"), "list mode is declared");
3625        assert!(html.contains("<details"), "a row collapses");
3626        assert!(
3627            html.contains("Googlebot"),
3628            "the naming field titles the row"
3629        );
3630        // The fields are still there, just behind the summary.
3631        assert!(html.contains(r#"name="rules[0][disallow]""#));
3632    }
3633
3634    #[test]
3635    fn a_row_with_no_name_yet_reads_as_untitled() {
3636        let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
3637        let html = render_with(&list_options(), &value);
3638        assert!(html.contains("lat-repeater__untitled"));
3639    }
3640
3641    #[test]
3642    fn the_default_layout_stays_inline() {
3643        // A single narrow column is better left expanded, so the mode is opt-in.
3644        let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
3645        let html = render_with(&options(), &value);
3646        assert!(!html.contains("lat-repeater--list"));
3647        assert!(!html.contains("<details"));
3648    }
3649
3650    #[test]
3651    fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
3652        // A summary that wraps is not a summary.
3653        assert_eq!(
3654            summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
3655            "/private"
3656        );
3657        assert_eq!(summary_text(&serde_json::json!("")), "");
3658    }
3659
3660    /// A repeater's sub-field labels are declared inside the field type and never
3661    /// pass through the caller that localizes an ordinary label, so before the
3662    /// context carried a translator they could only render from their source
3663    /// string: English on every screen, whatever the operator's locale.
3664    /// A module's own field type is offered to every form and settings screen,
3665    /// which is what keeps a custom input from meaning a framework change.
3666    #[test]
3667    fn a_contributed_field_type_names_itself_and_converts() {
3668        struct Money;
3669        impl FieldType for Money {
3670            fn view_key(&self) -> &'static str {
3671                "acme.money"
3672            }
3673            fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
3674                scalar_vm("acme.money", cx)
3675            }
3676            fn render_default(&self, _vm: &FieldVm) -> Markup {
3677                Markup::empty()
3678            }
3679            fn to_attr(
3680                &self,
3681                field: &SubmittedField<'_>,
3682                _opts: &ResolvedOptions,
3683                _mode: Mode,
3684            ) -> Result<Option<AttrValue>, String> {
3685                // Minor units, so the stored value never carries a float.
3686                let raw = field.value().unwrap_or("0");
3687                let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
3688                Ok(Some(AttrValue::Int(minor as i64)))
3689            }
3690        }
3691
3692        let reg = FieldTypeReg::new(Arc::new(Money));
3693        // The key is the type's own, so a registration cannot disagree with what
3694        // it registers.
3695        assert_eq!(reg.name(), "acme.money");
3696
3697        let mut registry = builtin_registry();
3698        let name = reg.name().to_string();
3699        assert!(
3700            registry.insert(name.clone(), reg.field_type).is_none(),
3701            "a dotted key does not collide with a built-in"
3702        );
3703
3704        let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
3705        let submitted = SubmittedField::new("price", &data);
3706        let converted = registry[&name]
3707            .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
3708            .unwrap();
3709        assert!(
3710            matches!(converted, Some(AttrValue::Int(1250))),
3711            "{converted:?}"
3712        );
3713    }
3714
3715    #[test]
3716    fn a_sub_field_label_is_localized_like_any_other() {
3717        use laterite_core::i18n::CatalogStore;
3718        use std::sync::Arc;
3719
3720        let po = concat!(
3721            "msgid \"\"\n",
3722            "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
3723            "\n",
3724            "msgid \"Path\"\n",
3725            "msgstr \"Path-kn\"\n",
3726        );
3727        let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
3728        let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
3729
3730        let value = FieldValue::Text(String::new());
3731        let cx = FieldCx {
3732            name: "rules",
3733            id: "rules",
3734            label: "Rules",
3735            value: &value,
3736            required: false,
3737            max_length: None,
3738            opts: &options(),
3739            base: "/admin",
3740            i18n: &i18n,
3741            icons: test_icons(),
3742        };
3743        let html = RepeaterField
3744            .render_default(&RepeaterField.view_model(&cx))
3745            .into_string();
3746
3747        assert!(
3748            html.contains("Path-kn"),
3749            "sub-label was not localized: {html}"
3750        );
3751        assert!(
3752            !html.contains(">Path<"),
3753            "the source string still leaked: {html}"
3754        );
3755    }
3756}
3757
3758#[cfg(test)]
3759mod save_contract_tests {
3760    use super::*;
3761
3762    /// One scalar submission, the way the form builds it for a field.
3763    fn one(raw: Option<&str>) -> HashMap<String, String> {
3764        raw.into_iter()
3765            .map(|v| ("f".to_string(), v.to_string()))
3766            .collect()
3767    }
3768
3769    fn none() -> ResolvedOptions {
3770        ResolvedOptions::none()
3771    }
3772
3773    #[test]
3774    fn a_switch_stores_a_bool_and_absence_means_off() {
3775        let f = SwitchField;
3776        // A browser sends nothing for an unchecked box, so absence must write
3777        // false rather than leave the column as it was.
3778        assert_eq!(
3779            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3780                .unwrap(),
3781            Some(AttrValue::Bool(false))
3782        );
3783        assert_eq!(
3784            f.to_attr(
3785                &SubmittedField::new("f", &one(Some("on"))),
3786                &none(),
3787                Mode::Create
3788            )
3789            .unwrap(),
3790            Some(AttrValue::Bool(true))
3791        );
3792    }
3793
3794    #[test]
3795    fn a_switch_reads_back_whatever_the_backend_gave() {
3796        let f = SwitchField;
3797        // sqlx::Any hands every backend's boolean back as text, and they differ.
3798        for on in ["1", "true", "t", "on"] {
3799            assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3800        }
3801        for off in ["0", "false", "f", ""] {
3802            assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3803        }
3804        assert!(f.to_control(None, &none()).is_empty());
3805    }
3806
3807    #[test]
3808    fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3809        let f = DateField;
3810        // A timestamp column hands back the whole instant; a date input rejects it.
3811        assert_eq!(
3812            f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3813            "2026-09-09"
3814        );
3815        assert_eq!(
3816            f.to_control(Some("2026-09-09 12:30:45"), &none()),
3817            "2026-09-09"
3818        );
3819        assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3820        assert_eq!(f.to_control(None, &none()), "");
3821    }
3822
3823    #[test]
3824    fn a_cleared_date_stores_null_not_an_empty_string() {
3825        let f = DateField;
3826        assert_eq!(
3827            f.to_attr(
3828                &SubmittedField::new("f", &one(Some(""))),
3829                &none(),
3830                Mode::Update
3831            )
3832            .unwrap(),
3833            Some(AttrValue::Null)
3834        );
3835        assert_eq!(
3836            f.to_attr(
3837                &SubmittedField::new("f", &one(Some("2026-09-09"))),
3838                &none(),
3839                Mode::Create
3840            )
3841            .unwrap(),
3842            Some(AttrValue::Text("2026-09-09".into()))
3843        );
3844        assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3845    }
3846
3847    #[test]
3848    fn a_password_hashes_and_never_echoes() {
3849        let f = PasswordField;
3850        let stored = f
3851            .to_attr(
3852                &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3853                &none(),
3854                Mode::Create,
3855            )
3856            .unwrap()
3857            .unwrap();
3858        let hash = stored.as_str().unwrap();
3859        assert!(
3860            hash.starts_with("$argon2"),
3861            "stored as a hash, not plaintext"
3862        );
3863        assert_ne!(hash, "hunter2hunter2");
3864        // The hash must never reach the page, whatever is stored.
3865        assert_eq!(f.to_control(Some(hash), &none()), "");
3866    }
3867
3868    #[test]
3869    fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3870        let f = PasswordField;
3871        // Omitted, so the write does not touch the column.
3872        assert_eq!(
3873            f.to_attr(
3874                &SubmittedField::new("f", &one(Some(""))),
3875                &none(),
3876                Mode::Update
3877            )
3878            .unwrap(),
3879            None
3880        );
3881        assert_eq!(
3882            f.to_attr(
3883                &SubmittedField::new("f", &one(Some("   "))),
3884                &none(),
3885                Mode::Update
3886            )
3887            .unwrap(),
3888            None
3889        );
3890        assert_eq!(
3891            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3892                .unwrap(),
3893            None
3894        );
3895    }
3896
3897    #[test]
3898    fn rich_text_is_cleaned_on_save_and_blank_when_empty() {
3899        let f = RichEditorField;
3900        let dirty = one(Some(
3901            r#"<p onclick="x()">Hi</p><script>1</script><img src="/a.png" data-media="4">"#,
3902        ));
3903        assert_eq!(
3904            f.to_attr(&SubmittedField::new("f", &dirty), &none(), Mode::Create)
3905                .unwrap(),
3906            Some(AttrValue::Text(
3907                r#"<p>Hi</p><img src="/a.png" data-media="4">"#.into()
3908            ))
3909        );
3910        assert_eq!(
3911            f.linked_media(Some(r#"<img data-media="4">"#), &none()),
3912            Some(vec![4])
3913        );
3914        // An empty editor submits a paragraph; the rules and the store see nothing.
3915        let blank = one(Some("<p></p>"));
3916        assert_eq!(
3917            f.submitted(&SubmittedField::new("f", &blank), &none()),
3918            Some(String::new())
3919        );
3920        assert_eq!(
3921            f.to_attr(&SubmittedField::new("f", &blank), &none(), Mode::Create)
3922                .unwrap(),
3923            Some(AttrValue::Text(String::new()))
3924        );
3925    }
3926
3927    #[test]
3928    fn a_plain_field_stores_and_shows_the_text_unchanged() {
3929        let f = TextareaField;
3930        assert_eq!(
3931            f.to_attr(
3932                &SubmittedField::new("f", &one(Some("hello"))),
3933                &none(),
3934                Mode::Create
3935            )
3936            .unwrap(),
3937            Some(AttrValue::Text("hello".into()))
3938        );
3939        // Absent stays absent, so the write leaves that column alone.
3940        assert_eq!(
3941            f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3942                .unwrap(),
3943            None
3944        );
3945        assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3946    }
3947}