1use std::any::Any;
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use askama::Template;
16use laterite_core::validation::{Mode, Rule};
17use laterite_core::{AttrValue, Translator};
18use serde::{Deserialize, Serialize};
19
20use crate::html::Markup;
21use crate::picker::PickerRegistry;
22
23#[derive(Debug, Clone, Serialize)]
26#[serde(untagged)]
27pub enum FieldValue {
28 Null,
29 Text(String),
30 Json(serde_json::Value),
31}
32
33impl FieldValue {
34 pub fn as_text(&self) -> &str {
36 match self {
37 FieldValue::Text(s) => s,
38 _ => "",
39 }
40 }
41}
42
43#[derive(Default)]
47pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
48
49impl ResolvedOptions {
50 pub fn none() -> Self {
52 Self(None)
53 }
54 pub fn new<T: Any + Send + Sync>(value: T) -> Self {
56 Self(Some(Arc::new(value)))
57 }
58 pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
60 self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Chrome {
68 Wrapped,
69 Bare,
70}
71
72#[non_exhaustive]
74pub struct FieldCx<'a> {
75 pub name: &'a str,
77 pub id: &'a str,
79 pub label: &'a str,
80 pub value: &'a FieldValue,
81 pub required: bool,
83 pub 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 {
595 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
596 _ => Vec::new(),
597 };
598 let summary_index = opts
601 .and_then(|o| {
602 o.summary_field
603 .as_ref()
604 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
605 })
606 .unwrap_or(0);
607 let summaries = stored
608 .iter()
609 .map(|row| {
610 opts.and_then(|o| o.rows.get(summary_index))
611 .and_then(|sub| row.get(&sub.field.name))
612 .map(summary_text)
613 .unwrap_or_default()
614 })
615 .collect();
616 let data = RepeaterData {
617 rows: opts
618 .map(|o| {
619 stored
620 .iter()
621 .enumerate()
622 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
623 .collect()
624 })
625 .unwrap_or_default(),
626 blank: opts
627 .map(|o| render_row(o, cx, Self::BLANK, None))
628 .unwrap_or_default(),
629 list: opts.is_some_and(RepeaterOptions::list),
630 summary_index,
631 summaries,
632 reorder: opts.is_none_or(|o| o.reorder),
633 duplicate: opts.is_none_or(|o| o.duplicate),
634 text: RepeaterText {
635 add: cx.i18n.t(&laterite_core::t!("Add")),
636 remove: cx.i18n.t(&laterite_core::t!("Remove")),
637 duplicate: cx.i18n.t(&laterite_core::t!("Duplicate")),
638 up: cx.i18n.t(&laterite_core::t!("Move up")),
639 down: cx.i18n.t(&laterite_core::t!("Move down")),
640 untitled: cx.i18n.t(&laterite_core::t!("Untitled")),
641 },
642 };
643
644 FieldVm {
645 view_key: self.view_key().to_string(),
646 name: cx.name.to_string(),
647 id: cx.id.to_string(),
648 label: cx.label.to_string(),
649 required: cx.required,
650 value: cx.value.clone(),
651 data: serde_json::to_value(data).unwrap_or_default(),
652 }
653 }
654
655 fn render_default(&self, vm: &FieldVm) -> Markup {
656 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
657 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
658 }
659
660 fn to_attr(
661 &self,
662 field: &SubmittedField<'_>,
663 opts: &ResolvedOptions,
664 mode: Mode,
665 ) -> Result<Option<AttrValue>, String> {
666 let Some(options) = opts.get::<RepeaterOptions>() else {
667 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
668 };
669
670 let mut indices: Vec<usize> = field
673 .nested()
674 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
675 .collect();
676 indices.sort_unstable();
677 indices.dedup();
678
679 let mut rows = Vec::new();
680 for index in indices {
681 let mut object = serde_json::Map::new();
682 for sub in &options.rows {
683 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
684 let scoped = SubmittedField::new(&key, field.all());
685 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
686 object.insert(sub.field.name.clone(), attr_to_json(value));
687 }
688 }
689 let row = serde_json::Value::Object(object);
690 if !is_blank_row(&row) {
692 rows.push(row);
693 }
694 }
695
696 if rows.len() < options.min_items {
697 return Err(format!("at least {} required", options.min_items));
698 }
699 if let Some(max) = options.max_items {
700 if rows.len() > max {
701 return Err(format!("at most {max} allowed"));
702 }
703 }
704 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
705 }
706
707 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
708 stored.unwrap_or("[]").to_string()
709 }
710}
711
712fn summary_text(value: &serde_json::Value) -> String {
716 let raw = match value {
717 serde_json::Value::String(s) => s.clone(),
718 serde_json::Value::Null => String::new(),
719 other => other.to_string(),
720 };
721 raw.lines().next().unwrap_or_default().trim().to_string()
722}
723
724fn render_row(
725 options: &RepeaterOptions,
726 cx: &FieldCx<'_>,
727 index: &str,
728 row: Option<&serde_json::Value>,
729) -> Vec<RepeaterCell> {
730 options
731 .rows
732 .iter()
733 .map(|sub| {
734 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
735 let stored = row.and_then(|r| r.get(&sub.field.name));
736 let value = match stored {
737 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
738 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
739 Some(other) => FieldValue::Text(
740 sub.field_type
741 .to_control(Some(&other.to_string()), &sub.opts),
742 ),
743 };
744 let sub_cx = FieldCx {
745 name: &name,
746 id: &name,
747 label: cx.label,
748 value: &value,
749 required: false,
750 max_length: crate::form::max_length(&sub.field.rules),
751 opts: &sub.opts,
752 base: cx.base,
753 i18n: cx.i18n,
754 };
755 let vm = sub.field_type.view_model(&sub_cx);
756 RepeaterCell {
757 label: cx.i18n.t(&sub.field.label),
758 control: sub.field_type.render_default(&vm).into_string(),
759 help: sub
760 .field
761 .help
762 .as_ref()
763 .map(|h| cx.i18n.t(h))
764 .unwrap_or_default(),
765 }
766 })
767 .collect()
768}
769
770fn attr_to_json(value: AttrValue) -> serde_json::Value {
772 match value {
773 AttrValue::Null => serde_json::Value::Null,
774 AttrValue::Bool(b) => serde_json::Value::Bool(b),
775 AttrValue::Int(i) => serde_json::Value::from(i),
776 AttrValue::Float(f) => serde_json::Value::from(f),
777 AttrValue::Json(j) => j,
778 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
779 }
780}
781
782fn is_blank_row(row: &serde_json::Value) -> bool {
784 row.as_object().is_none_or(|object| {
785 object.values().all(|v| match v {
786 serde_json::Value::String(s) => s.trim().is_empty(),
787 serde_json::Value::Bool(b) => !b,
788 serde_json::Value::Array(items) => items.is_empty(),
789 serde_json::Value::Null => true,
790 _ => false,
791 })
792 })
793}
794
795#[derive(Serialize, Deserialize)]
797struct RepeaterCell {
798 label: String,
799 control: String,
800 help: String,
804}
805
806#[derive(Serialize, Deserialize, Default)]
809struct RepeaterData {
810 rows: Vec<Vec<RepeaterCell>>,
811 blank: Vec<RepeaterCell>,
812 list: bool,
814 summary_index: usize,
816 summaries: Vec<String>,
819 #[serde(default = "on")]
821 reorder: bool,
822 #[serde(default = "on")]
824 duplicate: bool,
825 #[serde(default)]
827 text: RepeaterText,
828}
829
830#[derive(Debug, Clone, Default, Serialize, Deserialize)]
832struct RepeaterText {
833 add: String,
834 remove: String,
835 duplicate: String,
836 up: String,
837 down: String,
838 untitled: String,
839}
840
841#[derive(Template)]
842#[template(path = "fields/repeater.html")]
843struct RepeaterTmpl {
844 data: RepeaterData,
845}
846
847pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
850 let inputs = Arc::new(builtin_input_registry());
851 vec![
852 Arc::new(TextField::new(inputs)),
853 Arc::new(TextareaField),
854 Arc::new(RepeaterField),
855 Arc::new(SelectField),
856 Arc::new(ChecklistField),
857 Arc::new(SwitchField),
858 Arc::new(DateField),
859 Arc::new(PasswordField),
860 Arc::new(RadioField),
861 ]
862}
863
864pub(crate) fn builtin_registry() -> FieldRegistry {
866 builtin_field_types()
867 .into_iter()
868 .map(|ft| (ft.view_key().to_string(), ft))
869 .collect()
870}
871
872#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
874#[serde(rename_all = "snake_case")]
875pub enum Placement {
876 Leading,
877 Trailing,
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct Adornment {
885 pub placement: Placement,
886 pub button: bool,
888 pub label: String,
890 pub widget: Option<String>,
893}
894
895pub trait InputType: Send + Sync + 'static {
901 fn key(&self) -> &'static str;
903 fn html_type(&self) -> &'static str {
905 "text"
906 }
907 fn resolve_options(
910 &self,
911 _raw: &serde_json::Value,
912 _types: &FieldRegistry,
913 ) -> Result<ResolvedOptions, OptionsError> {
914 Ok(ResolvedOptions::none())
915 }
916 fn option_keys(&self) -> Vec<&'static str> {
919 Vec::new()
920 }
921 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
923 Vec::new()
924 }
925 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
928 BTreeMap::new()
929 }
930 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
932 Vec::new()
933 }
934 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
937 Vec::new()
938 }
939}
940
941pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
943
944struct TextInput;
945impl InputType for TextInput {
946 fn key(&self) -> &'static str {
947 "text"
948 }
949}
950
951struct EmailInput;
952impl InputType for EmailInput {
953 fn key(&self) -> &'static str {
954 "email"
955 }
956 fn html_type(&self) -> &'static str {
957 "email"
958 }
959 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
960 vec![Rule::Email]
961 }
962}
963
964struct TelInput;
965impl InputType for TelInput {
966 fn key(&self) -> &'static str {
967 "tel"
968 }
969 fn html_type(&self) -> &'static str {
970 "tel"
971 }
972}
973
974#[derive(Default, Deserialize)]
976struct NumberOptions {
977 #[serde(default)]
978 min: Option<f64>,
979 #[serde(default)]
980 max: Option<f64>,
981 #[serde(default)]
982 step: Option<f64>,
983}
984
985struct NumberInput;
986impl InputType for NumberInput {
987 fn key(&self) -> &'static str {
988 "number"
989 }
990 fn html_type(&self) -> &'static str {
991 "number"
992 }
993 fn option_keys(&self) -> Vec<&'static str> {
994 vec!["min", "max", "step"]
995 }
996 fn resolve_options(
997 &self,
998 raw: &serde_json::Value,
999 _types: &FieldRegistry,
1000 ) -> Result<ResolvedOptions, OptionsError> {
1001 let opts: NumberOptions = if raw.is_null() {
1002 NumberOptions::default()
1003 } else {
1004 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1005 };
1006 Ok(ResolvedOptions::new(opts))
1007 }
1008 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1009 vec![Rule::Numeric]
1010 }
1011 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
1012 let mut attrs = BTreeMap::new();
1013 if let Some(o) = opts.get::<NumberOptions>() {
1014 if let Some(min) = o.min {
1015 attrs.insert("min".to_string(), min.to_string());
1016 }
1017 if let Some(max) = o.max {
1018 attrs.insert("max".to_string(), max.to_string());
1019 }
1020 if let Some(step) = o.step {
1021 attrs.insert("step".to_string(), step.to_string());
1022 }
1023 }
1024 attrs
1025 }
1026}
1027
1028#[derive(Deserialize)]
1030struct UrlOptions {
1031 #[serde(default = "default_true")]
1032 copy: bool,
1033}
1034
1035impl Default for UrlOptions {
1036 fn default() -> Self {
1037 Self { copy: true }
1038 }
1039}
1040
1041fn default_true() -> bool {
1042 true
1043}
1044
1045struct UrlInput;
1046impl InputType for UrlInput {
1047 fn key(&self) -> &'static str {
1048 "url"
1049 }
1050 fn html_type(&self) -> &'static str {
1051 "url"
1052 }
1053 fn option_keys(&self) -> Vec<&'static str> {
1054 vec!["copy"]
1055 }
1056 fn resolve_options(
1057 &self,
1058 raw: &serde_json::Value,
1059 _types: &FieldRegistry,
1060 ) -> Result<ResolvedOptions, OptionsError> {
1061 let opts: UrlOptions = if raw.is_null() {
1062 UrlOptions::default()
1063 } else {
1064 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1065 };
1066 Ok(ResolvedOptions::new(opts))
1067 }
1068 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1069 vec![Rule::Url]
1070 }
1071 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
1072 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
1073 vec![Adornment {
1074 placement: Placement::Trailing,
1075 button: true,
1076 label: "Copy".to_string(),
1077 widget: Some("copy".to_string()),
1078 }]
1079 } else {
1080 Vec::new()
1081 }
1082 }
1083}
1084
1085pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1086 vec![
1087 Arc::new(TextInput),
1088 Arc::new(EmailInput),
1089 Arc::new(TelInput),
1090 Arc::new(NumberInput),
1091 Arc::new(UrlInput),
1092 ]
1093}
1094
1095pub(crate) fn builtin_input_registry() -> InputRegistry {
1097 builtin_input_types()
1098 .into_iter()
1099 .map(|i| (i.key().to_string(), i))
1100 .collect()
1101}
1102
1103const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1106
1107fn validate_contributions(
1112 attrs: &BTreeMap<String, String>,
1113 adornments: &[Adornment],
1114) -> Result<(), OptionsError> {
1115 for name in attrs.keys() {
1116 if !is_name(name, false) {
1117 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1118 }
1119 if RESERVED_ATTRS.contains(&name.as_str()) {
1120 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1121 }
1122 }
1123 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1124 if !is_name(widget, true) {
1125 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1126 }
1127 }
1128 Ok(())
1129}
1130
1131pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1133 let mut chars = s.chars();
1134 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1135 && chars.all(|c| {
1136 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1137 })
1138}
1139
1140#[derive(Deserialize)]
1142struct TextOptions {
1143 #[serde(default = "default_input")]
1144 input: String,
1145 #[serde(default)]
1147 placeholder: Option<String>,
1148 #[serde(default)]
1150 counter: crate::checklist::Offer,
1151}
1152
1153fn default_input() -> String {
1154 "text".to_string()
1155}
1156
1157struct TextResolved {
1160 html_type: &'static str,
1161 attrs: BTreeMap<String, String>,
1162 adornments: Vec<Adornment>,
1163 rules: Vec<Rule>,
1164 assets: Vec<&'static str>,
1165 counter: crate::checklist::Offer,
1166}
1167
1168fn length_attrs(
1171 attrs: &mut BTreeMap<String, String>,
1172 max_length: Option<usize>,
1173 counter: crate::checklist::Offer,
1174) {
1175 use crate::checklist::Offer;
1176 let Some(max) = max_length else {
1177 return;
1178 };
1179 attrs.insert("maxlength".to_string(), max.to_string());
1180 match counter {
1181 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1182 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1183 Offer::Never => None,
1184 };
1185}
1186
1187#[derive(Default, Serialize, Deserialize)]
1190struct TextData {
1191 input_type: String,
1192 #[serde(default)]
1193 attrs: BTreeMap<String, String>,
1194 #[serde(default)]
1195 adornments: Vec<Adornment>,
1196}
1197
1198#[derive(Template)]
1199#[template(path = "fields/text.html")]
1200struct TextTmpl<'a> {
1201 name: &'a str,
1202 id: &'a str,
1203 value: &'a str,
1204 required: bool,
1205 input_type: &'a str,
1206 attrs: &'a BTreeMap<String, String>,
1207 leading: &'a [Adornment],
1208 trailing: &'a [Adornment],
1209}
1210
1211pub(crate) struct TextField {
1215 inputs: Arc<InputRegistry>,
1216}
1217
1218impl TextField {
1219 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1220 Self { inputs }
1221 }
1222}
1223
1224impl FieldType for TextField {
1225 fn view_key(&self) -> &'static str {
1226 "text"
1227 }
1228 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1229 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1230 let input = self.inputs.get(input)?;
1233 let mut all = vec![
1234 "input".to_string(),
1235 "placeholder".to_string(),
1236 "counter".to_string(),
1237 ];
1238 all.extend(input.option_keys().into_iter().map(str::to_string));
1239 Some(all)
1240 }
1241 fn resolve_options(
1242 &self,
1243 raw: &serde_json::Value,
1244 _types: &FieldRegistry,
1245 ) -> Result<ResolvedOptions, OptionsError> {
1246 let opts: TextOptions = if raw.is_null() {
1247 TextOptions {
1248 input: default_input(),
1249 placeholder: None,
1250 counter: Default::default(),
1251 }
1252 } else {
1253 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1254 };
1255 let input = self
1256 .inputs
1257 .get(&opts.input)
1258 .cloned()
1259 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1260 let input_opts = input.resolve_options(raw, _types)?;
1263 let mut attrs = input.attributes(&input_opts);
1264 let adornments = input.adornments(&input_opts);
1265 validate_contributions(&attrs, &adornments)?;
1266 if let Some(placeholder) = opts.placeholder {
1267 attrs.insert("placeholder".to_string(), placeholder);
1268 }
1269 Ok(ResolvedOptions::new(TextResolved {
1270 html_type: input.html_type(),
1271 rules: input.rules(&input_opts),
1272 assets: input.assets(&input_opts),
1273 attrs,
1274 adornments,
1275 counter: opts.counter,
1276 }))
1277 }
1278 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1279 opts.get::<TextResolved>()
1280 .map(|r| r.rules.clone())
1281 .unwrap_or_default()
1282 }
1283 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1284 opts.get::<TextResolved>()
1285 .map(|r| r.assets.clone())
1286 .unwrap_or_default()
1287 }
1288 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1289 let mut data = match cx.opts.get::<TextResolved>() {
1290 Some(r) => TextData {
1291 input_type: r.html_type.to_string(),
1292 attrs: r.attrs.clone(),
1293 adornments: r.adornments.clone(),
1294 },
1295 None => TextData {
1296 input_type: "text".to_string(),
1297 ..TextData::default()
1298 },
1299 };
1300 if matches!(
1302 data.input_type.as_str(),
1303 "text" | "email" | "tel" | "url" | "search"
1304 ) {
1305 let counter = cx
1306 .opts
1307 .get::<TextResolved>()
1308 .map(|r| r.counter)
1309 .unwrap_or_default();
1310 length_attrs(&mut data.attrs, cx.max_length, counter);
1311 }
1312 FieldVm {
1313 view_key: "text".to_string(),
1314 name: cx.name.to_string(),
1315 id: cx.id.to_string(),
1316 label: cx.label.to_string(),
1317 required: cx.required,
1318 value: cx.value.clone(),
1319 data: serde_json::to_value(data).unwrap_or_default(),
1320 }
1321 }
1322 fn render_default(&self, vm: &FieldVm) -> Markup {
1323 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1324 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1325 .adornments
1326 .into_iter()
1327 .partition(|a| matches!(a.placement, Placement::Leading));
1328 Markup::from_template(&TextTmpl {
1329 name: &vm.name,
1330 id: &vm.id,
1331 value: vm.value.as_text(),
1332 required: vm.required,
1333 input_type: &data.input_type,
1334 attrs: &data.attrs,
1335 leading: &leading,
1336 trailing: &trailing,
1337 })
1338 .unwrap_or_default()
1339 }
1340}
1341
1342#[derive(Template)]
1343#[template(path = "fields/textarea.html")]
1344struct TextareaTmpl<'a> {
1345 name: &'a str,
1346 id: &'a str,
1347 value: &'a str,
1348 required: bool,
1349 rows: Option<u16>,
1350 placeholder: Option<&'a str>,
1351 grow: bool,
1352 attrs: &'a BTreeMap<String, String>,
1353}
1354
1355fn yes() -> bool {
1356 true
1357}
1358
1359#[derive(Debug, Clone, Serialize, Deserialize)]
1362struct TextareaOptions {
1363 #[serde(default)]
1364 rows: Option<u16>,
1365 #[serde(default)]
1366 placeholder: Option<String>,
1367 #[serde(default = "yes")]
1368 grow: bool,
1369 #[serde(default)]
1370 counter: crate::checklist::Offer,
1371}
1372
1373impl Default for TextareaOptions {
1374 fn default() -> Self {
1375 Self {
1376 rows: None,
1377 placeholder: None,
1378 grow: true,
1379 counter: Default::default(),
1380 }
1381 }
1382}
1383
1384#[derive(Debug, Clone, Serialize, Deserialize)]
1386struct TextareaData {
1387 #[serde(default)]
1388 rows: Option<u16>,
1389 #[serde(default)]
1390 placeholder: Option<String>,
1391 #[serde(default = "yes")]
1392 grow: bool,
1393 #[serde(default)]
1395 attrs: BTreeMap<String, String>,
1396}
1397
1398impl Default for TextareaData {
1399 fn default() -> Self {
1400 Self {
1401 rows: None,
1402 placeholder: None,
1403 grow: true,
1404 attrs: BTreeMap::new(),
1405 }
1406 }
1407}
1408
1409pub(crate) struct TextareaField;
1411
1412impl FieldType for TextareaField {
1413 fn view_key(&self) -> &'static str {
1414 "textarea"
1415 }
1416 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1417 keys(&["rows", "placeholder", "grow", "counter"])
1418 }
1419 fn resolve_options(
1420 &self,
1421 raw: &serde_json::Value,
1422 _types: &FieldRegistry,
1423 ) -> Result<ResolvedOptions, OptionsError> {
1424 let opts: TextareaOptions = if raw.is_null() {
1425 TextareaOptions::default()
1426 } else {
1427 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1428 };
1429 Ok(ResolvedOptions::new(opts))
1430 }
1431 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1432 let mut vm = scalar_vm("textarea", cx);
1433 let opts = cx
1434 .opts
1435 .get::<TextareaOptions>()
1436 .cloned()
1437 .unwrap_or_default();
1438 let mut data = TextareaData {
1439 rows: opts.rows,
1440 placeholder: opts.placeholder,
1441 grow: opts.grow,
1442 attrs: BTreeMap::new(),
1443 };
1444 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
1445 vm.data = serde_json::to_value(data).unwrap_or_default();
1446 vm
1447 }
1448 fn render_default(&self, vm: &FieldVm) -> Markup {
1449 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1450 Markup::from_template(&TextareaTmpl {
1451 name: &vm.name,
1452 id: &vm.id,
1453 value: vm.value.as_text(),
1454 required: vm.required,
1455 rows: data.rows,
1456 placeholder: data.placeholder.as_deref(),
1457 grow: data.grow,
1458 attrs: &data.attrs,
1459 })
1460 .unwrap_or_default()
1461 }
1462}
1463
1464pub const SELECT_SEARCH_PAST: usize = 10;
1466
1467#[derive(Debug, Default, Deserialize)]
1470struct SelectOptions {
1471 #[serde(default)]
1472 options: Vec<SelectOption>,
1473 #[serde(default)]
1474 search: crate::checklist::Offer,
1475}
1476
1477impl SelectOptions {
1478 fn searchable(&self) -> bool {
1479 use crate::checklist::Offer;
1480 match self.search {
1481 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
1482 Offer::Always => true,
1483 Offer::Never => false,
1484 }
1485 }
1486}
1487
1488#[derive(Debug, Default, Serialize, Deserialize)]
1490struct SelectData {
1491 options: Vec<OptionView>,
1492 #[serde(default)]
1493 search: bool,
1494}
1495
1496#[derive(Debug, Deserialize)]
1497struct SelectOption {
1498 value: String,
1499 #[serde(default)]
1501 label: Option<String>,
1502}
1503
1504#[derive(Debug, Serialize, Deserialize)]
1507struct OptionView {
1508 value: String,
1509 label: String,
1510 selected: bool,
1511}
1512
1513#[derive(Template)]
1514#[template(path = "fields/select.html")]
1515struct SelectTmpl<'a> {
1516 name: &'a str,
1517 id: &'a str,
1518 required: bool,
1519 options: &'a [OptionView],
1520 search: bool,
1521}
1522
1523pub(crate) struct SelectField;
1525
1526impl FieldType for SelectField {
1527 fn view_key(&self) -> &'static str {
1528 "select"
1529 }
1530 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1531 keys(&["options", "search"])
1532 }
1533 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1536 match opts.get::<SelectOptions>() {
1537 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
1538 _ => Vec::new(),
1539 }
1540 }
1541 fn resolve_options(
1542 &self,
1543 raw: &serde_json::Value,
1544 _types: &FieldRegistry,
1545 ) -> Result<ResolvedOptions, OptionsError> {
1546 let opts: SelectOptions =
1547 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1548 Ok(ResolvedOptions::new(opts))
1549 }
1550 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1551 let current = cx.value.as_text();
1552 let opts = cx.opts.get::<SelectOptions>();
1553 let data = SelectData {
1554 options: opts
1555 .map(|o| {
1556 o.options
1557 .iter()
1558 .map(|so| OptionView {
1559 value: so.value.clone(),
1560 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1561 selected: so.value == current,
1562 })
1563 .collect()
1564 })
1565 .unwrap_or_default(),
1566 search: opts.is_some_and(SelectOptions::searchable),
1567 };
1568 FieldVm {
1569 view_key: "select".to_string(),
1570 name: cx.name.to_string(),
1571 id: cx.id.to_string(),
1572 label: cx.label.to_string(),
1573 required: cx.required,
1574 value: cx.value.clone(),
1575 data: serde_json::to_value(&data).unwrap_or_default(),
1576 }
1577 }
1578 fn render_default(&self, vm: &FieldVm) -> Markup {
1579 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1580 Markup::from_template(&SelectTmpl {
1581 name: &vm.name,
1582 id: &vm.id,
1583 required: vm.required,
1584 options: &data.options,
1585 search: data.search,
1586 })
1587 .unwrap_or_default()
1588 }
1589}
1590
1591#[derive(Debug, Default, Deserialize)]
1593struct ChecklistOptions {
1594 #[serde(default)]
1595 options: Vec<ChecklistEntry>,
1596 #[serde(default)]
1597 select_all: crate::checklist::Offer,
1598 #[serde(default)]
1599 search: crate::checklist::Offer,
1600 #[serde(default)]
1601 expand: crate::checklist::Expand,
1602}
1603
1604#[derive(Debug, Deserialize)]
1606#[serde(untagged)]
1607enum ChecklistEntry {
1608 Group {
1609 group: String,
1610 #[serde(default)]
1611 options: Vec<ChecklistEntry>,
1612 },
1613 Choice {
1614 value: String,
1615 #[serde(default)]
1617 label: Option<String>,
1618 #[serde(default)]
1620 note: Option<String>,
1621 },
1622}
1623
1624impl ChecklistOptions {
1625 fn values(&self) -> Vec<&str> {
1627 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1628 for entry in entries {
1629 match entry {
1630 ChecklistEntry::Choice { value, .. } => out.push(value),
1631 ChecklistEntry::Group { options, .. } => walk(options, out),
1632 }
1633 }
1634 }
1635 let mut out = Vec::new();
1636 walk(&self.options, &mut out);
1637 out
1638 }
1639
1640 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1643 self.values()
1644 .into_iter()
1645 .filter(|value| submitted.contains(value))
1646 .collect()
1647 }
1648}
1649
1650fn checklist_values(text: &str) -> Vec<String> {
1652 serde_json::from_str(text).unwrap_or_default()
1653}
1654
1655pub(crate) struct ChecklistField;
1660
1661impl ChecklistField {
1662 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1663 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1664 match opts.get::<ChecklistOptions>() {
1665 Some(options) => options
1666 .ticked(&submitted)
1667 .into_iter()
1668 .map(str::to_string)
1669 .collect(),
1670 None => Vec::new(),
1671 }
1672 }
1673}
1674
1675impl FieldType for ChecklistField {
1676 fn view_key(&self) -> &'static str {
1677 "checklist"
1678 }
1679 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1680 keys(&["options", "select_all", "search", "expand"])
1681 }
1682 fn resolve_options(
1683 &self,
1684 raw: &serde_json::Value,
1685 _types: &FieldRegistry,
1686 ) -> Result<ResolvedOptions, OptionsError> {
1687 let opts: ChecklistOptions = if raw.is_null() {
1688 ChecklistOptions::default()
1689 } else {
1690 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1691 };
1692 let mut seen = std::collections::HashSet::new();
1693 for value in opts.values() {
1694 if !seen.insert(value) {
1695 return Err(OptionsError(format!("option `{value}` is listed twice")));
1696 }
1697 }
1698 Ok(ResolvedOptions::new(opts))
1699 }
1700
1701 fn to_attr(
1702 &self,
1703 field: &SubmittedField<'_>,
1704 opts: &ResolvedOptions,
1705 _mode: Mode,
1706 ) -> Result<Option<AttrValue>, String> {
1707 let values = Self::gathered(field, opts)
1710 .into_iter()
1711 .map(serde_json::Value::String)
1712 .collect();
1713 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1714 }
1715
1716 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1717 stored.unwrap_or("[]").to_string()
1718 }
1719
1720 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1721 let values = Self::gathered(field, opts);
1722 if values.is_empty() {
1723 return None;
1724 }
1725 serde_json::to_string(&values).ok()
1726 }
1727
1728 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1729 use crate::checklist::{Checklist, Choice, Entry, Group};
1730
1731 let ticked: Vec<String> = match cx.value {
1732 FieldValue::Json(serde_json::Value::Array(values)) => values
1733 .iter()
1734 .filter_map(|v| v.as_str().map(str::to_string))
1735 .collect(),
1736 FieldValue::Text(text) => checklist_values(text),
1737 _ => Vec::new(),
1738 };
1739
1740 fn build(
1741 entries: &[ChecklistEntry],
1742 name: &str,
1743 ticked: &[String],
1744 position: &mut usize,
1745 ) -> Vec<Entry> {
1746 entries
1747 .iter()
1748 .map(|entry| match entry {
1749 ChecklistEntry::Choice { value, label, note } => {
1750 let mut choice = Choice::new(
1751 format!("{name}[{position}]"),
1752 value.clone(),
1753 label.clone().unwrap_or_else(|| value.clone()),
1754 )
1755 .checked(ticked.contains(value));
1756 if let Some(note) = note {
1757 choice = choice.note(note.clone());
1758 }
1759 *position += 1;
1760 Entry::from(choice)
1761 }
1762 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1763 group.clone(),
1764 build(options, name, ticked, position),
1765 )),
1766 })
1767 .collect()
1768 }
1769
1770 let mut list = Checklist::new(cx.label).id(cx.id);
1771 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1772 let mut position = 0;
1773 for entry in build(&options.options, cx.name, &ticked, &mut position) {
1774 list = list.entry(entry);
1775 }
1776 list = list
1777 .select_all(options.select_all)
1778 .search(options.search)
1779 .expand(options.expand);
1780 }
1781 FieldVm {
1782 view_key: "checklist".to_string(),
1783 name: cx.name.to_string(),
1784 id: cx.id.to_string(),
1785 label: cx.label.to_string(),
1786 required: cx.required,
1787 value: cx.value.clone(),
1788 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1789 }
1790 }
1791
1792 fn render_default(&self, vm: &FieldVm) -> Markup {
1793 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1794 .map(|view| view.render_markup())
1795 .unwrap_or_default()
1796 }
1797}
1798
1799#[derive(Template)]
1800#[template(path = "fields/switch.html")]
1801struct SwitchTmpl<'a> {
1802 name: &'a str,
1803 id: &'a str,
1804 on: bool,
1805}
1806
1807pub(crate) struct SwitchField;
1813
1814fn stored_is_on(stored: Option<&str>) -> bool {
1817 matches!(
1818 stored.map(str::trim).unwrap_or_default(),
1819 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1820 )
1821}
1822
1823impl FieldType for SwitchField {
1824 fn view_key(&self) -> &'static str {
1825 "switch"
1826 }
1827 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1828 keys(&[])
1829 }
1830
1831 fn to_attr(
1832 &self,
1833 field: &SubmittedField<'_>,
1834 _opts: &ResolvedOptions,
1835 _mode: Mode,
1836 ) -> Result<Option<AttrValue>, String> {
1837 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1840 }
1841
1842 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1843 if stored_is_on(stored) {
1844 "1".to_string()
1845 } else {
1846 String::new()
1847 }
1848 }
1849
1850 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1851 scalar_vm("switch", cx)
1852 }
1853
1854 fn render_default(&self, vm: &FieldVm) -> Markup {
1855 Markup::from_template(&SwitchTmpl {
1856 name: &vm.name,
1857 id: &vm.id,
1858 on: stored_is_on(Some(vm.value.as_text())),
1859 })
1860 .unwrap_or_default()
1861 }
1862}
1863
1864#[derive(Template)]
1865#[template(path = "fields/date.html")]
1866struct DateTmpl<'a> {
1867 name: &'a str,
1868 id: &'a str,
1869 value: &'a str,
1870 required: bool,
1871}
1872
1873pub(crate) struct DateField;
1879
1880impl FieldType for DateField {
1881 fn view_key(&self) -> &'static str {
1882 "date"
1883 }
1884 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1885 keys(&[])
1886 }
1887
1888 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1889 vec![Rule::Date]
1890 }
1891
1892 fn to_attr(
1893 &self,
1894 field: &SubmittedField<'_>,
1895 _opts: &ResolvedOptions,
1896 _mode: Mode,
1897 ) -> Result<Option<AttrValue>, String> {
1898 Ok(field.value().map(|v| match v.trim() {
1899 "" => AttrValue::Null,
1900 date => AttrValue::Text(date.to_string()),
1901 }))
1902 }
1903
1904 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1905 let stored = stored.unwrap_or_default().trim();
1906 stored
1909 .split(['T', ' '])
1910 .next()
1911 .unwrap_or_default()
1912 .to_string()
1913 }
1914
1915 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1916 scalar_vm("date", cx)
1917 }
1918
1919 fn render_default(&self, vm: &FieldVm) -> Markup {
1920 Markup::from_template(&DateTmpl {
1921 name: &vm.name,
1922 id: &vm.id,
1923 value: vm.value.as_text(),
1924 required: vm.required,
1925 })
1926 .unwrap_or_default()
1927 }
1928}
1929
1930#[derive(Template)]
1931#[template(path = "fields/password.html")]
1932struct PasswordTmpl<'a> {
1933 name: &'a str,
1934 id: &'a str,
1935 required: bool,
1936 reveal: bool,
1937 show: &'a str,
1938}
1939
1940#[derive(Debug, Clone, Serialize, Deserialize)]
1942struct PasswordOptions {
1943 #[serde(default = "yes")]
1944 reveal: bool,
1945 #[serde(default)]
1947 show: String,
1948}
1949
1950impl Default for PasswordOptions {
1951 fn default() -> Self {
1952 Self {
1953 reveal: true,
1954 show: String::new(),
1955 }
1956 }
1957}
1958
1959pub(crate) struct PasswordField;
1967
1968impl FieldType for PasswordField {
1969 fn view_key(&self) -> &'static str {
1970 "password"
1971 }
1972 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1973 keys(&["reveal"])
1974 }
1975 fn resolve_options(
1976 &self,
1977 raw: &serde_json::Value,
1978 _types: &FieldRegistry,
1979 ) -> Result<ResolvedOptions, OptionsError> {
1980 let opts: PasswordOptions = if raw.is_null() {
1981 PasswordOptions::default()
1982 } else {
1983 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1984 };
1985 Ok(ResolvedOptions::new(opts))
1986 }
1987
1988 fn to_attr(
1989 &self,
1990 field: &SubmittedField<'_>,
1991 _opts: &ResolvedOptions,
1992 _mode: Mode,
1993 ) -> Result<Option<AttrValue>, String> {
1994 match field.value().map(str::trim).unwrap_or_default() {
1995 "" => Ok(None),
1997 plain => laterite_auth::password::hash_password(plain)
1998 .map(|hash| Some(AttrValue::Text(hash)))
1999 .map_err(|e| e.to_string()),
2000 }
2001 }
2002
2003 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2006 String::new()
2007 }
2008
2009 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2010 let mut vm = scalar_vm("password", cx);
2011 let mut opts = cx
2012 .opts
2013 .get::<PasswordOptions>()
2014 .cloned()
2015 .unwrap_or_default();
2016 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2017 vm.data = serde_json::to_value(opts).unwrap_or_default();
2018 vm
2019 }
2020
2021 fn render_default(&self, vm: &FieldVm) -> Markup {
2022 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2023 Markup::from_template(&PasswordTmpl {
2024 name: &vm.name,
2025 id: &vm.id,
2026 required: vm.required,
2027 reveal: opts.reveal,
2028 show: &opts.show,
2029 })
2030 .unwrap_or_default()
2031 }
2032}
2033
2034#[derive(Template)]
2035#[template(path = "fields/radio.html")]
2036struct RadioTmpl<'a> {
2037 name: &'a str,
2038 options: &'a [OptionView],
2039 required: bool,
2040}
2041
2042pub(crate) struct RadioField;
2047
2048impl FieldType for RadioField {
2049 fn view_key(&self) -> &'static str {
2050 "radio"
2051 }
2052 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2053 keys(&["options"])
2054 }
2055
2056 fn resolve_options(
2057 &self,
2058 raw: &serde_json::Value,
2059 _types: &FieldRegistry,
2060 ) -> Result<ResolvedOptions, OptionsError> {
2061 let opts: SelectOptions =
2062 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2063 Ok(ResolvedOptions::new(opts))
2064 }
2065
2066 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2067 let current = cx.value.as_text();
2068 let views: Vec<OptionView> = cx
2069 .opts
2070 .get::<SelectOptions>()
2071 .map(|o| {
2072 o.options
2073 .iter()
2074 .map(|so| OptionView {
2075 value: so.value.clone(),
2076 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2077 selected: so.value == current,
2078 })
2079 .collect()
2080 })
2081 .unwrap_or_default();
2082 FieldVm {
2083 view_key: "radio".to_string(),
2084 name: cx.name.to_string(),
2085 id: cx.id.to_string(),
2086 label: cx.label.to_string(),
2087 required: cx.required,
2088 value: cx.value.clone(),
2089 data: serde_json::to_value(&views).unwrap_or_default(),
2090 }
2091 }
2092
2093 fn render_default(&self, vm: &FieldVm) -> Markup {
2094 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2095 Markup::from_template(&RadioTmpl {
2096 name: &vm.name,
2097 options: &options,
2098 required: vm.required,
2099 })
2100 .unwrap_or_default()
2101 }
2102}
2103
2104fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2106 FieldVm {
2107 view_key: view_key.to_string(),
2108 name: cx.name.to_string(),
2109 id: cx.id.to_string(),
2110 label: cx.label.to_string(),
2111 required: cx.required,
2112 value: cx.value.clone(),
2113 data: serde_json::Value::Null,
2114 }
2115}
2116
2117#[derive(Deserialize)]
2119struct RefOptions {
2120 source: String,
2121}
2122
2123struct RefResolved {
2125 source: String,
2126}
2127
2128#[derive(Default, Serialize, Deserialize)]
2131struct RefData {
2132 source: String,
2133 search_url: String,
2134 resolve_url: String,
2135 value: String,
2136 placeholder: String,
2137}
2138
2139#[derive(Template)]
2140#[template(path = "fields/ref_picker.html")]
2141struct RefPickerTmpl<'a> {
2142 name: &'a str,
2143 id: &'a str,
2144 value: &'a str,
2145 search_url: &'a str,
2146 resolve_url: &'a str,
2147 placeholder: &'a str,
2148}
2149
2150pub(crate) struct RefPickerField {
2155 pickers: Arc<PickerRegistry>,
2156}
2157
2158impl RefPickerField {
2159 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2160 Self { pickers }
2161 }
2162}
2163
2164impl FieldType for RefPickerField {
2165 fn view_key(&self) -> &'static str {
2166 "reference"
2167 }
2168 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2169 keys(&["source"])
2170 }
2171 fn resolve_options(
2172 &self,
2173 raw: &serde_json::Value,
2174 _types: &FieldRegistry,
2175 ) -> Result<ResolvedOptions, OptionsError> {
2176 let opts: RefOptions =
2177 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2178 if !self.pickers.contains_key(&opts.source) {
2179 return Err(OptionsError(format!(
2180 "unknown picker source `{}`",
2181 opts.source
2182 )));
2183 }
2184 Ok(ResolvedOptions::new(RefResolved {
2185 source: opts.source,
2186 }))
2187 }
2188 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2189 let source = cx
2190 .opts
2191 .get::<RefResolved>()
2192 .map(|r| r.source.as_str())
2193 .unwrap_or_default();
2194 let data = RefData {
2195 search_url: format!("{}/pickers/{}/search", cx.base, source),
2196 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2197 source: source.to_string(),
2198 value: cx.value.as_text().to_string(),
2199 placeholder: "Search…".to_string(),
2200 };
2201 FieldVm {
2202 view_key: "reference".to_string(),
2203 name: cx.name.to_string(),
2204 id: cx.id.to_string(),
2205 label: cx.label.to_string(),
2206 required: cx.required,
2207 value: cx.value.clone(),
2208 data: serde_json::to_value(data).unwrap_or_default(),
2209 }
2210 }
2211 fn render_default(&self, vm: &FieldVm) -> Markup {
2212 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2213 Markup::from_template(&RefPickerTmpl {
2214 name: &vm.name,
2215 id: &vm.id,
2216 value: vm.value.as_text(),
2217 search_url: &data.search_url,
2218 resolve_url: &data.resolve_url,
2219 placeholder: &data.placeholder,
2220 })
2221 .unwrap_or_default()
2222 }
2223 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2224 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2225 }
2226}
2227
2228#[cfg(test)]
2229mod tests {
2230 use super::*;
2231
2232 fn untranslated() -> Translator {
2235 Translator::new("en")
2236 }
2237
2238 fn cx_with<'a>(
2239 name: &'a str,
2240 value: &'a FieldValue,
2241 opts: &'a ResolvedOptions,
2242 i18n: &'a Translator,
2243 ) -> FieldCx<'a> {
2244 FieldCx {
2245 name,
2246 id: name,
2247 label: "Label",
2248 value,
2249 required: true,
2250 max_length: None,
2251 opts,
2252 base: "/admin",
2253 i18n,
2254 }
2255 }
2256
2257 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2258 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2261 }
2262
2263 fn text_field() -> TextField {
2264 TextField::new(Arc::new(builtin_input_registry()))
2265 }
2266
2267 struct BadAttrInput;
2269 impl InputType for BadAttrInput {
2270 fn key(&self) -> &'static str {
2271 "bad"
2272 }
2273 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2274 BTreeMap::from([("class".to_string(), "x".to_string())])
2275 }
2276 }
2277
2278 #[test]
2279 fn text_renders_its_value_escaped() {
2280 let opts = ResolvedOptions::none();
2281 let value = FieldValue::Text("a<b>&\"c".to_string());
2282 let markup = render_field(
2283 &text_field(),
2284 &NoOverrides,
2285 &scope(),
2286 &cx("title", &value, &opts),
2287 );
2288 let html = markup.as_str();
2289 assert!(html.contains(r#"name="title""#));
2290 assert!(html.contains("required"));
2291 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2295 assert!(
2296 html.contains("<") || html.contains("<"),
2297 "angle escaped: {html}"
2298 );
2299 assert!(
2300 html.contains("&") || html.contains("&"),
2301 "ampersand escaped: {html}"
2302 );
2303 }
2304
2305 #[test]
2306 fn textarea_uses_its_own_control() {
2307 let opts = ResolvedOptions::none();
2308 let value = FieldValue::Text("body".to_string());
2309 let markup = render_field(
2310 &TextareaField,
2311 &NoOverrides,
2312 &scope(),
2313 &cx("body", &value, &opts),
2314 );
2315 assert!(markup.as_str().contains("<textarea"));
2316 }
2317
2318 #[test]
2319 fn no_overrides_uses_the_compiled_default() {
2320 let opts = ResolvedOptions::none();
2322 let value = FieldValue::Text("x".to_string());
2323 let ft = text_field();
2324 let vm = ft.view_model(&cx("t", &value, &opts));
2325 let direct = ft.render_default(&vm);
2326 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2327 assert_eq!(direct.as_str(), routed.as_str());
2328 }
2329
2330 #[test]
2331 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2332 let field = text_field();
2333 let opts = field
2334 .resolve_options(
2335 &serde_json::json!({ "input": "email" }),
2336 &builtin_registry(),
2337 )
2338 .unwrap();
2339 assert!(matches!(
2341 field.intrinsic_rules(&opts).as_slice(),
2342 [Rule::Email]
2343 ));
2344 let value = FieldValue::Text("a@b.test".to_string());
2345 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2346 assert!(markup.as_str().contains(r#"type="email""#));
2347 }
2348
2349 #[test]
2350 fn text_default_input_is_plain_text_with_no_extra_rule() {
2351 let field = text_field();
2352 let opts = field
2353 .resolve_options(&serde_json::Value::Null, &builtin_registry())
2354 .unwrap();
2355 assert!(field.intrinsic_rules(&opts).is_empty());
2356 let value = FieldValue::Text("hi".to_string());
2357 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2358 assert!(markup.as_str().contains(r#"type="text""#));
2359 }
2360
2361 #[test]
2362 fn text_number_input_sets_the_type_and_numeric_rule() {
2363 let field = text_field();
2364 let opts = field
2365 .resolve_options(
2366 &serde_json::json!({ "input": "number" }),
2367 &builtin_registry(),
2368 )
2369 .unwrap();
2370 assert!(matches!(
2371 field.intrinsic_rules(&opts).as_slice(),
2372 [Rule::Numeric]
2373 ));
2374 let value = FieldValue::Text("42".to_string());
2375 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2376 assert!(markup.as_str().contains(r#"type="number""#));
2377 }
2378
2379 #[test]
2380 fn text_tel_input_sets_the_type_with_no_rule() {
2381 let field = text_field();
2382 let opts = field
2383 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2384 .unwrap();
2385 assert!(field.intrinsic_rules(&opts).is_empty());
2386 let value = FieldValue::Text(String::new());
2387 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2388 assert!(markup.as_str().contains(r#"type="tel""#));
2389 }
2390
2391 #[test]
2392 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2393 let field = text_field();
2394 let opts = field
2395 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2396 .unwrap();
2397 assert!(matches!(
2398 field.intrinsic_rules(&opts).as_slice(),
2399 [Rule::Url]
2400 ));
2401 let value = FieldValue::Text("https://example.com".to_string());
2402 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2403 let html = markup.as_str();
2404 assert!(html.contains(r#"type="url""#), "{html}");
2405 assert!(html.contains("lat-input-group"), "{html}");
2406 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2407 }
2408
2409 #[test]
2410 fn text_url_input_copy_false_omits_the_button() {
2411 let field = text_field();
2412 let opts = field
2413 .resolve_options(
2414 &serde_json::json!({ "input": "url", "copy": false }),
2415 &builtin_registry(),
2416 )
2417 .unwrap();
2418 let value = FieldValue::Text("https://example.com".to_string());
2419 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2420 let html = markup.as_str();
2421 assert!(html.contains(r#"type="url""#), "{html}");
2422 assert!(!html.contains("lat-input-group"), "{html}");
2424 assert!(!html.contains("data-lat-widget"), "{html}");
2425 }
2426
2427 #[test]
2428 fn text_number_input_emits_min_max_step_attributes() {
2429 let field = text_field();
2430 let opts = field
2431 .resolve_options(
2432 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2433 &builtin_registry(),
2434 )
2435 .unwrap();
2436 let value = FieldValue::Text("5".to_string());
2439 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2440 let html = markup.as_str();
2441 assert!(html.contains(r#"min="0""#), "{html}");
2442 assert!(html.contains(r#"max="10""#), "{html}");
2443 assert!(html.contains(r#"step="2""#), "{html}");
2444 }
2445
2446 #[test]
2447 fn text_input_reserved_attribute_name_aborts_resolve() {
2448 let mut inputs = builtin_input_registry();
2449 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2450 let field = TextField::new(Arc::new(inputs));
2451 let Err(e) =
2452 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2453 else {
2454 panic!("expected a reserved-attribute rejection");
2455 };
2456 assert!(e.0.contains("class"), "{e}");
2457 }
2458
2459 #[test]
2460 fn attribute_and_widget_name_rules() {
2461 assert!(is_name("min", false));
2462 assert!(is_name("data-x", false));
2463 assert!(!is_name("Min", false));
2464 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
2467 }
2468
2469 #[test]
2470 fn select_renders_options_with_the_current_value_selected() {
2471 let raw = serde_json::json!({
2472 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2473 });
2474 let opts = SelectField
2475 .resolve_options(&raw, &builtin_registry())
2476 .unwrap();
2477 let value = FieldValue::Text("closed".to_string());
2478 let markup = render_field(
2479 &SelectField,
2480 &NoOverrides,
2481 &scope(),
2482 &cx("status", &value, &opts),
2483 );
2484 let html = markup.as_str();
2485 assert!(html.contains(r#"<option value="open">Open</option>"#));
2486 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2488 }
2489
2490 #[test]
2491 fn radio_renders_the_same_options_with_the_current_one_checked() {
2492 let raw = serde_json::json!({
2493 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2494 });
2495 let opts = RadioField
2496 .resolve_options(&raw, &builtin_registry())
2497 .unwrap();
2498 let value = FieldValue::Text("closed".to_string());
2499 let markup = render_field(
2500 &RadioField,
2501 &NoOverrides,
2502 &scope(),
2503 &cx("status", &value, &opts),
2504 );
2505 let html = markup.as_str();
2506 assert!(html.contains(r#"value="open""#));
2507 assert!(
2508 html.contains(r#"value="closed" checked"#),
2509 "current is checked"
2510 );
2511 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2514 assert!(html.contains("closed</label>"));
2516 }
2517
2518 fn scope<'a>() -> OverrideScope<'a> {
2519 OverrideScope {
2520 surface: Surface::Field,
2521 view_key: "text",
2522 resource: None,
2523 field: None,
2524 }
2525 }
2526
2527 fn ref_field() -> RefPickerField {
2528 let mut pickers = crate::picker::PickerRegistry::new();
2529 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2530 pickers.insert(
2531 "acme.place".to_string(),
2532 crate::picker::PickerSourceReg::new("acme.place", source),
2533 );
2534 RefPickerField::new(Arc::new(pickers))
2535 }
2536
2537 #[test]
2538 fn reference_rejects_an_unknown_source() {
2539 let field = ref_field();
2540 let Err(e) = field.resolve_options(
2541 &serde_json::json!({ "source": "no.such" }),
2542 &builtin_registry(),
2543 ) else {
2544 panic!("expected an unknown-source rejection");
2545 };
2546 assert!(e.0.contains("no.such"), "{e}");
2547 }
2548
2549 #[test]
2550 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2551 let field = ref_field();
2552 let opts = field
2553 .resolve_options(
2554 &serde_json::json!({ "source": "acme.place" }),
2555 &builtin_registry(),
2556 )
2557 .unwrap();
2558 let value = FieldValue::Text("42".to_string());
2559 let markup = render_field(
2560 &field,
2561 &NoOverrides,
2562 &scope(),
2563 &cx("place_id", &value, &opts),
2564 );
2565 let html = markup.as_str();
2566 assert!(html.contains(r#"type="hidden""#), "{html}");
2569 assert!(html.contains(r#"name="place_id""#), "{html}");
2570 assert!(html.contains(r#"value="42""#), "{html}");
2571 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2573 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2574 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2575 }
2576
2577 #[test]
2578 fn reference_declares_its_widget_assets() {
2579 let field = ref_field();
2580 let opts = field
2581 .resolve_options(
2582 &serde_json::json!({ "source": "acme.place" }),
2583 &builtin_registry(),
2584 )
2585 .unwrap();
2586 assert_eq!(
2587 field.assets(&opts),
2588 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2589 );
2590 }
2591}
2592
2593#[cfg(test)]
2594mod repeater_tests {
2595 use super::*;
2596
2597 fn options() -> ResolvedOptions {
2598 RepeaterField
2599 .resolve_options(
2600 &serde_json::json!({
2601 "fields": [
2602 { "name": "path", "label": "Path", "type": "text" },
2603 { "name": "allow", "label": "Allow", "type": "switch" },
2604 ]
2605 }),
2606 &builtin_registry(),
2607 )
2608 .unwrap()
2609 }
2610
2611 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2612 pairs
2613 .iter()
2614 .map(|(k, v)| (k.to_string(), v.to_string()))
2615 .collect()
2616 }
2617
2618 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2619 let field = SubmittedField::new("rules", data);
2620 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2621 Some(AttrValue::Json(value)) => value,
2622 other => panic!("expected a JSON array, got {other:?}"),
2623 }
2624 }
2625
2626 #[test]
2630 fn a_row_is_typed_by_its_sub_field_types() {
2631 let rows = stored(
2632 &submitted(&[
2633 ("rules[0][path]", "/private"),
2634 ("rules[1][path]", "/public"),
2635 ("rules[1][allow]", "on"),
2636 ]),
2637 &options(),
2638 );
2639
2640 assert_eq!(rows[0]["path"], "/private");
2641 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2642 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2643 }
2644
2645 #[test]
2648 fn rows_are_ordered_by_index_not_submission_order() {
2649 let rows = stored(
2650 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2651 &options(),
2652 );
2653 assert_eq!(rows.as_array().unwrap().len(), 2);
2654 assert_eq!(rows[0]["path"], "/first");
2655 assert_eq!(rows[1]["path"], "/last");
2656 }
2657
2658 #[test]
2659 fn a_row_left_blank_is_dropped() {
2660 let rows = stored(
2661 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2662 &options(),
2663 );
2664 assert_eq!(rows.as_array().unwrap().len(), 1);
2665 }
2666
2667 #[test]
2668 fn nothing_submitted_stores_an_empty_list() {
2669 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2670 }
2671
2672 #[test]
2673 fn a_repeater_refuses_to_hold_a_repeater() {
2674 let err = RepeaterField
2675 .resolve_options(
2676 &serde_json::json!({
2677 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2678 }),
2679 &builtin_registry(),
2680 )
2681 .err()
2682 .expect("a nested repeater is refused");
2683 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2684 }
2685
2686 #[test]
2687 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2688 let err = RepeaterField
2689 .resolve_options(
2690 &serde_json::json!({
2691 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2692 }),
2693 &builtin_registry(),
2694 )
2695 .err()
2696 .expect("an unregistered type is refused");
2697 assert!(err.0.contains("acme.nope"), "{}", err.0);
2698 }
2699
2700 #[test]
2701 fn row_counts_are_bounded_when_asked() {
2702 let opts = RepeaterField
2703 .resolve_options(
2704 &serde_json::json!({
2705 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2706 "min_items": 1,
2707 "max_items": 2
2708 }),
2709 &builtin_registry(),
2710 )
2711 .unwrap();
2712
2713 let empty = HashMap::new();
2714 let too_few = SubmittedField::new("rules", &empty);
2715 assert!(RepeaterField
2716 .to_attr(&too_few, &opts, Mode::Create)
2717 .is_err());
2718
2719 let three = submitted(&[
2720 ("rules[0][path]", "/a"),
2721 ("rules[1][path]", "/b"),
2722 ("rules[2][path]", "/c"),
2723 ]);
2724 let field = SubmittedField::new("rules", &three);
2725 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2726 }
2727
2728 #[test]
2729 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2730 let value = FieldValue::Json(serde_json::json!([
2731 { "path": "/private", "allow": false },
2732 ]));
2733 let cx = FieldCx {
2734 name: "rules",
2735 id: "rules",
2736 label: "Rules",
2737 value: &value,
2738 required: false,
2739 max_length: None,
2740 opts: &options(),
2741 base: "/admin",
2742 i18n: &Translator::new("en"),
2743 };
2744 let html = RepeaterField
2745 .render_default(&RepeaterField.view_model(&cx))
2746 .into_string();
2747
2748 assert!(html.contains(r#"name="rules[0][path]""#));
2749 assert!(html.contains(r#"value="/private""#));
2750 assert!(html.contains("lat-repeater__blank"));
2752 assert!(html.contains(r#"name="rules[__index__][path]""#));
2753 }
2754
2755 fn list_options() -> ResolvedOptions {
2757 RepeaterField
2758 .resolve_options(
2759 &serde_json::json!({
2760 "fields": [
2761 { "name": "agent", "label": "Agent", "type": "text" },
2762 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2763 ],
2764 "display": "list",
2765 "summary_field": "agent",
2766 }),
2767 &builtin_registry(),
2768 )
2769 .unwrap()
2770 }
2771
2772 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2773 let cx = FieldCx {
2774 name: "rules",
2775 id: "rules",
2776 label: "Rules",
2777 value,
2778 required: false,
2779 max_length: None,
2780 opts,
2781 base: "/admin",
2782 i18n: &Translator::new("en"),
2783 };
2784 RepeaterField
2785 .render_default(&RepeaterField.view_model(&cx))
2786 .into_string()
2787 }
2788
2789 #[test]
2790 fn a_list_repeater_collapses_its_rows_and_names_them() {
2791 let value = FieldValue::Json(serde_json::json!([
2792 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2793 ]));
2794 let html = render_with(&list_options(), &value);
2795 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2796 assert!(html.contains("<details"), "a row collapses");
2797 assert!(
2798 html.contains("Googlebot"),
2799 "the naming field titles the row"
2800 );
2801 assert!(html.contains(r#"name="rules[0][disallow]""#));
2803 }
2804
2805 #[test]
2806 fn a_row_with_no_name_yet_reads_as_untitled() {
2807 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2808 let html = render_with(&list_options(), &value);
2809 assert!(html.contains("lat-repeater__untitled"));
2810 }
2811
2812 #[test]
2813 fn the_default_layout_stays_inline() {
2814 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2816 let html = render_with(&options(), &value);
2817 assert!(!html.contains("lat-repeater--list"));
2818 assert!(!html.contains("<details"));
2819 }
2820
2821 #[test]
2822 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2823 assert_eq!(
2825 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2826 "/private"
2827 );
2828 assert_eq!(summary_text(&serde_json::json!("")), "");
2829 }
2830
2831 #[test]
2838 fn a_contributed_field_type_names_itself_and_converts() {
2839 struct Money;
2840 impl FieldType for Money {
2841 fn view_key(&self) -> &'static str {
2842 "acme.money"
2843 }
2844 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2845 scalar_vm("acme.money", cx)
2846 }
2847 fn render_default(&self, _vm: &FieldVm) -> Markup {
2848 Markup::empty()
2849 }
2850 fn to_attr(
2851 &self,
2852 field: &SubmittedField<'_>,
2853 _opts: &ResolvedOptions,
2854 _mode: Mode,
2855 ) -> Result<Option<AttrValue>, String> {
2856 let raw = field.value().unwrap_or("0");
2858 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2859 Ok(Some(AttrValue::Int(minor as i64)))
2860 }
2861 }
2862
2863 let reg = FieldTypeReg::new(Arc::new(Money));
2864 assert_eq!(reg.name(), "acme.money");
2867
2868 let mut registry = builtin_registry();
2869 let name = reg.name().to_string();
2870 assert!(
2871 registry.insert(name.clone(), reg.field_type).is_none(),
2872 "a dotted key does not collide with a built-in"
2873 );
2874
2875 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2876 let submitted = SubmittedField::new("price", &data);
2877 let converted = registry[&name]
2878 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2879 .unwrap();
2880 assert!(
2881 matches!(converted, Some(AttrValue::Int(1250))),
2882 "{converted:?}"
2883 );
2884 }
2885
2886 #[test]
2887 fn a_sub_field_label_is_localized_like_any_other() {
2888 use laterite_core::i18n::CatalogStore;
2889 use std::sync::Arc;
2890
2891 let po = concat!(
2892 "msgid \"\"\n",
2893 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2894 "\n",
2895 "msgid \"Path\"\n",
2896 "msgstr \"Path-kn\"\n",
2897 );
2898 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2899 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2900
2901 let value = FieldValue::Text(String::new());
2902 let cx = FieldCx {
2903 name: "rules",
2904 id: "rules",
2905 label: "Rules",
2906 value: &value,
2907 required: false,
2908 max_length: None,
2909 opts: &options(),
2910 base: "/admin",
2911 i18n: &i18n,
2912 };
2913 let html = RepeaterField
2914 .render_default(&RepeaterField.view_model(&cx))
2915 .into_string();
2916
2917 assert!(
2918 html.contains("Path-kn"),
2919 "sub-label was not localized: {html}"
2920 );
2921 assert!(
2922 !html.contains(">Path<"),
2923 "the source string still leaked: {html}"
2924 );
2925 }
2926}
2927
2928#[cfg(test)]
2929mod save_contract_tests {
2930 use super::*;
2931
2932 fn one(raw: Option<&str>) -> HashMap<String, String> {
2934 raw.into_iter()
2935 .map(|v| ("f".to_string(), v.to_string()))
2936 .collect()
2937 }
2938
2939 fn none() -> ResolvedOptions {
2940 ResolvedOptions::none()
2941 }
2942
2943 #[test]
2944 fn a_switch_stores_a_bool_and_absence_means_off() {
2945 let f = SwitchField;
2946 assert_eq!(
2949 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2950 .unwrap(),
2951 Some(AttrValue::Bool(false))
2952 );
2953 assert_eq!(
2954 f.to_attr(
2955 &SubmittedField::new("f", &one(Some("on"))),
2956 &none(),
2957 Mode::Create
2958 )
2959 .unwrap(),
2960 Some(AttrValue::Bool(true))
2961 );
2962 }
2963
2964 #[test]
2965 fn a_switch_reads_back_whatever_the_backend_gave() {
2966 let f = SwitchField;
2967 for on in ["1", "true", "t", "on"] {
2969 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2970 }
2971 for off in ["0", "false", "f", ""] {
2972 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2973 }
2974 assert!(f.to_control(None, &none()).is_empty());
2975 }
2976
2977 #[test]
2978 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2979 let f = DateField;
2980 assert_eq!(
2982 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2983 "2026-09-09"
2984 );
2985 assert_eq!(
2986 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2987 "2026-09-09"
2988 );
2989 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2990 assert_eq!(f.to_control(None, &none()), "");
2991 }
2992
2993 #[test]
2994 fn a_cleared_date_stores_null_not_an_empty_string() {
2995 let f = DateField;
2996 assert_eq!(
2997 f.to_attr(
2998 &SubmittedField::new("f", &one(Some(""))),
2999 &none(),
3000 Mode::Update
3001 )
3002 .unwrap(),
3003 Some(AttrValue::Null)
3004 );
3005 assert_eq!(
3006 f.to_attr(
3007 &SubmittedField::new("f", &one(Some("2026-09-09"))),
3008 &none(),
3009 Mode::Create
3010 )
3011 .unwrap(),
3012 Some(AttrValue::Text("2026-09-09".into()))
3013 );
3014 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3015 }
3016
3017 #[test]
3018 fn a_password_hashes_and_never_echoes() {
3019 let f = PasswordField;
3020 let stored = f
3021 .to_attr(
3022 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3023 &none(),
3024 Mode::Create,
3025 )
3026 .unwrap()
3027 .unwrap();
3028 let hash = stored.as_str().unwrap();
3029 assert!(
3030 hash.starts_with("$argon2"),
3031 "stored as a hash, not plaintext"
3032 );
3033 assert_ne!(hash, "hunter2hunter2");
3034 assert_eq!(f.to_control(Some(hash), &none()), "");
3036 }
3037
3038 #[test]
3039 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3040 let f = PasswordField;
3041 assert_eq!(
3043 f.to_attr(
3044 &SubmittedField::new("f", &one(Some(""))),
3045 &none(),
3046 Mode::Update
3047 )
3048 .unwrap(),
3049 None
3050 );
3051 assert_eq!(
3052 f.to_attr(
3053 &SubmittedField::new("f", &one(Some(" "))),
3054 &none(),
3055 Mode::Update
3056 )
3057 .unwrap(),
3058 None
3059 );
3060 assert_eq!(
3061 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3062 .unwrap(),
3063 None
3064 );
3065 }
3066
3067 #[test]
3068 fn a_plain_field_stores_and_shows_the_text_unchanged() {
3069 let f = TextareaField;
3070 assert_eq!(
3071 f.to_attr(
3072 &SubmittedField::new("f", &one(Some("hello"))),
3073 &none(),
3074 Mode::Create
3075 )
3076 .unwrap(),
3077 Some(AttrValue::Text("hello".into()))
3078 );
3079 assert_eq!(
3081 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3082 .unwrap(),
3083 None
3084 );
3085 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3086 }
3087}