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::Rule;
17use serde::{Deserialize, Serialize};
18
19use crate::html::Markup;
20use crate::picker::PickerRegistry;
21
22/// A field's value, general enough for scalar and (later) structured types.
23/// Serde so it rides in the view-model and a failed-validation re-render.
24#[derive(Debug, Clone, Serialize)]
25#[serde(untagged)]
26pub enum FieldValue {
27    Null,
28    Text(String),
29    Json(serde_json::Value),
30}
31
32impl FieldValue {
33    /// The value as text for a scalar field (empty for null or non-text).
34    pub fn as_text(&self) -> &str {
35        match self {
36            FieldValue::Text(s) => s,
37            _ => "",
38        }
39    }
40}
41
42/// Per-type options, resolved once at boot from the descriptor's options blob
43/// into a typed value cached for rendering (the text field resolves its input
44/// variant, select its option list; textarea carries none).
45#[derive(Default)]
46pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
47
48impl ResolvedOptions {
49    /// No options (the default for optionless types).
50    pub fn none() -> Self {
51        Self(None)
52    }
53    /// Wraps a resolved typed options value.
54    pub fn new<T: Any + Send + Sync>(value: T) -> Self {
55        Self(Some(Arc::new(value)))
56    }
57    /// Borrows the typed options, if present and of type `T`.
58    pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
59        self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
60    }
61}
62
63/// Whether the framework wraps a field in standard chrome (label, required
64/// marker, error list) or the type renders bare (a hidden field, a heading).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Chrome {
67    Wrapped,
68    Bare,
69}
70
71/// The resolved per-field state a field type renders from.
72#[non_exhaustive]
73pub struct FieldCx<'a> {
74    /// The submitted/DOM field name (becomes a path with repeaters).
75    pub name: &'a str,
76    /// A DOM-safe id for the control and its label's `for`.
77    pub id: &'a str,
78    pub label: &'a str,
79    pub value: &'a FieldValue,
80    /// Derived from the merged (intrinsic + descriptor) rules.
81    pub required: bool,
82    pub opts: &'a ResolvedOptions,
83    /// The admin mount path (e.g. `/admin`), so a type that calls an endpoint
84    /// (the reference picker) builds its URL without knowing routing conventions.
85    pub base: &'a str,
86}
87
88/// The serialisable payload both the built-in template and an override render,
89/// so an override never re-derives data. `data` is per-type data, never HTML.
90#[derive(Debug, Clone, Serialize)]
91pub struct FieldVm {
92    pub view_key: String,
93    pub name: String,
94    pub id: String,
95    pub label: String,
96    pub required: bool,
97    pub value: FieldValue,
98    pub data: serde_json::Value,
99}
100
101/// A field type: behaviour registered once per string key.
102pub trait FieldType: Send + Sync + 'static {
103    /// The stable key for override resolution and default-template selection
104    /// (bare like `text` for core; dotted `vendor.name` for plugins).
105    fn view_key(&self) -> &'static str;
106
107    /// Types the descriptor's raw options blob once at boot. A failure aborts
108    /// boot naming the resource + field. Default: no options.
109    fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
110        Ok(ResolvedOptions::none())
111    }
112
113    /// Rules this type always contributes, merged before the descriptor's own
114    /// (e.g. the text field's `email` input contributes [`Rule::Email`]).
115    fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
116        Vec::new()
117    }
118
119    /// Builds the serialisable presentation payload. Pure, no IO.
120    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
121
122    /// The built-in presenter over the view-model (an Askama partial). Not
123    /// called when an override wins.
124    fn render_default(&self, vm: &FieldVm) -> Markup;
125
126    /// Whether the framework wraps this field in standard chrome.
127    fn chrome(&self) -> Chrome {
128        Chrome::Wrapped
129    }
130
131    /// Asset-registry keys this type's control needs on any page that renders it
132    /// (a heavy widget's script/style). The page shell collects, dedupes, and
133    /// emits them; keys must exist in the asset registry. Default: none, so a
134    /// type whose widget ships in core `laterite.js` declares nothing.
135    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
136        Vec::new()
137    }
138}
139
140/// A field type failed to type its options blob at boot.
141#[derive(Debug, thiserror::Error)]
142#[error("invalid field options: {0}")]
143pub struct OptionsError(pub String);
144
145/// The surface being rendered, for override resolution.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Surface {
148    Field,
149    Column,
150}
151
152/// Where a render is happening, for override resolution (most-specific first:
153/// `resource.field` then `view_key` then the compiled default).
154pub struct OverrideScope<'a> {
155    pub surface: Surface,
156    pub view_key: &'a str,
157    pub resource: Option<&'a str>,
158    pub field: Option<&'a str>,
159}
160
161/// A runtime override failed to render.
162#[derive(Debug, thiserror::Error)]
163#[error("override render failed: {0}")]
164pub struct OverrideError(pub String);
165
166/// The seam a theme/CMS layer backs with a runtime template engine (MiniJinja)
167/// to let users override a field's presentation from outside the plugin.
168/// `laterite-admin` stays engine-agnostic; the default is [`NoOverrides`].
169pub trait OverrideResolver: Send + Sync {
170    /// An override for this view-model if one is registered for the scope, else
171    /// `None` (use the default). The `String` is engine-autoescaped HTML.
172    fn render_override(
173        &self,
174        scope: &OverrideScope<'_>,
175        vm: &serde_json::Value,
176    ) -> Option<Result<String, OverrideError>>;
177}
178
179/// The default resolver: no overrides, so the compiled path always runs at zero
180/// cost. The theme layer injects a real resolver when it is built.
181pub struct NoOverrides;
182
183impl OverrideResolver for NoOverrides {
184    fn render_override(
185        &self,
186        _scope: &OverrideScope<'_>,
187        _vm: &serde_json::Value,
188    ) -> Option<Result<String, OverrideError>> {
189        None
190    }
191}
192
193/// The field-type registry: type key to behaviour, built once at boot.
194pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
195
196/// Renders a field: an override if the resolver supplies one, else the compiled
197/// default. The one place the two paths meet.
198pub(crate) fn render_field(
199    ft: &dyn FieldType,
200    resolver: &dyn OverrideResolver,
201    scope: &OverrideScope<'_>,
202    cx: &FieldCx<'_>,
203) -> Markup {
204    let vm = ft.view_model(cx);
205    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
206        Some(Ok(html)) => Markup::from_override(html),
207        // A failing override falls back to the default rather than blank the field.
208        Some(Err(_)) | None => ft.render_default(&vm),
209    }
210}
211
212/// The framework's built-in field types. The text field is constructed with the
213/// built-in input-type registry it delegates to.
214pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
215    let inputs = Arc::new(builtin_input_registry());
216    vec![
217        Arc::new(TextField::new(inputs)),
218        Arc::new(TextareaField),
219        Arc::new(SelectField),
220    ]
221}
222
223/// The field registry seeded with the built-in types, keyed by [`FieldType::view_key`].
224pub(crate) fn builtin_registry() -> FieldRegistry {
225    builtin_field_types()
226        .into_iter()
227        .map(|ft| (ft.view_key().to_string(), ft))
228        .collect()
229}
230
231/// Where an adornment sits relative to the input.
232#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum Placement {
235    Leading,
236    Trailing,
237}
238
239/// A control placed beside the input (a copy button, a currency symbol): data in
240/// the view-model, never HTML, so an override re-presents it. The text template
241/// renders it into the input group.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Adornment {
244    pub placement: Placement,
245    /// `true` renders a `<button>` (an action); `false` an inert affix `<span>`.
246    pub button: bool,
247    /// Visible text and accessible label.
248    pub label: String,
249    /// The `data-lat-widget` island wiring a button (bare like `copy` for core,
250    /// dotted `vendor.name` for a plugin). `None` for an inert affix.
251    pub widget: Option<String>,
252}
253
254/// An input variant of the text field: its HTML `type`, the rules it contributes,
255/// and how it parameterises the one text control (extra attributes, adjacent
256/// adornments). The extension point a plugin builds on (a currency input, say)
257/// instead of reimplementing a field. An input parameterises the single text
258/// control; anything needing different control markup is a [`FieldType`].
259pub trait InputType: Send + Sync + 'static {
260    /// The registry key naming this input (`text`, `email`, `url`, `currency`).
261    fn key(&self) -> &'static str;
262    /// The HTML `type` attribute.
263    fn html_type(&self) -> &'static str {
264        "text"
265    }
266    /// Types this input's own keys from the field's options blob (the same blob
267    /// the field type reads) once at boot. Default: no options.
268    fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
269        Ok(ResolvedOptions::none())
270    }
271    /// Rules this input contributes (an `email` input adds [`Rule::Email`]).
272    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
273        Vec::new()
274    }
275    /// Extra `<input>` attributes (a number input's `min`/`max`/`step`). Names are
276    /// validated at boot and may not collide with the ones the template owns.
277    fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
278        BTreeMap::new()
279    }
280    /// Controls placed beside the input (a copy button, a currency symbol).
281    fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
282        Vec::new()
283    }
284    /// Asset-registry keys this input needs (a heavy widget's script/style). The
285    /// copy button needs none, since its island ships in core `laterite.js`.
286    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
287        Vec::new()
288    }
289}
290
291/// The registry the text field resolves its `input` option against.
292pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
293
294struct TextInput;
295impl InputType for TextInput {
296    fn key(&self) -> &'static str {
297        "text"
298    }
299}
300
301struct EmailInput;
302impl InputType for EmailInput {
303    fn key(&self) -> &'static str {
304        "email"
305    }
306    fn html_type(&self) -> &'static str {
307        "email"
308    }
309    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
310        vec![Rule::Email]
311    }
312}
313
314struct TelInput;
315impl InputType for TelInput {
316    fn key(&self) -> &'static str {
317        "tel"
318    }
319    fn html_type(&self) -> &'static str {
320        "tel"
321    }
322}
323
324/// A number input's optional bounds and step, emitted as `<input>` attributes.
325#[derive(Default, Deserialize)]
326struct NumberOptions {
327    #[serde(default)]
328    min: Option<f64>,
329    #[serde(default)]
330    max: Option<f64>,
331    #[serde(default)]
332    step: Option<f64>,
333}
334
335struct NumberInput;
336impl InputType for NumberInput {
337    fn key(&self) -> &'static str {
338        "number"
339    }
340    fn html_type(&self) -> &'static str {
341        "number"
342    }
343    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
344        let opts: NumberOptions = if raw.is_null() {
345            NumberOptions::default()
346        } else {
347            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
348        };
349        Ok(ResolvedOptions::new(opts))
350    }
351    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
352        vec![Rule::Numeric]
353    }
354    fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
355        let mut attrs = BTreeMap::new();
356        if let Some(o) = opts.get::<NumberOptions>() {
357            if let Some(min) = o.min {
358                attrs.insert("min".to_string(), min.to_string());
359            }
360            if let Some(max) = o.max {
361                attrs.insert("max".to_string(), max.to_string());
362            }
363            if let Some(step) = o.step {
364                attrs.insert("step".to_string(), step.to_string());
365            }
366        }
367        attrs
368    }
369}
370
371/// A url input's option: whether to show the copy-to-clipboard button.
372#[derive(Deserialize)]
373struct UrlOptions {
374    #[serde(default = "default_true")]
375    copy: bool,
376}
377
378impl Default for UrlOptions {
379    fn default() -> Self {
380        Self { copy: true }
381    }
382}
383
384fn default_true() -> bool {
385    true
386}
387
388struct UrlInput;
389impl InputType for UrlInput {
390    fn key(&self) -> &'static str {
391        "url"
392    }
393    fn html_type(&self) -> &'static str {
394        "url"
395    }
396    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
397        let opts: UrlOptions = if raw.is_null() {
398            UrlOptions::default()
399        } else {
400            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
401        };
402        Ok(ResolvedOptions::new(opts))
403    }
404    fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
405        vec![Rule::Url]
406    }
407    fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
408        if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
409            vec![Adornment {
410                placement: Placement::Trailing,
411                button: true,
412                label: "Copy".to_string(),
413                widget: Some("copy".to_string()),
414            }]
415        } else {
416            Vec::new()
417        }
418    }
419}
420
421pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
422    vec![
423        Arc::new(TextInput),
424        Arc::new(EmailInput),
425        Arc::new(TelInput),
426        Arc::new(NumberInput),
427        Arc::new(UrlInput),
428    ]
429}
430
431/// The input-type registry seeded with the built-in inputs.
432pub(crate) fn builtin_input_registry() -> InputRegistry {
433    builtin_input_types()
434        .into_iter()
435        .map(|i| (i.key().to_string(), i))
436        .collect()
437}
438
439/// Attribute names the text template emits itself; an input may not re-declare
440/// them (HTML resolves a duplicate to the first, silently dropping the input's).
441const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
442
443/// Validates an input's contributed attribute and widget names at boot. Names
444/// come from plugin code, so a bad one is a wiring bug caught at boot, not user
445/// input: an attribute name is lowercase kebab and unreserved; a widget name may
446/// carry a dotted plugin namespace.
447fn validate_contributions(
448    attrs: &BTreeMap<String, String>,
449    adornments: &[Adornment],
450) -> Result<(), OptionsError> {
451    for name in attrs.keys() {
452        if !is_name(name, false) {
453            return Err(OptionsError(format!("invalid attribute name `{name}`")));
454        }
455        if RESERVED_ATTRS.contains(&name.as_str()) {
456            return Err(OptionsError(format!("attribute `{name}` is reserved")));
457        }
458    }
459    for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
460        if !is_name(widget, true) {
461            return Err(OptionsError(format!("invalid widget name `{widget}`")));
462        }
463    }
464    Ok(())
465}
466
467/// `^[a-z][a-z0-9-]*$`, plus `.` when `dotted` (for a widget's `vendor.name`).
468pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
469    let mut chars = s.chars();
470    matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
471        && chars.all(|c| {
472            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
473        })
474}
475
476/// The text field's typed options: which input variant to render.
477#[derive(Deserialize)]
478struct TextOptions {
479    #[serde(default = "default_input")]
480    input: String,
481}
482
483fn default_input() -> String {
484    "text".to_string()
485}
486
487/// The text field's resolved options: everything needed to render and validate,
488/// computed (and name-checked) once at boot from the selected input.
489struct TextResolved {
490    html_type: &'static str,
491    attrs: BTreeMap<String, String>,
492    adornments: Vec<Adornment>,
493    rules: Vec<Rule>,
494    assets: Vec<&'static str>,
495}
496
497/// The text field's view-model payload. Additive over the prior `{input_type}`,
498/// so existing overrides keep working; this shape is the override contract.
499#[derive(Default, Serialize, Deserialize)]
500struct TextData {
501    input_type: String,
502    #[serde(default)]
503    attrs: BTreeMap<String, String>,
504    #[serde(default)]
505    adornments: Vec<Adornment>,
506}
507
508#[derive(Template)]
509#[template(path = "fields/text.html")]
510struct TextTmpl<'a> {
511    name: &'a str,
512    id: &'a str,
513    value: &'a str,
514    required: bool,
515    input_type: &'a str,
516    attrs: &'a BTreeMap<String, String>,
517    leading: &'a [Adornment],
518    trailing: &'a [Adornment],
519}
520
521/// A single-line text input. Its `input` option selects an [`InputType`] from
522/// the registry (text, email, ...; plugins add more) that drives the HTML type
523/// and intrinsic rules.
524pub(crate) struct TextField {
525    inputs: Arc<InputRegistry>,
526}
527
528impl TextField {
529    pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
530        Self { inputs }
531    }
532}
533
534impl FieldType for TextField {
535    fn view_key(&self) -> &'static str {
536        "text"
537    }
538    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
539        let opts: TextOptions = if raw.is_null() {
540            TextOptions {
541                input: default_input(),
542            }
543        } else {
544            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
545        };
546        let input = self
547            .inputs
548            .get(&opts.input)
549            .cloned()
550            .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
551        // The input reads its own keys from the same blob, then contributes its
552        // control shape; the contributed names are validated before caching.
553        let input_opts = input.resolve_options(raw)?;
554        let attrs = input.attributes(&input_opts);
555        let adornments = input.adornments(&input_opts);
556        validate_contributions(&attrs, &adornments)?;
557        Ok(ResolvedOptions::new(TextResolved {
558            html_type: input.html_type(),
559            rules: input.rules(&input_opts),
560            assets: input.assets(&input_opts),
561            attrs,
562            adornments,
563        }))
564    }
565    fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
566        opts.get::<TextResolved>()
567            .map(|r| r.rules.clone())
568            .unwrap_or_default()
569    }
570    fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
571        opts.get::<TextResolved>()
572            .map(|r| r.assets.clone())
573            .unwrap_or_default()
574    }
575    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
576        let data = match cx.opts.get::<TextResolved>() {
577            Some(r) => TextData {
578                input_type: r.html_type.to_string(),
579                attrs: r.attrs.clone(),
580                adornments: r.adornments.clone(),
581            },
582            None => TextData {
583                input_type: "text".to_string(),
584                ..TextData::default()
585            },
586        };
587        FieldVm {
588            view_key: "text".to_string(),
589            name: cx.name.to_string(),
590            id: cx.id.to_string(),
591            label: cx.label.to_string(),
592            required: cx.required,
593            value: cx.value.clone(),
594            data: serde_json::to_value(data).unwrap_or_default(),
595        }
596    }
597    fn render_default(&self, vm: &FieldVm) -> Markup {
598        let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
599        let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
600            .adornments
601            .into_iter()
602            .partition(|a| matches!(a.placement, Placement::Leading));
603        Markup::from_template(&TextTmpl {
604            name: &vm.name,
605            id: &vm.id,
606            value: vm.value.as_text(),
607            required: vm.required,
608            input_type: &data.input_type,
609            attrs: &data.attrs,
610            leading: &leading,
611            trailing: &trailing,
612        })
613        .unwrap_or_default()
614    }
615}
616
617#[derive(Template)]
618#[template(path = "fields/textarea.html")]
619struct TextareaTmpl<'a> {
620    name: &'a str,
621    id: &'a str,
622    value: &'a str,
623    required: bool,
624}
625
626/// A multi-line text input.
627pub(crate) struct TextareaField;
628
629impl FieldType for TextareaField {
630    fn view_key(&self) -> &'static str {
631        "textarea"
632    }
633    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
634        scalar_vm("textarea", cx)
635    }
636    fn render_default(&self, vm: &FieldVm) -> Markup {
637        Markup::from_template(&TextareaTmpl {
638            name: &vm.name,
639            id: &vm.id,
640            value: vm.value.as_text(),
641            required: vm.required,
642        })
643        .unwrap_or_default()
644    }
645}
646
647/// A `select` field's options: a list of value/label pairs.
648#[derive(Debug, Default, Deserialize)]
649struct SelectOptions {
650    #[serde(default)]
651    options: Vec<SelectOption>,
652}
653
654#[derive(Debug, Deserialize)]
655struct SelectOption {
656    value: String,
657    /// Display text; defaults to the value when omitted.
658    #[serde(default)]
659    label: Option<String>,
660}
661
662/// One rendered option: label resolved, `selected` computed against the current
663/// value. Carried in the view-model so an override presents the same list.
664#[derive(Debug, Serialize, Deserialize)]
665struct OptionView {
666    value: String,
667    label: String,
668    selected: bool,
669}
670
671#[derive(Template)]
672#[template(path = "fields/select.html")]
673struct SelectTmpl<'a> {
674    name: &'a str,
675    id: &'a str,
676    required: bool,
677    options: &'a [OptionView],
678}
679
680/// A dropdown over a fixed option list.
681pub(crate) struct SelectField;
682
683impl FieldType for SelectField {
684    fn view_key(&self) -> &'static str {
685        "select"
686    }
687    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
688        let opts: SelectOptions =
689            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
690        Ok(ResolvedOptions::new(opts))
691    }
692    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
693        let current = cx.value.as_text();
694        let views: Vec<OptionView> = cx
695            .opts
696            .get::<SelectOptions>()
697            .map(|o| {
698                o.options
699                    .iter()
700                    .map(|so| OptionView {
701                        value: so.value.clone(),
702                        label: so.label.clone().unwrap_or_else(|| so.value.clone()),
703                        selected: so.value == current,
704                    })
705                    .collect()
706            })
707            .unwrap_or_default();
708        FieldVm {
709            view_key: "select".to_string(),
710            name: cx.name.to_string(),
711            id: cx.id.to_string(),
712            label: cx.label.to_string(),
713            required: cx.required,
714            value: cx.value.clone(),
715            data: serde_json::to_value(&views).unwrap_or_default(),
716        }
717    }
718    fn render_default(&self, vm: &FieldVm) -> Markup {
719        let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
720        Markup::from_template(&SelectTmpl {
721            name: &vm.name,
722            id: &vm.id,
723            required: vm.required,
724            options: &options,
725        })
726        .unwrap_or_default()
727    }
728}
729
730/// The view-model common to scalar text-like fields (no per-type `data`).
731fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
732    FieldVm {
733        view_key: view_key.to_string(),
734        name: cx.name.to_string(),
735        id: cx.id.to_string(),
736        label: cx.label.to_string(),
737        required: cx.required,
738        value: cx.value.clone(),
739        data: serde_json::Value::Null,
740    }
741}
742
743/// The reference-picker field's option: which registered source to pick from.
744#[derive(Deserialize)]
745struct RefOptions {
746    source: String,
747}
748
749/// The reference-picker's resolved options: the source name, validated at boot.
750struct RefResolved {
751    source: String,
752}
753
754/// The reference-picker's view-model payload: the source and the endpoint URLs
755/// its widget calls, plus the stored id (which the hidden input submits).
756#[derive(Default, Serialize, Deserialize)]
757struct RefData {
758    source: String,
759    search_url: String,
760    resolve_url: String,
761    value: String,
762    placeholder: String,
763}
764
765#[derive(Template)]
766#[template(path = "fields/ref_picker.html")]
767struct RefPickerTmpl<'a> {
768    name: &'a str,
769    id: &'a str,
770    value: &'a str,
771    search_url: &'a str,
772    resolve_url: &'a str,
773    placeholder: &'a str,
774}
775
776/// A picker over a reference to another record: stores that record's id, shows
777/// its label, and offers typeahead candidates from a registered [`PickerSource`]
778/// (selected by the `source` option). The label and candidates are fetched by
779/// its widget from the source's endpoints; rendering stays pure.
780pub(crate) struct RefPickerField {
781    pickers: Arc<PickerRegistry>,
782}
783
784impl RefPickerField {
785    pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
786        Self { pickers }
787    }
788}
789
790impl FieldType for RefPickerField {
791    fn view_key(&self) -> &'static str {
792        "reference"
793    }
794    fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
795        let opts: RefOptions =
796            serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
797        if !self.pickers.contains_key(&opts.source) {
798            return Err(OptionsError(format!(
799                "unknown picker source `{}`",
800                opts.source
801            )));
802        }
803        Ok(ResolvedOptions::new(RefResolved {
804            source: opts.source,
805        }))
806    }
807    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
808        let source = cx
809            .opts
810            .get::<RefResolved>()
811            .map(|r| r.source.as_str())
812            .unwrap_or_default();
813        let data = RefData {
814            search_url: format!("{}/pickers/{}/search", cx.base, source),
815            resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
816            source: source.to_string(),
817            value: cx.value.as_text().to_string(),
818            placeholder: "Search…".to_string(),
819        };
820        FieldVm {
821            view_key: "reference".to_string(),
822            name: cx.name.to_string(),
823            id: cx.id.to_string(),
824            label: cx.label.to_string(),
825            required: cx.required,
826            value: cx.value.clone(),
827            data: serde_json::to_value(data).unwrap_or_default(),
828        }
829    }
830    fn render_default(&self, vm: &FieldVm) -> Markup {
831        let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
832        Markup::from_template(&RefPickerTmpl {
833            name: &vm.name,
834            id: &vm.id,
835            value: vm.value.as_text(),
836            search_url: &data.search_url,
837            resolve_url: &data.resolve_url,
838            placeholder: &data.placeholder,
839        })
840        .unwrap_or_default()
841    }
842    fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
843        vec!["fields/ref-picker.js", "fields/ref-picker.css"]
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850
851    fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
852        FieldCx {
853            name,
854            id: name,
855            label: "Label",
856            value,
857            required: true,
858            opts,
859            base: "/admin",
860        }
861    }
862
863    fn text_field() -> TextField {
864        TextField::new(Arc::new(builtin_input_registry()))
865    }
866
867    /// An input that contributes a reserved attribute name, to prove boot rejects it.
868    struct BadAttrInput;
869    impl InputType for BadAttrInput {
870        fn key(&self) -> &'static str {
871            "bad"
872        }
873        fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
874            BTreeMap::from([("class".to_string(), "x".to_string())])
875        }
876    }
877
878    #[test]
879    fn text_renders_its_value_escaped() {
880        let opts = ResolvedOptions::none();
881        let value = FieldValue::Text("a<b>&\"c".to_string());
882        let markup = render_field(
883            &text_field(),
884            &NoOverrides,
885            &scope(),
886            &cx("title", &value, &opts),
887        );
888        let html = markup.as_str();
889        assert!(html.contains(r#"name="title""#));
890        assert!(html.contains("required"));
891        // The value is HTML-escaped by the Askama partial: no raw tag injected,
892        // and the angle/ampersand are escaped (named or numeric entity, Askama's
893        // choice, so accept either).
894        assert!(!html.contains("<b>"), "no raw tag injected: {html}");
895        assert!(
896            html.contains("&#60;") || html.contains("&lt;"),
897            "angle escaped: {html}"
898        );
899        assert!(
900            html.contains("&#38;") || html.contains("&amp;"),
901            "ampersand escaped: {html}"
902        );
903    }
904
905    #[test]
906    fn textarea_uses_its_own_control() {
907        let opts = ResolvedOptions::none();
908        let value = FieldValue::Text("body".to_string());
909        let markup = render_field(
910            &TextareaField,
911            &NoOverrides,
912            &scope(),
913            &cx("body", &value, &opts),
914        );
915        assert!(markup.as_str().contains("<textarea"));
916    }
917
918    #[test]
919    fn no_overrides_uses_the_compiled_default() {
920        // A resolver that never overrides yields the built-in markup unchanged.
921        let opts = ResolvedOptions::none();
922        let value = FieldValue::Text("x".to_string());
923        let ft = text_field();
924        let vm = ft.view_model(&cx("t", &value, &opts));
925        let direct = ft.render_default(&vm);
926        let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
927        assert_eq!(direct.as_str(), routed.as_str());
928    }
929
930    #[test]
931    fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
932        let field = text_field();
933        let opts = field
934            .resolve_options(&serde_json::json!({ "input": "email" }))
935            .unwrap();
936        // The email input variant contributes the Email rule.
937        assert!(matches!(
938            field.intrinsic_rules(&opts).as_slice(),
939            [Rule::Email]
940        ));
941        let value = FieldValue::Text("a@b.test".to_string());
942        let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
943        assert!(markup.as_str().contains(r#"type="email""#));
944    }
945
946    #[test]
947    fn text_default_input_is_plain_text_with_no_extra_rule() {
948        let field = text_field();
949        let opts = field.resolve_options(&serde_json::Value::Null).unwrap();
950        assert!(field.intrinsic_rules(&opts).is_empty());
951        let value = FieldValue::Text("hi".to_string());
952        let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
953        assert!(markup.as_str().contains(r#"type="text""#));
954    }
955
956    #[test]
957    fn text_number_input_sets_the_type_and_numeric_rule() {
958        let field = text_field();
959        let opts = field
960            .resolve_options(&serde_json::json!({ "input": "number" }))
961            .unwrap();
962        assert!(matches!(
963            field.intrinsic_rules(&opts).as_slice(),
964            [Rule::Numeric]
965        ));
966        let value = FieldValue::Text("42".to_string());
967        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
968        assert!(markup.as_str().contains(r#"type="number""#));
969    }
970
971    #[test]
972    fn text_tel_input_sets_the_type_with_no_rule() {
973        let field = text_field();
974        let opts = field
975            .resolve_options(&serde_json::json!({ "input": "tel" }))
976            .unwrap();
977        assert!(field.intrinsic_rules(&opts).is_empty());
978        let value = FieldValue::Text(String::new());
979        let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
980        assert!(markup.as_str().contains(r#"type="tel""#));
981    }
982
983    #[test]
984    fn text_url_input_sets_the_type_url_rule_and_copy_button() {
985        let field = text_field();
986        let opts = field
987            .resolve_options(&serde_json::json!({ "input": "url" }))
988            .unwrap();
989        assert!(matches!(
990            field.intrinsic_rules(&opts).as_slice(),
991            [Rule::Url]
992        ));
993        let value = FieldValue::Text("https://example.com".to_string());
994        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
995        let html = markup.as_str();
996        assert!(html.contains(r#"type="url""#), "{html}");
997        assert!(html.contains("lat-input-group"), "{html}");
998        assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
999    }
1000
1001    #[test]
1002    fn text_url_input_copy_false_omits_the_button() {
1003        let field = text_field();
1004        let opts = field
1005            .resolve_options(&serde_json::json!({ "input": "url", "copy": false }))
1006            .unwrap();
1007        let value = FieldValue::Text("https://example.com".to_string());
1008        let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1009        let html = markup.as_str();
1010        assert!(html.contains(r#"type="url""#), "{html}");
1011        // No adornment, so a plain input with no group and no widget hook.
1012        assert!(!html.contains("lat-input-group"), "{html}");
1013        assert!(!html.contains("data-lat-widget"), "{html}");
1014    }
1015
1016    #[test]
1017    fn text_number_input_emits_min_max_step_attributes() {
1018        let field = text_field();
1019        let opts = field
1020            .resolve_options(
1021                &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
1022            )
1023            .unwrap();
1024        // Rendering runs vm -> data -> render_default, so attributes surviving into
1025        // the markup also proves the view-model serialize round-trip.
1026        let value = FieldValue::Text("5".to_string());
1027        let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1028        let html = markup.as_str();
1029        assert!(html.contains(r#"min="0""#), "{html}");
1030        assert!(html.contains(r#"max="10""#), "{html}");
1031        assert!(html.contains(r#"step="2""#), "{html}");
1032    }
1033
1034    #[test]
1035    fn text_input_reserved_attribute_name_aborts_resolve() {
1036        let mut inputs = builtin_input_registry();
1037        inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
1038        let field = TextField::new(Arc::new(inputs));
1039        let Err(e) = field.resolve_options(&serde_json::json!({ "input": "bad" })) else {
1040            panic!("expected a reserved-attribute rejection");
1041        };
1042        assert!(e.0.contains("class"), "{e}");
1043    }
1044
1045    #[test]
1046    fn attribute_and_widget_name_rules() {
1047        assert!(is_name("min", false));
1048        assert!(is_name("data-x", false));
1049        assert!(!is_name("Min", false));
1050        assert!(!is_name("min.x", false)); // a dot is not allowed in an attribute name
1051        assert!(is_name("vendor.copy", true)); // but is in a widget name
1052        assert!(!is_name("", false));
1053    }
1054
1055    #[test]
1056    fn select_renders_options_with_the_current_value_selected() {
1057        let raw = serde_json::json!({
1058            "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1059        });
1060        let opts = SelectField.resolve_options(&raw).unwrap();
1061        let value = FieldValue::Text("closed".to_string());
1062        let markup = render_field(
1063            &SelectField,
1064            &NoOverrides,
1065            &scope(),
1066            &cx("status", &value, &opts),
1067        );
1068        let html = markup.as_str();
1069        assert!(html.contains(r#"<option value="open">Open</option>"#));
1070        // The current value is selected; a missing label falls back to the value.
1071        assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
1072    }
1073
1074    fn scope<'a>() -> OverrideScope<'a> {
1075        OverrideScope {
1076            surface: Surface::Field,
1077            view_key: "text",
1078            resource: None,
1079            field: None,
1080        }
1081    }
1082
1083    fn ref_field() -> RefPickerField {
1084        let mut pickers = crate::picker::PickerRegistry::new();
1085        let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
1086        pickers.insert(
1087            "acme.place".to_string(),
1088            crate::picker::PickerSourceReg::new("acme.place", source),
1089        );
1090        RefPickerField::new(Arc::new(pickers))
1091    }
1092
1093    #[test]
1094    fn reference_rejects_an_unknown_source() {
1095        let field = ref_field();
1096        let Err(e) = field.resolve_options(&serde_json::json!({ "source": "no.such" })) else {
1097            panic!("expected an unknown-source rejection");
1098        };
1099        assert!(e.0.contains("no.such"), "{e}");
1100    }
1101
1102    #[test]
1103    fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
1104        let field = ref_field();
1105        let opts = field
1106            .resolve_options(&serde_json::json!({ "source": "acme.place" }))
1107            .unwrap();
1108        let value = FieldValue::Text("42".to_string());
1109        let markup = render_field(
1110            &field,
1111            &NoOverrides,
1112            &scope(),
1113            &cx("place_id", &value, &opts),
1114        );
1115        let html = markup.as_str();
1116        // The stored id rides in the hidden input the form submits, so an untouched
1117        // form (or one with JS off) preserves the reference.
1118        assert!(html.contains(r#"type="hidden""#), "{html}");
1119        assert!(html.contains(r#"name="place_id""#), "{html}");
1120        assert!(html.contains(r#"value="42""#), "{html}");
1121        // The widget hook and its endpoint URLs (built from the mount path).
1122        assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
1123        assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
1124        assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
1125    }
1126
1127    #[test]
1128    fn reference_declares_its_widget_assets() {
1129        let field = ref_field();
1130        let opts = field
1131            .resolve_options(&serde_json::json!({ "source": "acme.place" }))
1132            .unwrap();
1133        assert_eq!(
1134            field.assets(&opts),
1135            vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1136        );
1137    }
1138}