1use 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#[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 pub fn as_text(&self) -> &str {
35 match self {
36 FieldValue::Text(s) => s,
37 _ => "",
38 }
39 }
40}
41
42#[derive(Default)]
46pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
47
48impl ResolvedOptions {
49 pub fn none() -> Self {
51 Self(None)
52 }
53 pub fn new<T: Any + Send + Sync>(value: T) -> Self {
55 Self(Some(Arc::new(value)))
56 }
57 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Chrome {
67 Wrapped,
68 Bare,
69}
70
71#[non_exhaustive]
73pub struct FieldCx<'a> {
74 pub name: &'a str,
76 pub id: &'a str,
78 pub label: &'a str,
79 pub value: &'a FieldValue,
80 pub required: bool,
82 pub opts: &'a ResolvedOptions,
83 pub base: &'a str,
86}
87
88#[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
101pub trait FieldType: Send + Sync + 'static {
103 fn view_key(&self) -> &'static str;
106
107 fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
110 Ok(ResolvedOptions::none())
111 }
112
113 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
116 Vec::new()
117 }
118
119 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
121
122 fn render_default(&self, vm: &FieldVm) -> Markup;
125
126 fn chrome(&self) -> Chrome {
128 Chrome::Wrapped
129 }
130
131 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
136 Vec::new()
137 }
138}
139
140#[derive(Debug, thiserror::Error)]
142#[error("invalid field options: {0}")]
143pub struct OptionsError(pub String);
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Surface {
148 Field,
149 Column,
150}
151
152pub 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#[derive(Debug, thiserror::Error)]
163#[error("override render failed: {0}")]
164pub struct OverrideError(pub String);
165
166pub trait OverrideResolver: Send + Sync {
170 fn render_override(
173 &self,
174 scope: &OverrideScope<'_>,
175 vm: &serde_json::Value,
176 ) -> Option<Result<String, OverrideError>>;
177}
178
179pub 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
193pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
195
196pub(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 Some(Err(_)) | None => ft.render_default(&vm),
209 }
210}
211
212pub(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
223pub(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#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum Placement {
235 Leading,
236 Trailing,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Adornment {
244 pub placement: Placement,
245 pub button: bool,
247 pub label: String,
249 pub widget: Option<String>,
252}
253
254pub trait InputType: Send + Sync + 'static {
260 fn key(&self) -> &'static str;
262 fn html_type(&self) -> &'static str {
264 "text"
265 }
266 fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
269 Ok(ResolvedOptions::none())
270 }
271 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
273 Vec::new()
274 }
275 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
278 BTreeMap::new()
279 }
280 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
282 Vec::new()
283 }
284 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
287 Vec::new()
288 }
289}
290
291pub 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#[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#[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
431pub(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
439const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
442
443fn 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
467pub(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#[derive(Deserialize)]
478struct TextOptions {
479 #[serde(default = "default_input")]
480 input: String,
481}
482
483fn default_input() -> String {
484 "text".to_string()
485}
486
487struct 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#[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
521pub(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 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
626pub(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#[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 #[serde(default)]
659 label: Option<String>,
660}
661
662#[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
680pub(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
730fn 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#[derive(Deserialize)]
745struct RefOptions {
746 source: String,
747}
748
749struct RefResolved {
751 source: String,
752}
753
754#[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
776pub(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 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 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
895 assert!(
896 html.contains("<") || html.contains("<"),
897 "angle escaped: {html}"
898 );
899 assert!(
900 html.contains("&") || html.contains("&"),
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 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 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 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 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)); assert!(is_name("vendor.copy", true)); 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 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 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 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}