1use std::any::Any;
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use askama::Template;
16use laterite_core::validation::{Mode, Rule};
17use laterite_core::{AttrValue, Translator};
18use serde::{Deserialize, Serialize};
19
20use crate::html::Markup;
21use crate::picker::PickerRegistry;
22
23#[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 pub fn as_text(&self) -> &str {
36 match self {
37 FieldValue::Text(s) => s,
38 _ => "",
39 }
40 }
41}
42
43#[derive(Default)]
47pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
48
49impl ResolvedOptions {
50 pub fn none() -> Self {
52 Self(None)
53 }
54 pub fn new<T: Any + Send + Sync>(value: T) -> Self {
56 Self(Some(Arc::new(value)))
57 }
58 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Chrome {
68 Wrapped,
69 Bare,
70}
71
72#[non_exhaustive]
74pub struct FieldCx<'a> {
75 pub name: &'a str,
77 pub id: &'a str,
79 pub label: &'a str,
80 pub value: &'a FieldValue,
81 pub required: bool,
83 pub opts: &'a ResolvedOptions,
84 pub base: &'a str,
87 pub i18n: &'a Translator,
95}
96
97#[derive(Debug, Clone, Serialize)]
100pub struct FieldVm {
101 pub view_key: String,
102 pub name: String,
103 pub id: String,
104 pub label: String,
105 pub required: bool,
106 pub value: FieldValue,
107 pub data: serde_json::Value,
108}
109
110pub struct SubmittedField<'a> {
118 name: &'a str,
119 data: &'a HashMap<String, String>,
120}
121
122impl<'a> SubmittedField<'a> {
123 pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
124 Self { name, data }
125 }
126
127 pub fn name(&self) -> &str {
130 self.name
131 }
132
133 pub fn value(&self) -> Option<&str> {
138 self.data.get(self.name).map(String::as_str)
139 }
140
141 pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
146 let prefix = format!("{}[", self.name);
147 self.data.iter().filter_map(move |(key, value)| {
148 key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
149 })
150 }
151
152 pub fn all(&self) -> &'a HashMap<String, String> {
155 self.data
156 }
157}
158
159pub trait FieldType: Send + Sync + 'static {
160 fn view_key(&self) -> &'static str;
163
164 fn resolve_options(
171 &self,
172 _raw: &serde_json::Value,
173 _types: &FieldRegistry,
174 ) -> Result<ResolvedOptions, OptionsError> {
175 Ok(ResolvedOptions::none())
176 }
177
178 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
181 Vec::new()
182 }
183
184 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
186
187 fn render_default(&self, vm: &FieldVm) -> Markup;
190
191 fn to_attr(
205 &self,
206 field: &SubmittedField<'_>,
207 opts: &ResolvedOptions,
208 mode: Mode,
209 ) -> Result<Option<AttrValue>, String> {
210 let _ = (opts, mode);
211 Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
212 }
213
214 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
223 let _ = opts;
224 stored.unwrap_or_default().to_string()
225 }
226
227 fn chrome(&self) -> Chrome {
229 Chrome::Wrapped
230 }
231
232 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
237 Vec::new()
238 }
239}
240
241#[derive(Debug, thiserror::Error)]
243#[error("invalid field options: {0}")]
244pub struct OptionsError(pub String);
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum Surface {
249 Field,
250 Column,
251}
252
253pub struct OverrideScope<'a> {
256 pub surface: Surface,
257 pub view_key: &'a str,
258 pub resource: Option<&'a str>,
259 pub field: Option<&'a str>,
260}
261
262#[derive(Debug, thiserror::Error)]
264#[error("override render failed: {0}")]
265pub struct OverrideError(pub String);
266
267pub trait OverrideResolver: Send + Sync {
271 fn render_override(
274 &self,
275 scope: &OverrideScope<'_>,
276 vm: &serde_json::Value,
277 ) -> Option<Result<String, OverrideError>>;
278}
279
280pub struct NoOverrides;
283
284impl OverrideResolver for NoOverrides {
285 fn render_override(
286 &self,
287 _scope: &OverrideScope<'_>,
288 _vm: &serde_json::Value,
289 ) -> Option<Result<String, OverrideError>> {
290 None
291 }
292}
293
294pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
296
297pub struct FieldTypeReg {
304 pub field_type: Arc<dyn FieldType>,
305}
306
307impl FieldTypeReg {
308 pub fn new(field_type: Arc<dyn FieldType>) -> Self {
309 Self { field_type }
310 }
311
312 pub fn name(&self) -> &'static str {
314 self.field_type.view_key()
315 }
316}
317
318pub(crate) fn render_field(
321 ft: &dyn FieldType,
322 resolver: &dyn OverrideResolver,
323 scope: &OverrideScope<'_>,
324 cx: &FieldCx<'_>,
325) -> Markup {
326 let vm = ft.view_model(cx);
327 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
328 Some(Ok(html)) => Markup::from_override(html),
329 Some(Err(_)) | None => ft.render_default(&vm),
331 }
332}
333
334pub(crate) struct RepeaterOptions {
337 rows: Vec<RepeaterSub>,
338 min_items: usize,
339 max_items: Option<usize>,
340 display: RepeaterDisplay,
341 summary_field: Option<String>,
343}
344
345#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
352#[serde(rename_all = "snake_case")]
353pub enum RepeaterDisplay {
354 #[default]
357 Inline,
358 List,
361}
362
363pub(crate) struct RepeaterSub {
365 field: crate::form::FormField,
366 field_type: Arc<dyn FieldType>,
367 opts: ResolvedOptions,
368}
369
370#[derive(Deserialize)]
372struct RepeaterOptionsRaw {
373 fields: Vec<crate::form::FormField>,
374 #[serde(default)]
375 min_items: usize,
376 #[serde(default)]
377 max_items: Option<usize>,
378 #[serde(default)]
379 display: RepeaterDisplay,
380 #[serde(default)]
381 summary_field: Option<String>,
382}
383
384pub(crate) struct RepeaterField;
393
394impl RepeaterField {
395 const BLANK: &'static str = "__index__";
397}
398
399impl FieldType for RepeaterField {
400 fn view_key(&self) -> &'static str {
401 "repeater"
402 }
403
404 fn resolve_options(
405 &self,
406 raw: &serde_json::Value,
407 types: &FieldRegistry,
408 ) -> Result<ResolvedOptions, OptionsError> {
409 let parsed: RepeaterOptionsRaw =
410 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
411 let mut rows = Vec::new();
412 for field in parsed.fields {
413 if field.field_type == "repeater" {
414 return Err(OptionsError(
415 "a repeater cannot hold a repeater".to_string(),
416 ));
417 }
418 let field_type = types
419 .get(&field.field_type)
420 .cloned()
421 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
422 let opts = field_type.resolve_options(&field.options, types)?;
423 rows.push(RepeaterSub {
424 field,
425 field_type,
426 opts,
427 });
428 }
429 Ok(ResolvedOptions::new(RepeaterOptions {
430 rows,
431 min_items: parsed.min_items,
432 max_items: parsed.max_items,
433 display: parsed.display,
434 summary_field: parsed.summary_field,
435 }))
436 }
437
438 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
439 let opts = cx.opts.get::<RepeaterOptions>();
440 let stored = match cx.value {
441 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
442 _ => Vec::new(),
443 };
444 let summary_index = opts
447 .and_then(|o| {
448 o.summary_field
449 .as_ref()
450 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
451 })
452 .unwrap_or(0);
453 let summaries = stored
454 .iter()
455 .map(|row| {
456 opts.and_then(|o| o.rows.get(summary_index))
457 .and_then(|sub| row.get(&sub.field.name))
458 .map(summary_text)
459 .unwrap_or_default()
460 })
461 .collect();
462 let data = RepeaterData {
463 rows: opts
464 .map(|o| {
465 stored
466 .iter()
467 .enumerate()
468 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
469 .collect()
470 })
471 .unwrap_or_default(),
472 blank: opts
473 .map(|o| render_row(o, cx, Self::BLANK, None))
474 .unwrap_or_default(),
475 list: opts.is_some_and(|o| o.display == RepeaterDisplay::List),
476 summary_index,
477 summaries,
478 };
479
480 FieldVm {
481 view_key: self.view_key().to_string(),
482 name: cx.name.to_string(),
483 id: cx.id.to_string(),
484 label: cx.label.to_string(),
485 required: cx.required,
486 value: cx.value.clone(),
487 data: serde_json::to_value(data).unwrap_or_default(),
488 }
489 }
490
491 fn render_default(&self, vm: &FieldVm) -> Markup {
492 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
493 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
494 }
495
496 fn to_attr(
497 &self,
498 field: &SubmittedField<'_>,
499 opts: &ResolvedOptions,
500 mode: Mode,
501 ) -> Result<Option<AttrValue>, String> {
502 let Some(options) = opts.get::<RepeaterOptions>() else {
503 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
504 };
505
506 let mut indices: Vec<usize> = field
509 .nested()
510 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
511 .collect();
512 indices.sort_unstable();
513 indices.dedup();
514
515 let mut rows = Vec::new();
516 for index in indices {
517 let mut object = serde_json::Map::new();
518 for sub in &options.rows {
519 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
520 let scoped = SubmittedField::new(&key, field.all());
521 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
522 object.insert(sub.field.name.clone(), attr_to_json(value));
523 }
524 }
525 let row = serde_json::Value::Object(object);
526 if !is_blank_row(&row) {
528 rows.push(row);
529 }
530 }
531
532 if rows.len() < options.min_items {
533 return Err(format!("at least {} required", options.min_items));
534 }
535 if let Some(max) = options.max_items {
536 if rows.len() > max {
537 return Err(format!("at most {max} allowed"));
538 }
539 }
540 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
541 }
542
543 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
544 stored.unwrap_or("[]").to_string()
545 }
546}
547
548fn summary_text(value: &serde_json::Value) -> String {
552 let raw = match value {
553 serde_json::Value::String(s) => s.clone(),
554 serde_json::Value::Null => String::new(),
555 other => other.to_string(),
556 };
557 raw.lines().next().unwrap_or_default().trim().to_string()
558}
559
560fn render_row(
561 options: &RepeaterOptions,
562 cx: &FieldCx<'_>,
563 index: &str,
564 row: Option<&serde_json::Value>,
565) -> Vec<RepeaterCell> {
566 options
567 .rows
568 .iter()
569 .map(|sub| {
570 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
571 let stored = row.and_then(|r| r.get(&sub.field.name));
572 let value = match stored {
573 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
574 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
575 Some(other) => FieldValue::Text(
576 sub.field_type
577 .to_control(Some(&other.to_string()), &sub.opts),
578 ),
579 };
580 let sub_cx = FieldCx {
581 name: &name,
582 id: &name,
583 label: cx.label,
584 value: &value,
585 required: false,
586 opts: &sub.opts,
587 base: cx.base,
588 i18n: cx.i18n,
589 };
590 let vm = sub.field_type.view_model(&sub_cx);
591 RepeaterCell {
592 label: cx.i18n.t(&sub.field.label),
593 control: sub.field_type.render_default(&vm).into_string(),
594 help: sub
595 .field
596 .help
597 .as_ref()
598 .map(|h| cx.i18n.t(h))
599 .unwrap_or_default(),
600 }
601 })
602 .collect()
603}
604
605fn attr_to_json(value: AttrValue) -> serde_json::Value {
607 match value {
608 AttrValue::Null => serde_json::Value::Null,
609 AttrValue::Bool(b) => serde_json::Value::Bool(b),
610 AttrValue::Int(i) => serde_json::Value::from(i),
611 AttrValue::Float(f) => serde_json::Value::from(f),
612 AttrValue::Json(j) => j,
613 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
614 }
615}
616
617fn is_blank_row(row: &serde_json::Value) -> bool {
619 row.as_object().is_none_or(|object| {
620 object.values().all(|v| match v {
621 serde_json::Value::String(s) => s.trim().is_empty(),
622 serde_json::Value::Bool(b) => !b,
623 serde_json::Value::Array(items) => items.is_empty(),
624 serde_json::Value::Null => true,
625 _ => false,
626 })
627 })
628}
629
630#[derive(Serialize, Deserialize)]
632struct RepeaterCell {
633 label: String,
634 control: String,
635 help: String,
639}
640
641#[derive(Serialize, Deserialize, Default)]
644struct RepeaterData {
645 rows: Vec<Vec<RepeaterCell>>,
646 blank: Vec<RepeaterCell>,
647 list: bool,
649 summary_index: usize,
651 summaries: Vec<String>,
654}
655
656#[derive(Template)]
657#[template(path = "fields/repeater.html")]
658struct RepeaterTmpl {
659 data: RepeaterData,
660}
661
662pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
665 let inputs = Arc::new(builtin_input_registry());
666 vec![
667 Arc::new(TextField::new(inputs)),
668 Arc::new(TextareaField),
669 Arc::new(RepeaterField),
670 Arc::new(SelectField),
671 Arc::new(SwitchField),
672 Arc::new(DateField),
673 Arc::new(PasswordField),
674 Arc::new(RadioField),
675 ]
676}
677
678pub(crate) fn builtin_registry() -> FieldRegistry {
680 builtin_field_types()
681 .into_iter()
682 .map(|ft| (ft.view_key().to_string(), ft))
683 .collect()
684}
685
686#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
688#[serde(rename_all = "snake_case")]
689pub enum Placement {
690 Leading,
691 Trailing,
692}
693
694#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct Adornment {
699 pub placement: Placement,
700 pub button: bool,
702 pub label: String,
704 pub widget: Option<String>,
707}
708
709pub trait InputType: Send + Sync + 'static {
715 fn key(&self) -> &'static str;
717 fn html_type(&self) -> &'static str {
719 "text"
720 }
721 fn resolve_options(
724 &self,
725 _raw: &serde_json::Value,
726 _types: &FieldRegistry,
727 ) -> Result<ResolvedOptions, OptionsError> {
728 Ok(ResolvedOptions::none())
729 }
730 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
732 Vec::new()
733 }
734 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
737 BTreeMap::new()
738 }
739 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
741 Vec::new()
742 }
743 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
746 Vec::new()
747 }
748}
749
750pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
752
753struct TextInput;
754impl InputType for TextInput {
755 fn key(&self) -> &'static str {
756 "text"
757 }
758}
759
760struct EmailInput;
761impl InputType for EmailInput {
762 fn key(&self) -> &'static str {
763 "email"
764 }
765 fn html_type(&self) -> &'static str {
766 "email"
767 }
768 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
769 vec![Rule::Email]
770 }
771}
772
773struct TelInput;
774impl InputType for TelInput {
775 fn key(&self) -> &'static str {
776 "tel"
777 }
778 fn html_type(&self) -> &'static str {
779 "tel"
780 }
781}
782
783#[derive(Default, Deserialize)]
785struct NumberOptions {
786 #[serde(default)]
787 min: Option<f64>,
788 #[serde(default)]
789 max: Option<f64>,
790 #[serde(default)]
791 step: Option<f64>,
792}
793
794struct NumberInput;
795impl InputType for NumberInput {
796 fn key(&self) -> &'static str {
797 "number"
798 }
799 fn html_type(&self) -> &'static str {
800 "number"
801 }
802 fn resolve_options(
803 &self,
804 raw: &serde_json::Value,
805 _types: &FieldRegistry,
806 ) -> Result<ResolvedOptions, OptionsError> {
807 let opts: NumberOptions = if raw.is_null() {
808 NumberOptions::default()
809 } else {
810 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
811 };
812 Ok(ResolvedOptions::new(opts))
813 }
814 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
815 vec![Rule::Numeric]
816 }
817 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
818 let mut attrs = BTreeMap::new();
819 if let Some(o) = opts.get::<NumberOptions>() {
820 if let Some(min) = o.min {
821 attrs.insert("min".to_string(), min.to_string());
822 }
823 if let Some(max) = o.max {
824 attrs.insert("max".to_string(), max.to_string());
825 }
826 if let Some(step) = o.step {
827 attrs.insert("step".to_string(), step.to_string());
828 }
829 }
830 attrs
831 }
832}
833
834#[derive(Deserialize)]
836struct UrlOptions {
837 #[serde(default = "default_true")]
838 copy: bool,
839}
840
841impl Default for UrlOptions {
842 fn default() -> Self {
843 Self { copy: true }
844 }
845}
846
847fn default_true() -> bool {
848 true
849}
850
851struct UrlInput;
852impl InputType for UrlInput {
853 fn key(&self) -> &'static str {
854 "url"
855 }
856 fn html_type(&self) -> &'static str {
857 "url"
858 }
859 fn resolve_options(
860 &self,
861 raw: &serde_json::Value,
862 _types: &FieldRegistry,
863 ) -> Result<ResolvedOptions, OptionsError> {
864 let opts: UrlOptions = if raw.is_null() {
865 UrlOptions::default()
866 } else {
867 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
868 };
869 Ok(ResolvedOptions::new(opts))
870 }
871 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
872 vec![Rule::Url]
873 }
874 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
875 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
876 vec![Adornment {
877 placement: Placement::Trailing,
878 button: true,
879 label: "Copy".to_string(),
880 widget: Some("copy".to_string()),
881 }]
882 } else {
883 Vec::new()
884 }
885 }
886}
887
888pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
889 vec![
890 Arc::new(TextInput),
891 Arc::new(EmailInput),
892 Arc::new(TelInput),
893 Arc::new(NumberInput),
894 Arc::new(UrlInput),
895 ]
896}
897
898pub(crate) fn builtin_input_registry() -> InputRegistry {
900 builtin_input_types()
901 .into_iter()
902 .map(|i| (i.key().to_string(), i))
903 .collect()
904}
905
906const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
909
910fn validate_contributions(
915 attrs: &BTreeMap<String, String>,
916 adornments: &[Adornment],
917) -> Result<(), OptionsError> {
918 for name in attrs.keys() {
919 if !is_name(name, false) {
920 return Err(OptionsError(format!("invalid attribute name `{name}`")));
921 }
922 if RESERVED_ATTRS.contains(&name.as_str()) {
923 return Err(OptionsError(format!("attribute `{name}` is reserved")));
924 }
925 }
926 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
927 if !is_name(widget, true) {
928 return Err(OptionsError(format!("invalid widget name `{widget}`")));
929 }
930 }
931 Ok(())
932}
933
934pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
936 let mut chars = s.chars();
937 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
938 && chars.all(|c| {
939 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
940 })
941}
942
943#[derive(Deserialize)]
945struct TextOptions {
946 #[serde(default = "default_input")]
947 input: String,
948}
949
950fn default_input() -> String {
951 "text".to_string()
952}
953
954struct TextResolved {
957 html_type: &'static str,
958 attrs: BTreeMap<String, String>,
959 adornments: Vec<Adornment>,
960 rules: Vec<Rule>,
961 assets: Vec<&'static str>,
962}
963
964#[derive(Default, Serialize, Deserialize)]
967struct TextData {
968 input_type: String,
969 #[serde(default)]
970 attrs: BTreeMap<String, String>,
971 #[serde(default)]
972 adornments: Vec<Adornment>,
973}
974
975#[derive(Template)]
976#[template(path = "fields/text.html")]
977struct TextTmpl<'a> {
978 name: &'a str,
979 id: &'a str,
980 value: &'a str,
981 required: bool,
982 input_type: &'a str,
983 attrs: &'a BTreeMap<String, String>,
984 leading: &'a [Adornment],
985 trailing: &'a [Adornment],
986}
987
988pub(crate) struct TextField {
992 inputs: Arc<InputRegistry>,
993}
994
995impl TextField {
996 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
997 Self { inputs }
998 }
999}
1000
1001impl FieldType for TextField {
1002 fn view_key(&self) -> &'static str {
1003 "text"
1004 }
1005 fn resolve_options(
1006 &self,
1007 raw: &serde_json::Value,
1008 _types: &FieldRegistry,
1009 ) -> Result<ResolvedOptions, OptionsError> {
1010 let opts: TextOptions = if raw.is_null() {
1011 TextOptions {
1012 input: default_input(),
1013 }
1014 } else {
1015 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1016 };
1017 let input = self
1018 .inputs
1019 .get(&opts.input)
1020 .cloned()
1021 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1022 let input_opts = input.resolve_options(raw, _types)?;
1025 let attrs = input.attributes(&input_opts);
1026 let adornments = input.adornments(&input_opts);
1027 validate_contributions(&attrs, &adornments)?;
1028 Ok(ResolvedOptions::new(TextResolved {
1029 html_type: input.html_type(),
1030 rules: input.rules(&input_opts),
1031 assets: input.assets(&input_opts),
1032 attrs,
1033 adornments,
1034 }))
1035 }
1036 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1037 opts.get::<TextResolved>()
1038 .map(|r| r.rules.clone())
1039 .unwrap_or_default()
1040 }
1041 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1042 opts.get::<TextResolved>()
1043 .map(|r| r.assets.clone())
1044 .unwrap_or_default()
1045 }
1046 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1047 let data = match cx.opts.get::<TextResolved>() {
1048 Some(r) => TextData {
1049 input_type: r.html_type.to_string(),
1050 attrs: r.attrs.clone(),
1051 adornments: r.adornments.clone(),
1052 },
1053 None => TextData {
1054 input_type: "text".to_string(),
1055 ..TextData::default()
1056 },
1057 };
1058 FieldVm {
1059 view_key: "text".to_string(),
1060 name: cx.name.to_string(),
1061 id: cx.id.to_string(),
1062 label: cx.label.to_string(),
1063 required: cx.required,
1064 value: cx.value.clone(),
1065 data: serde_json::to_value(data).unwrap_or_default(),
1066 }
1067 }
1068 fn render_default(&self, vm: &FieldVm) -> Markup {
1069 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1070 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1071 .adornments
1072 .into_iter()
1073 .partition(|a| matches!(a.placement, Placement::Leading));
1074 Markup::from_template(&TextTmpl {
1075 name: &vm.name,
1076 id: &vm.id,
1077 value: vm.value.as_text(),
1078 required: vm.required,
1079 input_type: &data.input_type,
1080 attrs: &data.attrs,
1081 leading: &leading,
1082 trailing: &trailing,
1083 })
1084 .unwrap_or_default()
1085 }
1086}
1087
1088#[derive(Template)]
1089#[template(path = "fields/textarea.html")]
1090struct TextareaTmpl<'a> {
1091 name: &'a str,
1092 id: &'a str,
1093 value: &'a str,
1094 required: bool,
1095}
1096
1097pub(crate) struct TextareaField;
1099
1100impl FieldType for TextareaField {
1101 fn view_key(&self) -> &'static str {
1102 "textarea"
1103 }
1104 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1105 scalar_vm("textarea", cx)
1106 }
1107 fn render_default(&self, vm: &FieldVm) -> Markup {
1108 Markup::from_template(&TextareaTmpl {
1109 name: &vm.name,
1110 id: &vm.id,
1111 value: vm.value.as_text(),
1112 required: vm.required,
1113 })
1114 .unwrap_or_default()
1115 }
1116}
1117
1118#[derive(Debug, Default, Deserialize)]
1120struct SelectOptions {
1121 #[serde(default)]
1122 options: Vec<SelectOption>,
1123}
1124
1125#[derive(Debug, Deserialize)]
1126struct SelectOption {
1127 value: String,
1128 #[serde(default)]
1130 label: Option<String>,
1131}
1132
1133#[derive(Debug, Serialize, Deserialize)]
1136struct OptionView {
1137 value: String,
1138 label: String,
1139 selected: bool,
1140}
1141
1142#[derive(Template)]
1143#[template(path = "fields/select.html")]
1144struct SelectTmpl<'a> {
1145 name: &'a str,
1146 id: &'a str,
1147 required: bool,
1148 options: &'a [OptionView],
1149}
1150
1151pub(crate) struct SelectField;
1153
1154impl FieldType for SelectField {
1155 fn view_key(&self) -> &'static str {
1156 "select"
1157 }
1158 fn resolve_options(
1159 &self,
1160 raw: &serde_json::Value,
1161 _types: &FieldRegistry,
1162 ) -> Result<ResolvedOptions, OptionsError> {
1163 let opts: SelectOptions =
1164 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1165 Ok(ResolvedOptions::new(opts))
1166 }
1167 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1168 let current = cx.value.as_text();
1169 let views: Vec<OptionView> = cx
1170 .opts
1171 .get::<SelectOptions>()
1172 .map(|o| {
1173 o.options
1174 .iter()
1175 .map(|so| OptionView {
1176 value: so.value.clone(),
1177 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1178 selected: so.value == current,
1179 })
1180 .collect()
1181 })
1182 .unwrap_or_default();
1183 FieldVm {
1184 view_key: "select".to_string(),
1185 name: cx.name.to_string(),
1186 id: cx.id.to_string(),
1187 label: cx.label.to_string(),
1188 required: cx.required,
1189 value: cx.value.clone(),
1190 data: serde_json::to_value(&views).unwrap_or_default(),
1191 }
1192 }
1193 fn render_default(&self, vm: &FieldVm) -> Markup {
1194 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1195 Markup::from_template(&SelectTmpl {
1196 name: &vm.name,
1197 id: &vm.id,
1198 required: vm.required,
1199 options: &options,
1200 })
1201 .unwrap_or_default()
1202 }
1203}
1204
1205#[derive(Template)]
1206#[template(path = "fields/switch.html")]
1207struct SwitchTmpl<'a> {
1208 name: &'a str,
1209 id: &'a str,
1210 on: bool,
1211}
1212
1213pub(crate) struct SwitchField;
1219
1220fn stored_is_on(stored: Option<&str>) -> bool {
1223 matches!(
1224 stored.map(str::trim).unwrap_or_default(),
1225 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1226 )
1227}
1228
1229impl FieldType for SwitchField {
1230 fn view_key(&self) -> &'static str {
1231 "switch"
1232 }
1233
1234 fn to_attr(
1235 &self,
1236 field: &SubmittedField<'_>,
1237 _opts: &ResolvedOptions,
1238 _mode: Mode,
1239 ) -> Result<Option<AttrValue>, String> {
1240 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1243 }
1244
1245 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1246 if stored_is_on(stored) {
1247 "1".to_string()
1248 } else {
1249 String::new()
1250 }
1251 }
1252
1253 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1254 scalar_vm("switch", cx)
1255 }
1256
1257 fn render_default(&self, vm: &FieldVm) -> Markup {
1258 Markup::from_template(&SwitchTmpl {
1259 name: &vm.name,
1260 id: &vm.id,
1261 on: stored_is_on(Some(vm.value.as_text())),
1262 })
1263 .unwrap_or_default()
1264 }
1265}
1266
1267#[derive(Template)]
1268#[template(path = "fields/date.html")]
1269struct DateTmpl<'a> {
1270 name: &'a str,
1271 id: &'a str,
1272 value: &'a str,
1273 required: bool,
1274}
1275
1276pub(crate) struct DateField;
1282
1283impl FieldType for DateField {
1284 fn view_key(&self) -> &'static str {
1285 "date"
1286 }
1287
1288 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1289 vec![Rule::Date]
1290 }
1291
1292 fn to_attr(
1293 &self,
1294 field: &SubmittedField<'_>,
1295 _opts: &ResolvedOptions,
1296 _mode: Mode,
1297 ) -> Result<Option<AttrValue>, String> {
1298 Ok(field.value().map(|v| match v.trim() {
1299 "" => AttrValue::Null,
1300 date => AttrValue::Text(date.to_string()),
1301 }))
1302 }
1303
1304 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1305 let stored = stored.unwrap_or_default().trim();
1306 stored
1309 .split(['T', ' '])
1310 .next()
1311 .unwrap_or_default()
1312 .to_string()
1313 }
1314
1315 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1316 scalar_vm("date", cx)
1317 }
1318
1319 fn render_default(&self, vm: &FieldVm) -> Markup {
1320 Markup::from_template(&DateTmpl {
1321 name: &vm.name,
1322 id: &vm.id,
1323 value: vm.value.as_text(),
1324 required: vm.required,
1325 })
1326 .unwrap_or_default()
1327 }
1328}
1329
1330#[derive(Template)]
1331#[template(path = "fields/password.html")]
1332struct PasswordTmpl<'a> {
1333 name: &'a str,
1334 id: &'a str,
1335 required: bool,
1336}
1337
1338pub(crate) struct PasswordField;
1346
1347impl FieldType for PasswordField {
1348 fn view_key(&self) -> &'static str {
1349 "password"
1350 }
1351
1352 fn to_attr(
1353 &self,
1354 field: &SubmittedField<'_>,
1355 _opts: &ResolvedOptions,
1356 _mode: Mode,
1357 ) -> Result<Option<AttrValue>, String> {
1358 match field.value().map(str::trim).unwrap_or_default() {
1359 "" => Ok(None),
1361 plain => laterite_auth::password::hash_password(plain)
1362 .map(|hash| Some(AttrValue::Text(hash)))
1363 .map_err(|e| e.to_string()),
1364 }
1365 }
1366
1367 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1370 String::new()
1371 }
1372
1373 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1374 scalar_vm("password", cx)
1375 }
1376
1377 fn render_default(&self, vm: &FieldVm) -> Markup {
1378 Markup::from_template(&PasswordTmpl {
1379 name: &vm.name,
1380 id: &vm.id,
1381 required: vm.required,
1382 })
1383 .unwrap_or_default()
1384 }
1385}
1386
1387#[derive(Template)]
1388#[template(path = "fields/radio.html")]
1389struct RadioTmpl<'a> {
1390 name: &'a str,
1391 options: &'a [OptionView],
1392 required: bool,
1393}
1394
1395pub(crate) struct RadioField;
1400
1401impl FieldType for RadioField {
1402 fn view_key(&self) -> &'static str {
1403 "radio"
1404 }
1405
1406 fn resolve_options(
1407 &self,
1408 raw: &serde_json::Value,
1409 _types: &FieldRegistry,
1410 ) -> Result<ResolvedOptions, OptionsError> {
1411 let opts: SelectOptions =
1412 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1413 Ok(ResolvedOptions::new(opts))
1414 }
1415
1416 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1417 let current = cx.value.as_text();
1418 let views: Vec<OptionView> = cx
1419 .opts
1420 .get::<SelectOptions>()
1421 .map(|o| {
1422 o.options
1423 .iter()
1424 .map(|so| OptionView {
1425 value: so.value.clone(),
1426 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1427 selected: so.value == current,
1428 })
1429 .collect()
1430 })
1431 .unwrap_or_default();
1432 FieldVm {
1433 view_key: "radio".to_string(),
1434 name: cx.name.to_string(),
1435 id: cx.id.to_string(),
1436 label: cx.label.to_string(),
1437 required: cx.required,
1438 value: cx.value.clone(),
1439 data: serde_json::to_value(&views).unwrap_or_default(),
1440 }
1441 }
1442
1443 fn render_default(&self, vm: &FieldVm) -> Markup {
1444 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1445 Markup::from_template(&RadioTmpl {
1446 name: &vm.name,
1447 options: &options,
1448 required: vm.required,
1449 })
1450 .unwrap_or_default()
1451 }
1452}
1453
1454fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1456 FieldVm {
1457 view_key: view_key.to_string(),
1458 name: cx.name.to_string(),
1459 id: cx.id.to_string(),
1460 label: cx.label.to_string(),
1461 required: cx.required,
1462 value: cx.value.clone(),
1463 data: serde_json::Value::Null,
1464 }
1465}
1466
1467#[derive(Deserialize)]
1469struct RefOptions {
1470 source: String,
1471}
1472
1473struct RefResolved {
1475 source: String,
1476}
1477
1478#[derive(Default, Serialize, Deserialize)]
1481struct RefData {
1482 source: String,
1483 search_url: String,
1484 resolve_url: String,
1485 value: String,
1486 placeholder: String,
1487}
1488
1489#[derive(Template)]
1490#[template(path = "fields/ref_picker.html")]
1491struct RefPickerTmpl<'a> {
1492 name: &'a str,
1493 id: &'a str,
1494 value: &'a str,
1495 search_url: &'a str,
1496 resolve_url: &'a str,
1497 placeholder: &'a str,
1498}
1499
1500pub(crate) struct RefPickerField {
1505 pickers: Arc<PickerRegistry>,
1506}
1507
1508impl RefPickerField {
1509 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1510 Self { pickers }
1511 }
1512}
1513
1514impl FieldType for RefPickerField {
1515 fn view_key(&self) -> &'static str {
1516 "reference"
1517 }
1518 fn resolve_options(
1519 &self,
1520 raw: &serde_json::Value,
1521 _types: &FieldRegistry,
1522 ) -> Result<ResolvedOptions, OptionsError> {
1523 let opts: RefOptions =
1524 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1525 if !self.pickers.contains_key(&opts.source) {
1526 return Err(OptionsError(format!(
1527 "unknown picker source `{}`",
1528 opts.source
1529 )));
1530 }
1531 Ok(ResolvedOptions::new(RefResolved {
1532 source: opts.source,
1533 }))
1534 }
1535 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1536 let source = cx
1537 .opts
1538 .get::<RefResolved>()
1539 .map(|r| r.source.as_str())
1540 .unwrap_or_default();
1541 let data = RefData {
1542 search_url: format!("{}/pickers/{}/search", cx.base, source),
1543 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1544 source: source.to_string(),
1545 value: cx.value.as_text().to_string(),
1546 placeholder: "Search…".to_string(),
1547 };
1548 FieldVm {
1549 view_key: "reference".to_string(),
1550 name: cx.name.to_string(),
1551 id: cx.id.to_string(),
1552 label: cx.label.to_string(),
1553 required: cx.required,
1554 value: cx.value.clone(),
1555 data: serde_json::to_value(data).unwrap_or_default(),
1556 }
1557 }
1558 fn render_default(&self, vm: &FieldVm) -> Markup {
1559 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1560 Markup::from_template(&RefPickerTmpl {
1561 name: &vm.name,
1562 id: &vm.id,
1563 value: vm.value.as_text(),
1564 search_url: &data.search_url,
1565 resolve_url: &data.resolve_url,
1566 placeholder: &data.placeholder,
1567 })
1568 .unwrap_or_default()
1569 }
1570 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1571 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1572 }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578
1579 fn untranslated() -> Translator {
1582 Translator::new("en")
1583 }
1584
1585 fn cx_with<'a>(
1586 name: &'a str,
1587 value: &'a FieldValue,
1588 opts: &'a ResolvedOptions,
1589 i18n: &'a Translator,
1590 ) -> FieldCx<'a> {
1591 FieldCx {
1592 name,
1593 id: name,
1594 label: "Label",
1595 value,
1596 required: true,
1597 opts,
1598 base: "/admin",
1599 i18n,
1600 }
1601 }
1602
1603 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1604 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
1607 }
1608
1609 fn text_field() -> TextField {
1610 TextField::new(Arc::new(builtin_input_registry()))
1611 }
1612
1613 struct BadAttrInput;
1615 impl InputType for BadAttrInput {
1616 fn key(&self) -> &'static str {
1617 "bad"
1618 }
1619 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1620 BTreeMap::from([("class".to_string(), "x".to_string())])
1621 }
1622 }
1623
1624 #[test]
1625 fn text_renders_its_value_escaped() {
1626 let opts = ResolvedOptions::none();
1627 let value = FieldValue::Text("a<b>&\"c".to_string());
1628 let markup = render_field(
1629 &text_field(),
1630 &NoOverrides,
1631 &scope(),
1632 &cx("title", &value, &opts),
1633 );
1634 let html = markup.as_str();
1635 assert!(html.contains(r#"name="title""#));
1636 assert!(html.contains("required"));
1637 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
1641 assert!(
1642 html.contains("<") || html.contains("<"),
1643 "angle escaped: {html}"
1644 );
1645 assert!(
1646 html.contains("&") || html.contains("&"),
1647 "ampersand escaped: {html}"
1648 );
1649 }
1650
1651 #[test]
1652 fn textarea_uses_its_own_control() {
1653 let opts = ResolvedOptions::none();
1654 let value = FieldValue::Text("body".to_string());
1655 let markup = render_field(
1656 &TextareaField,
1657 &NoOverrides,
1658 &scope(),
1659 &cx("body", &value, &opts),
1660 );
1661 assert!(markup.as_str().contains("<textarea"));
1662 }
1663
1664 #[test]
1665 fn no_overrides_uses_the_compiled_default() {
1666 let opts = ResolvedOptions::none();
1668 let value = FieldValue::Text("x".to_string());
1669 let ft = text_field();
1670 let vm = ft.view_model(&cx("t", &value, &opts));
1671 let direct = ft.render_default(&vm);
1672 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
1673 assert_eq!(direct.as_str(), routed.as_str());
1674 }
1675
1676 #[test]
1677 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
1678 let field = text_field();
1679 let opts = field
1680 .resolve_options(
1681 &serde_json::json!({ "input": "email" }),
1682 &builtin_registry(),
1683 )
1684 .unwrap();
1685 assert!(matches!(
1687 field.intrinsic_rules(&opts).as_slice(),
1688 [Rule::Email]
1689 ));
1690 let value = FieldValue::Text("a@b.test".to_string());
1691 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
1692 assert!(markup.as_str().contains(r#"type="email""#));
1693 }
1694
1695 #[test]
1696 fn text_default_input_is_plain_text_with_no_extra_rule() {
1697 let field = text_field();
1698 let opts = field
1699 .resolve_options(&serde_json::Value::Null, &builtin_registry())
1700 .unwrap();
1701 assert!(field.intrinsic_rules(&opts).is_empty());
1702 let value = FieldValue::Text("hi".to_string());
1703 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
1704 assert!(markup.as_str().contains(r#"type="text""#));
1705 }
1706
1707 #[test]
1708 fn text_number_input_sets_the_type_and_numeric_rule() {
1709 let field = text_field();
1710 let opts = field
1711 .resolve_options(
1712 &serde_json::json!({ "input": "number" }),
1713 &builtin_registry(),
1714 )
1715 .unwrap();
1716 assert!(matches!(
1717 field.intrinsic_rules(&opts).as_slice(),
1718 [Rule::Numeric]
1719 ));
1720 let value = FieldValue::Text("42".to_string());
1721 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1722 assert!(markup.as_str().contains(r#"type="number""#));
1723 }
1724
1725 #[test]
1726 fn text_tel_input_sets_the_type_with_no_rule() {
1727 let field = text_field();
1728 let opts = field
1729 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
1730 .unwrap();
1731 assert!(field.intrinsic_rules(&opts).is_empty());
1732 let value = FieldValue::Text(String::new());
1733 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
1734 assert!(markup.as_str().contains(r#"type="tel""#));
1735 }
1736
1737 #[test]
1738 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
1739 let field = text_field();
1740 let opts = field
1741 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
1742 .unwrap();
1743 assert!(matches!(
1744 field.intrinsic_rules(&opts).as_slice(),
1745 [Rule::Url]
1746 ));
1747 let value = FieldValue::Text("https://example.com".to_string());
1748 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1749 let html = markup.as_str();
1750 assert!(html.contains(r#"type="url""#), "{html}");
1751 assert!(html.contains("lat-input-group"), "{html}");
1752 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
1753 }
1754
1755 #[test]
1756 fn text_url_input_copy_false_omits_the_button() {
1757 let field = text_field();
1758 let opts = field
1759 .resolve_options(
1760 &serde_json::json!({ "input": "url", "copy": false }),
1761 &builtin_registry(),
1762 )
1763 .unwrap();
1764 let value = FieldValue::Text("https://example.com".to_string());
1765 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1766 let html = markup.as_str();
1767 assert!(html.contains(r#"type="url""#), "{html}");
1768 assert!(!html.contains("lat-input-group"), "{html}");
1770 assert!(!html.contains("data-lat-widget"), "{html}");
1771 }
1772
1773 #[test]
1774 fn text_number_input_emits_min_max_step_attributes() {
1775 let field = text_field();
1776 let opts = field
1777 .resolve_options(
1778 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
1779 &builtin_registry(),
1780 )
1781 .unwrap();
1782 let value = FieldValue::Text("5".to_string());
1785 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1786 let html = markup.as_str();
1787 assert!(html.contains(r#"min="0""#), "{html}");
1788 assert!(html.contains(r#"max="10""#), "{html}");
1789 assert!(html.contains(r#"step="2""#), "{html}");
1790 }
1791
1792 #[test]
1793 fn text_input_reserved_attribute_name_aborts_resolve() {
1794 let mut inputs = builtin_input_registry();
1795 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
1796 let field = TextField::new(Arc::new(inputs));
1797 let Err(e) =
1798 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
1799 else {
1800 panic!("expected a reserved-attribute rejection");
1801 };
1802 assert!(e.0.contains("class"), "{e}");
1803 }
1804
1805 #[test]
1806 fn attribute_and_widget_name_rules() {
1807 assert!(is_name("min", false));
1808 assert!(is_name("data-x", false));
1809 assert!(!is_name("Min", false));
1810 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
1813 }
1814
1815 #[test]
1816 fn select_renders_options_with_the_current_value_selected() {
1817 let raw = serde_json::json!({
1818 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1819 });
1820 let opts = SelectField
1821 .resolve_options(&raw, &builtin_registry())
1822 .unwrap();
1823 let value = FieldValue::Text("closed".to_string());
1824 let markup = render_field(
1825 &SelectField,
1826 &NoOverrides,
1827 &scope(),
1828 &cx("status", &value, &opts),
1829 );
1830 let html = markup.as_str();
1831 assert!(html.contains(r#"<option value="open">Open</option>"#));
1832 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
1834 }
1835
1836 #[test]
1837 fn radio_renders_the_same_options_with_the_current_one_checked() {
1838 let raw = serde_json::json!({
1839 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1840 });
1841 let opts = RadioField
1842 .resolve_options(&raw, &builtin_registry())
1843 .unwrap();
1844 let value = FieldValue::Text("closed".to_string());
1845 let markup = render_field(
1846 &RadioField,
1847 &NoOverrides,
1848 &scope(),
1849 &cx("status", &value, &opts),
1850 );
1851 let html = markup.as_str();
1852 assert!(html.contains(r#"value="open""#));
1853 assert!(
1854 html.contains(r#"value="closed" checked"#),
1855 "current is checked"
1856 );
1857 assert_eq!(html.matches(r#"name="status""#).count(), 2);
1860 assert!(html.contains("closed</label>"));
1862 }
1863
1864 fn scope<'a>() -> OverrideScope<'a> {
1865 OverrideScope {
1866 surface: Surface::Field,
1867 view_key: "text",
1868 resource: None,
1869 field: None,
1870 }
1871 }
1872
1873 fn ref_field() -> RefPickerField {
1874 let mut pickers = crate::picker::PickerRegistry::new();
1875 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
1876 pickers.insert(
1877 "acme.place".to_string(),
1878 crate::picker::PickerSourceReg::new("acme.place", source),
1879 );
1880 RefPickerField::new(Arc::new(pickers))
1881 }
1882
1883 #[test]
1884 fn reference_rejects_an_unknown_source() {
1885 let field = ref_field();
1886 let Err(e) = field.resolve_options(
1887 &serde_json::json!({ "source": "no.such" }),
1888 &builtin_registry(),
1889 ) else {
1890 panic!("expected an unknown-source rejection");
1891 };
1892 assert!(e.0.contains("no.such"), "{e}");
1893 }
1894
1895 #[test]
1896 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
1897 let field = ref_field();
1898 let opts = field
1899 .resolve_options(
1900 &serde_json::json!({ "source": "acme.place" }),
1901 &builtin_registry(),
1902 )
1903 .unwrap();
1904 let value = FieldValue::Text("42".to_string());
1905 let markup = render_field(
1906 &field,
1907 &NoOverrides,
1908 &scope(),
1909 &cx("place_id", &value, &opts),
1910 );
1911 let html = markup.as_str();
1912 assert!(html.contains(r#"type="hidden""#), "{html}");
1915 assert!(html.contains(r#"name="place_id""#), "{html}");
1916 assert!(html.contains(r#"value="42""#), "{html}");
1917 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
1919 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
1920 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
1921 }
1922
1923 #[test]
1924 fn reference_declares_its_widget_assets() {
1925 let field = ref_field();
1926 let opts = field
1927 .resolve_options(
1928 &serde_json::json!({ "source": "acme.place" }),
1929 &builtin_registry(),
1930 )
1931 .unwrap();
1932 assert_eq!(
1933 field.assets(&opts),
1934 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1935 );
1936 }
1937}
1938
1939#[cfg(test)]
1940mod repeater_tests {
1941 use super::*;
1942
1943 fn options() -> ResolvedOptions {
1944 RepeaterField
1945 .resolve_options(
1946 &serde_json::json!({
1947 "fields": [
1948 { "name": "path", "label": "Path", "type": "text" },
1949 { "name": "allow", "label": "Allow", "type": "switch" },
1950 ]
1951 }),
1952 &builtin_registry(),
1953 )
1954 .unwrap()
1955 }
1956
1957 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1958 pairs
1959 .iter()
1960 .map(|(k, v)| (k.to_string(), v.to_string()))
1961 .collect()
1962 }
1963
1964 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
1965 let field = SubmittedField::new("rules", data);
1966 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
1967 Some(AttrValue::Json(value)) => value,
1968 other => panic!("expected a JSON array, got {other:?}"),
1969 }
1970 }
1971
1972 #[test]
1976 fn a_row_is_typed_by_its_sub_field_types() {
1977 let rows = stored(
1978 &submitted(&[
1979 ("rules[0][path]", "/private"),
1980 ("rules[1][path]", "/public"),
1981 ("rules[1][allow]", "on"),
1982 ]),
1983 &options(),
1984 );
1985
1986 assert_eq!(rows[0]["path"], "/private");
1987 assert_eq!(rows[0]["allow"], serde_json::json!(false));
1988 assert_eq!(rows[1]["allow"], serde_json::json!(true));
1989 }
1990
1991 #[test]
1994 fn rows_are_ordered_by_index_not_submission_order() {
1995 let rows = stored(
1996 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
1997 &options(),
1998 );
1999 assert_eq!(rows.as_array().unwrap().len(), 2);
2000 assert_eq!(rows[0]["path"], "/first");
2001 assert_eq!(rows[1]["path"], "/last");
2002 }
2003
2004 #[test]
2005 fn a_row_left_blank_is_dropped() {
2006 let rows = stored(
2007 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2008 &options(),
2009 );
2010 assert_eq!(rows.as_array().unwrap().len(), 1);
2011 }
2012
2013 #[test]
2014 fn nothing_submitted_stores_an_empty_list() {
2015 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2016 }
2017
2018 #[test]
2019 fn a_repeater_refuses_to_hold_a_repeater() {
2020 let err = RepeaterField
2021 .resolve_options(
2022 &serde_json::json!({
2023 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2024 }),
2025 &builtin_registry(),
2026 )
2027 .err()
2028 .expect("a nested repeater is refused");
2029 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2030 }
2031
2032 #[test]
2033 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2034 let err = RepeaterField
2035 .resolve_options(
2036 &serde_json::json!({
2037 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2038 }),
2039 &builtin_registry(),
2040 )
2041 .err()
2042 .expect("an unregistered type is refused");
2043 assert!(err.0.contains("acme.nope"), "{}", err.0);
2044 }
2045
2046 #[test]
2047 fn row_counts_are_bounded_when_asked() {
2048 let opts = RepeaterField
2049 .resolve_options(
2050 &serde_json::json!({
2051 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2052 "min_items": 1,
2053 "max_items": 2
2054 }),
2055 &builtin_registry(),
2056 )
2057 .unwrap();
2058
2059 let empty = HashMap::new();
2060 let too_few = SubmittedField::new("rules", &empty);
2061 assert!(RepeaterField
2062 .to_attr(&too_few, &opts, Mode::Create)
2063 .is_err());
2064
2065 let three = submitted(&[
2066 ("rules[0][path]", "/a"),
2067 ("rules[1][path]", "/b"),
2068 ("rules[2][path]", "/c"),
2069 ]);
2070 let field = SubmittedField::new("rules", &three);
2071 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2072 }
2073
2074 #[test]
2075 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2076 let value = FieldValue::Json(serde_json::json!([
2077 { "path": "/private", "allow": false },
2078 ]));
2079 let cx = FieldCx {
2080 name: "rules",
2081 id: "rules",
2082 label: "Rules",
2083 value: &value,
2084 required: false,
2085 opts: &options(),
2086 base: "/admin",
2087 i18n: &Translator::new("en"),
2088 };
2089 let html = RepeaterField
2090 .render_default(&RepeaterField.view_model(&cx))
2091 .into_string();
2092
2093 assert!(html.contains(r#"name="rules[0][path]""#));
2094 assert!(html.contains(r#"value="/private""#));
2095 assert!(html.contains("lat-repeater__blank"));
2097 assert!(html.contains(r#"name="rules[__index__][path]""#));
2098 }
2099
2100 fn list_options() -> ResolvedOptions {
2102 RepeaterField
2103 .resolve_options(
2104 &serde_json::json!({
2105 "fields": [
2106 { "name": "agent", "label": "Agent", "type": "text" },
2107 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2108 ],
2109 "display": "list",
2110 "summary_field": "agent",
2111 }),
2112 &builtin_registry(),
2113 )
2114 .unwrap()
2115 }
2116
2117 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2118 let cx = FieldCx {
2119 name: "rules",
2120 id: "rules",
2121 label: "Rules",
2122 value,
2123 required: false,
2124 opts,
2125 base: "/admin",
2126 i18n: &Translator::new("en"),
2127 };
2128 RepeaterField
2129 .render_default(&RepeaterField.view_model(&cx))
2130 .into_string()
2131 }
2132
2133 #[test]
2134 fn a_list_repeater_collapses_its_rows_and_names_them() {
2135 let value = FieldValue::Json(serde_json::json!([
2136 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2137 ]));
2138 let html = render_with(&list_options(), &value);
2139 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2140 assert!(html.contains("<details"), "a row collapses");
2141 assert!(
2142 html.contains("Googlebot"),
2143 "the naming field titles the row"
2144 );
2145 assert!(html.contains(r#"name="rules[0][disallow]""#));
2147 }
2148
2149 #[test]
2150 fn a_row_with_no_name_yet_reads_as_untitled() {
2151 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2152 let html = render_with(&list_options(), &value);
2153 assert!(html.contains("lat-repeater__untitled"));
2154 }
2155
2156 #[test]
2157 fn the_default_layout_stays_inline() {
2158 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2160 let html = render_with(&options(), &value);
2161 assert!(!html.contains("lat-repeater--list"));
2162 assert!(!html.contains("<details"));
2163 }
2164
2165 #[test]
2166 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2167 assert_eq!(
2169 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2170 "/private"
2171 );
2172 assert_eq!(summary_text(&serde_json::json!("")), "");
2173 }
2174
2175 #[test]
2182 fn a_contributed_field_type_names_itself_and_converts() {
2183 struct Money;
2184 impl FieldType for Money {
2185 fn view_key(&self) -> &'static str {
2186 "acme.money"
2187 }
2188 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2189 scalar_vm("acme.money", cx)
2190 }
2191 fn render_default(&self, _vm: &FieldVm) -> Markup {
2192 Markup::empty()
2193 }
2194 fn to_attr(
2195 &self,
2196 field: &SubmittedField<'_>,
2197 _opts: &ResolvedOptions,
2198 _mode: Mode,
2199 ) -> Result<Option<AttrValue>, String> {
2200 let raw = field.value().unwrap_or("0");
2202 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2203 Ok(Some(AttrValue::Int(minor as i64)))
2204 }
2205 }
2206
2207 let reg = FieldTypeReg::new(Arc::new(Money));
2208 assert_eq!(reg.name(), "acme.money");
2211
2212 let mut registry = builtin_registry();
2213 let name = reg.name().to_string();
2214 assert!(
2215 registry.insert(name.clone(), reg.field_type).is_none(),
2216 "a dotted key does not collide with a built-in"
2217 );
2218
2219 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2220 let submitted = SubmittedField::new("price", &data);
2221 let converted = registry[&name]
2222 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2223 .unwrap();
2224 assert!(
2225 matches!(converted, Some(AttrValue::Int(1250))),
2226 "{converted:?}"
2227 );
2228 }
2229
2230 #[test]
2231 fn a_sub_field_label_is_localized_like_any_other() {
2232 use laterite_core::i18n::CatalogStore;
2233 use std::sync::Arc;
2234
2235 let po = concat!(
2236 "msgid \"\"\n",
2237 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2238 "\n",
2239 "msgid \"Path\"\n",
2240 "msgstr \"Path-kn\"\n",
2241 );
2242 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2243 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2244
2245 let value = FieldValue::Text(String::new());
2246 let cx = FieldCx {
2247 name: "rules",
2248 id: "rules",
2249 label: "Rules",
2250 value: &value,
2251 required: false,
2252 opts: &options(),
2253 base: "/admin",
2254 i18n: &i18n,
2255 };
2256 let html = RepeaterField
2257 .render_default(&RepeaterField.view_model(&cx))
2258 .into_string();
2259
2260 assert!(
2261 html.contains("Path-kn"),
2262 "sub-label was not localized: {html}"
2263 );
2264 assert!(
2265 !html.contains(">Path<"),
2266 "the source string still leaked: {html}"
2267 );
2268 }
2269}
2270
2271#[cfg(test)]
2272mod save_contract_tests {
2273 use super::*;
2274
2275 fn one(raw: Option<&str>) -> HashMap<String, String> {
2277 raw.into_iter()
2278 .map(|v| ("f".to_string(), v.to_string()))
2279 .collect()
2280 }
2281
2282 fn none() -> ResolvedOptions {
2283 ResolvedOptions::none()
2284 }
2285
2286 #[test]
2287 fn a_switch_stores_a_bool_and_absence_means_off() {
2288 let f = SwitchField;
2289 assert_eq!(
2292 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2293 .unwrap(),
2294 Some(AttrValue::Bool(false))
2295 );
2296 assert_eq!(
2297 f.to_attr(
2298 &SubmittedField::new("f", &one(Some("on"))),
2299 &none(),
2300 Mode::Create
2301 )
2302 .unwrap(),
2303 Some(AttrValue::Bool(true))
2304 );
2305 }
2306
2307 #[test]
2308 fn a_switch_reads_back_whatever_the_backend_gave() {
2309 let f = SwitchField;
2310 for on in ["1", "true", "t", "on"] {
2312 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2313 }
2314 for off in ["0", "false", "f", ""] {
2315 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2316 }
2317 assert!(f.to_control(None, &none()).is_empty());
2318 }
2319
2320 #[test]
2321 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2322 let f = DateField;
2323 assert_eq!(
2325 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2326 "2026-09-09"
2327 );
2328 assert_eq!(
2329 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2330 "2026-09-09"
2331 );
2332 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2333 assert_eq!(f.to_control(None, &none()), "");
2334 }
2335
2336 #[test]
2337 fn a_cleared_date_stores_null_not_an_empty_string() {
2338 let f = DateField;
2339 assert_eq!(
2340 f.to_attr(
2341 &SubmittedField::new("f", &one(Some(""))),
2342 &none(),
2343 Mode::Update
2344 )
2345 .unwrap(),
2346 Some(AttrValue::Null)
2347 );
2348 assert_eq!(
2349 f.to_attr(
2350 &SubmittedField::new("f", &one(Some("2026-09-09"))),
2351 &none(),
2352 Mode::Create
2353 )
2354 .unwrap(),
2355 Some(AttrValue::Text("2026-09-09".into()))
2356 );
2357 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2358 }
2359
2360 #[test]
2361 fn a_password_hashes_and_never_echoes() {
2362 let f = PasswordField;
2363 let stored = f
2364 .to_attr(
2365 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2366 &none(),
2367 Mode::Create,
2368 )
2369 .unwrap()
2370 .unwrap();
2371 let hash = stored.as_str().unwrap();
2372 assert!(
2373 hash.starts_with("$argon2"),
2374 "stored as a hash, not plaintext"
2375 );
2376 assert_ne!(hash, "hunter2hunter2");
2377 assert_eq!(f.to_control(Some(hash), &none()), "");
2379 }
2380
2381 #[test]
2382 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2383 let f = PasswordField;
2384 assert_eq!(
2386 f.to_attr(
2387 &SubmittedField::new("f", &one(Some(""))),
2388 &none(),
2389 Mode::Update
2390 )
2391 .unwrap(),
2392 None
2393 );
2394 assert_eq!(
2395 f.to_attr(
2396 &SubmittedField::new("f", &one(Some(" "))),
2397 &none(),
2398 Mode::Update
2399 )
2400 .unwrap(),
2401 None
2402 );
2403 assert_eq!(
2404 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2405 .unwrap(),
2406 None
2407 );
2408 }
2409
2410 #[test]
2411 fn a_plain_field_stores_and_shows_the_text_unchanged() {
2412 let f = TextareaField;
2413 assert_eq!(
2414 f.to_attr(
2415 &SubmittedField::new("f", &one(Some("hello"))),
2416 &none(),
2417 Mode::Create
2418 )
2419 .unwrap(),
2420 Some(AttrValue::Text("hello".into()))
2421 );
2422 assert_eq!(
2424 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2425 .unwrap(),
2426 None
2427 );
2428 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
2429 }
2430}