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.
103pub trait FieldType: Send + Sync + 'static {
104    /// The stable key for override resolution and default-template selection
105    /// (bare like `text` for core; dotted `vendor.name` for plugins).
106    fn view_key(&self) -> &'static str;
107
108    /// Types the descriptor's raw options blob once at boot. A failure aborts
109    /// boot naming the resource + field. Default: no options.
110    fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
111        Ok(ResolvedOptions::none())
112    }
113
114    /// Rules this type always contributes, merged before the descriptor's own
115    /// (e.g. the text field's `email` input contributes [`Rule::Email`]).
116    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
117        Vec::new()
118    }
119
120    /// Builds the serialisable presentation payload. Pure, no IO.
121    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
122
123    /// The built-in presenter over the view-model (an Askama partial). Not
124    /// called when an override wins.
125    fn render_default(&self, vm: &FieldVm) -> Markup;
126
127    /// How a submitted value becomes the attribute that gets stored.
128    ///
129    /// `raw` is `None` when the submission omits the field entirely, which is
130    /// how an unchecked checkbox arrives. Returning `None` leaves the attribute
131    /// out of the write, so the column keeps whatever it held: that is how a
132    /// blank password on an edit means "unchanged" rather than "erase".
133    ///
134    /// Validation has already run, so a type that needs its input to parse
135    /// should guarantee that with an [`intrinsic_rules`](FieldType::intrinsic_rules)
136    /// entry and treat the value here as sound. The error is for a genuine
137    /// failure (hashing), not a bad value, and surfaces as a failed save.
138    ///
139    /// Default: the submitted text, unchanged.
140    fn to_attr(
141        &self,
142        raw: Option<&str>,
143        opts: &ResolvedOptions,
144        mode: Mode,
145    ) -> Result<Option<AttrValue>, String> {
146        let _ = (opts, mode);
147        Ok(raw.map(|v| AttrValue::Text(v.to_string())))
148    }
149
150    /// How a stored value is presented back in the control on an edit.
151    ///
152    /// The counterpart of [`to_attr`](FieldType::to_attr): storage and
153    /// presentation differ whenever a type stores something other than the text
154    /// the browser sent, and a type that never shows its value (a password)
155    /// returns the empty string here.
156    ///
157    /// Default: the stored text, unchanged.
158    fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
159        let _ = opts;
160        stored.unwrap_or_default().to_string()
161    }
162
163    /// Whether the framework wraps this field in standard chrome.
164    fn chrome(&self) -> Chrome {
165        Chrome::Wrapped
166    }
167
168    /// Asset-registry keys this type's control needs on any page that renders it
169    /// (a heavy widget's script/style). The page shell collects, dedupes, and
170    /// emits them; keys must exist in the asset registry. Default: none, so a
171    /// type whose widget ships in core `laterite.js` declares nothing.
172    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
173        Vec::new()
174    }
175}
176
177/// A field type failed to type its options blob at boot.
178#[derive(Debug, thiserror::Error)]
179#[error("invalid field options: {0}")]
180pub struct OptionsError(pub String);
181
182/// The surface being rendered, for override resolution.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Surface {
185    Field,
186    Column,
187}
188
189/// Where a render is happening, for override resolution (most-specific first:
190/// `resource.field` then `view_key` then the compiled default).
191pub struct OverrideScope<'a> {
192    pub surface: Surface,
193    pub view_key: &'a str,
194    pub resource: Option<&'a str>,
195    pub field: Option<&'a str>,
196}
197
198/// A runtime override failed to render.
199#[derive(Debug, thiserror::Error)]
200#[error("override render failed: {0}")]
201pub struct OverrideError(pub String);
202
203/// The seam a theme/CMS layer backs with a runtime template engine (MiniJinja)
204/// to let users override a field's presentation from outside the plugin.
205/// `laterite-admin` stays engine-agnostic; the default is [`NoOverrides`].
206pub trait OverrideResolver: Send + Sync {
207    /// An override for this view-model if one is registered for the scope, else
208    /// `None` (use the default). The `String` is engine-autoescaped HTML.
209    fn render_override(
210        &self,
211        scope: &OverrideScope<'_>,
212        vm: &serde_json::Value,
213    ) -> Option<Result<String, OverrideError>>;
214}
215
216/// The default resolver: no overrides, so the compiled path always runs at zero
217/// cost. The theme layer injects a real resolver when it is built.
218pub struct NoOverrides;
219
220impl OverrideResolver for NoOverrides {
221    fn render_override(
222        &self,
223        _scope: &OverrideScope<'_>,
224        _vm: &serde_json::Value,
225    ) -> Option<Result<String, OverrideError>> {
226        None
227    }
228}
229
230/// The field-type registry: type key to behaviour, built once at boot.
231pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
232
233/// Renders a field: an override if the resolver supplies one, else the compiled
234/// default. The one place the two paths meet.
235pub(crate) fn render_field(
236    ft: &dyn FieldType,
237    resolver: &dyn OverrideResolver,
238    scope: &OverrideScope<'_>,
239    cx: &FieldCx<'_>,
240) -> Markup {
241    let vm = ft.view_model(cx);
242    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
243        Some(Ok(html)) => Markup::from_override(html),
244        // A failing override falls back to the default rather than blank the field.
245        Some(Err(_)) | None => ft.render_default(&vm),
246    }
247}
248
249/// The framework's built-in field types. The text field is constructed with the
250/// built-in input-type registry it delegates to.
251pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
252    let inputs = Arc::new(builtin_input_registry());
253    vec![
254        Arc::new(TextField::new(inputs)),
255        Arc::new(TextareaField),
256        Arc::new(SelectField),
257        Arc::new(SwitchField),
258        Arc::new(DateField),
259        Arc::new(PasswordField),
260        Arc::new(RadioField),
261    ]
262}
263
264/// The field registry seeded with the built-in types, keyed by [`FieldType::view_key`].
265pub(crate) fn builtin_registry() -> FieldRegistry {
266    builtin_field_types()
267        .into_iter()
268        .map(|ft| (ft.view_key().to_string(), ft))
269        .collect()
270}
271
272/// Where an adornment sits relative to the input.
273#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
274#[serde(rename_all = "snake_case")]
275pub enum Placement {
276    Leading,
277    Trailing,
278}
279
280/// A control placed beside the input (a copy button, a currency symbol): data in
281/// the view-model, never HTML, so an override re-presents it. The text template
282/// renders it into the input group.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Adornment {
285    pub placement: Placement,
286    /// `true` renders a `<button>` (an action); `false` an inert affix `<span>`.
287    pub button: bool,
288    /// Visible text and accessible label.
289    pub label: String,
290    /// The `data-lat-widget` island wiring a button (bare like `copy` for core,
291    /// dotted `vendor.name` for a plugin). `None` for an inert affix.
292    pub widget: Option<String>,
293}
294
295/// An input variant of the text field: its HTML `type`, the rules it contributes,
296/// and how it parameterises the one text control (extra attributes, adjacent
297/// adornments). The extension point a plugin builds on (a currency input, say)
298/// instead of reimplementing a field. An input parameterises the single text
299/// control; anything needing different control markup is a [`FieldType`].
300pub trait InputType: Send + Sync + 'static {
301    /// The registry key naming this input (`text`, `email`, `url`, `currency`).
302    fn key(&self) -> &'static str;
303    /// The HTML `type` attribute.
304    fn html_type(&self) -> &'static str {
305        "text"
306    }
307    /// Types this input's own keys from the field's options blob (the same blob
308    /// the field type reads) once at boot. Default: no options.
309    fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
310        Ok(ResolvedOptions::none())
311    }
312    /// Rules this input contributes (an `email` input adds [`Rule::Email`]).
313    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
314        Vec::new()
315    }
316    /// Extra `<input>` attributes (a number input's `min`/`max`/`step`). Names are
317    /// validated at boot and may not collide with the ones the template owns.
318    fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
319        BTreeMap::new()
320    }
321    /// Controls placed beside the input (a copy button, a currency symbol).
322    fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
323        Vec::new()
324    }
325    /// Asset-registry keys this input needs (a heavy widget's script/style). The
326    /// copy button needs none, since its island ships in core `laterite.js`.
327    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
328        Vec::new()
329    }
330}
331
332/// The registry the text field resolves its `input` option against.
333pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
334
335struct TextInput;
336impl InputType for TextInput {
337    fn key(&self) -> &'static str {
338        "text"
339    }
340}
341
342struct EmailInput;
343impl InputType for EmailInput {
344    fn key(&self) -> &'static str {
345        "email"
346    }
347    fn html_type(&self) -> &'static str {
348        "email"
349    }
350    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
351        vec![Rule::Email]
352    }
353}
354
355struct TelInput;
356impl InputType for TelInput {
357    fn key(&self) -> &'static str {
358        "tel"
359    }
360    fn html_type(&self) -> &'static str {
361        "tel"
362    }
363}
364
365/// A number input's optional bounds and step, emitted as `<input>` attributes.
366#[derive(Default, Deserialize)]
367struct NumberOptions {
368    #[serde(default)]
369    min: Option<f64>,
370    #[serde(default)]
371    max: Option<f64>,
372    #[serde(default)]
373    step: Option<f64>,
374}
375
376struct NumberInput;
377impl InputType for NumberInput {
378    fn key(&self) -> &'static str {
379        "number"
380    }
381    fn html_type(&self) -> &'static str {
382        "number"
383    }
384    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
385        let opts: NumberOptions = if raw.is_null() {
386            NumberOptions::default()
387        } else {
388            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
389        };
390        Ok(ResolvedOptions::new(opts))
391    }
392    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
393        vec![Rule::Numeric]
394    }
395    fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
396        let mut attrs = BTreeMap::new();
397        if let Some(o) = opts.get::<NumberOptions>() {
398            if let Some(min) = o.min {
399                attrs.insert("min".to_string(), min.to_string());
400            }
401            if let Some(max) = o.max {
402                attrs.insert("max".to_string(), max.to_string());
403            }
404            if let Some(step) = o.step {
405                attrs.insert("step".to_string(), step.to_string());
406            }
407        }
408        attrs
409    }
410}
411
412/// A url input's option: whether to show the copy-to-clipboard button.
413#[derive(Deserialize)]
414struct UrlOptions {
415    #[serde(default = "default_true")]
416    copy: bool,
417}
418
419impl Default for UrlOptions {
420    fn default() -> Self {
421        Self { copy: true }
422    }
423}
424
425fn default_true() -> bool {
426    true
427}
428
429struct UrlInput;
430impl InputType for UrlInput {
431    fn key(&self) -> &'static str {
432        "url"
433    }
434    fn html_type(&self) -> &'static str {
435        "url"
436    }
437    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
438        let opts: UrlOptions = if raw.is_null() {
439            UrlOptions::default()
440        } else {
441            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
442        };
443        Ok(ResolvedOptions::new(opts))
444    }
445    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
446        vec![Rule::Url]
447    }
448    fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
449        if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
450            vec![Adornment {
451                placement: Placement::Trailing,
452                button: true,
453                label: "Copy".to_string(),
454                widget: Some("copy".to_string()),
455            }]
456        } else {
457            Vec::new()
458        }
459    }
460}
461
462pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
463    vec![
464        Arc::new(TextInput),
465        Arc::new(EmailInput),
466        Arc::new(TelInput),
467        Arc::new(NumberInput),
468        Arc::new(UrlInput),
469    ]
470}
471
472/// The input-type registry seeded with the built-in inputs.
473pub(crate) fn builtin_input_registry() -> InputRegistry {
474    builtin_input_types()
475        .into_iter()
476        .map(|i| (i.key().to_string(), i))
477        .collect()
478}
479
480/// Attribute names the text template emits itself; an input may not re-declare
481/// them (HTML resolves a duplicate to the first, silently dropping the input's).
482const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
483
484/// Validates an input's contributed attribute and widget names at boot. Names
485/// come from plugin code, so a bad one is a wiring bug caught at boot, not user
486/// input: an attribute name is lowercase kebab and unreserved; a widget name may
487/// carry a dotted plugin namespace.
488fn validate_contributions(
489    attrs: &BTreeMap<String, String>,
490    adornments: &[Adornment],
491) -> Result<(), OptionsError> {
492    for name in attrs.keys() {
493        if !is_name(name, false) {
494            return Err(OptionsError(format!("invalid attribute name `{name}`")));
495        }
496        if RESERVED_ATTRS.contains(&name.as_str()) {
497            return Err(OptionsError(format!("attribute `{name}` is reserved")));
498        }
499    }
500    for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
501        if !is_name(widget, true) {
502            return Err(OptionsError(format!("invalid widget name `{widget}`")));
503        }
504    }
505    Ok(())
506}
507
508/// `^[a-z][a-z0-9-]*$`, plus `.` when `dotted` (for a widget's `vendor.name`).
509pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
510    let mut chars = s.chars();
511    matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
512        && chars.all(|c| {
513            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
514        })
515}
516
517/// The text field's typed options: which input variant to render.
518#[derive(Deserialize)]
519struct TextOptions {
520    #[serde(default = "default_input")]
521    input: String,
522}
523
524fn default_input() -> String {
525    "text".to_string()
526}
527
528/// The text field's resolved options: everything needed to render and validate,
529/// computed (and name-checked) once at boot from the selected input.
530struct TextResolved {
531    html_type: &'static str,
532    attrs: BTreeMap<String, String>,
533    adornments: Vec<Adornment>,
534    rules: Vec<Rule>,
535    assets: Vec<&'static str>,
536}
537
538/// The text field's view-model payload. Additive over the prior `{input_type}`,
539/// so existing overrides keep working; this shape is the override contract.
540#[derive(Default, Serialize, Deserialize)]
541struct TextData {
542    input_type: String,
543    #[serde(default)]
544    attrs: BTreeMap<String, String>,
545    #[serde(default)]
546    adornments: Vec<Adornment>,
547}
548
549#[derive(Template)]
550#[template(path = "fields/text.html")]
551struct TextTmpl<'a> {
552    name: &'a str,
553    id: &'a str,
554    value: &'a str,
555    required: bool,
556    input_type: &'a str,
557    attrs: &'a BTreeMap<String, String>,
558    leading: &'a [Adornment],
559    trailing: &'a [Adornment],
560}
561
562/// A single-line text input. Its `input` option selects an [`InputType`] from
563/// the registry (text, email, ...; plugins add more) that drives the HTML type
564/// and intrinsic rules.
565pub(crate) struct TextField {
566    inputs: Arc<InputRegistry>,
567}
568
569impl TextField {
570    pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
571        Self { inputs }
572    }
573}
574
575impl FieldType for TextField {
576    fn view_key(&self) -> &'static str {
577        "text"
578    }
579    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
580        let opts: TextOptions = if raw.is_null() {
581            TextOptions {
582                input: default_input(),
583            }
584        } else {
585            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
586        };
587        let input = self
588            .inputs
589            .get(&opts.input)
590            .cloned()
591            .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
592        // The input reads its own keys from the same blob, then contributes its
593        // control shape; the contributed names are validated before caching.
594        let input_opts = input.resolve_options(raw)?;
595        let attrs = input.attributes(&input_opts);
596        let adornments = input.adornments(&input_opts);
597        validate_contributions(&attrs, &adornments)?;
598        Ok(ResolvedOptions::new(TextResolved {
599            html_type: input.html_type(),
600            rules: input.rules(&input_opts),
601            assets: input.assets(&input_opts),
602            attrs,
603            adornments,
604        }))
605    }
606    fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
607        opts.get::<TextResolved>()
608            .map(|r| r.rules.clone())
609            .unwrap_or_default()
610    }
611    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
612        opts.get::<TextResolved>()
613            .map(|r| r.assets.clone())
614            .unwrap_or_default()
615    }
616    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
617        let data = match cx.opts.get::<TextResolved>() {
618            Some(r) => TextData {
619                input_type: r.html_type.to_string(),
620                attrs: r.attrs.clone(),
621                adornments: r.adornments.clone(),
622            },
623            None => TextData {
624                input_type: "text".to_string(),
625                ..TextData::default()
626            },
627        };
628        FieldVm {
629            view_key: "text".to_string(),
630            name: cx.name.to_string(),
631            id: cx.id.to_string(),
632            label: cx.label.to_string(),
633            required: cx.required,
634            value: cx.value.clone(),
635            data: serde_json::to_value(data).unwrap_or_default(),
636        }
637    }
638    fn render_default(&self, vm: &FieldVm) -> Markup {
639        let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
640        let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
641            .adornments
642            .into_iter()
643            .partition(|a| matches!(a.placement, Placement::Leading));
644        Markup::from_template(&TextTmpl {
645            name: &vm.name,
646            id: &vm.id,
647            value: vm.value.as_text(),
648            required: vm.required,
649            input_type: &data.input_type,
650            attrs: &data.attrs,
651            leading: &leading,
652            trailing: &trailing,
653        })
654        .unwrap_or_default()
655    }
656}
657
658#[derive(Template)]
659#[template(path = "fields/textarea.html")]
660struct TextareaTmpl<'a> {
661    name: &'a str,
662    id: &'a str,
663    value: &'a str,
664    required: bool,
665}
666
667/// A multi-line text input.
668pub(crate) struct TextareaField;
669
670impl FieldType for TextareaField {
671    fn view_key(&self) -> &'static str {
672        "textarea"
673    }
674    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
675        scalar_vm("textarea", cx)
676    }
677    fn render_default(&self, vm: &FieldVm) -> Markup {
678        Markup::from_template(&TextareaTmpl {
679            name: &vm.name,
680            id: &vm.id,
681            value: vm.value.as_text(),
682            required: vm.required,
683        })
684        .unwrap_or_default()
685    }
686}
687
688/// A `select` field's options: a list of value/label pairs.
689#[derive(Debug, Default, Deserialize)]
690struct SelectOptions {
691    #[serde(default)]
692    options: Vec<SelectOption>,
693}
694
695#[derive(Debug, Deserialize)]
696struct SelectOption {
697    value: String,
698    /// Display text; defaults to the value when omitted.
699    #[serde(default)]
700    label: Option<String>,
701}
702
703/// One rendered option: label resolved, `selected` computed against the current
704/// value. Carried in the view-model so an override presents the same list.
705#[derive(Debug, Serialize, Deserialize)]
706struct OptionView {
707    value: String,
708    label: String,
709    selected: bool,
710}
711
712#[derive(Template)]
713#[template(path = "fields/select.html")]
714struct SelectTmpl<'a> {
715    name: &'a str,
716    id: &'a str,
717    required: bool,
718    options: &'a [OptionView],
719}
720
721/// A dropdown over a fixed option list.
722pub(crate) struct SelectField;
723
724impl FieldType for SelectField {
725    fn view_key(&self) -> &'static str {
726        "select"
727    }
728    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
729        let opts: SelectOptions =
730            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
731        Ok(ResolvedOptions::new(opts))
732    }
733    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
734        let current = cx.value.as_text();
735        let views: Vec<OptionView> = cx
736            .opts
737            .get::<SelectOptions>()
738            .map(|o| {
739                o.options
740                    .iter()
741                    .map(|so| OptionView {
742                        value: so.value.clone(),
743                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
744                        selected: so.value == current,
745                    })
746                    .collect()
747            })
748            .unwrap_or_default();
749        FieldVm {
750            view_key: "select".to_string(),
751            name: cx.name.to_string(),
752            id: cx.id.to_string(),
753            label: cx.label.to_string(),
754            required: cx.required,
755            value: cx.value.clone(),
756            data: serde_json::to_value(&views).unwrap_or_default(),
757        }
758    }
759    fn render_default(&self, vm: &FieldVm) -> Markup {
760        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
761        Markup::from_template(&SelectTmpl {
762            name: &vm.name,
763            id: &vm.id,
764            required: vm.required,
765            options: &options,
766        })
767        .unwrap_or_default()
768    }
769}
770
771#[derive(Template)]
772#[template(path = "fields/switch.html")]
773struct SwitchTmpl<'a> {
774    name: &'a str,
775    id: &'a str,
776    on: bool,
777}
778
779/// A boolean toggle over a bool column.
780///
781/// The only field whose submission can be absent rather than empty: a browser
782/// sends nothing for an unchecked box, which is why absence has to mean `false`
783/// here rather than "leave it alone".
784pub(crate) struct SwitchField;
785
786/// Whether a stored value reads as true. Accepts what each backend gives back
787/// for a boolean column, since `sqlx::Any` reads them all as text.
788fn stored_is_on(stored: Option<&str>) -> bool {
789    matches!(
790        stored.map(str::trim).unwrap_or_default(),
791        "1" | "true" | "TRUE" | "t" | "yes" | "on"
792    )
793}
794
795impl FieldType for SwitchField {
796    fn view_key(&self) -> &'static str {
797        "switch"
798    }
799
800    fn to_attr(
801        &self,
802        raw: Option<&str>,
803        _opts: &ResolvedOptions,
804        _mode: Mode,
805    ) -> Result<Option<AttrValue>, String> {
806        // Absent means unchecked, so this always writes a value: leaving the
807        // attribute out would keep the old one and the box would never clear.
808        Ok(Some(AttrValue::Bool(stored_is_on(raw))))
809    }
810
811    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
812        if stored_is_on(stored) {
813            "1".to_string()
814        } else {
815            String::new()
816        }
817    }
818
819    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
820        scalar_vm("switch", cx)
821    }
822
823    fn render_default(&self, vm: &FieldVm) -> Markup {
824        Markup::from_template(&SwitchTmpl {
825            name: &vm.name,
826            id: &vm.id,
827            on: stored_is_on(Some(vm.value.as_text())),
828        })
829        .unwrap_or_default()
830    }
831}
832
833#[derive(Template)]
834#[template(path = "fields/date.html")]
835struct DateTmpl<'a> {
836    name: &'a str,
837    id: &'a str,
838    value: &'a str,
839    required: bool,
840}
841
842/// A calendar date.
843///
844/// Stores `YYYY-MM-DD`, the format a date control sends. Presenting is where the
845/// work is: the column may hold a full timestamp, which a date input rejects, so
846/// a stored value is trimmed back to its date.
847pub(crate) struct DateField;
848
849impl FieldType for DateField {
850    fn view_key(&self) -> &'static str {
851        "date"
852    }
853
854    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
855        vec![Rule::Date]
856    }
857
858    fn to_attr(
859        &self,
860        raw: Option<&str>,
861        _opts: &ResolvedOptions,
862        _mode: Mode,
863    ) -> Result<Option<AttrValue>, String> {
864        Ok(raw.map(|v| match v.trim() {
865            "" => AttrValue::Null,
866            date => AttrValue::Text(date.to_string()),
867        }))
868    }
869
870    fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
871        let stored = stored.unwrap_or_default().trim();
872        // A timestamp column hands back the whole instant; the control wants the
873        // date, and everything the framework stores starts with one.
874        stored
875            .split(['T', ' '])
876            .next()
877            .unwrap_or_default()
878            .to_string()
879    }
880
881    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
882        scalar_vm("date", cx)
883    }
884
885    fn render_default(&self, vm: &FieldVm) -> Markup {
886        Markup::from_template(&DateTmpl {
887            name: &vm.name,
888            id: &vm.id,
889            value: vm.value.as_text(),
890            required: vm.required,
891        })
892        .unwrap_or_default()
893    }
894}
895
896#[derive(Template)]
897#[template(path = "fields/password.html")]
898struct PasswordTmpl<'a> {
899    name: &'a str,
900    id: &'a str,
901    required: bool,
902}
903
904/// A password: hashed on the way in, never shown on the way out.
905///
906/// Both halves matter. The control renders with no value, so a stored hash
907/// cannot leak into the page, and a blank submission omits the attribute, so
908/// editing a record without touching its password leaves that password alone.
909/// Require it on create (`required_on(Mode::Create)`) if a blank one is not
910/// acceptable there.
911pub(crate) struct PasswordField;
912
913impl FieldType for PasswordField {
914    fn view_key(&self) -> &'static str {
915        "password"
916    }
917
918    fn to_attr(
919        &self,
920        raw: Option<&str>,
921        _opts: &ResolvedOptions,
922        _mode: Mode,
923    ) -> Result<Option<AttrValue>, String> {
924        match raw.map(str::trim).unwrap_or_default() {
925            // Absent or blank: leave the stored password as it is.
926            "" => Ok(None),
927            plain => laterite_auth::password::hash_password(plain)
928                .map(|hash| Some(AttrValue::Text(hash)))
929                .map_err(|e| e.to_string()),
930        }
931    }
932
933    /// Never echoes: a hash must not reach the page, and a browser must not be
934    /// invited to refill it.
935    fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
936        String::new()
937    }
938
939    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
940        scalar_vm("password", cx)
941    }
942
943    fn render_default(&self, vm: &FieldVm) -> Markup {
944        Markup::from_template(&PasswordTmpl {
945            name: &vm.name,
946            id: &vm.id,
947            required: vm.required,
948        })
949        .unwrap_or_default()
950    }
951}
952
953#[derive(Template)]
954#[template(path = "fields/radio.html")]
955struct RadioTmpl<'a> {
956    name: &'a str,
957    options: &'a [OptionView],
958    required: bool,
959}
960
961/// One choice from a fixed list, shown as radios rather than a dropdown.
962///
963/// The same options as `select`, so a descriptor swaps between them by changing
964/// the type alone. Suits a short list where seeing every choice matters.
965pub(crate) struct RadioField;
966
967impl FieldType for RadioField {
968    fn view_key(&self) -> &'static str {
969        "radio"
970    }
971
972    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
973        let opts: SelectOptions =
974            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
975        Ok(ResolvedOptions::new(opts))
976    }
977
978    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
979        let current = cx.value.as_text();
980        let views: Vec<OptionView> = cx
981            .opts
982            .get::<SelectOptions>()
983            .map(|o| {
984                o.options
985                    .iter()
986                    .map(|so| OptionView {
987                        value: so.value.clone(),
988                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
989                        selected: so.value == current,
990                    })
991                    .collect()
992            })
993            .unwrap_or_default();
994        FieldVm {
995            view_key: "radio".to_string(),
996            name: cx.name.to_string(),
997            id: cx.id.to_string(),
998            label: cx.label.to_string(),
999            required: cx.required,
1000            value: cx.value.clone(),
1001            data: serde_json::to_value(&views).unwrap_or_default(),
1002        }
1003    }
1004
1005    fn render_default(&self, vm: &FieldVm) -> Markup {
1006        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1007        Markup::from_template(&RadioTmpl {
1008            name: &vm.name,
1009            options: &options,
1010            required: vm.required,
1011        })
1012        .unwrap_or_default()
1013    }
1014}
1015
1016/// The view-model common to scalar text-like fields (no per-type `data`).
1017fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1018    FieldVm {
1019        view_key: view_key.to_string(),
1020        name: cx.name.to_string(),
1021        id: cx.id.to_string(),
1022        label: cx.label.to_string(),
1023        required: cx.required,
1024        value: cx.value.clone(),
1025        data: serde_json::Value::Null,
1026    }
1027}
1028
1029/// The reference-picker field's option: which registered source to pick from.
1030#[derive(Deserialize)]
1031struct RefOptions {
1032    source: String,
1033}
1034
1035/// The reference-picker's resolved options: the source name, validated at boot.
1036struct RefResolved {
1037    source: String,
1038}
1039
1040/// The reference-picker's view-model payload: the source and the endpoint URLs
1041/// its widget calls, plus the stored id (which the hidden input submits).
1042#[derive(Default, Serialize, Deserialize)]
1043struct RefData {
1044    source: String,
1045    search_url: String,
1046    resolve_url: String,
1047    value: String,
1048    placeholder: String,
1049}
1050
1051#[derive(Template)]
1052#[template(path = "fields/ref_picker.html")]
1053struct RefPickerTmpl<'a> {
1054    name: &'a str,
1055    id: &'a str,
1056    value: &'a str,
1057    search_url: &'a str,
1058    resolve_url: &'a str,
1059    placeholder: &'a str,
1060}
1061
1062/// A picker over a reference to another record: stores that record's id, shows
1063/// its label, and offers typeahead candidates from a registered [`PickerSource`]
1064/// (selected by the `source` option). The label and candidates are fetched by
1065/// its widget from the source's endpoints; rendering stays pure.
1066pub(crate) struct RefPickerField {
1067    pickers: Arc<PickerRegistry>,
1068}
1069
1070impl RefPickerField {
1071    pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1072        Self { pickers }
1073    }
1074}
1075
1076impl FieldType for RefPickerField {
1077    fn view_key(&self) -> &'static str {
1078        "reference"
1079    }
1080    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
1081        let opts: RefOptions =
1082            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1083        if !self.pickers.contains_key(&opts.source) {
1084            return Err(OptionsError(format!(
1085                "unknown picker source `{}`",
1086                opts.source
1087            )));
1088        }
1089        Ok(ResolvedOptions::new(RefResolved {
1090            source: opts.source,
1091        }))
1092    }
1093    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1094        let source = cx
1095            .opts
1096            .get::<RefResolved>()
1097            .map(|r| r.source.as_str())
1098            .unwrap_or_default();
1099        let data = RefData {
1100            search_url: format!("{}/pickers/{}/search", cx.base, source),
1101            resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1102            source: source.to_string(),
1103            value: cx.value.as_text().to_string(),
1104            placeholder: "Search…".to_string(),
1105        };
1106        FieldVm {
1107            view_key: "reference".to_string(),
1108            name: cx.name.to_string(),
1109            id: cx.id.to_string(),
1110            label: cx.label.to_string(),
1111            required: cx.required,
1112            value: cx.value.clone(),
1113            data: serde_json::to_value(data).unwrap_or_default(),
1114        }
1115    }
1116    fn render_default(&self, vm: &FieldVm) -> Markup {
1117        let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1118        Markup::from_template(&RefPickerTmpl {
1119            name: &vm.name,
1120            id: &vm.id,
1121            value: vm.value.as_text(),
1122            search_url: &data.search_url,
1123            resolve_url: &data.resolve_url,
1124            placeholder: &data.placeholder,
1125        })
1126        .unwrap_or_default()
1127    }
1128    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1129        vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1130    }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136
1137    fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1138        FieldCx {
1139            name,
1140            id: name,
1141            label: "Label",
1142            value,
1143            required: true,
1144            opts,
1145            base: "/admin",
1146        }
1147    }
1148
1149    fn text_field() -> TextField {
1150        TextField::new(Arc::new(builtin_input_registry()))
1151    }
1152
1153    /// An input that contributes a reserved attribute name, to prove boot rejects it.
1154    struct BadAttrInput;
1155    impl InputType for BadAttrInput {
1156        fn key(&self) -> &'static str {
1157            "bad"
1158        }
1159        fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1160            BTreeMap::from([("class".to_string(), "x".to_string())])
1161        }
1162    }
1163
1164    #[test]
1165    fn text_renders_its_value_escaped() {
1166        let opts = ResolvedOptions::none();
1167        let value = FieldValue::Text("a<b>&\"c".to_string());
1168        let markup = render_field(
1169            &text_field(),
1170            &NoOverrides,
1171            &scope(),
1172            &cx("title", &value, &opts),
1173        );
1174        let html = markup.as_str();
1175        assert!(html.contains(r#"name="title""#));
1176        assert!(html.contains("required"));
1177        // The value is HTML-escaped by the Askama partial: no raw tag injected,
1178        // and the angle/ampersand are escaped (named or numeric entity, Askama's
1179        // choice, so accept either).
1180        assert!(!html.contains("<b>"), "no raw tag injected: {html}");
1181        assert!(
1182            html.contains("&#60;") || html.contains("&lt;"),
1183            "angle escaped: {html}"
1184        );
1185        assert!(
1186            html.contains("&#38;") || html.contains("&amp;"),
1187            "ampersand escaped: {html}"
1188        );
1189    }
1190
1191    #[test]
1192    fn textarea_uses_its_own_control() {
1193        let opts = ResolvedOptions::none();
1194        let value = FieldValue::Text("body".to_string());
1195        let markup = render_field(
1196            &TextareaField,
1197            &NoOverrides,
1198            &scope(),
1199            &cx("body", &value, &opts),
1200        );
1201        assert!(markup.as_str().contains("<textarea"));
1202    }
1203
1204    #[test]
1205    fn no_overrides_uses_the_compiled_default() {
1206        // A resolver that never overrides yields the built-in markup unchanged.
1207        let opts = ResolvedOptions::none();
1208        let value = FieldValue::Text("x".to_string());
1209        let ft = text_field();
1210        let vm = ft.view_model(&cx("t", &value, &opts));
1211        let direct = ft.render_default(&vm);
1212        let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
1213        assert_eq!(direct.as_str(), routed.as_str());
1214    }
1215
1216    #[test]
1217    fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
1218        let field = text_field();
1219        let opts = field
1220            .resolve_options(&serde_json::json!({ "input": "email" }))
1221            .unwrap();
1222        // The email input variant contributes the Email rule.
1223        assert!(matches!(
1224            field.intrinsic_rules(&opts).as_slice(),
1225            [Rule::Email]
1226        ));
1227        let value = FieldValue::Text("a@b.test".to_string());
1228        let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
1229        assert!(markup.as_str().contains(r#"type="email""#));
1230    }
1231
1232    #[test]
1233    fn text_default_input_is_plain_text_with_no_extra_rule() {
1234        let field = text_field();
1235        let opts = field.resolve_options(&serde_json::Value::Null).unwrap();
1236        assert!(field.intrinsic_rules(&opts).is_empty());
1237        let value = FieldValue::Text("hi".to_string());
1238        let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
1239        assert!(markup.as_str().contains(r#"type="text""#));
1240    }
1241
1242    #[test]
1243    fn text_number_input_sets_the_type_and_numeric_rule() {
1244        let field = text_field();
1245        let opts = field
1246            .resolve_options(&serde_json::json!({ "input": "number" }))
1247            .unwrap();
1248        assert!(matches!(
1249            field.intrinsic_rules(&opts).as_slice(),
1250            [Rule::Numeric]
1251        ));
1252        let value = FieldValue::Text("42".to_string());
1253        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1254        assert!(markup.as_str().contains(r#"type="number""#));
1255    }
1256
1257    #[test]
1258    fn text_tel_input_sets_the_type_with_no_rule() {
1259        let field = text_field();
1260        let opts = field
1261            .resolve_options(&serde_json::json!({ "input": "tel" }))
1262            .unwrap();
1263        assert!(field.intrinsic_rules(&opts).is_empty());
1264        let value = FieldValue::Text(String::new());
1265        let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
1266        assert!(markup.as_str().contains(r#"type="tel""#));
1267    }
1268
1269    #[test]
1270    fn text_url_input_sets_the_type_url_rule_and_copy_button() {
1271        let field = text_field();
1272        let opts = field
1273            .resolve_options(&serde_json::json!({ "input": "url" }))
1274            .unwrap();
1275        assert!(matches!(
1276            field.intrinsic_rules(&opts).as_slice(),
1277            [Rule::Url]
1278        ));
1279        let value = FieldValue::Text("https://example.com".to_string());
1280        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1281        let html = markup.as_str();
1282        assert!(html.contains(r#"type="url""#), "{html}");
1283        assert!(html.contains("lat-input-group"), "{html}");
1284        assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
1285    }
1286
1287    #[test]
1288    fn text_url_input_copy_false_omits_the_button() {
1289        let field = text_field();
1290        let opts = field
1291            .resolve_options(&serde_json::json!({ "input": "url", "copy": false }))
1292            .unwrap();
1293        let value = FieldValue::Text("https://example.com".to_string());
1294        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1295        let html = markup.as_str();
1296        assert!(html.contains(r#"type="url""#), "{html}");
1297        // No adornment, so a plain input with no group and no widget hook.
1298        assert!(!html.contains("lat-input-group"), "{html}");
1299        assert!(!html.contains("data-lat-widget"), "{html}");
1300    }
1301
1302    #[test]
1303    fn text_number_input_emits_min_max_step_attributes() {
1304        let field = text_field();
1305        let opts = field
1306            .resolve_options(
1307                &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
1308            )
1309            .unwrap();
1310        // Rendering runs vm -> data -> render_default, so attributes surviving into
1311        // the markup also proves the view-model serialize round-trip.
1312        let value = FieldValue::Text("5".to_string());
1313        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1314        let html = markup.as_str();
1315        assert!(html.contains(r#"min="0""#), "{html}");
1316        assert!(html.contains(r#"max="10""#), "{html}");
1317        assert!(html.contains(r#"step="2""#), "{html}");
1318    }
1319
1320    #[test]
1321    fn text_input_reserved_attribute_name_aborts_resolve() {
1322        let mut inputs = builtin_input_registry();
1323        inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
1324        let field = TextField::new(Arc::new(inputs));
1325        let Err(e) = field.resolve_options(&serde_json::json!({ "input": "bad" })) else {
1326            panic!("expected a reserved-attribute rejection");
1327        };
1328        assert!(e.0.contains("class"), "{e}");
1329    }
1330
1331    #[test]
1332    fn attribute_and_widget_name_rules() {
1333        assert!(is_name("min", false));
1334        assert!(is_name("data-x", false));
1335        assert!(!is_name("Min", false));
1336        assert!(!is_name("min.x", false)); // a dot is not allowed in an attribute name
1337        assert!(is_name("vendor.copy", true)); // but is in a widget name
1338        assert!(!is_name("", false));
1339    }
1340
1341    #[test]
1342    fn select_renders_options_with_the_current_value_selected() {
1343        let raw = serde_json::json!({
1344            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1345        });
1346        let opts = SelectField.resolve_options(&raw).unwrap();
1347        let value = FieldValue::Text("closed".to_string());
1348        let markup = render_field(
1349            &SelectField,
1350            &NoOverrides,
1351            &scope(),
1352            &cx("status", &value, &opts),
1353        );
1354        let html = markup.as_str();
1355        assert!(html.contains(r#"<option value="open">Open</option>"#));
1356        // The current value is selected; a missing label falls back to the value.
1357        assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
1358    }
1359
1360    #[test]
1361    fn radio_renders_the_same_options_with_the_current_one_checked() {
1362        let raw = serde_json::json!({
1363            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1364        });
1365        let opts = RadioField.resolve_options(&raw).unwrap();
1366        let value = FieldValue::Text("closed".to_string());
1367        let markup = render_field(
1368            &RadioField,
1369            &NoOverrides,
1370            &scope(),
1371            &cx("status", &value, &opts),
1372        );
1373        let html = markup.as_str();
1374        assert!(html.contains(r#"value="open""#));
1375        assert!(
1376            html.contains(r#"value="closed" checked"#),
1377            "current is checked"
1378        );
1379        // Every radio shares the field's name, or the browser treats them as
1380        // separate controls and lets more than one be chosen.
1381        assert_eq!(html.matches(r#"name="status""#).count(), 2);
1382        // A missing label falls back to the value, as select does.
1383        assert!(html.contains("closed</label>"));
1384    }
1385
1386    fn scope<'a>() -> OverrideScope<'a> {
1387        OverrideScope {
1388            surface: Surface::Field,
1389            view_key: "text",
1390            resource: None,
1391            field: None,
1392        }
1393    }
1394
1395    fn ref_field() -> RefPickerField {
1396        let mut pickers = crate::picker::PickerRegistry::new();
1397        let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
1398        pickers.insert(
1399            "acme.place".to_string(),
1400            crate::picker::PickerSourceReg::new("acme.place", source),
1401        );
1402        RefPickerField::new(Arc::new(pickers))
1403    }
1404
1405    #[test]
1406    fn reference_rejects_an_unknown_source() {
1407        let field = ref_field();
1408        let Err(e) = field.resolve_options(&serde_json::json!({ "source": "no.such" })) else {
1409            panic!("expected an unknown-source rejection");
1410        };
1411        assert!(e.0.contains("no.such"), "{e}");
1412    }
1413
1414    #[test]
1415    fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
1416        let field = ref_field();
1417        let opts = field
1418            .resolve_options(&serde_json::json!({ "source": "acme.place" }))
1419            .unwrap();
1420        let value = FieldValue::Text("42".to_string());
1421        let markup = render_field(
1422            &field,
1423            &NoOverrides,
1424            &scope(),
1425            &cx("place_id", &value, &opts),
1426        );
1427        let html = markup.as_str();
1428        // The stored id rides in the hidden input the form submits, so an untouched
1429        // form (or one with JS off) preserves the reference.
1430        assert!(html.contains(r#"type="hidden""#), "{html}");
1431        assert!(html.contains(r#"name="place_id""#), "{html}");
1432        assert!(html.contains(r#"value="42""#), "{html}");
1433        // The widget hook and its endpoint URLs (built from the mount path).
1434        assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
1435        assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
1436        assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
1437    }
1438
1439    #[test]
1440    fn reference_declares_its_widget_assets() {
1441        let field = ref_field();
1442        let opts = field
1443            .resolve_options(&serde_json::json!({ "source": "acme.place" }))
1444            .unwrap();
1445        assert_eq!(
1446            field.assets(&opts),
1447            vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1448        );
1449    }
1450}
1451
1452#[cfg(test)]
1453mod save_contract_tests {
1454    use super::*;
1455
1456    fn none() -> ResolvedOptions {
1457        ResolvedOptions::none()
1458    }
1459
1460    #[test]
1461    fn a_switch_stores_a_bool_and_absence_means_off() {
1462        let f = SwitchField;
1463        // A browser sends nothing for an unchecked box, so absence must write
1464        // false rather than leave the column as it was.
1465        assert_eq!(
1466            f.to_attr(None, &none(), Mode::Update).unwrap(),
1467            Some(AttrValue::Bool(false))
1468        );
1469        assert_eq!(
1470            f.to_attr(Some("on"), &none(), Mode::Create).unwrap(),
1471            Some(AttrValue::Bool(true))
1472        );
1473    }
1474
1475    #[test]
1476    fn a_switch_reads_back_whatever_the_backend_gave() {
1477        let f = SwitchField;
1478        // sqlx::Any hands every backend's boolean back as text, and they differ.
1479        for on in ["1", "true", "t", "on"] {
1480            assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
1481        }
1482        for off in ["0", "false", "f", ""] {
1483            assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
1484        }
1485        assert!(f.to_control(None, &none()).is_empty());
1486    }
1487
1488    #[test]
1489    fn a_date_shows_only_the_date_part_of_a_stored_instant() {
1490        let f = DateField;
1491        // A timestamp column hands back the whole instant; a date input rejects it.
1492        assert_eq!(
1493            f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
1494            "2026-09-09"
1495        );
1496        assert_eq!(
1497            f.to_control(Some("2026-09-09 12:30:45"), &none()),
1498            "2026-09-09"
1499        );
1500        assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
1501        assert_eq!(f.to_control(None, &none()), "");
1502    }
1503
1504    #[test]
1505    fn a_cleared_date_stores_null_not_an_empty_string() {
1506        let f = DateField;
1507        assert_eq!(
1508            f.to_attr(Some(""), &none(), Mode::Update).unwrap(),
1509            Some(AttrValue::Null)
1510        );
1511        assert_eq!(
1512            f.to_attr(Some("2026-09-09"), &none(), Mode::Create)
1513                .unwrap(),
1514            Some(AttrValue::Text("2026-09-09".into()))
1515        );
1516        assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
1517    }
1518
1519    #[test]
1520    fn a_password_hashes_and_never_echoes() {
1521        let f = PasswordField;
1522        let stored = f
1523            .to_attr(Some("hunter2hunter2"), &none(), Mode::Create)
1524            .unwrap()
1525            .unwrap();
1526        let hash = stored.as_str().unwrap();
1527        assert!(
1528            hash.starts_with("$argon2"),
1529            "stored as a hash, not plaintext"
1530        );
1531        assert_ne!(hash, "hunter2hunter2");
1532        // The hash must never reach the page, whatever is stored.
1533        assert_eq!(f.to_control(Some(hash), &none()), "");
1534    }
1535
1536    #[test]
1537    fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
1538        let f = PasswordField;
1539        // Omitted, so the write does not touch the column.
1540        assert_eq!(f.to_attr(Some(""), &none(), Mode::Update).unwrap(), None);
1541        assert_eq!(f.to_attr(Some("   "), &none(), Mode::Update).unwrap(), None);
1542        assert_eq!(f.to_attr(None, &none(), Mode::Update).unwrap(), None);
1543    }
1544
1545    #[test]
1546    fn a_plain_field_stores_and_shows_the_text_unchanged() {
1547        let f = TextareaField;
1548        assert_eq!(
1549            f.to_attr(Some("hello"), &none(), Mode::Create).unwrap(),
1550            Some(AttrValue::Text("hello".into()))
1551        );
1552        // Absent stays absent, so the write leaves that column alone.
1553        assert_eq!(f.to_attr(None, &none(), Mode::Update).unwrap(), None);
1554        assert_eq!(f.to_control(Some("hello"), &none()), "hello");
1555    }
1556}