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