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(RepeaterField),
894 Arc::new(SelectField),
895 Arc::new(ChecklistField),
896 Arc::new(SwitchField),
897 Arc::new(DateField),
898 Arc::new(PasswordField),
899 Arc::new(RadioField),
900 ]
901}
902
903pub(crate) fn builtin_registry() -> FieldRegistry {
905 builtin_field_types()
906 .into_iter()
907 .map(|ft| (ft.view_key().to_string(), ft))
908 .collect()
909}
910
911#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
913#[serde(rename_all = "snake_case")]
914pub enum Placement {
915 Leading,
916 Trailing,
917}
918
919#[derive(Debug, Clone, Serialize, Deserialize)]
923pub struct Adornment {
924 pub placement: Placement,
925 pub button: bool,
927 pub label: String,
929 pub widget: Option<String>,
932}
933
934pub trait InputType: Send + Sync + 'static {
940 fn key(&self) -> &'static str;
942 fn html_type(&self) -> &'static str {
944 "text"
945 }
946 fn resolve_options(
949 &self,
950 _raw: &serde_json::Value,
951 _types: &FieldRegistry,
952 ) -> Result<ResolvedOptions, OptionsError> {
953 Ok(ResolvedOptions::none())
954 }
955 fn option_keys(&self) -> Vec<&'static str> {
958 Vec::new()
959 }
960 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
962 Vec::new()
963 }
964 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
967 BTreeMap::new()
968 }
969 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
971 Vec::new()
972 }
973 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
976 Vec::new()
977 }
978}
979
980pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
982
983struct TextInput;
984impl InputType for TextInput {
985 fn key(&self) -> &'static str {
986 "text"
987 }
988}
989
990struct EmailInput;
991impl InputType for EmailInput {
992 fn key(&self) -> &'static str {
993 "email"
994 }
995 fn html_type(&self) -> &'static str {
996 "email"
997 }
998 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
999 vec![Rule::Email]
1000 }
1001}
1002
1003struct TelInput;
1004impl InputType for TelInput {
1005 fn key(&self) -> &'static str {
1006 "tel"
1007 }
1008 fn html_type(&self) -> &'static str {
1009 "tel"
1010 }
1011}
1012
1013#[derive(Default, Deserialize)]
1015struct NumberOptions {
1016 #[serde(default)]
1017 min: Option<f64>,
1018 #[serde(default)]
1019 max: Option<f64>,
1020 #[serde(default)]
1021 step: Option<f64>,
1022}
1023
1024struct NumberInput;
1025impl InputType for NumberInput {
1026 fn key(&self) -> &'static str {
1027 "number"
1028 }
1029 fn html_type(&self) -> &'static str {
1030 "number"
1031 }
1032 fn option_keys(&self) -> Vec<&'static str> {
1033 vec!["min", "max", "step"]
1034 }
1035 fn resolve_options(
1036 &self,
1037 raw: &serde_json::Value,
1038 _types: &FieldRegistry,
1039 ) -> Result<ResolvedOptions, OptionsError> {
1040 let opts: NumberOptions = if raw.is_null() {
1041 NumberOptions::default()
1042 } else {
1043 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1044 };
1045 Ok(ResolvedOptions::new(opts))
1046 }
1047 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1048 vec![Rule::Numeric]
1049 }
1050 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
1051 let mut attrs = BTreeMap::new();
1052 if let Some(o) = opts.get::<NumberOptions>() {
1053 if let Some(min) = o.min {
1054 attrs.insert("min".to_string(), min.to_string());
1055 }
1056 if let Some(max) = o.max {
1057 attrs.insert("max".to_string(), max.to_string());
1058 }
1059 if let Some(step) = o.step {
1060 attrs.insert("step".to_string(), step.to_string());
1061 }
1062 }
1063 attrs
1064 }
1065}
1066
1067#[derive(Deserialize)]
1069struct UrlOptions {
1070 #[serde(default = "default_true")]
1071 copy: bool,
1072}
1073
1074impl Default for UrlOptions {
1075 fn default() -> Self {
1076 Self { copy: true }
1077 }
1078}
1079
1080fn default_true() -> bool {
1081 true
1082}
1083
1084struct UrlInput;
1085impl InputType for UrlInput {
1086 fn key(&self) -> &'static str {
1087 "url"
1088 }
1089 fn html_type(&self) -> &'static str {
1090 "url"
1091 }
1092 fn option_keys(&self) -> Vec<&'static str> {
1093 vec!["copy"]
1094 }
1095 fn resolve_options(
1096 &self,
1097 raw: &serde_json::Value,
1098 _types: &FieldRegistry,
1099 ) -> Result<ResolvedOptions, OptionsError> {
1100 let opts: UrlOptions = if raw.is_null() {
1101 UrlOptions::default()
1102 } else {
1103 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1104 };
1105 Ok(ResolvedOptions::new(opts))
1106 }
1107 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1108 vec![Rule::Url]
1109 }
1110 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
1111 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
1112 vec![Adornment {
1113 placement: Placement::Trailing,
1114 button: true,
1115 label: "Copy".to_string(),
1116 widget: Some("copy".to_string()),
1117 }]
1118 } else {
1119 Vec::new()
1120 }
1121 }
1122}
1123
1124pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1125 vec![
1126 Arc::new(TextInput),
1127 Arc::new(EmailInput),
1128 Arc::new(TelInput),
1129 Arc::new(NumberInput),
1130 Arc::new(UrlInput),
1131 ]
1132}
1133
1134pub(crate) fn builtin_input_registry() -> InputRegistry {
1136 builtin_input_types()
1137 .into_iter()
1138 .map(|i| (i.key().to_string(), i))
1139 .collect()
1140}
1141
1142const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1145
1146fn validate_contributions(
1151 attrs: &BTreeMap<String, String>,
1152 adornments: &[Adornment],
1153) -> Result<(), OptionsError> {
1154 for name in attrs.keys() {
1155 if !is_name(name, false) {
1156 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1157 }
1158 if RESERVED_ATTRS.contains(&name.as_str()) {
1159 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1160 }
1161 }
1162 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1163 if !is_name(widget, true) {
1164 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1165 }
1166 }
1167 Ok(())
1168}
1169
1170pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1172 let mut chars = s.chars();
1173 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1174 && chars.all(|c| {
1175 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1176 })
1177}
1178
1179#[derive(Deserialize)]
1181struct TextOptions {
1182 #[serde(default = "default_input")]
1183 input: String,
1184 #[serde(default)]
1186 placeholder: Option<String>,
1187 #[serde(default)]
1189 counter: crate::checklist::Offer,
1190}
1191
1192fn default_input() -> String {
1193 "text".to_string()
1194}
1195
1196struct TextResolved {
1199 html_type: &'static str,
1200 attrs: BTreeMap<String, String>,
1201 adornments: Vec<Adornment>,
1202 rules: Vec<Rule>,
1203 assets: Vec<&'static str>,
1204 counter: crate::checklist::Offer,
1205}
1206
1207fn length_attrs(
1210 attrs: &mut BTreeMap<String, String>,
1211 max_length: Option<usize>,
1212 counter: crate::checklist::Offer,
1213) {
1214 use crate::checklist::Offer;
1215 let Some(max) = max_length else {
1216 return;
1217 };
1218 attrs.insert("maxlength".to_string(), max.to_string());
1219 match counter {
1220 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1221 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1222 Offer::Never => None,
1223 };
1224}
1225
1226#[derive(Default, Serialize, Deserialize)]
1229struct TextData {
1230 input_type: String,
1231 #[serde(default)]
1232 attrs: BTreeMap<String, String>,
1233 #[serde(default)]
1234 adornments: Vec<Adornment>,
1235}
1236
1237#[derive(Template)]
1238#[template(path = "fields/text.html")]
1239struct TextTmpl<'a> {
1240 name: &'a str,
1241 id: &'a str,
1242 value: &'a str,
1243 required: bool,
1244 input_type: &'a str,
1245 attrs: &'a BTreeMap<String, String>,
1246 leading: &'a [Adornment],
1247 trailing: &'a [Adornment],
1248}
1249
1250pub(crate) struct TextField {
1254 inputs: Arc<InputRegistry>,
1255}
1256
1257impl TextField {
1258 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1259 Self { inputs }
1260 }
1261}
1262
1263impl FieldType for TextField {
1264 fn view_key(&self) -> &'static str {
1265 "text"
1266 }
1267 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1268 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1269 let input = self.inputs.get(input)?;
1272 let mut all = vec![
1273 "input".to_string(),
1274 "placeholder".to_string(),
1275 "counter".to_string(),
1276 ];
1277 all.extend(input.option_keys().into_iter().map(str::to_string));
1278 Some(all)
1279 }
1280 fn resolve_options(
1281 &self,
1282 raw: &serde_json::Value,
1283 _types: &FieldRegistry,
1284 ) -> Result<ResolvedOptions, OptionsError> {
1285 let opts: TextOptions = if raw.is_null() {
1286 TextOptions {
1287 input: default_input(),
1288 placeholder: None,
1289 counter: Default::default(),
1290 }
1291 } else {
1292 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1293 };
1294 let input = self
1295 .inputs
1296 .get(&opts.input)
1297 .cloned()
1298 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1299 let input_opts = input.resolve_options(raw, _types)?;
1302 let mut attrs = input.attributes(&input_opts);
1303 let adornments = input.adornments(&input_opts);
1304 validate_contributions(&attrs, &adornments)?;
1305 if let Some(placeholder) = opts.placeholder {
1306 attrs.insert("placeholder".to_string(), placeholder);
1307 }
1308 Ok(ResolvedOptions::new(TextResolved {
1309 html_type: input.html_type(),
1310 rules: input.rules(&input_opts),
1311 assets: input.assets(&input_opts),
1312 attrs,
1313 adornments,
1314 counter: opts.counter,
1315 }))
1316 }
1317 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1318 opts.get::<TextResolved>()
1319 .map(|r| r.rules.clone())
1320 .unwrap_or_default()
1321 }
1322 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1323 opts.get::<TextResolved>()
1324 .map(|r| r.assets.clone())
1325 .unwrap_or_default()
1326 }
1327 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1328 let mut data = match cx.opts.get::<TextResolved>() {
1329 Some(r) => TextData {
1330 input_type: r.html_type.to_string(),
1331 attrs: r.attrs.clone(),
1332 adornments: r.adornments.clone(),
1333 },
1334 None => TextData {
1335 input_type: "text".to_string(),
1336 ..TextData::default()
1337 },
1338 };
1339 if matches!(
1341 data.input_type.as_str(),
1342 "text" | "email" | "tel" | "url" | "search"
1343 ) {
1344 let counter = cx
1345 .opts
1346 .get::<TextResolved>()
1347 .map(|r| r.counter)
1348 .unwrap_or_default();
1349 length_attrs(&mut data.attrs, cx.max_length, counter);
1350 }
1351 FieldVm {
1352 view_key: "text".to_string(),
1353 name: cx.name.to_string(),
1354 id: cx.id.to_string(),
1355 label: cx.label.to_string(),
1356 required: cx.required,
1357 value: cx.value.clone(),
1358 data: serde_json::to_value(data).unwrap_or_default(),
1359 }
1360 }
1361 fn render_default(&self, vm: &FieldVm) -> Markup {
1362 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1363 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1364 .adornments
1365 .into_iter()
1366 .partition(|a| matches!(a.placement, Placement::Leading));
1367 Markup::from_template(&TextTmpl {
1368 name: &vm.name,
1369 id: &vm.id,
1370 value: vm.value.as_text(),
1371 required: vm.required,
1372 input_type: &data.input_type,
1373 attrs: &data.attrs,
1374 leading: &leading,
1375 trailing: &trailing,
1376 })
1377 .unwrap_or_default()
1378 }
1379}
1380
1381#[derive(Template)]
1382#[template(path = "fields/textarea.html")]
1383struct TextareaTmpl<'a> {
1384 name: &'a str,
1385 id: &'a str,
1386 value: &'a str,
1387 required: bool,
1388 rows: Option<u16>,
1389 placeholder: Option<&'a str>,
1390 grow: bool,
1391 attrs: &'a BTreeMap<String, String>,
1392}
1393
1394fn yes() -> bool {
1395 true
1396}
1397
1398#[derive(Debug, Clone, Serialize, Deserialize)]
1401struct TextareaOptions {
1402 #[serde(default)]
1403 rows: Option<u16>,
1404 #[serde(default)]
1405 placeholder: Option<String>,
1406 #[serde(default = "yes")]
1407 grow: bool,
1408 #[serde(default)]
1409 counter: crate::checklist::Offer,
1410}
1411
1412impl Default for TextareaOptions {
1413 fn default() -> Self {
1414 Self {
1415 rows: None,
1416 placeholder: None,
1417 grow: true,
1418 counter: Default::default(),
1419 }
1420 }
1421}
1422
1423#[derive(Debug, Clone, Serialize, Deserialize)]
1425struct TextareaData {
1426 #[serde(default)]
1427 rows: Option<u16>,
1428 #[serde(default)]
1429 placeholder: Option<String>,
1430 #[serde(default = "yes")]
1431 grow: bool,
1432 #[serde(default)]
1434 attrs: BTreeMap<String, String>,
1435}
1436
1437impl Default for TextareaData {
1438 fn default() -> Self {
1439 Self {
1440 rows: None,
1441 placeholder: None,
1442 grow: true,
1443 attrs: BTreeMap::new(),
1444 }
1445 }
1446}
1447
1448pub(crate) struct TextareaField;
1450
1451impl FieldType for TextareaField {
1452 fn view_key(&self) -> &'static str {
1453 "textarea"
1454 }
1455 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1456 keys(&["rows", "placeholder", "grow", "counter"])
1457 }
1458 fn resolve_options(
1459 &self,
1460 raw: &serde_json::Value,
1461 _types: &FieldRegistry,
1462 ) -> Result<ResolvedOptions, OptionsError> {
1463 let opts: TextareaOptions = if raw.is_null() {
1464 TextareaOptions::default()
1465 } else {
1466 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1467 };
1468 Ok(ResolvedOptions::new(opts))
1469 }
1470 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1471 let mut vm = scalar_vm("textarea", cx);
1472 let opts = cx
1473 .opts
1474 .get::<TextareaOptions>()
1475 .cloned()
1476 .unwrap_or_default();
1477 let mut data = TextareaData {
1478 rows: opts.rows,
1479 placeholder: opts.placeholder,
1480 grow: opts.grow,
1481 attrs: BTreeMap::new(),
1482 };
1483 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
1484 vm.data = serde_json::to_value(data).unwrap_or_default();
1485 vm
1486 }
1487 fn render_default(&self, vm: &FieldVm) -> Markup {
1488 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1489 Markup::from_template(&TextareaTmpl {
1490 name: &vm.name,
1491 id: &vm.id,
1492 value: vm.value.as_text(),
1493 required: vm.required,
1494 rows: data.rows,
1495 placeholder: data.placeholder.as_deref(),
1496 grow: data.grow,
1497 attrs: &data.attrs,
1498 })
1499 .unwrap_or_default()
1500 }
1501}
1502
1503pub const SELECT_SEARCH_PAST: usize = 10;
1505
1506#[derive(Debug, Default, Deserialize)]
1509struct SelectOptions {
1510 #[serde(default)]
1511 options: Vec<SelectOption>,
1512 #[serde(default)]
1513 search: crate::checklist::Offer,
1514}
1515
1516impl SelectOptions {
1517 fn searchable(&self) -> bool {
1518 use crate::checklist::Offer;
1519 match self.search {
1520 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
1521 Offer::Always => true,
1522 Offer::Never => false,
1523 }
1524 }
1525}
1526
1527#[derive(Debug, Default, Serialize, Deserialize)]
1529struct SelectData {
1530 options: Vec<OptionView>,
1531 #[serde(default)]
1532 search: bool,
1533}
1534
1535#[derive(Debug, Deserialize)]
1536struct SelectOption {
1537 value: String,
1538 #[serde(default)]
1540 label: Option<String>,
1541}
1542
1543#[derive(Debug, Serialize, Deserialize)]
1546struct OptionView {
1547 value: String,
1548 label: String,
1549 selected: bool,
1550}
1551
1552#[derive(Template)]
1553#[template(path = "fields/select.html")]
1554struct SelectTmpl<'a> {
1555 name: &'a str,
1556 id: &'a str,
1557 required: bool,
1558 options: &'a [OptionView],
1559 search: bool,
1560}
1561
1562pub(crate) struct SelectField;
1564
1565impl FieldType for SelectField {
1566 fn view_key(&self) -> &'static str {
1567 "select"
1568 }
1569 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1570 keys(&["options", "search"])
1571 }
1572 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1575 match opts.get::<SelectOptions>() {
1576 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
1577 _ => Vec::new(),
1578 }
1579 }
1580 fn resolve_options(
1581 &self,
1582 raw: &serde_json::Value,
1583 _types: &FieldRegistry,
1584 ) -> Result<ResolvedOptions, OptionsError> {
1585 let opts: SelectOptions =
1586 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1587 Ok(ResolvedOptions::new(opts))
1588 }
1589 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1590 let current = cx.value.as_text();
1591 let opts = cx.opts.get::<SelectOptions>();
1592 let data = SelectData {
1593 options: opts
1594 .map(|o| {
1595 o.options
1596 .iter()
1597 .map(|so| OptionView {
1598 value: so.value.clone(),
1599 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1600 selected: so.value == current,
1601 })
1602 .collect()
1603 })
1604 .unwrap_or_default(),
1605 search: opts.is_some_and(SelectOptions::searchable),
1606 };
1607 FieldVm {
1608 view_key: "select".to_string(),
1609 name: cx.name.to_string(),
1610 id: cx.id.to_string(),
1611 label: cx.label.to_string(),
1612 required: cx.required,
1613 value: cx.value.clone(),
1614 data: serde_json::to_value(&data).unwrap_or_default(),
1615 }
1616 }
1617 fn render_default(&self, vm: &FieldVm) -> Markup {
1618 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1619 Markup::from_template(&SelectTmpl {
1620 name: &vm.name,
1621 id: &vm.id,
1622 required: vm.required,
1623 options: &data.options,
1624 search: data.search,
1625 })
1626 .unwrap_or_default()
1627 }
1628}
1629
1630#[derive(Debug, Default, Deserialize)]
1632struct ChecklistOptions {
1633 #[serde(default)]
1634 options: Vec<ChecklistEntry>,
1635 #[serde(default)]
1636 select_all: crate::checklist::Offer,
1637 #[serde(default)]
1638 search: crate::checklist::Offer,
1639 #[serde(default)]
1640 expand: crate::checklist::Expand,
1641}
1642
1643#[derive(Debug, Deserialize)]
1645#[serde(untagged)]
1646enum ChecklistEntry {
1647 Group {
1648 group: String,
1649 #[serde(default)]
1650 options: Vec<ChecklistEntry>,
1651 },
1652 Choice {
1653 value: String,
1654 #[serde(default)]
1656 label: Option<String>,
1657 #[serde(default)]
1659 note: Option<String>,
1660 },
1661}
1662
1663impl ChecklistOptions {
1664 fn values(&self) -> Vec<&str> {
1666 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1667 for entry in entries {
1668 match entry {
1669 ChecklistEntry::Choice { value, .. } => out.push(value),
1670 ChecklistEntry::Group { options, .. } => walk(options, out),
1671 }
1672 }
1673 }
1674 let mut out = Vec::new();
1675 walk(&self.options, &mut out);
1676 out
1677 }
1678
1679 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1682 self.values()
1683 .into_iter()
1684 .filter(|value| submitted.contains(value))
1685 .collect()
1686 }
1687}
1688
1689fn checklist_values(text: &str) -> Vec<String> {
1691 serde_json::from_str(text).unwrap_or_default()
1692}
1693
1694pub(crate) struct ChecklistField;
1699
1700impl ChecklistField {
1701 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1702 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1703 match opts.get::<ChecklistOptions>() {
1704 Some(options) => options
1705 .ticked(&submitted)
1706 .into_iter()
1707 .map(str::to_string)
1708 .collect(),
1709 None => Vec::new(),
1710 }
1711 }
1712}
1713
1714impl FieldType for ChecklistField {
1715 fn view_key(&self) -> &'static str {
1716 "checklist"
1717 }
1718 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1719 keys(&["options", "select_all", "search", "expand"])
1720 }
1721 fn resolve_options(
1722 &self,
1723 raw: &serde_json::Value,
1724 _types: &FieldRegistry,
1725 ) -> Result<ResolvedOptions, OptionsError> {
1726 let opts: ChecklistOptions = if raw.is_null() {
1727 ChecklistOptions::default()
1728 } else {
1729 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1730 };
1731 let mut seen = std::collections::HashSet::new();
1732 for value in opts.values() {
1733 if !seen.insert(value) {
1734 return Err(OptionsError(format!("option `{value}` is listed twice")));
1735 }
1736 }
1737 Ok(ResolvedOptions::new(opts))
1738 }
1739
1740 fn to_attr(
1741 &self,
1742 field: &SubmittedField<'_>,
1743 opts: &ResolvedOptions,
1744 _mode: Mode,
1745 ) -> Result<Option<AttrValue>, String> {
1746 let values = Self::gathered(field, opts)
1749 .into_iter()
1750 .map(serde_json::Value::String)
1751 .collect();
1752 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1753 }
1754
1755 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1756 stored.unwrap_or("[]").to_string()
1757 }
1758
1759 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1760 let values = Self::gathered(field, opts);
1761 if values.is_empty() {
1762 return None;
1763 }
1764 serde_json::to_string(&values).ok()
1765 }
1766
1767 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1768 use crate::checklist::{Checklist, Choice, Entry, Group};
1769
1770 let ticked: Vec<String> = match cx.value {
1771 FieldValue::Json(serde_json::Value::Array(values)) => values
1772 .iter()
1773 .filter_map(|v| v.as_str().map(str::to_string))
1774 .collect(),
1775 FieldValue::Text(text) => checklist_values(text),
1776 _ => Vec::new(),
1777 };
1778
1779 fn build(
1780 entries: &[ChecklistEntry],
1781 name: &str,
1782 ticked: &[String],
1783 position: &mut usize,
1784 ) -> Vec<Entry> {
1785 entries
1786 .iter()
1787 .map(|entry| match entry {
1788 ChecklistEntry::Choice { value, label, note } => {
1789 let mut choice = Choice::new(
1790 format!("{name}[{position}]"),
1791 value.clone(),
1792 label.clone().unwrap_or_else(|| value.clone()),
1793 )
1794 .checked(ticked.contains(value));
1795 if let Some(note) = note {
1796 choice = choice.note(note.clone());
1797 }
1798 *position += 1;
1799 Entry::from(choice)
1800 }
1801 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1802 group.clone(),
1803 build(options, name, ticked, position),
1804 )),
1805 })
1806 .collect()
1807 }
1808
1809 let mut list = Checklist::new(cx.label).id(cx.id);
1810 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1811 let mut position = 0;
1812 for entry in build(&options.options, cx.name, &ticked, &mut position) {
1813 list = list.entry(entry);
1814 }
1815 list = list
1816 .select_all(options.select_all)
1817 .search(options.search)
1818 .expand(options.expand);
1819 }
1820 FieldVm {
1821 view_key: "checklist".to_string(),
1822 name: cx.name.to_string(),
1823 id: cx.id.to_string(),
1824 label: cx.label.to_string(),
1825 required: cx.required,
1826 value: cx.value.clone(),
1827 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1828 }
1829 }
1830
1831 fn render_default(&self, vm: &FieldVm) -> Markup {
1832 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1833 .map(|view| view.render_markup())
1834 .unwrap_or_default()
1835 }
1836}
1837
1838#[derive(Template)]
1839#[template(path = "fields/switch.html")]
1840struct SwitchTmpl<'a> {
1841 name: &'a str,
1842 id: &'a str,
1843 on: bool,
1844}
1845
1846pub(crate) struct SwitchField;
1852
1853fn stored_is_on(stored: Option<&str>) -> bool {
1856 matches!(
1857 stored.map(str::trim).unwrap_or_default(),
1858 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1859 )
1860}
1861
1862impl FieldType for SwitchField {
1863 fn view_key(&self) -> &'static str {
1864 "switch"
1865 }
1866 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1867 keys(&[])
1868 }
1869
1870 fn to_attr(
1871 &self,
1872 field: &SubmittedField<'_>,
1873 _opts: &ResolvedOptions,
1874 _mode: Mode,
1875 ) -> Result<Option<AttrValue>, String> {
1876 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1879 }
1880
1881 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1882 if stored_is_on(stored) {
1883 "1".to_string()
1884 } else {
1885 String::new()
1886 }
1887 }
1888
1889 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1890 scalar_vm("switch", cx)
1891 }
1892
1893 fn render_default(&self, vm: &FieldVm) -> Markup {
1894 Markup::from_template(&SwitchTmpl {
1895 name: &vm.name,
1896 id: &vm.id,
1897 on: stored_is_on(Some(vm.value.as_text())),
1898 })
1899 .unwrap_or_default()
1900 }
1901}
1902
1903#[derive(Template)]
1904#[template(path = "fields/date.html")]
1905struct DateTmpl<'a> {
1906 name: &'a str,
1907 id: &'a str,
1908 value: &'a str,
1909 required: bool,
1910}
1911
1912pub(crate) struct DateField;
1918
1919impl FieldType for DateField {
1920 fn view_key(&self) -> &'static str {
1921 "date"
1922 }
1923 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1924 keys(&[])
1925 }
1926
1927 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1928 vec![Rule::Date]
1929 }
1930
1931 fn to_attr(
1932 &self,
1933 field: &SubmittedField<'_>,
1934 _opts: &ResolvedOptions,
1935 _mode: Mode,
1936 ) -> Result<Option<AttrValue>, String> {
1937 Ok(field.value().map(|v| match v.trim() {
1938 "" => AttrValue::Null,
1939 date => AttrValue::Text(date.to_string()),
1940 }))
1941 }
1942
1943 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1944 let stored = stored.unwrap_or_default().trim();
1945 stored
1948 .split(['T', ' '])
1949 .next()
1950 .unwrap_or_default()
1951 .to_string()
1952 }
1953
1954 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1955 scalar_vm("date", cx)
1956 }
1957
1958 fn render_default(&self, vm: &FieldVm) -> Markup {
1959 Markup::from_template(&DateTmpl {
1960 name: &vm.name,
1961 id: &vm.id,
1962 value: vm.value.as_text(),
1963 required: vm.required,
1964 })
1965 .unwrap_or_default()
1966 }
1967}
1968
1969#[derive(Template)]
1970#[template(path = "fields/password.html")]
1971struct PasswordTmpl<'a> {
1972 name: &'a str,
1973 id: &'a str,
1974 required: bool,
1975 reveal: bool,
1976 show: &'a str,
1977}
1978
1979#[derive(Debug, Clone, Serialize, Deserialize)]
1981struct PasswordOptions {
1982 #[serde(default = "yes")]
1983 reveal: bool,
1984 #[serde(default)]
1986 show: String,
1987}
1988
1989impl Default for PasswordOptions {
1990 fn default() -> Self {
1991 Self {
1992 reveal: true,
1993 show: String::new(),
1994 }
1995 }
1996}
1997
1998pub(crate) struct PasswordField;
2006
2007impl FieldType for PasswordField {
2008 fn view_key(&self) -> &'static str {
2009 "password"
2010 }
2011 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2012 keys(&["reveal"])
2013 }
2014 fn resolve_options(
2015 &self,
2016 raw: &serde_json::Value,
2017 _types: &FieldRegistry,
2018 ) -> Result<ResolvedOptions, OptionsError> {
2019 let opts: PasswordOptions = if raw.is_null() {
2020 PasswordOptions::default()
2021 } else {
2022 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2023 };
2024 Ok(ResolvedOptions::new(opts))
2025 }
2026
2027 fn to_attr(
2028 &self,
2029 field: &SubmittedField<'_>,
2030 _opts: &ResolvedOptions,
2031 _mode: Mode,
2032 ) -> Result<Option<AttrValue>, String> {
2033 match field.value().map(str::trim).unwrap_or_default() {
2034 "" => Ok(None),
2036 plain => laterite_auth::password::hash_password(plain)
2037 .map(|hash| Some(AttrValue::Text(hash)))
2038 .map_err(|e| e.to_string()),
2039 }
2040 }
2041
2042 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2045 String::new()
2046 }
2047
2048 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2049 let mut vm = scalar_vm("password", cx);
2050 let mut opts = cx
2051 .opts
2052 .get::<PasswordOptions>()
2053 .cloned()
2054 .unwrap_or_default();
2055 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2056 vm.data = serde_json::to_value(opts).unwrap_or_default();
2057 vm
2058 }
2059
2060 fn render_default(&self, vm: &FieldVm) -> Markup {
2061 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2062 Markup::from_template(&PasswordTmpl {
2063 name: &vm.name,
2064 id: &vm.id,
2065 required: vm.required,
2066 reveal: opts.reveal,
2067 show: &opts.show,
2068 })
2069 .unwrap_or_default()
2070 }
2071}
2072
2073#[derive(Template)]
2074#[template(path = "fields/radio.html")]
2075struct RadioTmpl<'a> {
2076 name: &'a str,
2077 options: &'a [OptionView],
2078 required: bool,
2079}
2080
2081pub(crate) struct RadioField;
2086
2087impl FieldType for RadioField {
2088 fn view_key(&self) -> &'static str {
2089 "radio"
2090 }
2091 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2092 keys(&["options"])
2093 }
2094
2095 fn resolve_options(
2096 &self,
2097 raw: &serde_json::Value,
2098 _types: &FieldRegistry,
2099 ) -> Result<ResolvedOptions, OptionsError> {
2100 let opts: SelectOptions =
2101 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2102 Ok(ResolvedOptions::new(opts))
2103 }
2104
2105 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2106 let current = cx.value.as_text();
2107 let views: Vec<OptionView> = cx
2108 .opts
2109 .get::<SelectOptions>()
2110 .map(|o| {
2111 o.options
2112 .iter()
2113 .map(|so| OptionView {
2114 value: so.value.clone(),
2115 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2116 selected: so.value == current,
2117 })
2118 .collect()
2119 })
2120 .unwrap_or_default();
2121 FieldVm {
2122 view_key: "radio".to_string(),
2123 name: cx.name.to_string(),
2124 id: cx.id.to_string(),
2125 label: cx.label.to_string(),
2126 required: cx.required,
2127 value: cx.value.clone(),
2128 data: serde_json::to_value(&views).unwrap_or_default(),
2129 }
2130 }
2131
2132 fn render_default(&self, vm: &FieldVm) -> Markup {
2133 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2134 Markup::from_template(&RadioTmpl {
2135 name: &vm.name,
2136 options: &options,
2137 required: vm.required,
2138 })
2139 .unwrap_or_default()
2140 }
2141}
2142
2143fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2145 FieldVm {
2146 view_key: view_key.to_string(),
2147 name: cx.name.to_string(),
2148 id: cx.id.to_string(),
2149 label: cx.label.to_string(),
2150 required: cx.required,
2151 value: cx.value.clone(),
2152 data: serde_json::Value::Null,
2153 }
2154}
2155
2156#[derive(Deserialize)]
2158struct RefOptions {
2159 source: String,
2160}
2161
2162struct RefResolved {
2164 source: String,
2165}
2166
2167#[derive(Default, Serialize, Deserialize)]
2170struct RefData {
2171 source: String,
2172 search_url: String,
2173 resolve_url: String,
2174 value: String,
2175 placeholder: String,
2176}
2177
2178#[derive(Template)]
2179#[template(path = "fields/ref_picker.html")]
2180struct RefPickerTmpl<'a> {
2181 name: &'a str,
2182 id: &'a str,
2183 value: &'a str,
2184 search_url: &'a str,
2185 resolve_url: &'a str,
2186 placeholder: &'a str,
2187}
2188
2189pub(crate) struct RefPickerField {
2194 pickers: Arc<PickerRegistry>,
2195}
2196
2197impl RefPickerField {
2198 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2199 Self { pickers }
2200 }
2201}
2202
2203impl FieldType for RefPickerField {
2204 fn view_key(&self) -> &'static str {
2205 "reference"
2206 }
2207 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2208 keys(&["source"])
2209 }
2210 fn resolve_options(
2211 &self,
2212 raw: &serde_json::Value,
2213 _types: &FieldRegistry,
2214 ) -> Result<ResolvedOptions, OptionsError> {
2215 let opts: RefOptions =
2216 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2217 if !self.pickers.contains_key(&opts.source) {
2218 return Err(OptionsError(format!(
2219 "unknown picker source `{}`",
2220 opts.source
2221 )));
2222 }
2223 Ok(ResolvedOptions::new(RefResolved {
2224 source: opts.source,
2225 }))
2226 }
2227 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2228 let source = cx
2229 .opts
2230 .get::<RefResolved>()
2231 .map(|r| r.source.as_str())
2232 .unwrap_or_default();
2233 let data = RefData {
2234 search_url: format!("{}/pickers/{}/search", cx.base, source),
2235 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2236 source: source.to_string(),
2237 value: cx.value.as_text().to_string(),
2238 placeholder: "Search…".to_string(),
2239 };
2240 FieldVm {
2241 view_key: "reference".to_string(),
2242 name: cx.name.to_string(),
2243 id: cx.id.to_string(),
2244 label: cx.label.to_string(),
2245 required: cx.required,
2246 value: cx.value.clone(),
2247 data: serde_json::to_value(data).unwrap_or_default(),
2248 }
2249 }
2250 fn render_default(&self, vm: &FieldVm) -> Markup {
2251 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2252 Markup::from_template(&RefPickerTmpl {
2253 name: &vm.name,
2254 id: &vm.id,
2255 value: vm.value.as_text(),
2256 search_url: &data.search_url,
2257 resolve_url: &data.resolve_url,
2258 placeholder: &data.placeholder,
2259 })
2260 .unwrap_or_default()
2261 }
2262 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2263 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2264 }
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269 use super::*;
2270
2271 fn untranslated() -> Translator {
2274 Translator::new("en")
2275 }
2276
2277 fn cx_with<'a>(
2278 name: &'a str,
2279 value: &'a FieldValue,
2280 opts: &'a ResolvedOptions,
2281 i18n: &'a Translator,
2282 ) -> FieldCx<'a> {
2283 FieldCx {
2284 name,
2285 id: name,
2286 label: "Label",
2287 value,
2288 required: true,
2289 max_length: None,
2290 opts,
2291 base: "/admin",
2292 i18n,
2293 }
2294 }
2295
2296 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2297 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2300 }
2301
2302 fn text_field() -> TextField {
2303 TextField::new(Arc::new(builtin_input_registry()))
2304 }
2305
2306 struct BadAttrInput;
2308 impl InputType for BadAttrInput {
2309 fn key(&self) -> &'static str {
2310 "bad"
2311 }
2312 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2313 BTreeMap::from([("class".to_string(), "x".to_string())])
2314 }
2315 }
2316
2317 #[test]
2318 fn text_renders_its_value_escaped() {
2319 let opts = ResolvedOptions::none();
2320 let value = FieldValue::Text("a<b>&\"c".to_string());
2321 let markup = render_field(
2322 &text_field(),
2323 &NoOverrides,
2324 &scope(),
2325 &cx("title", &value, &opts),
2326 );
2327 let html = markup.as_str();
2328 assert!(html.contains(r#"name="title""#));
2329 assert!(html.contains("required"));
2330 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2334 assert!(
2335 html.contains("<") || html.contains("<"),
2336 "angle escaped: {html}"
2337 );
2338 assert!(
2339 html.contains("&") || html.contains("&"),
2340 "ampersand escaped: {html}"
2341 );
2342 }
2343
2344 #[test]
2345 fn textarea_uses_its_own_control() {
2346 let opts = ResolvedOptions::none();
2347 let value = FieldValue::Text("body".to_string());
2348 let markup = render_field(
2349 &TextareaField,
2350 &NoOverrides,
2351 &scope(),
2352 &cx("body", &value, &opts),
2353 );
2354 assert!(markup.as_str().contains("<textarea"));
2355 }
2356
2357 #[test]
2358 fn no_overrides_uses_the_compiled_default() {
2359 let opts = ResolvedOptions::none();
2361 let value = FieldValue::Text("x".to_string());
2362 let ft = text_field();
2363 let vm = ft.view_model(&cx("t", &value, &opts));
2364 let direct = ft.render_default(&vm);
2365 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2366 assert_eq!(direct.as_str(), routed.as_str());
2367 }
2368
2369 #[test]
2370 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2371 let field = text_field();
2372 let opts = field
2373 .resolve_options(
2374 &serde_json::json!({ "input": "email" }),
2375 &builtin_registry(),
2376 )
2377 .unwrap();
2378 assert!(matches!(
2380 field.intrinsic_rules(&opts).as_slice(),
2381 [Rule::Email]
2382 ));
2383 let value = FieldValue::Text("a@b.test".to_string());
2384 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2385 assert!(markup.as_str().contains(r#"type="email""#));
2386 }
2387
2388 #[test]
2389 fn text_default_input_is_plain_text_with_no_extra_rule() {
2390 let field = text_field();
2391 let opts = field
2392 .resolve_options(&serde_json::Value::Null, &builtin_registry())
2393 .unwrap();
2394 assert!(field.intrinsic_rules(&opts).is_empty());
2395 let value = FieldValue::Text("hi".to_string());
2396 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2397 assert!(markup.as_str().contains(r#"type="text""#));
2398 }
2399
2400 #[test]
2401 fn text_number_input_sets_the_type_and_numeric_rule() {
2402 let field = text_field();
2403 let opts = field
2404 .resolve_options(
2405 &serde_json::json!({ "input": "number" }),
2406 &builtin_registry(),
2407 )
2408 .unwrap();
2409 assert!(matches!(
2410 field.intrinsic_rules(&opts).as_slice(),
2411 [Rule::Numeric]
2412 ));
2413 let value = FieldValue::Text("42".to_string());
2414 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2415 assert!(markup.as_str().contains(r#"type="number""#));
2416 }
2417
2418 #[test]
2419 fn text_tel_input_sets_the_type_with_no_rule() {
2420 let field = text_field();
2421 let opts = field
2422 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2423 .unwrap();
2424 assert!(field.intrinsic_rules(&opts).is_empty());
2425 let value = FieldValue::Text(String::new());
2426 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2427 assert!(markup.as_str().contains(r#"type="tel""#));
2428 }
2429
2430 #[test]
2431 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2432 let field = text_field();
2433 let opts = field
2434 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2435 .unwrap();
2436 assert!(matches!(
2437 field.intrinsic_rules(&opts).as_slice(),
2438 [Rule::Url]
2439 ));
2440 let value = FieldValue::Text("https://example.com".to_string());
2441 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2442 let html = markup.as_str();
2443 assert!(html.contains(r#"type="url""#), "{html}");
2444 assert!(html.contains("lat-input-group"), "{html}");
2445 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2446 }
2447
2448 #[test]
2449 fn text_url_input_copy_false_omits_the_button() {
2450 let field = text_field();
2451 let opts = field
2452 .resolve_options(
2453 &serde_json::json!({ "input": "url", "copy": false }),
2454 &builtin_registry(),
2455 )
2456 .unwrap();
2457 let value = FieldValue::Text("https://example.com".to_string());
2458 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2459 let html = markup.as_str();
2460 assert!(html.contains(r#"type="url""#), "{html}");
2461 assert!(!html.contains("lat-input-group"), "{html}");
2463 assert!(!html.contains("data-lat-widget"), "{html}");
2464 }
2465
2466 #[test]
2467 fn text_number_input_emits_min_max_step_attributes() {
2468 let field = text_field();
2469 let opts = field
2470 .resolve_options(
2471 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2472 &builtin_registry(),
2473 )
2474 .unwrap();
2475 let value = FieldValue::Text("5".to_string());
2478 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2479 let html = markup.as_str();
2480 assert!(html.contains(r#"min="0""#), "{html}");
2481 assert!(html.contains(r#"max="10""#), "{html}");
2482 assert!(html.contains(r#"step="2""#), "{html}");
2483 }
2484
2485 #[test]
2486 fn text_input_reserved_attribute_name_aborts_resolve() {
2487 let mut inputs = builtin_input_registry();
2488 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2489 let field = TextField::new(Arc::new(inputs));
2490 let Err(e) =
2491 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2492 else {
2493 panic!("expected a reserved-attribute rejection");
2494 };
2495 assert!(e.0.contains("class"), "{e}");
2496 }
2497
2498 #[test]
2499 fn attribute_and_widget_name_rules() {
2500 assert!(is_name("min", false));
2501 assert!(is_name("data-x", false));
2502 assert!(!is_name("Min", false));
2503 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
2506 }
2507
2508 #[test]
2509 fn select_renders_options_with_the_current_value_selected() {
2510 let raw = serde_json::json!({
2511 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2512 });
2513 let opts = SelectField
2514 .resolve_options(&raw, &builtin_registry())
2515 .unwrap();
2516 let value = FieldValue::Text("closed".to_string());
2517 let markup = render_field(
2518 &SelectField,
2519 &NoOverrides,
2520 &scope(),
2521 &cx("status", &value, &opts),
2522 );
2523 let html = markup.as_str();
2524 assert!(html.contains(r#"<option value="open">Open</option>"#));
2525 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2527 }
2528
2529 #[test]
2530 fn radio_renders_the_same_options_with_the_current_one_checked() {
2531 let raw = serde_json::json!({
2532 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2533 });
2534 let opts = RadioField
2535 .resolve_options(&raw, &builtin_registry())
2536 .unwrap();
2537 let value = FieldValue::Text("closed".to_string());
2538 let markup = render_field(
2539 &RadioField,
2540 &NoOverrides,
2541 &scope(),
2542 &cx("status", &value, &opts),
2543 );
2544 let html = markup.as_str();
2545 assert!(html.contains(r#"value="open""#));
2546 assert!(
2547 html.contains(r#"value="closed" checked"#),
2548 "current is checked"
2549 );
2550 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2553 assert!(html.contains("closed</label>"));
2555 }
2556
2557 fn scope<'a>() -> OverrideScope<'a> {
2558 OverrideScope {
2559 surface: Surface::Field,
2560 view_key: "text",
2561 resource: None,
2562 field: None,
2563 }
2564 }
2565
2566 fn ref_field() -> RefPickerField {
2567 let mut pickers = crate::picker::PickerRegistry::new();
2568 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2569 pickers.insert(
2570 "acme.place".to_string(),
2571 crate::picker::PickerSourceReg::new("acme.place", source),
2572 );
2573 RefPickerField::new(Arc::new(pickers))
2574 }
2575
2576 #[test]
2577 fn reference_rejects_an_unknown_source() {
2578 let field = ref_field();
2579 let Err(e) = field.resolve_options(
2580 &serde_json::json!({ "source": "no.such" }),
2581 &builtin_registry(),
2582 ) else {
2583 panic!("expected an unknown-source rejection");
2584 };
2585 assert!(e.0.contains("no.such"), "{e}");
2586 }
2587
2588 #[test]
2589 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2590 let field = ref_field();
2591 let opts = field
2592 .resolve_options(
2593 &serde_json::json!({ "source": "acme.place" }),
2594 &builtin_registry(),
2595 )
2596 .unwrap();
2597 let value = FieldValue::Text("42".to_string());
2598 let markup = render_field(
2599 &field,
2600 &NoOverrides,
2601 &scope(),
2602 &cx("place_id", &value, &opts),
2603 );
2604 let html = markup.as_str();
2605 assert!(html.contains(r#"type="hidden""#), "{html}");
2608 assert!(html.contains(r#"name="place_id""#), "{html}");
2609 assert!(html.contains(r#"value="42""#), "{html}");
2610 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2612 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2613 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2614 }
2615
2616 #[test]
2617 fn reference_declares_its_widget_assets() {
2618 let field = ref_field();
2619 let opts = field
2620 .resolve_options(
2621 &serde_json::json!({ "source": "acme.place" }),
2622 &builtin_registry(),
2623 )
2624 .unwrap();
2625 assert_eq!(
2626 field.assets(&opts),
2627 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2628 );
2629 }
2630}
2631
2632#[cfg(test)]
2633mod repeater_tests {
2634 use super::*;
2635
2636 fn options() -> ResolvedOptions {
2637 RepeaterField
2638 .resolve_options(
2639 &serde_json::json!({
2640 "fields": [
2641 { "name": "path", "label": "Path", "type": "text" },
2642 { "name": "allow", "label": "Allow", "type": "switch" },
2643 ]
2644 }),
2645 &builtin_registry(),
2646 )
2647 .unwrap()
2648 }
2649
2650 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2651 pairs
2652 .iter()
2653 .map(|(k, v)| (k.to_string(), v.to_string()))
2654 .collect()
2655 }
2656
2657 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2658 let field = SubmittedField::new("rules", data);
2659 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2660 Some(AttrValue::Json(value)) => value,
2661 other => panic!("expected a JSON array, got {other:?}"),
2662 }
2663 }
2664
2665 #[test]
2669 fn a_row_is_typed_by_its_sub_field_types() {
2670 let rows = stored(
2671 &submitted(&[
2672 ("rules[0][path]", "/private"),
2673 ("rules[1][path]", "/public"),
2674 ("rules[1][allow]", "on"),
2675 ]),
2676 &options(),
2677 );
2678
2679 assert_eq!(rows[0]["path"], "/private");
2680 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2681 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2682 }
2683
2684 #[test]
2687 fn rows_are_ordered_by_index_not_submission_order() {
2688 let rows = stored(
2689 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2690 &options(),
2691 );
2692 assert_eq!(rows.as_array().unwrap().len(), 2);
2693 assert_eq!(rows[0]["path"], "/first");
2694 assert_eq!(rows[1]["path"], "/last");
2695 }
2696
2697 #[test]
2698 fn a_row_left_blank_is_dropped() {
2699 let rows = stored(
2700 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2701 &options(),
2702 );
2703 assert_eq!(rows.as_array().unwrap().len(), 1);
2704 }
2705
2706 #[test]
2707 fn nothing_submitted_stores_an_empty_list() {
2708 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2709 }
2710
2711 #[test]
2712 fn a_repeater_refuses_to_hold_a_repeater() {
2713 let err = RepeaterField
2714 .resolve_options(
2715 &serde_json::json!({
2716 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2717 }),
2718 &builtin_registry(),
2719 )
2720 .err()
2721 .expect("a nested repeater is refused");
2722 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2723 }
2724
2725 #[test]
2726 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2727 let err = RepeaterField
2728 .resolve_options(
2729 &serde_json::json!({
2730 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2731 }),
2732 &builtin_registry(),
2733 )
2734 .err()
2735 .expect("an unregistered type is refused");
2736 assert!(err.0.contains("acme.nope"), "{}", err.0);
2737 }
2738
2739 #[test]
2740 fn row_counts_are_bounded_when_asked() {
2741 let opts = RepeaterField
2742 .resolve_options(
2743 &serde_json::json!({
2744 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2745 "min_items": 1,
2746 "max_items": 2
2747 }),
2748 &builtin_registry(),
2749 )
2750 .unwrap();
2751
2752 let empty = HashMap::new();
2753 let too_few = SubmittedField::new("rules", &empty);
2754 assert!(RepeaterField
2755 .to_attr(&too_few, &opts, Mode::Create)
2756 .is_err());
2757
2758 let three = submitted(&[
2759 ("rules[0][path]", "/a"),
2760 ("rules[1][path]", "/b"),
2761 ("rules[2][path]", "/c"),
2762 ]);
2763 let field = SubmittedField::new("rules", &three);
2764 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2765 }
2766
2767 #[test]
2768 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2769 let value = FieldValue::Json(serde_json::json!([
2770 { "path": "/private", "allow": false },
2771 ]));
2772 let cx = FieldCx {
2773 name: "rules",
2774 id: "rules",
2775 label: "Rules",
2776 value: &value,
2777 required: false,
2778 max_length: None,
2779 opts: &options(),
2780 base: "/admin",
2781 i18n: &Translator::new("en"),
2782 };
2783 let html = RepeaterField
2784 .render_default(&RepeaterField.view_model(&cx))
2785 .into_string();
2786
2787 assert!(html.contains(r#"name="rules[0][path]""#));
2788 assert!(html.contains(r#"value="/private""#));
2789 assert!(html.contains("lat-repeater__blank"));
2791 assert!(html.contains(r#"name="rules[__index__][path]""#));
2792 }
2793
2794 fn list_options() -> ResolvedOptions {
2796 RepeaterField
2797 .resolve_options(
2798 &serde_json::json!({
2799 "fields": [
2800 { "name": "agent", "label": "Agent", "type": "text" },
2801 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2802 ],
2803 "display": "list",
2804 "summary_field": "agent",
2805 }),
2806 &builtin_registry(),
2807 )
2808 .unwrap()
2809 }
2810
2811 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2812 let cx = FieldCx {
2813 name: "rules",
2814 id: "rules",
2815 label: "Rules",
2816 value,
2817 required: false,
2818 max_length: None,
2819 opts,
2820 base: "/admin",
2821 i18n: &Translator::new("en"),
2822 };
2823 RepeaterField
2824 .render_default(&RepeaterField.view_model(&cx))
2825 .into_string()
2826 }
2827
2828 #[test]
2829 fn a_list_repeater_collapses_its_rows_and_names_them() {
2830 let value = FieldValue::Json(serde_json::json!([
2831 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2832 ]));
2833 let html = render_with(&list_options(), &value);
2834 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2835 assert!(html.contains("<details"), "a row collapses");
2836 assert!(
2837 html.contains("Googlebot"),
2838 "the naming field titles the row"
2839 );
2840 assert!(html.contains(r#"name="rules[0][disallow]""#));
2842 }
2843
2844 #[test]
2845 fn a_row_with_no_name_yet_reads_as_untitled() {
2846 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2847 let html = render_with(&list_options(), &value);
2848 assert!(html.contains("lat-repeater__untitled"));
2849 }
2850
2851 #[test]
2852 fn the_default_layout_stays_inline() {
2853 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2855 let html = render_with(&options(), &value);
2856 assert!(!html.contains("lat-repeater--list"));
2857 assert!(!html.contains("<details"));
2858 }
2859
2860 #[test]
2861 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2862 assert_eq!(
2864 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2865 "/private"
2866 );
2867 assert_eq!(summary_text(&serde_json::json!("")), "");
2868 }
2869
2870 #[test]
2877 fn a_contributed_field_type_names_itself_and_converts() {
2878 struct Money;
2879 impl FieldType for Money {
2880 fn view_key(&self) -> &'static str {
2881 "acme.money"
2882 }
2883 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2884 scalar_vm("acme.money", cx)
2885 }
2886 fn render_default(&self, _vm: &FieldVm) -> Markup {
2887 Markup::empty()
2888 }
2889 fn to_attr(
2890 &self,
2891 field: &SubmittedField<'_>,
2892 _opts: &ResolvedOptions,
2893 _mode: Mode,
2894 ) -> Result<Option<AttrValue>, String> {
2895 let raw = field.value().unwrap_or("0");
2897 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2898 Ok(Some(AttrValue::Int(minor as i64)))
2899 }
2900 }
2901
2902 let reg = FieldTypeReg::new(Arc::new(Money));
2903 assert_eq!(reg.name(), "acme.money");
2906
2907 let mut registry = builtin_registry();
2908 let name = reg.name().to_string();
2909 assert!(
2910 registry.insert(name.clone(), reg.field_type).is_none(),
2911 "a dotted key does not collide with a built-in"
2912 );
2913
2914 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2915 let submitted = SubmittedField::new("price", &data);
2916 let converted = registry[&name]
2917 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2918 .unwrap();
2919 assert!(
2920 matches!(converted, Some(AttrValue::Int(1250))),
2921 "{converted:?}"
2922 );
2923 }
2924
2925 #[test]
2926 fn a_sub_field_label_is_localized_like_any_other() {
2927 use laterite_core::i18n::CatalogStore;
2928 use std::sync::Arc;
2929
2930 let po = concat!(
2931 "msgid \"\"\n",
2932 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2933 "\n",
2934 "msgid \"Path\"\n",
2935 "msgstr \"Path-kn\"\n",
2936 );
2937 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2938 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2939
2940 let value = FieldValue::Text(String::new());
2941 let cx = FieldCx {
2942 name: "rules",
2943 id: "rules",
2944 label: "Rules",
2945 value: &value,
2946 required: false,
2947 max_length: None,
2948 opts: &options(),
2949 base: "/admin",
2950 i18n: &i18n,
2951 };
2952 let html = RepeaterField
2953 .render_default(&RepeaterField.view_model(&cx))
2954 .into_string();
2955
2956 assert!(
2957 html.contains("Path-kn"),
2958 "sub-label was not localized: {html}"
2959 );
2960 assert!(
2961 !html.contains(">Path<"),
2962 "the source string still leaked: {html}"
2963 );
2964 }
2965}
2966
2967#[cfg(test)]
2968mod save_contract_tests {
2969 use super::*;
2970
2971 fn one(raw: Option<&str>) -> HashMap<String, String> {
2973 raw.into_iter()
2974 .map(|v| ("f".to_string(), v.to_string()))
2975 .collect()
2976 }
2977
2978 fn none() -> ResolvedOptions {
2979 ResolvedOptions::none()
2980 }
2981
2982 #[test]
2983 fn a_switch_stores_a_bool_and_absence_means_off() {
2984 let f = SwitchField;
2985 assert_eq!(
2988 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2989 .unwrap(),
2990 Some(AttrValue::Bool(false))
2991 );
2992 assert_eq!(
2993 f.to_attr(
2994 &SubmittedField::new("f", &one(Some("on"))),
2995 &none(),
2996 Mode::Create
2997 )
2998 .unwrap(),
2999 Some(AttrValue::Bool(true))
3000 );
3001 }
3002
3003 #[test]
3004 fn a_switch_reads_back_whatever_the_backend_gave() {
3005 let f = SwitchField;
3006 for on in ["1", "true", "t", "on"] {
3008 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3009 }
3010 for off in ["0", "false", "f", ""] {
3011 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3012 }
3013 assert!(f.to_control(None, &none()).is_empty());
3014 }
3015
3016 #[test]
3017 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3018 let f = DateField;
3019 assert_eq!(
3021 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3022 "2026-09-09"
3023 );
3024 assert_eq!(
3025 f.to_control(Some("2026-09-09 12:30:45"), &none()),
3026 "2026-09-09"
3027 );
3028 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3029 assert_eq!(f.to_control(None, &none()), "");
3030 }
3031
3032 #[test]
3033 fn a_cleared_date_stores_null_not_an_empty_string() {
3034 let f = DateField;
3035 assert_eq!(
3036 f.to_attr(
3037 &SubmittedField::new("f", &one(Some(""))),
3038 &none(),
3039 Mode::Update
3040 )
3041 .unwrap(),
3042 Some(AttrValue::Null)
3043 );
3044 assert_eq!(
3045 f.to_attr(
3046 &SubmittedField::new("f", &one(Some("2026-09-09"))),
3047 &none(),
3048 Mode::Create
3049 )
3050 .unwrap(),
3051 Some(AttrValue::Text("2026-09-09".into()))
3052 );
3053 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3054 }
3055
3056 #[test]
3057 fn a_password_hashes_and_never_echoes() {
3058 let f = PasswordField;
3059 let stored = f
3060 .to_attr(
3061 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3062 &none(),
3063 Mode::Create,
3064 )
3065 .unwrap()
3066 .unwrap();
3067 let hash = stored.as_str().unwrap();
3068 assert!(
3069 hash.starts_with("$argon2"),
3070 "stored as a hash, not plaintext"
3071 );
3072 assert_ne!(hash, "hunter2hunter2");
3073 assert_eq!(f.to_control(Some(hash), &none()), "");
3075 }
3076
3077 #[test]
3078 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3079 let f = PasswordField;
3080 assert_eq!(
3082 f.to_attr(
3083 &SubmittedField::new("f", &one(Some(""))),
3084 &none(),
3085 Mode::Update
3086 )
3087 .unwrap(),
3088 None
3089 );
3090 assert_eq!(
3091 f.to_attr(
3092 &SubmittedField::new("f", &one(Some(" "))),
3093 &none(),
3094 Mode::Update
3095 )
3096 .unwrap(),
3097 None
3098 );
3099 assert_eq!(
3100 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3101 .unwrap(),
3102 None
3103 );
3104 }
3105
3106 #[test]
3107 fn a_plain_field_stores_and_shows_the_text_unchanged() {
3108 let f = TextareaField;
3109 assert_eq!(
3110 f.to_attr(
3111 &SubmittedField::new("f", &one(Some("hello"))),
3112 &none(),
3113 Mode::Create
3114 )
3115 .unwrap(),
3116 Some(AttrValue::Text("hello".into()))
3117 );
3118 assert_eq!(
3120 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3121 .unwrap(),
3122 None
3123 );
3124 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3125 }
3126}