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, Clone)]
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 max_length: Option<usize>,
85 pub opts: &'a ResolvedOptions,
86 pub base: &'a str,
89 pub i18n: &'a Translator,
97}
98
99#[derive(Debug, Clone, Serialize)]
102pub struct FieldVm {
103 pub view_key: String,
104 pub name: String,
105 pub id: String,
106 pub label: String,
107 pub required: bool,
108 pub value: FieldValue,
109 pub data: serde_json::Value,
110}
111
112pub struct SubmittedField<'a> {
120 name: &'a str,
121 data: &'a HashMap<String, String>,
122}
123
124impl<'a> SubmittedField<'a> {
125 pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
126 Self { name, data }
127 }
128
129 pub fn name(&self) -> &str {
132 self.name
133 }
134
135 pub fn value(&self) -> Option<&str> {
140 self.data.get(self.name).map(String::as_str)
141 }
142
143 pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
148 let prefix = format!("{}[", self.name);
149 self.data.iter().filter_map(move |(key, value)| {
150 key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
151 })
152 }
153
154 pub fn all(&self) -> &'a HashMap<String, String> {
157 self.data
158 }
159}
160
161pub trait FieldType: Send + Sync + 'static {
162 fn view_key(&self) -> &'static str;
165
166 fn resolve_options(
173 &self,
174 _raw: &serde_json::Value,
175 _types: &FieldRegistry,
176 ) -> Result<ResolvedOptions, OptionsError> {
177 Ok(ResolvedOptions::none())
178 }
179 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
185 None
186 }
187
188 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
191 Vec::new()
192 }
193
194 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
196
197 fn render_default(&self, vm: &FieldVm) -> Markup;
200
201 fn to_attr(
215 &self,
216 field: &SubmittedField<'_>,
217 opts: &ResolvedOptions,
218 mode: Mode,
219 ) -> Result<Option<AttrValue>, String> {
220 let _ = (opts, mode);
221 Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
222 }
223
224 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
233 let _ = opts;
234 stored.unwrap_or_default().to_string()
235 }
236
237 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
248 let _ = opts;
249 field.value().map(str::to_string)
250 }
251
252 fn chrome(&self) -> Chrome {
254 Chrome::Wrapped
255 }
256
257 fn linked_media(&self, submitted: Option<&str>, opts: &ResolvedOptions) -> Option<Vec<i64>> {
262 let _ = (submitted, opts);
263 None
264 }
265
266 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
271 Vec::new()
272 }
273}
274
275#[derive(Debug, thiserror::Error)]
277#[error("invalid field options: {0}")]
278pub struct OptionsError(pub String);
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum Surface {
283 Field,
284 Column,
285}
286
287pub struct OverrideScope<'a> {
290 pub surface: Surface,
291 pub view_key: &'a str,
292 pub resource: Option<&'a str>,
293 pub field: Option<&'a str>,
294}
295
296#[derive(Debug, thiserror::Error)]
298#[error("override render failed: {0}")]
299pub struct OverrideError(pub String);
300
301pub trait OverrideResolver: Send + Sync {
305 fn render_override(
308 &self,
309 scope: &OverrideScope<'_>,
310 vm: &serde_json::Value,
311 ) -> Option<Result<String, OverrideError>>;
312}
313
314pub struct NoOverrides;
317
318impl OverrideResolver for NoOverrides {
319 fn render_override(
320 &self,
321 _scope: &OverrideScope<'_>,
322 _vm: &serde_json::Value,
323 ) -> Option<Result<String, OverrideError>> {
324 None
325 }
326}
327
328pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
330
331pub struct FieldTypeReg {
338 pub field_type: Arc<dyn FieldType>,
339}
340
341impl FieldTypeReg {
342 pub fn new(field_type: Arc<dyn FieldType>) -> Self {
343 Self { field_type }
344 }
345
346 pub fn name(&self) -> &'static str {
348 self.field_type.view_key()
349 }
350}
351
352pub(crate) fn render_field(
355 ft: &dyn FieldType,
356 resolver: &dyn OverrideResolver,
357 scope: &OverrideScope<'_>,
358 cx: &FieldCx<'_>,
359) -> Markup {
360 let vm = ft.view_model(cx);
361 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
362 Some(Ok(html)) => Markup::from_override(html),
363 Some(Err(_)) | None => ft.render_default(&vm),
365 }
366}
367
368pub(crate) struct RepeaterOptions {
371 rows: Vec<RepeaterSub>,
372 min_items: usize,
373 max_items: Option<usize>,
374 display: Option<RepeaterDisplay>,
377 summary_field: Option<String>,
379 reorder: bool,
381 duplicate: bool,
383}
384
385pub const REPEATER_LIST_PAST: usize = 3;
387
388impl RepeaterOptions {
389 fn list(&self) -> bool {
390 match self.display {
391 Some(RepeaterDisplay::List) => true,
392 Some(RepeaterDisplay::Inline) => false,
393 None => self.rows.len() > REPEATER_LIST_PAST,
394 }
395 }
396}
397
398fn auto_or_display<'de, D: serde::Deserializer<'de>>(
400 de: D,
401) -> Result<Option<RepeaterDisplay>, D::Error> {
402 #[derive(Deserialize)]
403 #[serde(rename_all = "snake_case")]
404 enum Raw {
405 Auto,
406 Inline,
407 List,
408 }
409 Ok(match Raw::deserialize(de)? {
410 Raw::Auto => None,
411 Raw::Inline => Some(RepeaterDisplay::Inline),
412 Raw::List => Some(RepeaterDisplay::List),
413 })
414}
415
416fn on() -> bool {
417 true
418}
419
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
427#[serde(rename_all = "snake_case")]
428pub enum RepeaterDisplay {
429 #[default]
432 Inline,
433 List,
436}
437
438pub(crate) struct RepeaterSub {
440 field: crate::form::FormField,
441 field_type: Arc<dyn FieldType>,
442 opts: ResolvedOptions,
443}
444
445#[derive(Deserialize)]
447struct RepeaterOptionsRaw {
448 #[serde(deserialize_with = "named_or_listed")]
450 fields: Vec<crate::form::FormField>,
451 #[serde(default)]
452 min_items: usize,
453 #[serde(default)]
454 max_items: Option<usize>,
455 #[serde(default, deserialize_with = "auto_or_display")]
456 display: Option<RepeaterDisplay>,
457 #[serde(default)]
458 summary_field: Option<String>,
459 #[serde(default = "on")]
460 reorder: bool,
461 #[serde(default = "on")]
462 duplicate: bool,
463}
464
465pub(crate) fn check_option_keys(
467 raw: &serde_json::Value,
468 accepted: &[String],
469) -> Result<(), OptionsError> {
470 let serde_json::Value::Object(entries) = raw else {
471 return Ok(());
472 };
473 for key in entries.keys() {
474 if !accepted.iter().any(|k| k == key) {
475 return Err(OptionsError(if accepted.is_empty() {
476 format!("unknown key `{key}`: this type takes no keys of its own")
477 } else {
478 format!(
479 "unknown key `{key}`, expected one of: {}",
480 accepted.join(", ")
481 )
482 }));
483 }
484 }
485 Ok(())
486}
487
488pub(crate) fn check_field_keys(
491 field: &crate::form::FormField,
492 field_type: &dyn FieldType,
493) -> Result<(), OptionsError> {
494 match field_type.option_keys(&field.options) {
495 Some(keys) => check_option_keys(&field.options, &keys),
496 None => Ok(()),
497 }
498}
499
500fn keys(names: &[&str]) -> Option<Vec<String>> {
501 Some(names.iter().map(|k| k.to_string()).collect())
502}
503
504fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
507where
508 D: serde::Deserializer<'de>,
509{
510 use crate::keyed::Keyed;
511 #[derive(Deserialize)]
512 #[serde(untagged)]
513 enum Shape {
514 Listed(Vec<crate::form::FormField>),
515 Named(serde_json::Map<String, serde_json::Value>),
516 }
517 match Shape::deserialize(de)? {
518 Shape::Listed(fields) => Ok(fields),
519 Shape::Named(entries) => entries
520 .into_iter()
521 .map(|(name, entry)| {
522 let mut field: crate::form::FormField =
523 serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
524 field.set_key(name);
525 Ok(field)
526 })
527 .collect(),
528 }
529}
530
531pub(crate) struct RepeaterField;
540
541impl RepeaterField {
542 const BLANK: &'static str = "__index__";
544}
545
546impl FieldType for RepeaterField {
547 fn view_key(&self) -> &'static str {
548 "repeater"
549 }
550
551 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
552 keys(&[
553 "fields",
554 "min_items",
555 "max_items",
556 "display",
557 "summary_field",
558 "reorder",
559 "duplicate",
560 ])
561 }
562
563 fn resolve_options(
564 &self,
565 raw: &serde_json::Value,
566 types: &FieldRegistry,
567 ) -> Result<ResolvedOptions, OptionsError> {
568 let parsed: RepeaterOptionsRaw =
569 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
570 let mut rows = Vec::new();
571 for field in parsed.fields {
572 if field.field_type == "repeater" {
573 return Err(OptionsError(
574 "a repeater cannot hold a repeater".to_string(),
575 ));
576 }
577 let field_type = types
578 .get(&field.field_type)
579 .cloned()
580 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
581 check_field_keys(&field, field_type.as_ref())
582 .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
583 let opts = field_type.resolve_options(&field.options, types)?;
584 rows.push(RepeaterSub {
585 field,
586 field_type,
587 opts,
588 });
589 }
590 Ok(ResolvedOptions::new(RepeaterOptions {
591 rows,
592 min_items: parsed.min_items,
593 max_items: parsed.max_items,
594 display: parsed.display,
595 summary_field: parsed.summary_field,
596 reorder: parsed.reorder,
597 duplicate: parsed.duplicate,
598 }))
599 }
600
601 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
602 let opts = cx.opts.get::<RepeaterOptions>();
603 let stored = match cx.value {
606 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
607 FieldValue::Text(text) => serde_json::from_str::<serde_json::Value>(text)
608 .ok()
609 .and_then(|v| v.as_array().cloned())
610 .unwrap_or_default(),
611 _ => Vec::new(),
612 };
613 let summary_index = opts
616 .and_then(|o| {
617 o.summary_field
618 .as_ref()
619 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
620 })
621 .unwrap_or(0);
622 let summaries = stored
623 .iter()
624 .map(|row| {
625 opts.and_then(|o| o.rows.get(summary_index))
626 .and_then(|sub| row.get(&sub.field.name))
627 .map(summary_text)
628 .unwrap_or_default()
629 })
630 .collect();
631 let data = RepeaterData {
632 rows: opts
633 .map(|o| {
634 stored
635 .iter()
636 .enumerate()
637 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
638 .collect()
639 })
640 .unwrap_or_default(),
641 blank: opts
642 .map(|o| render_row(o, cx, Self::BLANK, None))
643 .unwrap_or_default(),
644 list: opts.is_some_and(RepeaterOptions::list),
645 summary_index,
646 summaries,
647 reorder: opts.is_none_or(|o| o.reorder),
648 duplicate: opts.is_none_or(|o| o.duplicate),
649 text: RepeaterText {
650 add: cx.i18n.t(&laterite_core::t!("Add")),
651 remove: cx.i18n.t(&laterite_core::t!("Remove")),
652 duplicate: cx.i18n.t(&laterite_core::t!("Duplicate")),
653 up: cx.i18n.t(&laterite_core::t!("Move up")),
654 down: cx.i18n.t(&laterite_core::t!("Move down")),
655 untitled: cx.i18n.t(&laterite_core::t!("Untitled")),
656 },
657 };
658
659 FieldVm {
660 view_key: self.view_key().to_string(),
661 name: cx.name.to_string(),
662 id: cx.id.to_string(),
663 label: cx.label.to_string(),
664 required: cx.required,
665 value: cx.value.clone(),
666 data: serde_json::to_value(data).unwrap_or_default(),
667 }
668 }
669
670 fn render_default(&self, vm: &FieldVm) -> Markup {
671 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
672 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
673 }
674
675 fn to_attr(
676 &self,
677 field: &SubmittedField<'_>,
678 opts: &ResolvedOptions,
679 mode: Mode,
680 ) -> Result<Option<AttrValue>, String> {
681 let Some(options) = opts.get::<RepeaterOptions>() else {
682 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
683 };
684
685 let mut indices: Vec<usize> = field
688 .nested()
689 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
690 .collect();
691 indices.sort_unstable();
692 indices.dedup();
693
694 let mut rows = Vec::new();
695 for index in indices {
696 let mut object = serde_json::Map::new();
697 for sub in &options.rows {
698 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
699 let scoped = SubmittedField::new(&key, field.all());
700 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
701 object.insert(sub.field.name.clone(), attr_to_json(value));
702 }
703 }
704 let row = serde_json::Value::Object(object);
705 if !is_blank_row(&row) {
707 rows.push(row);
708 }
709 }
710
711 if rows.len() < options.min_items {
712 return Err(format!("at least {} required", options.min_items));
713 }
714 if let Some(max) = options.max_items {
715 if rows.len() > max {
716 return Err(format!("at most {max} allowed"));
717 }
718 }
719 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
720 }
721
722 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
723 stored.unwrap_or("[]").to_string()
724 }
725
726 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
729 let options = opts.get::<RepeaterOptions>()?;
730 let mut indices: Vec<usize> = field
731 .nested()
732 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
733 .collect();
734 indices.sort_unstable();
735 indices.dedup();
736 let rows: Vec<serde_json::Value> = indices
737 .into_iter()
738 .map(|index| {
739 let mut object = serde_json::Map::new();
740 for sub in &options.rows {
741 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
742 if let Some(value) = field.all().get(&key) {
743 object.insert(
744 sub.field.name.clone(),
745 serde_json::Value::String(value.clone()),
746 );
747 }
748 }
749 serde_json::Value::Object(object)
750 })
751 .filter(|row| !is_blank_row(row))
752 .collect();
753 if rows.is_empty() {
754 return None;
755 }
756 serde_json::to_string(&rows).ok()
757 }
758}
759
760fn summary_text(value: &serde_json::Value) -> String {
764 let raw = match value {
765 serde_json::Value::String(s) => s.clone(),
766 serde_json::Value::Null => String::new(),
767 other => other.to_string(),
768 };
769 raw.lines().next().unwrap_or_default().trim().to_string()
770}
771
772fn render_row(
773 options: &RepeaterOptions,
774 cx: &FieldCx<'_>,
775 index: &str,
776 row: Option<&serde_json::Value>,
777) -> Vec<RepeaterCell> {
778 options
779 .rows
780 .iter()
781 .map(|sub| {
782 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
783 let stored = row.and_then(|r| r.get(&sub.field.name));
784 let value = match stored {
785 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
786 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
787 Some(other) => FieldValue::Text(
788 sub.field_type
789 .to_control(Some(&other.to_string()), &sub.opts),
790 ),
791 };
792 let sub_cx = FieldCx {
793 name: &name,
794 id: &name,
795 label: cx.label,
796 value: &value,
797 required: false,
798 max_length: crate::form::max_length(&sub.field.rules),
799 opts: &sub.opts,
800 base: cx.base,
801 i18n: cx.i18n,
802 };
803 let vm = sub.field_type.view_model(&sub_cx);
804 RepeaterCell {
805 label: cx.i18n.t(&sub.field.label),
806 control: sub.field_type.render_default(&vm).into_string(),
807 help: sub
808 .field
809 .help
810 .as_ref()
811 .map(|h| cx.i18n.t(h))
812 .unwrap_or_default(),
813 }
814 })
815 .collect()
816}
817
818fn attr_to_json(value: AttrValue) -> serde_json::Value {
820 match value {
821 AttrValue::Null => serde_json::Value::Null,
822 AttrValue::Bool(b) => serde_json::Value::Bool(b),
823 AttrValue::Int(i) => serde_json::Value::from(i),
824 AttrValue::Float(f) => serde_json::Value::from(f),
825 AttrValue::Json(j) => j,
826 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
827 }
828}
829
830fn is_blank_row(row: &serde_json::Value) -> bool {
832 row.as_object().is_none_or(|object| {
833 object.values().all(|v| match v {
834 serde_json::Value::String(s) => s.trim().is_empty(),
835 serde_json::Value::Bool(b) => !b,
836 serde_json::Value::Array(items) => items.is_empty(),
837 serde_json::Value::Null => true,
838 _ => false,
839 })
840 })
841}
842
843#[derive(Serialize, Deserialize)]
845struct RepeaterCell {
846 label: String,
847 control: String,
848 help: String,
852}
853
854#[derive(Serialize, Deserialize, Default)]
857struct RepeaterData {
858 rows: Vec<Vec<RepeaterCell>>,
859 blank: Vec<RepeaterCell>,
860 list: bool,
862 summary_index: usize,
864 summaries: Vec<String>,
867 #[serde(default = "on")]
869 reorder: bool,
870 #[serde(default = "on")]
872 duplicate: bool,
873 #[serde(default)]
875 text: RepeaterText,
876}
877
878#[derive(Debug, Clone, Default, Serialize, Deserialize)]
880struct RepeaterText {
881 add: String,
882 remove: String,
883 duplicate: String,
884 up: String,
885 down: String,
886 untitled: String,
887}
888
889#[derive(Template)]
890#[template(path = "fields/repeater.html")]
891struct RepeaterTmpl {
892 data: RepeaterData,
893}
894
895pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
898 let inputs = Arc::new(builtin_input_registry());
899 vec![
900 Arc::new(TextField::new(inputs)),
901 Arc::new(TextareaField),
902 Arc::new(IdField),
903 Arc::new(RepeaterField),
904 Arc::new(SelectField),
905 Arc::new(ChecklistField),
906 Arc::new(SwitchField),
907 Arc::new(DateField),
908 Arc::new(PasswordField),
909 Arc::new(RadioField),
910 Arc::new(crate::medialib::field::MediaField),
911 ]
912}
913
914pub(crate) fn builtin_registry() -> FieldRegistry {
916 builtin_field_types()
917 .into_iter()
918 .map(|ft| (ft.view_key().to_string(), ft))
919 .collect()
920}
921
922#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
924#[serde(rename_all = "snake_case")]
925pub enum Placement {
926 Leading,
927 Trailing,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize)]
934pub struct Adornment {
935 pub placement: Placement,
936 pub button: bool,
938 pub label: String,
940 pub widget: Option<String>,
943}
944
945pub trait InputType: Send + Sync + 'static {
951 fn key(&self) -> &'static str;
953 fn html_type(&self) -> &'static str {
955 "text"
956 }
957 fn resolve_options(
960 &self,
961 _raw: &serde_json::Value,
962 _types: &FieldRegistry,
963 ) -> Result<ResolvedOptions, OptionsError> {
964 Ok(ResolvedOptions::none())
965 }
966 fn option_keys(&self) -> Vec<&'static str> {
969 Vec::new()
970 }
971 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
973 Vec::new()
974 }
975 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
978 BTreeMap::new()
979 }
980 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
982 Vec::new()
983 }
984 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
987 Vec::new()
988 }
989}
990
991pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
993
994struct TextInput;
995impl InputType for TextInput {
996 fn key(&self) -> &'static str {
997 "text"
998 }
999}
1000
1001struct EmailInput;
1002impl InputType for EmailInput {
1003 fn key(&self) -> &'static str {
1004 "email"
1005 }
1006 fn html_type(&self) -> &'static str {
1007 "email"
1008 }
1009 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1010 vec![Rule::Email]
1011 }
1012}
1013
1014struct TelInput;
1015impl InputType for TelInput {
1016 fn key(&self) -> &'static str {
1017 "tel"
1018 }
1019 fn html_type(&self) -> &'static str {
1020 "tel"
1021 }
1022}
1023
1024#[derive(Default, Deserialize)]
1026struct NumberOptions {
1027 #[serde(default)]
1028 min: Option<f64>,
1029 #[serde(default)]
1030 max: Option<f64>,
1031 #[serde(default)]
1032 step: Option<f64>,
1033}
1034
1035struct NumberInput;
1036impl InputType for NumberInput {
1037 fn key(&self) -> &'static str {
1038 "number"
1039 }
1040 fn html_type(&self) -> &'static str {
1041 "number"
1042 }
1043 fn option_keys(&self) -> Vec<&'static str> {
1044 vec!["min", "max", "step"]
1045 }
1046 fn resolve_options(
1047 &self,
1048 raw: &serde_json::Value,
1049 _types: &FieldRegistry,
1050 ) -> Result<ResolvedOptions, OptionsError> {
1051 let opts: NumberOptions = if raw.is_null() {
1052 NumberOptions::default()
1053 } else {
1054 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1055 };
1056 Ok(ResolvedOptions::new(opts))
1057 }
1058 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1059 vec![Rule::Numeric]
1060 }
1061 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
1062 let mut attrs = BTreeMap::new();
1063 if let Some(o) = opts.get::<NumberOptions>() {
1064 if let Some(min) = o.min {
1065 attrs.insert("min".to_string(), min.to_string());
1066 }
1067 if let Some(max) = o.max {
1068 attrs.insert("max".to_string(), max.to_string());
1069 }
1070 if let Some(step) = o.step {
1071 attrs.insert("step".to_string(), step.to_string());
1072 }
1073 }
1074 attrs
1075 }
1076}
1077
1078#[derive(Deserialize)]
1080struct UrlOptions {
1081 #[serde(default = "default_true")]
1082 copy: bool,
1083}
1084
1085impl Default for UrlOptions {
1086 fn default() -> Self {
1087 Self { copy: true }
1088 }
1089}
1090
1091fn default_true() -> bool {
1092 true
1093}
1094
1095struct UrlInput;
1096impl InputType for UrlInput {
1097 fn key(&self) -> &'static str {
1098 "url"
1099 }
1100 fn html_type(&self) -> &'static str {
1101 "url"
1102 }
1103 fn option_keys(&self) -> Vec<&'static str> {
1104 vec!["copy"]
1105 }
1106 fn resolve_options(
1107 &self,
1108 raw: &serde_json::Value,
1109 _types: &FieldRegistry,
1110 ) -> Result<ResolvedOptions, OptionsError> {
1111 let opts: UrlOptions = if raw.is_null() {
1112 UrlOptions::default()
1113 } else {
1114 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1115 };
1116 Ok(ResolvedOptions::new(opts))
1117 }
1118 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1119 vec![Rule::Url]
1120 }
1121 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
1122 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
1123 vec![Adornment {
1124 placement: Placement::Trailing,
1125 button: true,
1126 label: "Copy".to_string(),
1127 widget: Some("copy".to_string()),
1128 }]
1129 } else {
1130 Vec::new()
1131 }
1132 }
1133}
1134
1135pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1136 vec![
1137 Arc::new(TextInput),
1138 Arc::new(EmailInput),
1139 Arc::new(TelInput),
1140 Arc::new(NumberInput),
1141 Arc::new(UrlInput),
1142 ]
1143}
1144
1145pub(crate) fn builtin_input_registry() -> InputRegistry {
1147 builtin_input_types()
1148 .into_iter()
1149 .map(|i| (i.key().to_string(), i))
1150 .collect()
1151}
1152
1153const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1156
1157fn validate_contributions(
1162 attrs: &BTreeMap<String, String>,
1163 adornments: &[Adornment],
1164) -> Result<(), OptionsError> {
1165 for name in attrs.keys() {
1166 if !is_name(name, false) {
1167 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1168 }
1169 if RESERVED_ATTRS.contains(&name.as_str()) {
1170 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1171 }
1172 }
1173 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1174 if !is_name(widget, true) {
1175 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1176 }
1177 }
1178 Ok(())
1179}
1180
1181pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1183 let mut chars = s.chars();
1184 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1185 && chars.all(|c| {
1186 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1187 })
1188}
1189
1190#[derive(Deserialize)]
1192struct TextOptions {
1193 #[serde(default = "default_input")]
1194 input: String,
1195 #[serde(default)]
1197 placeholder: Option<String>,
1198 #[serde(default)]
1200 counter: crate::checklist::Offer,
1201}
1202
1203fn default_input() -> String {
1204 "text".to_string()
1205}
1206
1207struct TextResolved {
1210 html_type: &'static str,
1211 attrs: BTreeMap<String, String>,
1212 adornments: Vec<Adornment>,
1213 rules: Vec<Rule>,
1214 assets: Vec<&'static str>,
1215 counter: crate::checklist::Offer,
1216}
1217
1218fn length_attrs(
1221 attrs: &mut BTreeMap<String, String>,
1222 max_length: Option<usize>,
1223 counter: crate::checklist::Offer,
1224) {
1225 use crate::checklist::Offer;
1226 let Some(max) = max_length else {
1227 return;
1228 };
1229 attrs.insert("maxlength".to_string(), max.to_string());
1230 match counter {
1231 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1232 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1233 Offer::Never => None,
1234 };
1235}
1236
1237#[derive(Default, Serialize, Deserialize)]
1240struct TextData {
1241 input_type: String,
1242 #[serde(default)]
1243 attrs: BTreeMap<String, String>,
1244 #[serde(default)]
1245 adornments: Vec<Adornment>,
1246}
1247
1248#[derive(Template)]
1249#[template(path = "fields/text.html")]
1250struct TextTmpl<'a> {
1251 name: &'a str,
1252 id: &'a str,
1253 value: &'a str,
1254 required: bool,
1255 input_type: &'a str,
1256 attrs: &'a BTreeMap<String, String>,
1257 leading: &'a [Adornment],
1258 trailing: &'a [Adornment],
1259}
1260
1261pub(crate) struct TextField {
1265 inputs: Arc<InputRegistry>,
1266}
1267
1268impl TextField {
1269 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1270 Self { inputs }
1271 }
1272}
1273
1274impl FieldType for TextField {
1275 fn view_key(&self) -> &'static str {
1276 "text"
1277 }
1278 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1279 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1280 let input = self.inputs.get(input)?;
1283 let mut all = vec![
1284 "input".to_string(),
1285 "placeholder".to_string(),
1286 "counter".to_string(),
1287 ];
1288 all.extend(input.option_keys().into_iter().map(str::to_string));
1289 Some(all)
1290 }
1291 fn resolve_options(
1292 &self,
1293 raw: &serde_json::Value,
1294 _types: &FieldRegistry,
1295 ) -> Result<ResolvedOptions, OptionsError> {
1296 let opts: TextOptions = if raw.is_null() {
1297 TextOptions {
1298 input: default_input(),
1299 placeholder: None,
1300 counter: Default::default(),
1301 }
1302 } else {
1303 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1304 };
1305 let input = self
1306 .inputs
1307 .get(&opts.input)
1308 .cloned()
1309 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1310 let input_opts = input.resolve_options(raw, _types)?;
1313 let mut attrs = input.attributes(&input_opts);
1314 let adornments = input.adornments(&input_opts);
1315 validate_contributions(&attrs, &adornments)?;
1316 if let Some(placeholder) = opts.placeholder {
1317 attrs.insert("placeholder".to_string(), placeholder);
1318 }
1319 Ok(ResolvedOptions::new(TextResolved {
1320 html_type: input.html_type(),
1321 rules: input.rules(&input_opts),
1322 assets: input.assets(&input_opts),
1323 attrs,
1324 adornments,
1325 counter: opts.counter,
1326 }))
1327 }
1328 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1329 opts.get::<TextResolved>()
1330 .map(|r| r.rules.clone())
1331 .unwrap_or_default()
1332 }
1333 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1334 opts.get::<TextResolved>()
1335 .map(|r| r.assets.clone())
1336 .unwrap_or_default()
1337 }
1338 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1339 let mut data = match cx.opts.get::<TextResolved>() {
1340 Some(r) => TextData {
1341 input_type: r.html_type.to_string(),
1342 attrs: r.attrs.clone(),
1343 adornments: r.adornments.clone(),
1344 },
1345 None => TextData {
1346 input_type: "text".to_string(),
1347 ..TextData::default()
1348 },
1349 };
1350 if matches!(
1352 data.input_type.as_str(),
1353 "text" | "email" | "tel" | "url" | "search"
1354 ) {
1355 let counter = cx
1356 .opts
1357 .get::<TextResolved>()
1358 .map(|r| r.counter)
1359 .unwrap_or_default();
1360 length_attrs(&mut data.attrs, cx.max_length, counter);
1361 }
1362 FieldVm {
1363 view_key: "text".to_string(),
1364 name: cx.name.to_string(),
1365 id: cx.id.to_string(),
1366 label: cx.label.to_string(),
1367 required: cx.required,
1368 value: cx.value.clone(),
1369 data: serde_json::to_value(data).unwrap_or_default(),
1370 }
1371 }
1372 fn render_default(&self, vm: &FieldVm) -> Markup {
1373 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1374 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1375 .adornments
1376 .into_iter()
1377 .partition(|a| matches!(a.placement, Placement::Leading));
1378 Markup::from_template(&TextTmpl {
1379 name: &vm.name,
1380 id: &vm.id,
1381 value: vm.value.as_text(),
1382 required: vm.required,
1383 input_type: &data.input_type,
1384 attrs: &data.attrs,
1385 leading: &leading,
1386 trailing: &trailing,
1387 })
1388 .unwrap_or_default()
1389 }
1390}
1391
1392#[derive(Template)]
1393#[template(path = "fields/textarea.html")]
1394struct TextareaTmpl<'a> {
1395 name: &'a str,
1396 id: &'a str,
1397 value: &'a str,
1398 required: bool,
1399 rows: Option<u16>,
1400 placeholder: Option<&'a str>,
1401 grow: bool,
1402 attrs: &'a BTreeMap<String, String>,
1403}
1404
1405fn yes() -> bool {
1406 true
1407}
1408
1409#[derive(Debug, Clone, Serialize, Deserialize)]
1412struct TextareaOptions {
1413 #[serde(default)]
1414 rows: Option<u16>,
1415 #[serde(default)]
1416 placeholder: Option<String>,
1417 #[serde(default = "yes")]
1418 grow: bool,
1419 #[serde(default)]
1420 counter: crate::checklist::Offer,
1421}
1422
1423impl Default for TextareaOptions {
1424 fn default() -> Self {
1425 Self {
1426 rows: None,
1427 placeholder: None,
1428 grow: true,
1429 counter: Default::default(),
1430 }
1431 }
1432}
1433
1434#[derive(Debug, Clone, Serialize, Deserialize)]
1436struct TextareaData {
1437 #[serde(default)]
1438 rows: Option<u16>,
1439 #[serde(default)]
1440 placeholder: Option<String>,
1441 #[serde(default = "yes")]
1442 grow: bool,
1443 #[serde(default)]
1445 attrs: BTreeMap<String, String>,
1446}
1447
1448impl Default for TextareaData {
1449 fn default() -> Self {
1450 Self {
1451 rows: None,
1452 placeholder: None,
1453 grow: true,
1454 attrs: BTreeMap::new(),
1455 }
1456 }
1457}
1458
1459pub(crate) struct TextareaField;
1461
1462impl FieldType for TextareaField {
1463 fn view_key(&self) -> &'static str {
1464 "textarea"
1465 }
1466 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1467 keys(&["rows", "placeholder", "grow", "counter"])
1468 }
1469 fn resolve_options(
1470 &self,
1471 raw: &serde_json::Value,
1472 _types: &FieldRegistry,
1473 ) -> Result<ResolvedOptions, OptionsError> {
1474 let opts: TextareaOptions = if raw.is_null() {
1475 TextareaOptions::default()
1476 } else {
1477 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1478 };
1479 Ok(ResolvedOptions::new(opts))
1480 }
1481 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1482 let mut vm = scalar_vm("textarea", cx);
1483 let opts = cx
1484 .opts
1485 .get::<TextareaOptions>()
1486 .cloned()
1487 .unwrap_or_default();
1488 let mut data = TextareaData {
1489 rows: opts.rows,
1490 placeholder: opts.placeholder,
1491 grow: opts.grow,
1492 attrs: BTreeMap::new(),
1493 };
1494 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
1495 vm.data = serde_json::to_value(data).unwrap_or_default();
1496 vm
1497 }
1498 fn render_default(&self, vm: &FieldVm) -> Markup {
1499 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1500 Markup::from_template(&TextareaTmpl {
1501 name: &vm.name,
1502 id: &vm.id,
1503 value: vm.value.as_text(),
1504 required: vm.required,
1505 rows: data.rows,
1506 placeholder: data.placeholder.as_deref(),
1507 grow: data.grow,
1508 attrs: &data.attrs,
1509 })
1510 .unwrap_or_default()
1511 }
1512}
1513
1514pub const SELECT_SEARCH_PAST: usize = 10;
1516
1517#[derive(Debug, Default, Deserialize)]
1520struct SelectOptions {
1521 #[serde(default)]
1522 options: Vec<SelectOption>,
1523 #[serde(default)]
1524 search: crate::checklist::Offer,
1525}
1526
1527impl SelectOptions {
1528 fn searchable(&self) -> bool {
1529 use crate::checklist::Offer;
1530 match self.search {
1531 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
1532 Offer::Always => true,
1533 Offer::Never => false,
1534 }
1535 }
1536}
1537
1538#[derive(Debug, Default, Serialize, Deserialize)]
1540struct SelectData {
1541 options: Vec<OptionView>,
1542 #[serde(default)]
1543 search: bool,
1544}
1545
1546#[derive(Debug, Deserialize)]
1547struct SelectOption {
1548 value: String,
1549 #[serde(default)]
1551 label: Option<String>,
1552}
1553
1554#[derive(Debug, Serialize, Deserialize)]
1557struct OptionView {
1558 value: String,
1559 label: String,
1560 selected: bool,
1561}
1562
1563#[derive(Template)]
1564#[template(path = "fields/select.html")]
1565struct SelectTmpl<'a> {
1566 name: &'a str,
1567 id: &'a str,
1568 required: bool,
1569 options: &'a [OptionView],
1570 search: bool,
1571}
1572
1573pub(crate) struct SelectField;
1575
1576impl FieldType for SelectField {
1577 fn view_key(&self) -> &'static str {
1578 "select"
1579 }
1580 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1581 keys(&["options", "search"])
1582 }
1583 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1586 match opts.get::<SelectOptions>() {
1587 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
1588 _ => Vec::new(),
1589 }
1590 }
1591 fn resolve_options(
1592 &self,
1593 raw: &serde_json::Value,
1594 _types: &FieldRegistry,
1595 ) -> Result<ResolvedOptions, OptionsError> {
1596 let opts: SelectOptions =
1597 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1598 Ok(ResolvedOptions::new(opts))
1599 }
1600 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1601 let current = cx.value.as_text();
1602 let opts = cx.opts.get::<SelectOptions>();
1603 let data = SelectData {
1604 options: opts
1605 .map(|o| {
1606 o.options
1607 .iter()
1608 .map(|so| OptionView {
1609 value: so.value.clone(),
1610 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1611 selected: so.value == current,
1612 })
1613 .collect()
1614 })
1615 .unwrap_or_default(),
1616 search: opts.is_some_and(SelectOptions::searchable),
1617 };
1618 FieldVm {
1619 view_key: "select".to_string(),
1620 name: cx.name.to_string(),
1621 id: cx.id.to_string(),
1622 label: cx.label.to_string(),
1623 required: cx.required,
1624 value: cx.value.clone(),
1625 data: serde_json::to_value(&data).unwrap_or_default(),
1626 }
1627 }
1628 fn render_default(&self, vm: &FieldVm) -> Markup {
1629 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1630 Markup::from_template(&SelectTmpl {
1631 name: &vm.name,
1632 id: &vm.id,
1633 required: vm.required,
1634 options: &data.options,
1635 search: data.search,
1636 })
1637 .unwrap_or_default()
1638 }
1639}
1640
1641#[derive(Debug, Default, Deserialize)]
1643struct ChecklistOptions {
1644 #[serde(default)]
1645 options: Vec<ChecklistEntry>,
1646 #[serde(default)]
1647 select_all: crate::checklist::Offer,
1648 #[serde(default)]
1649 search: crate::checklist::Offer,
1650 #[serde(default)]
1651 expand: crate::checklist::Expand,
1652}
1653
1654#[derive(Debug, Deserialize)]
1656#[serde(untagged)]
1657enum ChecklistEntry {
1658 Group {
1659 group: String,
1660 #[serde(default)]
1661 options: Vec<ChecklistEntry>,
1662 },
1663 Choice {
1664 value: String,
1665 #[serde(default)]
1667 label: Option<String>,
1668 #[serde(default)]
1670 note: Option<String>,
1671 },
1672}
1673
1674impl ChecklistOptions {
1675 fn values(&self) -> Vec<&str> {
1677 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1678 for entry in entries {
1679 match entry {
1680 ChecklistEntry::Choice { value, .. } => out.push(value),
1681 ChecklistEntry::Group { options, .. } => walk(options, out),
1682 }
1683 }
1684 }
1685 let mut out = Vec::new();
1686 walk(&self.options, &mut out);
1687 out
1688 }
1689
1690 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1693 self.values()
1694 .into_iter()
1695 .filter(|value| submitted.contains(value))
1696 .collect()
1697 }
1698}
1699
1700fn checklist_values(text: &str) -> Vec<String> {
1702 serde_json::from_str(text).unwrap_or_default()
1703}
1704
1705pub(crate) struct ChecklistField;
1710
1711impl ChecklistField {
1712 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1713 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1714 match opts.get::<ChecklistOptions>() {
1715 Some(options) => options
1716 .ticked(&submitted)
1717 .into_iter()
1718 .map(str::to_string)
1719 .collect(),
1720 None => Vec::new(),
1721 }
1722 }
1723}
1724
1725impl FieldType for ChecklistField {
1726 fn view_key(&self) -> &'static str {
1727 "checklist"
1728 }
1729 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1730 keys(&["options", "select_all", "search", "expand"])
1731 }
1732 fn resolve_options(
1733 &self,
1734 raw: &serde_json::Value,
1735 _types: &FieldRegistry,
1736 ) -> Result<ResolvedOptions, OptionsError> {
1737 let opts: ChecklistOptions = if raw.is_null() {
1738 ChecklistOptions::default()
1739 } else {
1740 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1741 };
1742 let mut seen = std::collections::HashSet::new();
1743 for value in opts.values() {
1744 if !seen.insert(value) {
1745 return Err(OptionsError(format!("option `{value}` is listed twice")));
1746 }
1747 }
1748 Ok(ResolvedOptions::new(opts))
1749 }
1750
1751 fn to_attr(
1752 &self,
1753 field: &SubmittedField<'_>,
1754 opts: &ResolvedOptions,
1755 _mode: Mode,
1756 ) -> Result<Option<AttrValue>, String> {
1757 let values = Self::gathered(field, opts)
1760 .into_iter()
1761 .map(serde_json::Value::String)
1762 .collect();
1763 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1764 }
1765
1766 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1767 stored.unwrap_or("[]").to_string()
1768 }
1769
1770 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1771 let values = Self::gathered(field, opts);
1772 if values.is_empty() {
1773 return None;
1774 }
1775 serde_json::to_string(&values).ok()
1776 }
1777
1778 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1779 use crate::checklist::{Checklist, Choice, Entry, Group};
1780
1781 let ticked: Vec<String> = match cx.value {
1782 FieldValue::Json(serde_json::Value::Array(values)) => values
1783 .iter()
1784 .filter_map(|v| v.as_str().map(str::to_string))
1785 .collect(),
1786 FieldValue::Text(text) => checklist_values(text),
1787 _ => Vec::new(),
1788 };
1789
1790 fn build(
1791 entries: &[ChecklistEntry],
1792 name: &str,
1793 ticked: &[String],
1794 position: &mut usize,
1795 ) -> Vec<Entry> {
1796 entries
1797 .iter()
1798 .map(|entry| match entry {
1799 ChecklistEntry::Choice { value, label, note } => {
1800 let mut choice = Choice::new(
1801 format!("{name}[{position}]"),
1802 value.clone(),
1803 label.clone().unwrap_or_else(|| value.clone()),
1804 )
1805 .checked(ticked.contains(value));
1806 if let Some(note) = note {
1807 choice = choice.note(note.clone());
1808 }
1809 *position += 1;
1810 Entry::from(choice)
1811 }
1812 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1813 group.clone(),
1814 build(options, name, ticked, position),
1815 )),
1816 })
1817 .collect()
1818 }
1819
1820 let mut list = Checklist::new(cx.label).id(cx.id);
1821 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1822 let mut position = 0;
1823 for entry in build(&options.options, cx.name, &ticked, &mut position) {
1824 list = list.entry(entry);
1825 }
1826 list = list
1827 .select_all(options.select_all)
1828 .search(options.search)
1829 .expand(options.expand);
1830 }
1831 FieldVm {
1832 view_key: "checklist".to_string(),
1833 name: cx.name.to_string(),
1834 id: cx.id.to_string(),
1835 label: cx.label.to_string(),
1836 required: cx.required,
1837 value: cx.value.clone(),
1838 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1839 }
1840 }
1841
1842 fn render_default(&self, vm: &FieldVm) -> Markup {
1843 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1844 .map(|view| view.render_markup())
1845 .unwrap_or_default()
1846 }
1847}
1848
1849#[derive(Template)]
1850#[template(path = "fields/switch.html")]
1851struct SwitchTmpl<'a> {
1852 name: &'a str,
1853 id: &'a str,
1854 on: bool,
1855}
1856
1857pub(crate) struct SwitchField;
1863
1864fn stored_is_on(stored: Option<&str>) -> bool {
1867 matches!(
1868 stored.map(str::trim).unwrap_or_default(),
1869 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1870 )
1871}
1872
1873impl FieldType for SwitchField {
1874 fn view_key(&self) -> &'static str {
1875 "switch"
1876 }
1877 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1878 keys(&[])
1879 }
1880
1881 fn to_attr(
1882 &self,
1883 field: &SubmittedField<'_>,
1884 _opts: &ResolvedOptions,
1885 _mode: Mode,
1886 ) -> Result<Option<AttrValue>, String> {
1887 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1890 }
1891
1892 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1893 if stored_is_on(stored) {
1894 "1".to_string()
1895 } else {
1896 String::new()
1897 }
1898 }
1899
1900 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1901 scalar_vm("switch", cx)
1902 }
1903
1904 fn render_default(&self, vm: &FieldVm) -> Markup {
1905 Markup::from_template(&SwitchTmpl {
1906 name: &vm.name,
1907 id: &vm.id,
1908 on: stored_is_on(Some(vm.value.as_text())),
1909 })
1910 .unwrap_or_default()
1911 }
1912}
1913
1914#[derive(Template)]
1915#[template(path = "fields/date.html")]
1916struct DateTmpl<'a> {
1917 name: &'a str,
1918 id: &'a str,
1919 value: &'a str,
1920 required: bool,
1921}
1922
1923pub(crate) struct DateField;
1929
1930impl FieldType for DateField {
1931 fn view_key(&self) -> &'static str {
1932 "date"
1933 }
1934 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1935 keys(&[])
1936 }
1937
1938 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1939 vec![Rule::Date]
1940 }
1941
1942 fn to_attr(
1943 &self,
1944 field: &SubmittedField<'_>,
1945 _opts: &ResolvedOptions,
1946 _mode: Mode,
1947 ) -> Result<Option<AttrValue>, String> {
1948 Ok(field.value().map(|v| match v.trim() {
1949 "" => AttrValue::Null,
1950 date => AttrValue::Text(date.to_string()),
1951 }))
1952 }
1953
1954 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1955 let stored = stored.unwrap_or_default().trim();
1956 stored
1959 .split(['T', ' '])
1960 .next()
1961 .unwrap_or_default()
1962 .to_string()
1963 }
1964
1965 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1966 scalar_vm("date", cx)
1967 }
1968
1969 fn render_default(&self, vm: &FieldVm) -> Markup {
1970 Markup::from_template(&DateTmpl {
1971 name: &vm.name,
1972 id: &vm.id,
1973 value: vm.value.as_text(),
1974 required: vm.required,
1975 })
1976 .unwrap_or_default()
1977 }
1978}
1979
1980#[derive(Template)]
1981#[template(path = "fields/password.html")]
1982struct PasswordTmpl<'a> {
1983 name: &'a str,
1984 id: &'a str,
1985 required: bool,
1986 reveal: bool,
1987 show: &'a str,
1988}
1989
1990#[derive(Debug, Clone, Serialize, Deserialize)]
1992struct PasswordOptions {
1993 #[serde(default = "yes")]
1994 reveal: bool,
1995 #[serde(default)]
1997 show: String,
1998}
1999
2000impl Default for PasswordOptions {
2001 fn default() -> Self {
2002 Self {
2003 reveal: true,
2004 show: String::new(),
2005 }
2006 }
2007}
2008
2009pub(crate) struct PasswordField;
2017
2018impl FieldType for PasswordField {
2019 fn view_key(&self) -> &'static str {
2020 "password"
2021 }
2022 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2023 keys(&["reveal"])
2024 }
2025 fn resolve_options(
2026 &self,
2027 raw: &serde_json::Value,
2028 _types: &FieldRegistry,
2029 ) -> Result<ResolvedOptions, OptionsError> {
2030 let opts: PasswordOptions = if raw.is_null() {
2031 PasswordOptions::default()
2032 } else {
2033 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2034 };
2035 Ok(ResolvedOptions::new(opts))
2036 }
2037
2038 fn to_attr(
2039 &self,
2040 field: &SubmittedField<'_>,
2041 _opts: &ResolvedOptions,
2042 _mode: Mode,
2043 ) -> Result<Option<AttrValue>, String> {
2044 match field.value().map(str::trim).unwrap_or_default() {
2045 "" => Ok(None),
2047 plain => laterite_auth::password::hash_password(plain)
2048 .map(|hash| Some(AttrValue::Text(hash)))
2049 .map_err(|e| e.to_string()),
2050 }
2051 }
2052
2053 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2056 String::new()
2057 }
2058
2059 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2060 let mut vm = scalar_vm("password", cx);
2061 let mut opts = cx
2062 .opts
2063 .get::<PasswordOptions>()
2064 .cloned()
2065 .unwrap_or_default();
2066 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2067 vm.data = serde_json::to_value(opts).unwrap_or_default();
2068 vm
2069 }
2070
2071 fn render_default(&self, vm: &FieldVm) -> Markup {
2072 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2073 Markup::from_template(&PasswordTmpl {
2074 name: &vm.name,
2075 id: &vm.id,
2076 required: vm.required,
2077 reveal: opts.reveal,
2078 show: &opts.show,
2079 })
2080 .unwrap_or_default()
2081 }
2082}
2083
2084#[derive(Template)]
2085#[template(path = "fields/radio.html")]
2086struct RadioTmpl<'a> {
2087 name: &'a str,
2088 options: &'a [OptionView],
2089 required: bool,
2090}
2091
2092pub(crate) struct RadioField;
2097
2098impl FieldType for RadioField {
2099 fn view_key(&self) -> &'static str {
2100 "radio"
2101 }
2102 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2103 keys(&["options"])
2104 }
2105
2106 fn resolve_options(
2107 &self,
2108 raw: &serde_json::Value,
2109 _types: &FieldRegistry,
2110 ) -> Result<ResolvedOptions, OptionsError> {
2111 let opts: SelectOptions =
2112 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2113 Ok(ResolvedOptions::new(opts))
2114 }
2115
2116 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2117 let current = cx.value.as_text();
2118 let views: Vec<OptionView> = cx
2119 .opts
2120 .get::<SelectOptions>()
2121 .map(|o| {
2122 o.options
2123 .iter()
2124 .map(|so| OptionView {
2125 value: so.value.clone(),
2126 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2127 selected: so.value == current,
2128 })
2129 .collect()
2130 })
2131 .unwrap_or_default();
2132 FieldVm {
2133 view_key: "radio".to_string(),
2134 name: cx.name.to_string(),
2135 id: cx.id.to_string(),
2136 label: cx.label.to_string(),
2137 required: cx.required,
2138 value: cx.value.clone(),
2139 data: serde_json::to_value(&views).unwrap_or_default(),
2140 }
2141 }
2142
2143 fn render_default(&self, vm: &FieldVm) -> Markup {
2144 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2145 Markup::from_template(&RadioTmpl {
2146 name: &vm.name,
2147 options: &options,
2148 required: vm.required,
2149 })
2150 .unwrap_or_default()
2151 }
2152}
2153
2154fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2156 FieldVm {
2157 view_key: view_key.to_string(),
2158 name: cx.name.to_string(),
2159 id: cx.id.to_string(),
2160 label: cx.label.to_string(),
2161 required: cx.required,
2162 value: cx.value.clone(),
2163 data: serde_json::Value::Null,
2164 }
2165}
2166
2167#[derive(Deserialize)]
2169struct RefOptions {
2170 source: String,
2171}
2172
2173struct RefResolved {
2175 source: String,
2176}
2177
2178#[derive(Default, Serialize, Deserialize)]
2181struct RefData {
2182 source: String,
2183 search_url: String,
2184 resolve_url: String,
2185 value: String,
2186 placeholder: String,
2187}
2188
2189#[derive(Template)]
2190#[template(path = "fields/ref_picker.html")]
2191struct RefPickerTmpl<'a> {
2192 name: &'a str,
2193 id: &'a str,
2194 value: &'a str,
2195 search_url: &'a str,
2196 resolve_url: &'a str,
2197 placeholder: &'a str,
2198}
2199
2200pub(crate) struct RefPickerField {
2205 pickers: Arc<PickerRegistry>,
2206}
2207
2208impl RefPickerField {
2209 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2210 Self { pickers }
2211 }
2212}
2213
2214pub(crate) struct IdField;
2219
2220impl FieldType for IdField {
2221 fn view_key(&self) -> &'static str {
2222 "id"
2223 }
2224 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2225 keys(&[])
2226 }
2227 fn resolve_options(
2228 &self,
2229 _raw: &serde_json::Value,
2230 _types: &FieldRegistry,
2231 ) -> Result<ResolvedOptions, OptionsError> {
2232 Ok(ResolvedOptions::new(()))
2233 }
2234 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2235 FieldVm {
2236 view_key: "id".to_string(),
2237 name: cx.name.to_string(),
2238 id: cx.id.to_string(),
2239 label: cx.label.to_string(),
2240 required: cx.required,
2241 value: cx.value.clone(),
2242 data: serde_json::Value::Null,
2243 }
2244 }
2245 fn render_default(&self, vm: &FieldVm) -> Markup {
2246 Markup::from_override(format!(
2247 r#"<input type="hidden" name="{}" value="{}">"#,
2248 crate::form::escape_attr(&vm.name),
2249 crate::form::escape_attr(vm.value.as_text())
2250 ))
2251 }
2252 fn to_attr(
2253 &self,
2254 field: &SubmittedField<'_>,
2255 _opts: &ResolvedOptions,
2256 _mode: Mode,
2257 ) -> Result<Option<AttrValue>, String> {
2258 Ok(field.value().map(id_attr))
2259 }
2260}
2261
2262fn id_attr(value: &str) -> AttrValue {
2265 let value = value.trim();
2266 if value.is_empty() {
2267 AttrValue::Null
2268 } else if let Ok(id) = value.parse::<i64>() {
2269 AttrValue::Int(id)
2270 } else {
2271 AttrValue::Text(value.to_string())
2272 }
2273}
2274
2275impl FieldType for RefPickerField {
2276 fn to_attr(
2279 &self,
2280 field: &SubmittedField<'_>,
2281 _opts: &ResolvedOptions,
2282 _mode: Mode,
2283 ) -> Result<Option<AttrValue>, String> {
2284 Ok(field.value().map(id_attr))
2285 }
2286 fn view_key(&self) -> &'static str {
2287 "reference"
2288 }
2289 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2290 keys(&["source"])
2291 }
2292 fn resolve_options(
2293 &self,
2294 raw: &serde_json::Value,
2295 _types: &FieldRegistry,
2296 ) -> Result<ResolvedOptions, OptionsError> {
2297 let opts: RefOptions =
2298 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2299 if !self.pickers.contains_key(&opts.source) {
2300 return Err(OptionsError(format!(
2301 "unknown picker source `{}`",
2302 opts.source
2303 )));
2304 }
2305 Ok(ResolvedOptions::new(RefResolved {
2306 source: opts.source,
2307 }))
2308 }
2309 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2310 let source = cx
2311 .opts
2312 .get::<RefResolved>()
2313 .map(|r| r.source.as_str())
2314 .unwrap_or_default();
2315 let data = RefData {
2316 search_url: format!("{}/pickers/{}/search", cx.base, source),
2317 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2318 source: source.to_string(),
2319 value: cx.value.as_text().to_string(),
2320 placeholder: "Search…".to_string(),
2321 };
2322 FieldVm {
2323 view_key: "reference".to_string(),
2324 name: cx.name.to_string(),
2325 id: cx.id.to_string(),
2326 label: cx.label.to_string(),
2327 required: cx.required,
2328 value: cx.value.clone(),
2329 data: serde_json::to_value(data).unwrap_or_default(),
2330 }
2331 }
2332 fn render_default(&self, vm: &FieldVm) -> Markup {
2333 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2334 Markup::from_template(&RefPickerTmpl {
2335 name: &vm.name,
2336 id: &vm.id,
2337 value: vm.value.as_text(),
2338 search_url: &data.search_url,
2339 resolve_url: &data.resolve_url,
2340 placeholder: &data.placeholder,
2341 })
2342 .unwrap_or_default()
2343 }
2344 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2345 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2346 }
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351 use super::*;
2352
2353 #[test]
2354 fn an_id_is_stored_as_a_number_or_null() {
2355 assert!(matches!(id_attr("7"), AttrValue::Int(7)));
2356 assert!(matches!(id_attr(" 12 "), AttrValue::Int(12)));
2357 assert!(matches!(id_attr(""), AttrValue::Null));
2358 assert!(matches!(id_attr("x1"), AttrValue::Text(t) if t == "x1"));
2359 }
2360
2361 fn untranslated() -> Translator {
2364 Translator::new("en")
2365 }
2366
2367 fn cx_with<'a>(
2368 name: &'a str,
2369 value: &'a FieldValue,
2370 opts: &'a ResolvedOptions,
2371 i18n: &'a Translator,
2372 ) -> FieldCx<'a> {
2373 FieldCx {
2374 name,
2375 id: name,
2376 label: "Label",
2377 value,
2378 required: true,
2379 max_length: None,
2380 opts,
2381 base: "/admin",
2382 i18n,
2383 }
2384 }
2385
2386 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2387 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2390 }
2391
2392 fn text_field() -> TextField {
2393 TextField::new(Arc::new(builtin_input_registry()))
2394 }
2395
2396 struct BadAttrInput;
2398 impl InputType for BadAttrInput {
2399 fn key(&self) -> &'static str {
2400 "bad"
2401 }
2402 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2403 BTreeMap::from([("class".to_string(), "x".to_string())])
2404 }
2405 }
2406
2407 #[test]
2408 fn text_renders_its_value_escaped() {
2409 let opts = ResolvedOptions::none();
2410 let value = FieldValue::Text("a<b>&\"c".to_string());
2411 let markup = render_field(
2412 &text_field(),
2413 &NoOverrides,
2414 &scope(),
2415 &cx("title", &value, &opts),
2416 );
2417 let html = markup.as_str();
2418 assert!(html.contains(r#"name="title""#));
2419 assert!(html.contains("required"));
2420 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2424 assert!(
2425 html.contains("<") || html.contains("<"),
2426 "angle escaped: {html}"
2427 );
2428 assert!(
2429 html.contains("&") || html.contains("&"),
2430 "ampersand escaped: {html}"
2431 );
2432 }
2433
2434 #[test]
2435 fn textarea_uses_its_own_control() {
2436 let opts = ResolvedOptions::none();
2437 let value = FieldValue::Text("body".to_string());
2438 let markup = render_field(
2439 &TextareaField,
2440 &NoOverrides,
2441 &scope(),
2442 &cx("body", &value, &opts),
2443 );
2444 assert!(markup.as_str().contains("<textarea"));
2445 }
2446
2447 #[test]
2448 fn no_overrides_uses_the_compiled_default() {
2449 let opts = ResolvedOptions::none();
2451 let value = FieldValue::Text("x".to_string());
2452 let ft = text_field();
2453 let vm = ft.view_model(&cx("t", &value, &opts));
2454 let direct = ft.render_default(&vm);
2455 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2456 assert_eq!(direct.as_str(), routed.as_str());
2457 }
2458
2459 #[test]
2460 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2461 let field = text_field();
2462 let opts = field
2463 .resolve_options(
2464 &serde_json::json!({ "input": "email" }),
2465 &builtin_registry(),
2466 )
2467 .unwrap();
2468 assert!(matches!(
2470 field.intrinsic_rules(&opts).as_slice(),
2471 [Rule::Email]
2472 ));
2473 let value = FieldValue::Text("a@b.test".to_string());
2474 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2475 assert!(markup.as_str().contains(r#"type="email""#));
2476 }
2477
2478 #[test]
2479 fn text_default_input_is_plain_text_with_no_extra_rule() {
2480 let field = text_field();
2481 let opts = field
2482 .resolve_options(&serde_json::Value::Null, &builtin_registry())
2483 .unwrap();
2484 assert!(field.intrinsic_rules(&opts).is_empty());
2485 let value = FieldValue::Text("hi".to_string());
2486 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2487 assert!(markup.as_str().contains(r#"type="text""#));
2488 }
2489
2490 #[test]
2491 fn text_number_input_sets_the_type_and_numeric_rule() {
2492 let field = text_field();
2493 let opts = field
2494 .resolve_options(
2495 &serde_json::json!({ "input": "number" }),
2496 &builtin_registry(),
2497 )
2498 .unwrap();
2499 assert!(matches!(
2500 field.intrinsic_rules(&opts).as_slice(),
2501 [Rule::Numeric]
2502 ));
2503 let value = FieldValue::Text("42".to_string());
2504 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2505 assert!(markup.as_str().contains(r#"type="number""#));
2506 }
2507
2508 #[test]
2509 fn text_tel_input_sets_the_type_with_no_rule() {
2510 let field = text_field();
2511 let opts = field
2512 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2513 .unwrap();
2514 assert!(field.intrinsic_rules(&opts).is_empty());
2515 let value = FieldValue::Text(String::new());
2516 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2517 assert!(markup.as_str().contains(r#"type="tel""#));
2518 }
2519
2520 #[test]
2521 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2522 let field = text_field();
2523 let opts = field
2524 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2525 .unwrap();
2526 assert!(matches!(
2527 field.intrinsic_rules(&opts).as_slice(),
2528 [Rule::Url]
2529 ));
2530 let value = FieldValue::Text("https://example.com".to_string());
2531 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2532 let html = markup.as_str();
2533 assert!(html.contains(r#"type="url""#), "{html}");
2534 assert!(html.contains("lat-input-group"), "{html}");
2535 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2536 }
2537
2538 #[test]
2539 fn text_url_input_copy_false_omits_the_button() {
2540 let field = text_field();
2541 let opts = field
2542 .resolve_options(
2543 &serde_json::json!({ "input": "url", "copy": false }),
2544 &builtin_registry(),
2545 )
2546 .unwrap();
2547 let value = FieldValue::Text("https://example.com".to_string());
2548 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2549 let html = markup.as_str();
2550 assert!(html.contains(r#"type="url""#), "{html}");
2551 assert!(!html.contains("lat-input-group"), "{html}");
2553 assert!(!html.contains("data-lat-widget"), "{html}");
2554 }
2555
2556 #[test]
2557 fn text_number_input_emits_min_max_step_attributes() {
2558 let field = text_field();
2559 let opts = field
2560 .resolve_options(
2561 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2562 &builtin_registry(),
2563 )
2564 .unwrap();
2565 let value = FieldValue::Text("5".to_string());
2568 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2569 let html = markup.as_str();
2570 assert!(html.contains(r#"min="0""#), "{html}");
2571 assert!(html.contains(r#"max="10""#), "{html}");
2572 assert!(html.contains(r#"step="2""#), "{html}");
2573 }
2574
2575 #[test]
2576 fn text_input_reserved_attribute_name_aborts_resolve() {
2577 let mut inputs = builtin_input_registry();
2578 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2579 let field = TextField::new(Arc::new(inputs));
2580 let Err(e) =
2581 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2582 else {
2583 panic!("expected a reserved-attribute rejection");
2584 };
2585 assert!(e.0.contains("class"), "{e}");
2586 }
2587
2588 #[test]
2589 fn attribute_and_widget_name_rules() {
2590 assert!(is_name("min", false));
2591 assert!(is_name("data-x", false));
2592 assert!(!is_name("Min", false));
2593 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
2596 }
2597
2598 #[test]
2599 fn select_renders_options_with_the_current_value_selected() {
2600 let raw = serde_json::json!({
2601 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2602 });
2603 let opts = SelectField
2604 .resolve_options(&raw, &builtin_registry())
2605 .unwrap();
2606 let value = FieldValue::Text("closed".to_string());
2607 let markup = render_field(
2608 &SelectField,
2609 &NoOverrides,
2610 &scope(),
2611 &cx("status", &value, &opts),
2612 );
2613 let html = markup.as_str();
2614 assert!(html.contains(r#"<option value="open">Open</option>"#));
2615 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2617 }
2618
2619 #[test]
2620 fn radio_renders_the_same_options_with_the_current_one_checked() {
2621 let raw = serde_json::json!({
2622 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2623 });
2624 let opts = RadioField
2625 .resolve_options(&raw, &builtin_registry())
2626 .unwrap();
2627 let value = FieldValue::Text("closed".to_string());
2628 let markup = render_field(
2629 &RadioField,
2630 &NoOverrides,
2631 &scope(),
2632 &cx("status", &value, &opts),
2633 );
2634 let html = markup.as_str();
2635 assert!(html.contains(r#"value="open""#));
2636 assert!(
2637 html.contains(r#"value="closed" checked"#),
2638 "current is checked"
2639 );
2640 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2643 assert!(html.contains("closed</label>"));
2645 }
2646
2647 fn scope<'a>() -> OverrideScope<'a> {
2648 OverrideScope {
2649 surface: Surface::Field,
2650 view_key: "text",
2651 resource: None,
2652 field: None,
2653 }
2654 }
2655
2656 fn ref_field() -> RefPickerField {
2657 let mut pickers = crate::picker::PickerRegistry::new();
2658 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2659 pickers.insert(
2660 "acme.place".to_string(),
2661 crate::picker::PickerSourceReg::new("acme.place", source),
2662 );
2663 RefPickerField::new(Arc::new(pickers))
2664 }
2665
2666 #[test]
2667 fn reference_rejects_an_unknown_source() {
2668 let field = ref_field();
2669 let Err(e) = field.resolve_options(
2670 &serde_json::json!({ "source": "no.such" }),
2671 &builtin_registry(),
2672 ) else {
2673 panic!("expected an unknown-source rejection");
2674 };
2675 assert!(e.0.contains("no.such"), "{e}");
2676 }
2677
2678 #[test]
2679 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2680 let field = ref_field();
2681 let opts = field
2682 .resolve_options(
2683 &serde_json::json!({ "source": "acme.place" }),
2684 &builtin_registry(),
2685 )
2686 .unwrap();
2687 let value = FieldValue::Text("42".to_string());
2688 let markup = render_field(
2689 &field,
2690 &NoOverrides,
2691 &scope(),
2692 &cx("place_id", &value, &opts),
2693 );
2694 let html = markup.as_str();
2695 assert!(html.contains(r#"type="hidden""#), "{html}");
2698 assert!(html.contains(r#"name="place_id""#), "{html}");
2699 assert!(html.contains(r#"value="42""#), "{html}");
2700 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2702 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2703 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2704 }
2705
2706 #[test]
2707 fn reference_declares_its_widget_assets() {
2708 let field = ref_field();
2709 let opts = field
2710 .resolve_options(
2711 &serde_json::json!({ "source": "acme.place" }),
2712 &builtin_registry(),
2713 )
2714 .unwrap();
2715 assert_eq!(
2716 field.assets(&opts),
2717 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2718 );
2719 }
2720}
2721
2722#[cfg(test)]
2723mod repeater_tests {
2724 use super::*;
2725
2726 fn options() -> ResolvedOptions {
2727 RepeaterField
2728 .resolve_options(
2729 &serde_json::json!({
2730 "fields": [
2731 { "name": "path", "label": "Path", "type": "text" },
2732 { "name": "allow", "label": "Allow", "type": "switch" },
2733 ]
2734 }),
2735 &builtin_registry(),
2736 )
2737 .unwrap()
2738 }
2739
2740 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2741 pairs
2742 .iter()
2743 .map(|(k, v)| (k.to_string(), v.to_string()))
2744 .collect()
2745 }
2746
2747 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2748 let field = SubmittedField::new("rules", data);
2749 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2750 Some(AttrValue::Json(value)) => value,
2751 other => panic!("expected a JSON array, got {other:?}"),
2752 }
2753 }
2754
2755 #[test]
2759 fn a_row_is_typed_by_its_sub_field_types() {
2760 let rows = stored(
2761 &submitted(&[
2762 ("rules[0][path]", "/private"),
2763 ("rules[1][path]", "/public"),
2764 ("rules[1][allow]", "on"),
2765 ]),
2766 &options(),
2767 );
2768
2769 assert_eq!(rows[0]["path"], "/private");
2770 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2771 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2772 }
2773
2774 #[test]
2777 fn rows_are_ordered_by_index_not_submission_order() {
2778 let rows = stored(
2779 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2780 &options(),
2781 );
2782 assert_eq!(rows.as_array().unwrap().len(), 2);
2783 assert_eq!(rows[0]["path"], "/first");
2784 assert_eq!(rows[1]["path"], "/last");
2785 }
2786
2787 #[test]
2788 fn a_row_left_blank_is_dropped() {
2789 let rows = stored(
2790 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2791 &options(),
2792 );
2793 assert_eq!(rows.as_array().unwrap().len(), 1);
2794 }
2795
2796 #[test]
2797 fn nothing_submitted_stores_an_empty_list() {
2798 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2799 }
2800
2801 #[test]
2802 fn a_repeater_refuses_to_hold_a_repeater() {
2803 let err = RepeaterField
2804 .resolve_options(
2805 &serde_json::json!({
2806 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2807 }),
2808 &builtin_registry(),
2809 )
2810 .err()
2811 .expect("a nested repeater is refused");
2812 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2813 }
2814
2815 #[test]
2816 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2817 let err = RepeaterField
2818 .resolve_options(
2819 &serde_json::json!({
2820 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2821 }),
2822 &builtin_registry(),
2823 )
2824 .err()
2825 .expect("an unregistered type is refused");
2826 assert!(err.0.contains("acme.nope"), "{}", err.0);
2827 }
2828
2829 #[test]
2830 fn row_counts_are_bounded_when_asked() {
2831 let opts = RepeaterField
2832 .resolve_options(
2833 &serde_json::json!({
2834 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2835 "min_items": 1,
2836 "max_items": 2
2837 }),
2838 &builtin_registry(),
2839 )
2840 .unwrap();
2841
2842 let empty = HashMap::new();
2843 let too_few = SubmittedField::new("rules", &empty);
2844 assert!(RepeaterField
2845 .to_attr(&too_few, &opts, Mode::Create)
2846 .is_err());
2847
2848 let three = submitted(&[
2849 ("rules[0][path]", "/a"),
2850 ("rules[1][path]", "/b"),
2851 ("rules[2][path]", "/c"),
2852 ]);
2853 let field = SubmittedField::new("rules", &three);
2854 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2855 }
2856
2857 #[test]
2858 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2859 let value = FieldValue::Json(serde_json::json!([
2860 { "path": "/private", "allow": false },
2861 ]));
2862 let cx = FieldCx {
2863 name: "rules",
2864 id: "rules",
2865 label: "Rules",
2866 value: &value,
2867 required: false,
2868 max_length: None,
2869 opts: &options(),
2870 base: "/admin",
2871 i18n: &Translator::new("en"),
2872 };
2873 let html = RepeaterField
2874 .render_default(&RepeaterField.view_model(&cx))
2875 .into_string();
2876
2877 assert!(html.contains(r#"name="rules[0][path]""#));
2878 assert!(html.contains(r#"value="/private""#));
2879 assert!(html.contains("lat-repeater__blank"));
2881 assert!(html.contains(r#"name="rules[__index__][path]""#));
2882 }
2883
2884 fn list_options() -> ResolvedOptions {
2886 RepeaterField
2887 .resolve_options(
2888 &serde_json::json!({
2889 "fields": [
2890 { "name": "agent", "label": "Agent", "type": "text" },
2891 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2892 ],
2893 "display": "list",
2894 "summary_field": "agent",
2895 }),
2896 &builtin_registry(),
2897 )
2898 .unwrap()
2899 }
2900
2901 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2902 let cx = FieldCx {
2903 name: "rules",
2904 id: "rules",
2905 label: "Rules",
2906 value,
2907 required: false,
2908 max_length: None,
2909 opts,
2910 base: "/admin",
2911 i18n: &Translator::new("en"),
2912 };
2913 RepeaterField
2914 .render_default(&RepeaterField.view_model(&cx))
2915 .into_string()
2916 }
2917
2918 #[test]
2919 fn a_list_repeater_collapses_its_rows_and_names_them() {
2920 let value = FieldValue::Json(serde_json::json!([
2921 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2922 ]));
2923 let html = render_with(&list_options(), &value);
2924 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2925 assert!(html.contains("<details"), "a row collapses");
2926 assert!(
2927 html.contains("Googlebot"),
2928 "the naming field titles the row"
2929 );
2930 assert!(html.contains(r#"name="rules[0][disallow]""#));
2932 }
2933
2934 #[test]
2935 fn a_row_with_no_name_yet_reads_as_untitled() {
2936 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2937 let html = render_with(&list_options(), &value);
2938 assert!(html.contains("lat-repeater__untitled"));
2939 }
2940
2941 #[test]
2942 fn the_default_layout_stays_inline() {
2943 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2945 let html = render_with(&options(), &value);
2946 assert!(!html.contains("lat-repeater--list"));
2947 assert!(!html.contains("<details"));
2948 }
2949
2950 #[test]
2951 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2952 assert_eq!(
2954 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2955 "/private"
2956 );
2957 assert_eq!(summary_text(&serde_json::json!("")), "");
2958 }
2959
2960 #[test]
2967 fn a_contributed_field_type_names_itself_and_converts() {
2968 struct Money;
2969 impl FieldType for Money {
2970 fn view_key(&self) -> &'static str {
2971 "acme.money"
2972 }
2973 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2974 scalar_vm("acme.money", cx)
2975 }
2976 fn render_default(&self, _vm: &FieldVm) -> Markup {
2977 Markup::empty()
2978 }
2979 fn to_attr(
2980 &self,
2981 field: &SubmittedField<'_>,
2982 _opts: &ResolvedOptions,
2983 _mode: Mode,
2984 ) -> Result<Option<AttrValue>, String> {
2985 let raw = field.value().unwrap_or("0");
2987 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2988 Ok(Some(AttrValue::Int(minor as i64)))
2989 }
2990 }
2991
2992 let reg = FieldTypeReg::new(Arc::new(Money));
2993 assert_eq!(reg.name(), "acme.money");
2996
2997 let mut registry = builtin_registry();
2998 let name = reg.name().to_string();
2999 assert!(
3000 registry.insert(name.clone(), reg.field_type).is_none(),
3001 "a dotted key does not collide with a built-in"
3002 );
3003
3004 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
3005 let submitted = SubmittedField::new("price", &data);
3006 let converted = registry[&name]
3007 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
3008 .unwrap();
3009 assert!(
3010 matches!(converted, Some(AttrValue::Int(1250))),
3011 "{converted:?}"
3012 );
3013 }
3014
3015 #[test]
3016 fn a_sub_field_label_is_localized_like_any_other() {
3017 use laterite_core::i18n::CatalogStore;
3018 use std::sync::Arc;
3019
3020 let po = concat!(
3021 "msgid \"\"\n",
3022 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
3023 "\n",
3024 "msgid \"Path\"\n",
3025 "msgstr \"Path-kn\"\n",
3026 );
3027 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
3028 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
3029
3030 let value = FieldValue::Text(String::new());
3031 let cx = FieldCx {
3032 name: "rules",
3033 id: "rules",
3034 label: "Rules",
3035 value: &value,
3036 required: false,
3037 max_length: None,
3038 opts: &options(),
3039 base: "/admin",
3040 i18n: &i18n,
3041 };
3042 let html = RepeaterField
3043 .render_default(&RepeaterField.view_model(&cx))
3044 .into_string();
3045
3046 assert!(
3047 html.contains("Path-kn"),
3048 "sub-label was not localized: {html}"
3049 );
3050 assert!(
3051 !html.contains(">Path<"),
3052 "the source string still leaked: {html}"
3053 );
3054 }
3055}
3056
3057#[cfg(test)]
3058mod save_contract_tests {
3059 use super::*;
3060
3061 fn one(raw: Option<&str>) -> HashMap<String, String> {
3063 raw.into_iter()
3064 .map(|v| ("f".to_string(), v.to_string()))
3065 .collect()
3066 }
3067
3068 fn none() -> ResolvedOptions {
3069 ResolvedOptions::none()
3070 }
3071
3072 #[test]
3073 fn a_switch_stores_a_bool_and_absence_means_off() {
3074 let f = SwitchField;
3075 assert_eq!(
3078 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3079 .unwrap(),
3080 Some(AttrValue::Bool(false))
3081 );
3082 assert_eq!(
3083 f.to_attr(
3084 &SubmittedField::new("f", &one(Some("on"))),
3085 &none(),
3086 Mode::Create
3087 )
3088 .unwrap(),
3089 Some(AttrValue::Bool(true))
3090 );
3091 }
3092
3093 #[test]
3094 fn a_switch_reads_back_whatever_the_backend_gave() {
3095 let f = SwitchField;
3096 for on in ["1", "true", "t", "on"] {
3098 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3099 }
3100 for off in ["0", "false", "f", ""] {
3101 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3102 }
3103 assert!(f.to_control(None, &none()).is_empty());
3104 }
3105
3106 #[test]
3107 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3108 let f = DateField;
3109 assert_eq!(
3111 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3112 "2026-09-09"
3113 );
3114 assert_eq!(
3115 f.to_control(Some("2026-09-09 12:30:45"), &none()),
3116 "2026-09-09"
3117 );
3118 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3119 assert_eq!(f.to_control(None, &none()), "");
3120 }
3121
3122 #[test]
3123 fn a_cleared_date_stores_null_not_an_empty_string() {
3124 let f = DateField;
3125 assert_eq!(
3126 f.to_attr(
3127 &SubmittedField::new("f", &one(Some(""))),
3128 &none(),
3129 Mode::Update
3130 )
3131 .unwrap(),
3132 Some(AttrValue::Null)
3133 );
3134 assert_eq!(
3135 f.to_attr(
3136 &SubmittedField::new("f", &one(Some("2026-09-09"))),
3137 &none(),
3138 Mode::Create
3139 )
3140 .unwrap(),
3141 Some(AttrValue::Text("2026-09-09".into()))
3142 );
3143 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3144 }
3145
3146 #[test]
3147 fn a_password_hashes_and_never_echoes() {
3148 let f = PasswordField;
3149 let stored = f
3150 .to_attr(
3151 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3152 &none(),
3153 Mode::Create,
3154 )
3155 .unwrap()
3156 .unwrap();
3157 let hash = stored.as_str().unwrap();
3158 assert!(
3159 hash.starts_with("$argon2"),
3160 "stored as a hash, not plaintext"
3161 );
3162 assert_ne!(hash, "hunter2hunter2");
3163 assert_eq!(f.to_control(Some(hash), &none()), "");
3165 }
3166
3167 #[test]
3168 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3169 let f = PasswordField;
3170 assert_eq!(
3172 f.to_attr(
3173 &SubmittedField::new("f", &one(Some(""))),
3174 &none(),
3175 Mode::Update
3176 )
3177 .unwrap(),
3178 None
3179 );
3180 assert_eq!(
3181 f.to_attr(
3182 &SubmittedField::new("f", &one(Some(" "))),
3183 &none(),
3184 Mode::Update
3185 )
3186 .unwrap(),
3187 None
3188 );
3189 assert_eq!(
3190 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3191 .unwrap(),
3192 None
3193 );
3194 }
3195
3196 #[test]
3197 fn a_plain_field_stores_and_shows_the_text_unchanged() {
3198 let f = TextareaField;
3199 assert_eq!(
3200 f.to_attr(
3201 &SubmittedField::new("f", &one(Some("hello"))),
3202 &none(),
3203 Mode::Create
3204 )
3205 .unwrap(),
3206 Some(AttrValue::Text("hello".into()))
3207 );
3208 assert_eq!(
3210 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3211 .unwrap(),
3212 None
3213 );
3214 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3215 }
3216}