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