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