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: RepeaterDisplay,
366 summary_field: Option<String>,
368}
369
370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
377#[serde(rename_all = "snake_case")]
378pub enum RepeaterDisplay {
379 #[default]
382 Inline,
383 List,
386}
387
388pub(crate) struct RepeaterSub {
390 field: crate::form::FormField,
391 field_type: Arc<dyn FieldType>,
392 opts: ResolvedOptions,
393}
394
395#[derive(Deserialize)]
397struct RepeaterOptionsRaw {
398 #[serde(deserialize_with = "named_or_listed")]
400 fields: Vec<crate::form::FormField>,
401 #[serde(default)]
402 min_items: usize,
403 #[serde(default)]
404 max_items: Option<usize>,
405 #[serde(default)]
406 display: RepeaterDisplay,
407 #[serde(default)]
408 summary_field: Option<String>,
409}
410
411pub(crate) fn check_option_keys(
413 raw: &serde_json::Value,
414 accepted: &[String],
415) -> Result<(), OptionsError> {
416 let serde_json::Value::Object(entries) = raw else {
417 return Ok(());
418 };
419 for key in entries.keys() {
420 if !accepted.iter().any(|k| k == key) {
421 return Err(OptionsError(if accepted.is_empty() {
422 format!("unknown key `{key}`: this type takes no keys of its own")
423 } else {
424 format!(
425 "unknown key `{key}`, expected one of: {}",
426 accepted.join(", ")
427 )
428 }));
429 }
430 }
431 Ok(())
432}
433
434pub(crate) fn check_field_keys(
437 field: &crate::form::FormField,
438 field_type: &dyn FieldType,
439) -> Result<(), OptionsError> {
440 match field_type.option_keys(&field.options) {
441 Some(keys) => check_option_keys(&field.options, &keys),
442 None => Ok(()),
443 }
444}
445
446fn keys(names: &[&str]) -> Option<Vec<String>> {
447 Some(names.iter().map(|k| k.to_string()).collect())
448}
449
450fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
453where
454 D: serde::Deserializer<'de>,
455{
456 use crate::keyed::Keyed;
457 #[derive(Deserialize)]
458 #[serde(untagged)]
459 enum Shape {
460 Listed(Vec<crate::form::FormField>),
461 Named(serde_json::Map<String, serde_json::Value>),
462 }
463 match Shape::deserialize(de)? {
464 Shape::Listed(fields) => Ok(fields),
465 Shape::Named(entries) => entries
466 .into_iter()
467 .map(|(name, entry)| {
468 let mut field: crate::form::FormField =
469 serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
470 field.set_key(name);
471 Ok(field)
472 })
473 .collect(),
474 }
475}
476
477pub(crate) struct RepeaterField;
486
487impl RepeaterField {
488 const BLANK: &'static str = "__index__";
490}
491
492impl FieldType for RepeaterField {
493 fn view_key(&self) -> &'static str {
494 "repeater"
495 }
496
497 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
498 keys(&[
499 "fields",
500 "min_items",
501 "max_items",
502 "display",
503 "summary_field",
504 ])
505 }
506
507 fn resolve_options(
508 &self,
509 raw: &serde_json::Value,
510 types: &FieldRegistry,
511 ) -> Result<ResolvedOptions, OptionsError> {
512 let parsed: RepeaterOptionsRaw =
513 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
514 let mut rows = Vec::new();
515 for field in parsed.fields {
516 if field.field_type == "repeater" {
517 return Err(OptionsError(
518 "a repeater cannot hold a repeater".to_string(),
519 ));
520 }
521 let field_type = types
522 .get(&field.field_type)
523 .cloned()
524 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
525 check_field_keys(&field, field_type.as_ref())
526 .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
527 let opts = field_type.resolve_options(&field.options, types)?;
528 rows.push(RepeaterSub {
529 field,
530 field_type,
531 opts,
532 });
533 }
534 Ok(ResolvedOptions::new(RepeaterOptions {
535 rows,
536 min_items: parsed.min_items,
537 max_items: parsed.max_items,
538 display: parsed.display,
539 summary_field: parsed.summary_field,
540 }))
541 }
542
543 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
544 let opts = cx.opts.get::<RepeaterOptions>();
545 let stored = match cx.value {
546 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
547 _ => Vec::new(),
548 };
549 let summary_index = opts
552 .and_then(|o| {
553 o.summary_field
554 .as_ref()
555 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
556 })
557 .unwrap_or(0);
558 let summaries = stored
559 .iter()
560 .map(|row| {
561 opts.and_then(|o| o.rows.get(summary_index))
562 .and_then(|sub| row.get(&sub.field.name))
563 .map(summary_text)
564 .unwrap_or_default()
565 })
566 .collect();
567 let data = RepeaterData {
568 rows: opts
569 .map(|o| {
570 stored
571 .iter()
572 .enumerate()
573 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
574 .collect()
575 })
576 .unwrap_or_default(),
577 blank: opts
578 .map(|o| render_row(o, cx, Self::BLANK, None))
579 .unwrap_or_default(),
580 list: opts.is_some_and(|o| o.display == RepeaterDisplay::List),
581 summary_index,
582 summaries,
583 };
584
585 FieldVm {
586 view_key: self.view_key().to_string(),
587 name: cx.name.to_string(),
588 id: cx.id.to_string(),
589 label: cx.label.to_string(),
590 required: cx.required,
591 value: cx.value.clone(),
592 data: serde_json::to_value(data).unwrap_or_default(),
593 }
594 }
595
596 fn render_default(&self, vm: &FieldVm) -> Markup {
597 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
598 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
599 }
600
601 fn to_attr(
602 &self,
603 field: &SubmittedField<'_>,
604 opts: &ResolvedOptions,
605 mode: Mode,
606 ) -> Result<Option<AttrValue>, String> {
607 let Some(options) = opts.get::<RepeaterOptions>() else {
608 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
609 };
610
611 let mut indices: Vec<usize> = field
614 .nested()
615 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
616 .collect();
617 indices.sort_unstable();
618 indices.dedup();
619
620 let mut rows = Vec::new();
621 for index in indices {
622 let mut object = serde_json::Map::new();
623 for sub in &options.rows {
624 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
625 let scoped = SubmittedField::new(&key, field.all());
626 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
627 object.insert(sub.field.name.clone(), attr_to_json(value));
628 }
629 }
630 let row = serde_json::Value::Object(object);
631 if !is_blank_row(&row) {
633 rows.push(row);
634 }
635 }
636
637 if rows.len() < options.min_items {
638 return Err(format!("at least {} required", options.min_items));
639 }
640 if let Some(max) = options.max_items {
641 if rows.len() > max {
642 return Err(format!("at most {max} allowed"));
643 }
644 }
645 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
646 }
647
648 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
649 stored.unwrap_or("[]").to_string()
650 }
651}
652
653fn summary_text(value: &serde_json::Value) -> String {
657 let raw = match value {
658 serde_json::Value::String(s) => s.clone(),
659 serde_json::Value::Null => String::new(),
660 other => other.to_string(),
661 };
662 raw.lines().next().unwrap_or_default().trim().to_string()
663}
664
665fn render_row(
666 options: &RepeaterOptions,
667 cx: &FieldCx<'_>,
668 index: &str,
669 row: Option<&serde_json::Value>,
670) -> Vec<RepeaterCell> {
671 options
672 .rows
673 .iter()
674 .map(|sub| {
675 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
676 let stored = row.and_then(|r| r.get(&sub.field.name));
677 let value = match stored {
678 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
679 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
680 Some(other) => FieldValue::Text(
681 sub.field_type
682 .to_control(Some(&other.to_string()), &sub.opts),
683 ),
684 };
685 let sub_cx = FieldCx {
686 name: &name,
687 id: &name,
688 label: cx.label,
689 value: &value,
690 required: false,
691 max_length: crate::form::max_length(&sub.field.rules),
692 opts: &sub.opts,
693 base: cx.base,
694 i18n: cx.i18n,
695 };
696 let vm = sub.field_type.view_model(&sub_cx);
697 RepeaterCell {
698 label: cx.i18n.t(&sub.field.label),
699 control: sub.field_type.render_default(&vm).into_string(),
700 help: sub
701 .field
702 .help
703 .as_ref()
704 .map(|h| cx.i18n.t(h))
705 .unwrap_or_default(),
706 }
707 })
708 .collect()
709}
710
711fn attr_to_json(value: AttrValue) -> serde_json::Value {
713 match value {
714 AttrValue::Null => serde_json::Value::Null,
715 AttrValue::Bool(b) => serde_json::Value::Bool(b),
716 AttrValue::Int(i) => serde_json::Value::from(i),
717 AttrValue::Float(f) => serde_json::Value::from(f),
718 AttrValue::Json(j) => j,
719 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
720 }
721}
722
723fn is_blank_row(row: &serde_json::Value) -> bool {
725 row.as_object().is_none_or(|object| {
726 object.values().all(|v| match v {
727 serde_json::Value::String(s) => s.trim().is_empty(),
728 serde_json::Value::Bool(b) => !b,
729 serde_json::Value::Array(items) => items.is_empty(),
730 serde_json::Value::Null => true,
731 _ => false,
732 })
733 })
734}
735
736#[derive(Serialize, Deserialize)]
738struct RepeaterCell {
739 label: String,
740 control: String,
741 help: String,
745}
746
747#[derive(Serialize, Deserialize, Default)]
750struct RepeaterData {
751 rows: Vec<Vec<RepeaterCell>>,
752 blank: Vec<RepeaterCell>,
753 list: bool,
755 summary_index: usize,
757 summaries: Vec<String>,
760}
761
762#[derive(Template)]
763#[template(path = "fields/repeater.html")]
764struct RepeaterTmpl {
765 data: RepeaterData,
766}
767
768pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
771 let inputs = Arc::new(builtin_input_registry());
772 vec![
773 Arc::new(TextField::new(inputs)),
774 Arc::new(TextareaField),
775 Arc::new(RepeaterField),
776 Arc::new(SelectField),
777 Arc::new(ChecklistField),
778 Arc::new(SwitchField),
779 Arc::new(DateField),
780 Arc::new(PasswordField),
781 Arc::new(RadioField),
782 ]
783}
784
785pub(crate) fn builtin_registry() -> FieldRegistry {
787 builtin_field_types()
788 .into_iter()
789 .map(|ft| (ft.view_key().to_string(), ft))
790 .collect()
791}
792
793#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
795#[serde(rename_all = "snake_case")]
796pub enum Placement {
797 Leading,
798 Trailing,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize)]
805pub struct Adornment {
806 pub placement: Placement,
807 pub button: bool,
809 pub label: String,
811 pub widget: Option<String>,
814}
815
816pub trait InputType: Send + Sync + 'static {
822 fn key(&self) -> &'static str;
824 fn html_type(&self) -> &'static str {
826 "text"
827 }
828 fn resolve_options(
831 &self,
832 _raw: &serde_json::Value,
833 _types: &FieldRegistry,
834 ) -> Result<ResolvedOptions, OptionsError> {
835 Ok(ResolvedOptions::none())
836 }
837 fn option_keys(&self) -> Vec<&'static str> {
840 Vec::new()
841 }
842 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
844 Vec::new()
845 }
846 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
849 BTreeMap::new()
850 }
851 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
853 Vec::new()
854 }
855 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
858 Vec::new()
859 }
860}
861
862pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
864
865struct TextInput;
866impl InputType for TextInput {
867 fn key(&self) -> &'static str {
868 "text"
869 }
870}
871
872struct EmailInput;
873impl InputType for EmailInput {
874 fn key(&self) -> &'static str {
875 "email"
876 }
877 fn html_type(&self) -> &'static str {
878 "email"
879 }
880 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
881 vec![Rule::Email]
882 }
883}
884
885struct TelInput;
886impl InputType for TelInput {
887 fn key(&self) -> &'static str {
888 "tel"
889 }
890 fn html_type(&self) -> &'static str {
891 "tel"
892 }
893}
894
895#[derive(Default, Deserialize)]
897struct NumberOptions {
898 #[serde(default)]
899 min: Option<f64>,
900 #[serde(default)]
901 max: Option<f64>,
902 #[serde(default)]
903 step: Option<f64>,
904}
905
906struct NumberInput;
907impl InputType for NumberInput {
908 fn key(&self) -> &'static str {
909 "number"
910 }
911 fn html_type(&self) -> &'static str {
912 "number"
913 }
914 fn option_keys(&self) -> Vec<&'static str> {
915 vec!["min", "max", "step"]
916 }
917 fn resolve_options(
918 &self,
919 raw: &serde_json::Value,
920 _types: &FieldRegistry,
921 ) -> Result<ResolvedOptions, OptionsError> {
922 let opts: NumberOptions = if raw.is_null() {
923 NumberOptions::default()
924 } else {
925 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
926 };
927 Ok(ResolvedOptions::new(opts))
928 }
929 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
930 vec![Rule::Numeric]
931 }
932 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
933 let mut attrs = BTreeMap::new();
934 if let Some(o) = opts.get::<NumberOptions>() {
935 if let Some(min) = o.min {
936 attrs.insert("min".to_string(), min.to_string());
937 }
938 if let Some(max) = o.max {
939 attrs.insert("max".to_string(), max.to_string());
940 }
941 if let Some(step) = o.step {
942 attrs.insert("step".to_string(), step.to_string());
943 }
944 }
945 attrs
946 }
947}
948
949#[derive(Deserialize)]
951struct UrlOptions {
952 #[serde(default = "default_true")]
953 copy: bool,
954}
955
956impl Default for UrlOptions {
957 fn default() -> Self {
958 Self { copy: true }
959 }
960}
961
962fn default_true() -> bool {
963 true
964}
965
966struct UrlInput;
967impl InputType for UrlInput {
968 fn key(&self) -> &'static str {
969 "url"
970 }
971 fn html_type(&self) -> &'static str {
972 "url"
973 }
974 fn option_keys(&self) -> Vec<&'static str> {
975 vec!["copy"]
976 }
977 fn resolve_options(
978 &self,
979 raw: &serde_json::Value,
980 _types: &FieldRegistry,
981 ) -> Result<ResolvedOptions, OptionsError> {
982 let opts: UrlOptions = if raw.is_null() {
983 UrlOptions::default()
984 } else {
985 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
986 };
987 Ok(ResolvedOptions::new(opts))
988 }
989 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
990 vec![Rule::Url]
991 }
992 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
993 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
994 vec![Adornment {
995 placement: Placement::Trailing,
996 button: true,
997 label: "Copy".to_string(),
998 widget: Some("copy".to_string()),
999 }]
1000 } else {
1001 Vec::new()
1002 }
1003 }
1004}
1005
1006pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1007 vec![
1008 Arc::new(TextInput),
1009 Arc::new(EmailInput),
1010 Arc::new(TelInput),
1011 Arc::new(NumberInput),
1012 Arc::new(UrlInput),
1013 ]
1014}
1015
1016pub(crate) fn builtin_input_registry() -> InputRegistry {
1018 builtin_input_types()
1019 .into_iter()
1020 .map(|i| (i.key().to_string(), i))
1021 .collect()
1022}
1023
1024const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1027
1028fn validate_contributions(
1033 attrs: &BTreeMap<String, String>,
1034 adornments: &[Adornment],
1035) -> Result<(), OptionsError> {
1036 for name in attrs.keys() {
1037 if !is_name(name, false) {
1038 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1039 }
1040 if RESERVED_ATTRS.contains(&name.as_str()) {
1041 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1042 }
1043 }
1044 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1045 if !is_name(widget, true) {
1046 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1047 }
1048 }
1049 Ok(())
1050}
1051
1052pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1054 let mut chars = s.chars();
1055 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1056 && chars.all(|c| {
1057 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1058 })
1059}
1060
1061#[derive(Deserialize)]
1063struct TextOptions {
1064 #[serde(default = "default_input")]
1065 input: String,
1066 #[serde(default)]
1068 placeholder: Option<String>,
1069 #[serde(default)]
1071 counter: crate::checklist::Offer,
1072}
1073
1074fn default_input() -> String {
1075 "text".to_string()
1076}
1077
1078struct TextResolved {
1081 html_type: &'static str,
1082 attrs: BTreeMap<String, String>,
1083 adornments: Vec<Adornment>,
1084 rules: Vec<Rule>,
1085 assets: Vec<&'static str>,
1086 counter: crate::checklist::Offer,
1087}
1088
1089fn length_attrs(
1092 attrs: &mut BTreeMap<String, String>,
1093 max_length: Option<usize>,
1094 counter: crate::checklist::Offer,
1095) {
1096 use crate::checklist::Offer;
1097 let Some(max) = max_length else {
1098 return;
1099 };
1100 attrs.insert("maxlength".to_string(), max.to_string());
1101 match counter {
1102 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1103 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1104 Offer::Never => None,
1105 };
1106}
1107
1108#[derive(Default, Serialize, Deserialize)]
1111struct TextData {
1112 input_type: String,
1113 #[serde(default)]
1114 attrs: BTreeMap<String, String>,
1115 #[serde(default)]
1116 adornments: Vec<Adornment>,
1117}
1118
1119#[derive(Template)]
1120#[template(path = "fields/text.html")]
1121struct TextTmpl<'a> {
1122 name: &'a str,
1123 id: &'a str,
1124 value: &'a str,
1125 required: bool,
1126 input_type: &'a str,
1127 attrs: &'a BTreeMap<String, String>,
1128 leading: &'a [Adornment],
1129 trailing: &'a [Adornment],
1130}
1131
1132pub(crate) struct TextField {
1136 inputs: Arc<InputRegistry>,
1137}
1138
1139impl TextField {
1140 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1141 Self { inputs }
1142 }
1143}
1144
1145impl FieldType for TextField {
1146 fn view_key(&self) -> &'static str {
1147 "text"
1148 }
1149 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1150 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1151 let input = self.inputs.get(input)?;
1154 let mut all = vec![
1155 "input".to_string(),
1156 "placeholder".to_string(),
1157 "counter".to_string(),
1158 ];
1159 all.extend(input.option_keys().into_iter().map(str::to_string));
1160 Some(all)
1161 }
1162 fn resolve_options(
1163 &self,
1164 raw: &serde_json::Value,
1165 _types: &FieldRegistry,
1166 ) -> Result<ResolvedOptions, OptionsError> {
1167 let opts: TextOptions = if raw.is_null() {
1168 TextOptions {
1169 input: default_input(),
1170 placeholder: None,
1171 counter: Default::default(),
1172 }
1173 } else {
1174 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1175 };
1176 let input = self
1177 .inputs
1178 .get(&opts.input)
1179 .cloned()
1180 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1181 let input_opts = input.resolve_options(raw, _types)?;
1184 let mut attrs = input.attributes(&input_opts);
1185 let adornments = input.adornments(&input_opts);
1186 validate_contributions(&attrs, &adornments)?;
1187 if let Some(placeholder) = opts.placeholder {
1188 attrs.insert("placeholder".to_string(), placeholder);
1189 }
1190 Ok(ResolvedOptions::new(TextResolved {
1191 html_type: input.html_type(),
1192 rules: input.rules(&input_opts),
1193 assets: input.assets(&input_opts),
1194 attrs,
1195 adornments,
1196 counter: opts.counter,
1197 }))
1198 }
1199 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1200 opts.get::<TextResolved>()
1201 .map(|r| r.rules.clone())
1202 .unwrap_or_default()
1203 }
1204 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1205 opts.get::<TextResolved>()
1206 .map(|r| r.assets.clone())
1207 .unwrap_or_default()
1208 }
1209 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1210 let mut data = match cx.opts.get::<TextResolved>() {
1211 Some(r) => TextData {
1212 input_type: r.html_type.to_string(),
1213 attrs: r.attrs.clone(),
1214 adornments: r.adornments.clone(),
1215 },
1216 None => TextData {
1217 input_type: "text".to_string(),
1218 ..TextData::default()
1219 },
1220 };
1221 if matches!(
1223 data.input_type.as_str(),
1224 "text" | "email" | "tel" | "url" | "search"
1225 ) {
1226 let counter = cx
1227 .opts
1228 .get::<TextResolved>()
1229 .map(|r| r.counter)
1230 .unwrap_or_default();
1231 length_attrs(&mut data.attrs, cx.max_length, counter);
1232 }
1233 FieldVm {
1234 view_key: "text".to_string(),
1235 name: cx.name.to_string(),
1236 id: cx.id.to_string(),
1237 label: cx.label.to_string(),
1238 required: cx.required,
1239 value: cx.value.clone(),
1240 data: serde_json::to_value(data).unwrap_or_default(),
1241 }
1242 }
1243 fn render_default(&self, vm: &FieldVm) -> Markup {
1244 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1245 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1246 .adornments
1247 .into_iter()
1248 .partition(|a| matches!(a.placement, Placement::Leading));
1249 Markup::from_template(&TextTmpl {
1250 name: &vm.name,
1251 id: &vm.id,
1252 value: vm.value.as_text(),
1253 required: vm.required,
1254 input_type: &data.input_type,
1255 attrs: &data.attrs,
1256 leading: &leading,
1257 trailing: &trailing,
1258 })
1259 .unwrap_or_default()
1260 }
1261}
1262
1263#[derive(Template)]
1264#[template(path = "fields/textarea.html")]
1265struct TextareaTmpl<'a> {
1266 name: &'a str,
1267 id: &'a str,
1268 value: &'a str,
1269 required: bool,
1270 rows: Option<u16>,
1271 placeholder: Option<&'a str>,
1272 grow: bool,
1273 attrs: &'a BTreeMap<String, String>,
1274}
1275
1276fn yes() -> bool {
1277 true
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1283struct TextareaOptions {
1284 #[serde(default)]
1285 rows: Option<u16>,
1286 #[serde(default)]
1287 placeholder: Option<String>,
1288 #[serde(default = "yes")]
1289 grow: bool,
1290 #[serde(default)]
1291 counter: crate::checklist::Offer,
1292}
1293
1294impl Default for TextareaOptions {
1295 fn default() -> Self {
1296 Self {
1297 rows: None,
1298 placeholder: None,
1299 grow: true,
1300 counter: Default::default(),
1301 }
1302 }
1303}
1304
1305#[derive(Debug, Clone, Serialize, Deserialize)]
1307struct TextareaData {
1308 #[serde(default)]
1309 rows: Option<u16>,
1310 #[serde(default)]
1311 placeholder: Option<String>,
1312 #[serde(default = "yes")]
1313 grow: bool,
1314 #[serde(default)]
1316 attrs: BTreeMap<String, String>,
1317}
1318
1319impl Default for TextareaData {
1320 fn default() -> Self {
1321 Self {
1322 rows: None,
1323 placeholder: None,
1324 grow: true,
1325 attrs: BTreeMap::new(),
1326 }
1327 }
1328}
1329
1330pub(crate) struct TextareaField;
1332
1333impl FieldType for TextareaField {
1334 fn view_key(&self) -> &'static str {
1335 "textarea"
1336 }
1337 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1338 keys(&["rows", "placeholder", "grow", "counter"])
1339 }
1340 fn resolve_options(
1341 &self,
1342 raw: &serde_json::Value,
1343 _types: &FieldRegistry,
1344 ) -> Result<ResolvedOptions, OptionsError> {
1345 let opts: TextareaOptions = if raw.is_null() {
1346 TextareaOptions::default()
1347 } else {
1348 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1349 };
1350 Ok(ResolvedOptions::new(opts))
1351 }
1352 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1353 let mut vm = scalar_vm("textarea", cx);
1354 let opts = cx
1355 .opts
1356 .get::<TextareaOptions>()
1357 .cloned()
1358 .unwrap_or_default();
1359 let mut data = TextareaData {
1360 rows: opts.rows,
1361 placeholder: opts.placeholder,
1362 grow: opts.grow,
1363 attrs: BTreeMap::new(),
1364 };
1365 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
1366 vm.data = serde_json::to_value(data).unwrap_or_default();
1367 vm
1368 }
1369 fn render_default(&self, vm: &FieldVm) -> Markup {
1370 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1371 Markup::from_template(&TextareaTmpl {
1372 name: &vm.name,
1373 id: &vm.id,
1374 value: vm.value.as_text(),
1375 required: vm.required,
1376 rows: data.rows,
1377 placeholder: data.placeholder.as_deref(),
1378 grow: data.grow,
1379 attrs: &data.attrs,
1380 })
1381 .unwrap_or_default()
1382 }
1383}
1384
1385pub const SELECT_SEARCH_PAST: usize = 10;
1387
1388#[derive(Debug, Default, Deserialize)]
1391struct SelectOptions {
1392 #[serde(default)]
1393 options: Vec<SelectOption>,
1394 #[serde(default)]
1395 search: crate::checklist::Offer,
1396}
1397
1398impl SelectOptions {
1399 fn searchable(&self) -> bool {
1400 use crate::checklist::Offer;
1401 match self.search {
1402 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
1403 Offer::Always => true,
1404 Offer::Never => false,
1405 }
1406 }
1407}
1408
1409#[derive(Debug, Default, Serialize, Deserialize)]
1411struct SelectData {
1412 options: Vec<OptionView>,
1413 #[serde(default)]
1414 search: bool,
1415}
1416
1417#[derive(Debug, Deserialize)]
1418struct SelectOption {
1419 value: String,
1420 #[serde(default)]
1422 label: Option<String>,
1423}
1424
1425#[derive(Debug, Serialize, Deserialize)]
1428struct OptionView {
1429 value: String,
1430 label: String,
1431 selected: bool,
1432}
1433
1434#[derive(Template)]
1435#[template(path = "fields/select.html")]
1436struct SelectTmpl<'a> {
1437 name: &'a str,
1438 id: &'a str,
1439 required: bool,
1440 options: &'a [OptionView],
1441 search: bool,
1442}
1443
1444pub(crate) struct SelectField;
1446
1447impl FieldType for SelectField {
1448 fn view_key(&self) -> &'static str {
1449 "select"
1450 }
1451 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1452 keys(&["options", "search"])
1453 }
1454 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1457 match opts.get::<SelectOptions>() {
1458 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
1459 _ => Vec::new(),
1460 }
1461 }
1462 fn resolve_options(
1463 &self,
1464 raw: &serde_json::Value,
1465 _types: &FieldRegistry,
1466 ) -> Result<ResolvedOptions, OptionsError> {
1467 let opts: SelectOptions =
1468 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1469 Ok(ResolvedOptions::new(opts))
1470 }
1471 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1472 let current = cx.value.as_text();
1473 let opts = cx.opts.get::<SelectOptions>();
1474 let data = SelectData {
1475 options: opts
1476 .map(|o| {
1477 o.options
1478 .iter()
1479 .map(|so| OptionView {
1480 value: so.value.clone(),
1481 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1482 selected: so.value == current,
1483 })
1484 .collect()
1485 })
1486 .unwrap_or_default(),
1487 search: opts.is_some_and(SelectOptions::searchable),
1488 };
1489 FieldVm {
1490 view_key: "select".to_string(),
1491 name: cx.name.to_string(),
1492 id: cx.id.to_string(),
1493 label: cx.label.to_string(),
1494 required: cx.required,
1495 value: cx.value.clone(),
1496 data: serde_json::to_value(&data).unwrap_or_default(),
1497 }
1498 }
1499 fn render_default(&self, vm: &FieldVm) -> Markup {
1500 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1501 Markup::from_template(&SelectTmpl {
1502 name: &vm.name,
1503 id: &vm.id,
1504 required: vm.required,
1505 options: &data.options,
1506 search: data.search,
1507 })
1508 .unwrap_or_default()
1509 }
1510}
1511
1512#[derive(Debug, Default, Deserialize)]
1514struct ChecklistOptions {
1515 #[serde(default)]
1516 options: Vec<ChecklistEntry>,
1517 #[serde(default)]
1518 select_all: crate::checklist::Offer,
1519 #[serde(default)]
1520 search: crate::checklist::Offer,
1521 #[serde(default)]
1522 expand: crate::checklist::Expand,
1523}
1524
1525#[derive(Debug, Deserialize)]
1527#[serde(untagged)]
1528enum ChecklistEntry {
1529 Group {
1530 group: String,
1531 #[serde(default)]
1532 options: Vec<ChecklistEntry>,
1533 },
1534 Choice {
1535 value: String,
1536 #[serde(default)]
1538 label: Option<String>,
1539 #[serde(default)]
1541 note: Option<String>,
1542 },
1543}
1544
1545impl ChecklistOptions {
1546 fn values(&self) -> Vec<&str> {
1548 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1549 for entry in entries {
1550 match entry {
1551 ChecklistEntry::Choice { value, .. } => out.push(value),
1552 ChecklistEntry::Group { options, .. } => walk(options, out),
1553 }
1554 }
1555 }
1556 let mut out = Vec::new();
1557 walk(&self.options, &mut out);
1558 out
1559 }
1560
1561 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1564 self.values()
1565 .into_iter()
1566 .filter(|value| submitted.contains(value))
1567 .collect()
1568 }
1569}
1570
1571fn checklist_values(text: &str) -> Vec<String> {
1573 serde_json::from_str(text).unwrap_or_default()
1574}
1575
1576pub(crate) struct ChecklistField;
1581
1582impl ChecklistField {
1583 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1584 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1585 match opts.get::<ChecklistOptions>() {
1586 Some(options) => options
1587 .ticked(&submitted)
1588 .into_iter()
1589 .map(str::to_string)
1590 .collect(),
1591 None => Vec::new(),
1592 }
1593 }
1594}
1595
1596impl FieldType for ChecklistField {
1597 fn view_key(&self) -> &'static str {
1598 "checklist"
1599 }
1600 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1601 keys(&["options", "select_all", "search", "expand"])
1602 }
1603 fn resolve_options(
1604 &self,
1605 raw: &serde_json::Value,
1606 _types: &FieldRegistry,
1607 ) -> Result<ResolvedOptions, OptionsError> {
1608 let opts: ChecklistOptions = if raw.is_null() {
1609 ChecklistOptions::default()
1610 } else {
1611 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1612 };
1613 let mut seen = std::collections::HashSet::new();
1614 for value in opts.values() {
1615 if !seen.insert(value) {
1616 return Err(OptionsError(format!("option `{value}` is listed twice")));
1617 }
1618 }
1619 Ok(ResolvedOptions::new(opts))
1620 }
1621
1622 fn to_attr(
1623 &self,
1624 field: &SubmittedField<'_>,
1625 opts: &ResolvedOptions,
1626 _mode: Mode,
1627 ) -> Result<Option<AttrValue>, String> {
1628 let values = Self::gathered(field, opts)
1631 .into_iter()
1632 .map(serde_json::Value::String)
1633 .collect();
1634 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1635 }
1636
1637 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1638 stored.unwrap_or("[]").to_string()
1639 }
1640
1641 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1642 let values = Self::gathered(field, opts);
1643 if values.is_empty() {
1644 return None;
1645 }
1646 serde_json::to_string(&values).ok()
1647 }
1648
1649 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1650 use crate::checklist::{Checklist, Choice, Entry, Group};
1651
1652 let ticked: Vec<String> = match cx.value {
1653 FieldValue::Json(serde_json::Value::Array(values)) => values
1654 .iter()
1655 .filter_map(|v| v.as_str().map(str::to_string))
1656 .collect(),
1657 FieldValue::Text(text) => checklist_values(text),
1658 _ => Vec::new(),
1659 };
1660
1661 fn build(
1662 entries: &[ChecklistEntry],
1663 name: &str,
1664 ticked: &[String],
1665 position: &mut usize,
1666 ) -> Vec<Entry> {
1667 entries
1668 .iter()
1669 .map(|entry| match entry {
1670 ChecklistEntry::Choice { value, label, note } => {
1671 let mut choice = Choice::new(
1672 format!("{name}[{position}]"),
1673 value.clone(),
1674 label.clone().unwrap_or_else(|| value.clone()),
1675 )
1676 .checked(ticked.contains(value));
1677 if let Some(note) = note {
1678 choice = choice.note(note.clone());
1679 }
1680 *position += 1;
1681 Entry::from(choice)
1682 }
1683 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1684 group.clone(),
1685 build(options, name, ticked, position),
1686 )),
1687 })
1688 .collect()
1689 }
1690
1691 let mut list = Checklist::new(cx.label).id(cx.id);
1692 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1693 let mut position = 0;
1694 for entry in build(&options.options, cx.name, &ticked, &mut position) {
1695 list = list.entry(entry);
1696 }
1697 list = list
1698 .select_all(options.select_all)
1699 .search(options.search)
1700 .expand(options.expand);
1701 }
1702 FieldVm {
1703 view_key: "checklist".to_string(),
1704 name: cx.name.to_string(),
1705 id: cx.id.to_string(),
1706 label: cx.label.to_string(),
1707 required: cx.required,
1708 value: cx.value.clone(),
1709 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1710 }
1711 }
1712
1713 fn render_default(&self, vm: &FieldVm) -> Markup {
1714 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1715 .map(|view| view.render_markup())
1716 .unwrap_or_default()
1717 }
1718}
1719
1720#[derive(Template)]
1721#[template(path = "fields/switch.html")]
1722struct SwitchTmpl<'a> {
1723 name: &'a str,
1724 id: &'a str,
1725 on: bool,
1726}
1727
1728pub(crate) struct SwitchField;
1734
1735fn stored_is_on(stored: Option<&str>) -> bool {
1738 matches!(
1739 stored.map(str::trim).unwrap_or_default(),
1740 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1741 )
1742}
1743
1744impl FieldType for SwitchField {
1745 fn view_key(&self) -> &'static str {
1746 "switch"
1747 }
1748 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1749 keys(&[])
1750 }
1751
1752 fn to_attr(
1753 &self,
1754 field: &SubmittedField<'_>,
1755 _opts: &ResolvedOptions,
1756 _mode: Mode,
1757 ) -> Result<Option<AttrValue>, String> {
1758 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1761 }
1762
1763 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1764 if stored_is_on(stored) {
1765 "1".to_string()
1766 } else {
1767 String::new()
1768 }
1769 }
1770
1771 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1772 scalar_vm("switch", cx)
1773 }
1774
1775 fn render_default(&self, vm: &FieldVm) -> Markup {
1776 Markup::from_template(&SwitchTmpl {
1777 name: &vm.name,
1778 id: &vm.id,
1779 on: stored_is_on(Some(vm.value.as_text())),
1780 })
1781 .unwrap_or_default()
1782 }
1783}
1784
1785#[derive(Template)]
1786#[template(path = "fields/date.html")]
1787struct DateTmpl<'a> {
1788 name: &'a str,
1789 id: &'a str,
1790 value: &'a str,
1791 required: bool,
1792}
1793
1794pub(crate) struct DateField;
1800
1801impl FieldType for DateField {
1802 fn view_key(&self) -> &'static str {
1803 "date"
1804 }
1805 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1806 keys(&[])
1807 }
1808
1809 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1810 vec![Rule::Date]
1811 }
1812
1813 fn to_attr(
1814 &self,
1815 field: &SubmittedField<'_>,
1816 _opts: &ResolvedOptions,
1817 _mode: Mode,
1818 ) -> Result<Option<AttrValue>, String> {
1819 Ok(field.value().map(|v| match v.trim() {
1820 "" => AttrValue::Null,
1821 date => AttrValue::Text(date.to_string()),
1822 }))
1823 }
1824
1825 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1826 let stored = stored.unwrap_or_default().trim();
1827 stored
1830 .split(['T', ' '])
1831 .next()
1832 .unwrap_or_default()
1833 .to_string()
1834 }
1835
1836 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1837 scalar_vm("date", cx)
1838 }
1839
1840 fn render_default(&self, vm: &FieldVm) -> Markup {
1841 Markup::from_template(&DateTmpl {
1842 name: &vm.name,
1843 id: &vm.id,
1844 value: vm.value.as_text(),
1845 required: vm.required,
1846 })
1847 .unwrap_or_default()
1848 }
1849}
1850
1851#[derive(Template)]
1852#[template(path = "fields/password.html")]
1853struct PasswordTmpl<'a> {
1854 name: &'a str,
1855 id: &'a str,
1856 required: bool,
1857 reveal: bool,
1858 show: &'a str,
1859}
1860
1861#[derive(Debug, Clone, Serialize, Deserialize)]
1863struct PasswordOptions {
1864 #[serde(default = "yes")]
1865 reveal: bool,
1866 #[serde(default)]
1868 show: String,
1869}
1870
1871impl Default for PasswordOptions {
1872 fn default() -> Self {
1873 Self {
1874 reveal: true,
1875 show: String::new(),
1876 }
1877 }
1878}
1879
1880pub(crate) struct PasswordField;
1888
1889impl FieldType for PasswordField {
1890 fn view_key(&self) -> &'static str {
1891 "password"
1892 }
1893 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1894 keys(&["reveal"])
1895 }
1896 fn resolve_options(
1897 &self,
1898 raw: &serde_json::Value,
1899 _types: &FieldRegistry,
1900 ) -> Result<ResolvedOptions, OptionsError> {
1901 let opts: PasswordOptions = if raw.is_null() {
1902 PasswordOptions::default()
1903 } else {
1904 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1905 };
1906 Ok(ResolvedOptions::new(opts))
1907 }
1908
1909 fn to_attr(
1910 &self,
1911 field: &SubmittedField<'_>,
1912 _opts: &ResolvedOptions,
1913 _mode: Mode,
1914 ) -> Result<Option<AttrValue>, String> {
1915 match field.value().map(str::trim).unwrap_or_default() {
1916 "" => Ok(None),
1918 plain => laterite_auth::password::hash_password(plain)
1919 .map(|hash| Some(AttrValue::Text(hash)))
1920 .map_err(|e| e.to_string()),
1921 }
1922 }
1923
1924 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1927 String::new()
1928 }
1929
1930 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1931 let mut vm = scalar_vm("password", cx);
1932 let mut opts = cx
1933 .opts
1934 .get::<PasswordOptions>()
1935 .cloned()
1936 .unwrap_or_default();
1937 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
1938 vm.data = serde_json::to_value(opts).unwrap_or_default();
1939 vm
1940 }
1941
1942 fn render_default(&self, vm: &FieldVm) -> Markup {
1943 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1944 Markup::from_template(&PasswordTmpl {
1945 name: &vm.name,
1946 id: &vm.id,
1947 required: vm.required,
1948 reveal: opts.reveal,
1949 show: &opts.show,
1950 })
1951 .unwrap_or_default()
1952 }
1953}
1954
1955#[derive(Template)]
1956#[template(path = "fields/radio.html")]
1957struct RadioTmpl<'a> {
1958 name: &'a str,
1959 options: &'a [OptionView],
1960 required: bool,
1961}
1962
1963pub(crate) struct RadioField;
1968
1969impl FieldType for RadioField {
1970 fn view_key(&self) -> &'static str {
1971 "radio"
1972 }
1973 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1974 keys(&["options"])
1975 }
1976
1977 fn resolve_options(
1978 &self,
1979 raw: &serde_json::Value,
1980 _types: &FieldRegistry,
1981 ) -> Result<ResolvedOptions, OptionsError> {
1982 let opts: SelectOptions =
1983 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1984 Ok(ResolvedOptions::new(opts))
1985 }
1986
1987 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1988 let current = cx.value.as_text();
1989 let views: Vec<OptionView> = cx
1990 .opts
1991 .get::<SelectOptions>()
1992 .map(|o| {
1993 o.options
1994 .iter()
1995 .map(|so| OptionView {
1996 value: so.value.clone(),
1997 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1998 selected: so.value == current,
1999 })
2000 .collect()
2001 })
2002 .unwrap_or_default();
2003 FieldVm {
2004 view_key: "radio".to_string(),
2005 name: cx.name.to_string(),
2006 id: cx.id.to_string(),
2007 label: cx.label.to_string(),
2008 required: cx.required,
2009 value: cx.value.clone(),
2010 data: serde_json::to_value(&views).unwrap_or_default(),
2011 }
2012 }
2013
2014 fn render_default(&self, vm: &FieldVm) -> Markup {
2015 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2016 Markup::from_template(&RadioTmpl {
2017 name: &vm.name,
2018 options: &options,
2019 required: vm.required,
2020 })
2021 .unwrap_or_default()
2022 }
2023}
2024
2025fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2027 FieldVm {
2028 view_key: view_key.to_string(),
2029 name: cx.name.to_string(),
2030 id: cx.id.to_string(),
2031 label: cx.label.to_string(),
2032 required: cx.required,
2033 value: cx.value.clone(),
2034 data: serde_json::Value::Null,
2035 }
2036}
2037
2038#[derive(Deserialize)]
2040struct RefOptions {
2041 source: String,
2042}
2043
2044struct RefResolved {
2046 source: String,
2047}
2048
2049#[derive(Default, Serialize, Deserialize)]
2052struct RefData {
2053 source: String,
2054 search_url: String,
2055 resolve_url: String,
2056 value: String,
2057 placeholder: String,
2058}
2059
2060#[derive(Template)]
2061#[template(path = "fields/ref_picker.html")]
2062struct RefPickerTmpl<'a> {
2063 name: &'a str,
2064 id: &'a str,
2065 value: &'a str,
2066 search_url: &'a str,
2067 resolve_url: &'a str,
2068 placeholder: &'a str,
2069}
2070
2071pub(crate) struct RefPickerField {
2076 pickers: Arc<PickerRegistry>,
2077}
2078
2079impl RefPickerField {
2080 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2081 Self { pickers }
2082 }
2083}
2084
2085impl FieldType for RefPickerField {
2086 fn view_key(&self) -> &'static str {
2087 "reference"
2088 }
2089 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2090 keys(&["source"])
2091 }
2092 fn resolve_options(
2093 &self,
2094 raw: &serde_json::Value,
2095 _types: &FieldRegistry,
2096 ) -> Result<ResolvedOptions, OptionsError> {
2097 let opts: RefOptions =
2098 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2099 if !self.pickers.contains_key(&opts.source) {
2100 return Err(OptionsError(format!(
2101 "unknown picker source `{}`",
2102 opts.source
2103 )));
2104 }
2105 Ok(ResolvedOptions::new(RefResolved {
2106 source: opts.source,
2107 }))
2108 }
2109 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2110 let source = cx
2111 .opts
2112 .get::<RefResolved>()
2113 .map(|r| r.source.as_str())
2114 .unwrap_or_default();
2115 let data = RefData {
2116 search_url: format!("{}/pickers/{}/search", cx.base, source),
2117 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2118 source: source.to_string(),
2119 value: cx.value.as_text().to_string(),
2120 placeholder: "Search…".to_string(),
2121 };
2122 FieldVm {
2123 view_key: "reference".to_string(),
2124 name: cx.name.to_string(),
2125 id: cx.id.to_string(),
2126 label: cx.label.to_string(),
2127 required: cx.required,
2128 value: cx.value.clone(),
2129 data: serde_json::to_value(data).unwrap_or_default(),
2130 }
2131 }
2132 fn render_default(&self, vm: &FieldVm) -> Markup {
2133 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2134 Markup::from_template(&RefPickerTmpl {
2135 name: &vm.name,
2136 id: &vm.id,
2137 value: vm.value.as_text(),
2138 search_url: &data.search_url,
2139 resolve_url: &data.resolve_url,
2140 placeholder: &data.placeholder,
2141 })
2142 .unwrap_or_default()
2143 }
2144 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2145 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2146 }
2147}
2148
2149#[cfg(test)]
2150mod tests {
2151 use super::*;
2152
2153 fn untranslated() -> Translator {
2156 Translator::new("en")
2157 }
2158
2159 fn cx_with<'a>(
2160 name: &'a str,
2161 value: &'a FieldValue,
2162 opts: &'a ResolvedOptions,
2163 i18n: &'a Translator,
2164 ) -> FieldCx<'a> {
2165 FieldCx {
2166 name,
2167 id: name,
2168 label: "Label",
2169 value,
2170 required: true,
2171 max_length: None,
2172 opts,
2173 base: "/admin",
2174 i18n,
2175 }
2176 }
2177
2178 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2179 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2182 }
2183
2184 fn text_field() -> TextField {
2185 TextField::new(Arc::new(builtin_input_registry()))
2186 }
2187
2188 struct BadAttrInput;
2190 impl InputType for BadAttrInput {
2191 fn key(&self) -> &'static str {
2192 "bad"
2193 }
2194 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2195 BTreeMap::from([("class".to_string(), "x".to_string())])
2196 }
2197 }
2198
2199 #[test]
2200 fn text_renders_its_value_escaped() {
2201 let opts = ResolvedOptions::none();
2202 let value = FieldValue::Text("a<b>&\"c".to_string());
2203 let markup = render_field(
2204 &text_field(),
2205 &NoOverrides,
2206 &scope(),
2207 &cx("title", &value, &opts),
2208 );
2209 let html = markup.as_str();
2210 assert!(html.contains(r#"name="title""#));
2211 assert!(html.contains("required"));
2212 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2216 assert!(
2217 html.contains("<") || html.contains("<"),
2218 "angle escaped: {html}"
2219 );
2220 assert!(
2221 html.contains("&") || html.contains("&"),
2222 "ampersand escaped: {html}"
2223 );
2224 }
2225
2226 #[test]
2227 fn textarea_uses_its_own_control() {
2228 let opts = ResolvedOptions::none();
2229 let value = FieldValue::Text("body".to_string());
2230 let markup = render_field(
2231 &TextareaField,
2232 &NoOverrides,
2233 &scope(),
2234 &cx("body", &value, &opts),
2235 );
2236 assert!(markup.as_str().contains("<textarea"));
2237 }
2238
2239 #[test]
2240 fn no_overrides_uses_the_compiled_default() {
2241 let opts = ResolvedOptions::none();
2243 let value = FieldValue::Text("x".to_string());
2244 let ft = text_field();
2245 let vm = ft.view_model(&cx("t", &value, &opts));
2246 let direct = ft.render_default(&vm);
2247 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2248 assert_eq!(direct.as_str(), routed.as_str());
2249 }
2250
2251 #[test]
2252 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2253 let field = text_field();
2254 let opts = field
2255 .resolve_options(
2256 &serde_json::json!({ "input": "email" }),
2257 &builtin_registry(),
2258 )
2259 .unwrap();
2260 assert!(matches!(
2262 field.intrinsic_rules(&opts).as_slice(),
2263 [Rule::Email]
2264 ));
2265 let value = FieldValue::Text("a@b.test".to_string());
2266 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2267 assert!(markup.as_str().contains(r#"type="email""#));
2268 }
2269
2270 #[test]
2271 fn text_default_input_is_plain_text_with_no_extra_rule() {
2272 let field = text_field();
2273 let opts = field
2274 .resolve_options(&serde_json::Value::Null, &builtin_registry())
2275 .unwrap();
2276 assert!(field.intrinsic_rules(&opts).is_empty());
2277 let value = FieldValue::Text("hi".to_string());
2278 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2279 assert!(markup.as_str().contains(r#"type="text""#));
2280 }
2281
2282 #[test]
2283 fn text_number_input_sets_the_type_and_numeric_rule() {
2284 let field = text_field();
2285 let opts = field
2286 .resolve_options(
2287 &serde_json::json!({ "input": "number" }),
2288 &builtin_registry(),
2289 )
2290 .unwrap();
2291 assert!(matches!(
2292 field.intrinsic_rules(&opts).as_slice(),
2293 [Rule::Numeric]
2294 ));
2295 let value = FieldValue::Text("42".to_string());
2296 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2297 assert!(markup.as_str().contains(r#"type="number""#));
2298 }
2299
2300 #[test]
2301 fn text_tel_input_sets_the_type_with_no_rule() {
2302 let field = text_field();
2303 let opts = field
2304 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2305 .unwrap();
2306 assert!(field.intrinsic_rules(&opts).is_empty());
2307 let value = FieldValue::Text(String::new());
2308 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2309 assert!(markup.as_str().contains(r#"type="tel""#));
2310 }
2311
2312 #[test]
2313 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2314 let field = text_field();
2315 let opts = field
2316 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2317 .unwrap();
2318 assert!(matches!(
2319 field.intrinsic_rules(&opts).as_slice(),
2320 [Rule::Url]
2321 ));
2322 let value = FieldValue::Text("https://example.com".to_string());
2323 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2324 let html = markup.as_str();
2325 assert!(html.contains(r#"type="url""#), "{html}");
2326 assert!(html.contains("lat-input-group"), "{html}");
2327 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2328 }
2329
2330 #[test]
2331 fn text_url_input_copy_false_omits_the_button() {
2332 let field = text_field();
2333 let opts = field
2334 .resolve_options(
2335 &serde_json::json!({ "input": "url", "copy": false }),
2336 &builtin_registry(),
2337 )
2338 .unwrap();
2339 let value = FieldValue::Text("https://example.com".to_string());
2340 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2341 let html = markup.as_str();
2342 assert!(html.contains(r#"type="url""#), "{html}");
2343 assert!(!html.contains("lat-input-group"), "{html}");
2345 assert!(!html.contains("data-lat-widget"), "{html}");
2346 }
2347
2348 #[test]
2349 fn text_number_input_emits_min_max_step_attributes() {
2350 let field = text_field();
2351 let opts = field
2352 .resolve_options(
2353 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2354 &builtin_registry(),
2355 )
2356 .unwrap();
2357 let value = FieldValue::Text("5".to_string());
2360 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2361 let html = markup.as_str();
2362 assert!(html.contains(r#"min="0""#), "{html}");
2363 assert!(html.contains(r#"max="10""#), "{html}");
2364 assert!(html.contains(r#"step="2""#), "{html}");
2365 }
2366
2367 #[test]
2368 fn text_input_reserved_attribute_name_aborts_resolve() {
2369 let mut inputs = builtin_input_registry();
2370 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2371 let field = TextField::new(Arc::new(inputs));
2372 let Err(e) =
2373 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2374 else {
2375 panic!("expected a reserved-attribute rejection");
2376 };
2377 assert!(e.0.contains("class"), "{e}");
2378 }
2379
2380 #[test]
2381 fn attribute_and_widget_name_rules() {
2382 assert!(is_name("min", false));
2383 assert!(is_name("data-x", false));
2384 assert!(!is_name("Min", false));
2385 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
2388 }
2389
2390 #[test]
2391 fn select_renders_options_with_the_current_value_selected() {
2392 let raw = serde_json::json!({
2393 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2394 });
2395 let opts = SelectField
2396 .resolve_options(&raw, &builtin_registry())
2397 .unwrap();
2398 let value = FieldValue::Text("closed".to_string());
2399 let markup = render_field(
2400 &SelectField,
2401 &NoOverrides,
2402 &scope(),
2403 &cx("status", &value, &opts),
2404 );
2405 let html = markup.as_str();
2406 assert!(html.contains(r#"<option value="open">Open</option>"#));
2407 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2409 }
2410
2411 #[test]
2412 fn radio_renders_the_same_options_with_the_current_one_checked() {
2413 let raw = serde_json::json!({
2414 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2415 });
2416 let opts = RadioField
2417 .resolve_options(&raw, &builtin_registry())
2418 .unwrap();
2419 let value = FieldValue::Text("closed".to_string());
2420 let markup = render_field(
2421 &RadioField,
2422 &NoOverrides,
2423 &scope(),
2424 &cx("status", &value, &opts),
2425 );
2426 let html = markup.as_str();
2427 assert!(html.contains(r#"value="open""#));
2428 assert!(
2429 html.contains(r#"value="closed" checked"#),
2430 "current is checked"
2431 );
2432 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2435 assert!(html.contains("closed</label>"));
2437 }
2438
2439 fn scope<'a>() -> OverrideScope<'a> {
2440 OverrideScope {
2441 surface: Surface::Field,
2442 view_key: "text",
2443 resource: None,
2444 field: None,
2445 }
2446 }
2447
2448 fn ref_field() -> RefPickerField {
2449 let mut pickers = crate::picker::PickerRegistry::new();
2450 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2451 pickers.insert(
2452 "acme.place".to_string(),
2453 crate::picker::PickerSourceReg::new("acme.place", source),
2454 );
2455 RefPickerField::new(Arc::new(pickers))
2456 }
2457
2458 #[test]
2459 fn reference_rejects_an_unknown_source() {
2460 let field = ref_field();
2461 let Err(e) = field.resolve_options(
2462 &serde_json::json!({ "source": "no.such" }),
2463 &builtin_registry(),
2464 ) else {
2465 panic!("expected an unknown-source rejection");
2466 };
2467 assert!(e.0.contains("no.such"), "{e}");
2468 }
2469
2470 #[test]
2471 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2472 let field = ref_field();
2473 let opts = field
2474 .resolve_options(
2475 &serde_json::json!({ "source": "acme.place" }),
2476 &builtin_registry(),
2477 )
2478 .unwrap();
2479 let value = FieldValue::Text("42".to_string());
2480 let markup = render_field(
2481 &field,
2482 &NoOverrides,
2483 &scope(),
2484 &cx("place_id", &value, &opts),
2485 );
2486 let html = markup.as_str();
2487 assert!(html.contains(r#"type="hidden""#), "{html}");
2490 assert!(html.contains(r#"name="place_id""#), "{html}");
2491 assert!(html.contains(r#"value="42""#), "{html}");
2492 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2494 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2495 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2496 }
2497
2498 #[test]
2499 fn reference_declares_its_widget_assets() {
2500 let field = ref_field();
2501 let opts = field
2502 .resolve_options(
2503 &serde_json::json!({ "source": "acme.place" }),
2504 &builtin_registry(),
2505 )
2506 .unwrap();
2507 assert_eq!(
2508 field.assets(&opts),
2509 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2510 );
2511 }
2512}
2513
2514#[cfg(test)]
2515mod repeater_tests {
2516 use super::*;
2517
2518 fn options() -> ResolvedOptions {
2519 RepeaterField
2520 .resolve_options(
2521 &serde_json::json!({
2522 "fields": [
2523 { "name": "path", "label": "Path", "type": "text" },
2524 { "name": "allow", "label": "Allow", "type": "switch" },
2525 ]
2526 }),
2527 &builtin_registry(),
2528 )
2529 .unwrap()
2530 }
2531
2532 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2533 pairs
2534 .iter()
2535 .map(|(k, v)| (k.to_string(), v.to_string()))
2536 .collect()
2537 }
2538
2539 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2540 let field = SubmittedField::new("rules", data);
2541 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2542 Some(AttrValue::Json(value)) => value,
2543 other => panic!("expected a JSON array, got {other:?}"),
2544 }
2545 }
2546
2547 #[test]
2551 fn a_row_is_typed_by_its_sub_field_types() {
2552 let rows = stored(
2553 &submitted(&[
2554 ("rules[0][path]", "/private"),
2555 ("rules[1][path]", "/public"),
2556 ("rules[1][allow]", "on"),
2557 ]),
2558 &options(),
2559 );
2560
2561 assert_eq!(rows[0]["path"], "/private");
2562 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2563 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2564 }
2565
2566 #[test]
2569 fn rows_are_ordered_by_index_not_submission_order() {
2570 let rows = stored(
2571 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2572 &options(),
2573 );
2574 assert_eq!(rows.as_array().unwrap().len(), 2);
2575 assert_eq!(rows[0]["path"], "/first");
2576 assert_eq!(rows[1]["path"], "/last");
2577 }
2578
2579 #[test]
2580 fn a_row_left_blank_is_dropped() {
2581 let rows = stored(
2582 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2583 &options(),
2584 );
2585 assert_eq!(rows.as_array().unwrap().len(), 1);
2586 }
2587
2588 #[test]
2589 fn nothing_submitted_stores_an_empty_list() {
2590 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2591 }
2592
2593 #[test]
2594 fn a_repeater_refuses_to_hold_a_repeater() {
2595 let err = RepeaterField
2596 .resolve_options(
2597 &serde_json::json!({
2598 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2599 }),
2600 &builtin_registry(),
2601 )
2602 .err()
2603 .expect("a nested repeater is refused");
2604 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2605 }
2606
2607 #[test]
2608 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2609 let err = RepeaterField
2610 .resolve_options(
2611 &serde_json::json!({
2612 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2613 }),
2614 &builtin_registry(),
2615 )
2616 .err()
2617 .expect("an unregistered type is refused");
2618 assert!(err.0.contains("acme.nope"), "{}", err.0);
2619 }
2620
2621 #[test]
2622 fn row_counts_are_bounded_when_asked() {
2623 let opts = RepeaterField
2624 .resolve_options(
2625 &serde_json::json!({
2626 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2627 "min_items": 1,
2628 "max_items": 2
2629 }),
2630 &builtin_registry(),
2631 )
2632 .unwrap();
2633
2634 let empty = HashMap::new();
2635 let too_few = SubmittedField::new("rules", &empty);
2636 assert!(RepeaterField
2637 .to_attr(&too_few, &opts, Mode::Create)
2638 .is_err());
2639
2640 let three = submitted(&[
2641 ("rules[0][path]", "/a"),
2642 ("rules[1][path]", "/b"),
2643 ("rules[2][path]", "/c"),
2644 ]);
2645 let field = SubmittedField::new("rules", &three);
2646 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2647 }
2648
2649 #[test]
2650 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2651 let value = FieldValue::Json(serde_json::json!([
2652 { "path": "/private", "allow": false },
2653 ]));
2654 let cx = FieldCx {
2655 name: "rules",
2656 id: "rules",
2657 label: "Rules",
2658 value: &value,
2659 required: false,
2660 max_length: None,
2661 opts: &options(),
2662 base: "/admin",
2663 i18n: &Translator::new("en"),
2664 };
2665 let html = RepeaterField
2666 .render_default(&RepeaterField.view_model(&cx))
2667 .into_string();
2668
2669 assert!(html.contains(r#"name="rules[0][path]""#));
2670 assert!(html.contains(r#"value="/private""#));
2671 assert!(html.contains("lat-repeater__blank"));
2673 assert!(html.contains(r#"name="rules[__index__][path]""#));
2674 }
2675
2676 fn list_options() -> ResolvedOptions {
2678 RepeaterField
2679 .resolve_options(
2680 &serde_json::json!({
2681 "fields": [
2682 { "name": "agent", "label": "Agent", "type": "text" },
2683 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2684 ],
2685 "display": "list",
2686 "summary_field": "agent",
2687 }),
2688 &builtin_registry(),
2689 )
2690 .unwrap()
2691 }
2692
2693 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2694 let cx = FieldCx {
2695 name: "rules",
2696 id: "rules",
2697 label: "Rules",
2698 value,
2699 required: false,
2700 max_length: None,
2701 opts,
2702 base: "/admin",
2703 i18n: &Translator::new("en"),
2704 };
2705 RepeaterField
2706 .render_default(&RepeaterField.view_model(&cx))
2707 .into_string()
2708 }
2709
2710 #[test]
2711 fn a_list_repeater_collapses_its_rows_and_names_them() {
2712 let value = FieldValue::Json(serde_json::json!([
2713 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2714 ]));
2715 let html = render_with(&list_options(), &value);
2716 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2717 assert!(html.contains("<details"), "a row collapses");
2718 assert!(
2719 html.contains("Googlebot"),
2720 "the naming field titles the row"
2721 );
2722 assert!(html.contains(r#"name="rules[0][disallow]""#));
2724 }
2725
2726 #[test]
2727 fn a_row_with_no_name_yet_reads_as_untitled() {
2728 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2729 let html = render_with(&list_options(), &value);
2730 assert!(html.contains("lat-repeater__untitled"));
2731 }
2732
2733 #[test]
2734 fn the_default_layout_stays_inline() {
2735 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2737 let html = render_with(&options(), &value);
2738 assert!(!html.contains("lat-repeater--list"));
2739 assert!(!html.contains("<details"));
2740 }
2741
2742 #[test]
2743 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2744 assert_eq!(
2746 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2747 "/private"
2748 );
2749 assert_eq!(summary_text(&serde_json::json!("")), "");
2750 }
2751
2752 #[test]
2759 fn a_contributed_field_type_names_itself_and_converts() {
2760 struct Money;
2761 impl FieldType for Money {
2762 fn view_key(&self) -> &'static str {
2763 "acme.money"
2764 }
2765 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2766 scalar_vm("acme.money", cx)
2767 }
2768 fn render_default(&self, _vm: &FieldVm) -> Markup {
2769 Markup::empty()
2770 }
2771 fn to_attr(
2772 &self,
2773 field: &SubmittedField<'_>,
2774 _opts: &ResolvedOptions,
2775 _mode: Mode,
2776 ) -> Result<Option<AttrValue>, String> {
2777 let raw = field.value().unwrap_or("0");
2779 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2780 Ok(Some(AttrValue::Int(minor as i64)))
2781 }
2782 }
2783
2784 let reg = FieldTypeReg::new(Arc::new(Money));
2785 assert_eq!(reg.name(), "acme.money");
2788
2789 let mut registry = builtin_registry();
2790 let name = reg.name().to_string();
2791 assert!(
2792 registry.insert(name.clone(), reg.field_type).is_none(),
2793 "a dotted key does not collide with a built-in"
2794 );
2795
2796 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2797 let submitted = SubmittedField::new("price", &data);
2798 let converted = registry[&name]
2799 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2800 .unwrap();
2801 assert!(
2802 matches!(converted, Some(AttrValue::Int(1250))),
2803 "{converted:?}"
2804 );
2805 }
2806
2807 #[test]
2808 fn a_sub_field_label_is_localized_like_any_other() {
2809 use laterite_core::i18n::CatalogStore;
2810 use std::sync::Arc;
2811
2812 let po = concat!(
2813 "msgid \"\"\n",
2814 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2815 "\n",
2816 "msgid \"Path\"\n",
2817 "msgstr \"Path-kn\"\n",
2818 );
2819 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2820 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2821
2822 let value = FieldValue::Text(String::new());
2823 let cx = FieldCx {
2824 name: "rules",
2825 id: "rules",
2826 label: "Rules",
2827 value: &value,
2828 required: false,
2829 max_length: None,
2830 opts: &options(),
2831 base: "/admin",
2832 i18n: &i18n,
2833 };
2834 let html = RepeaterField
2835 .render_default(&RepeaterField.view_model(&cx))
2836 .into_string();
2837
2838 assert!(
2839 html.contains("Path-kn"),
2840 "sub-label was not localized: {html}"
2841 );
2842 assert!(
2843 !html.contains(">Path<"),
2844 "the source string still leaked: {html}"
2845 );
2846 }
2847}
2848
2849#[cfg(test)]
2850mod save_contract_tests {
2851 use super::*;
2852
2853 fn one(raw: Option<&str>) -> HashMap<String, String> {
2855 raw.into_iter()
2856 .map(|v| ("f".to_string(), v.to_string()))
2857 .collect()
2858 }
2859
2860 fn none() -> ResolvedOptions {
2861 ResolvedOptions::none()
2862 }
2863
2864 #[test]
2865 fn a_switch_stores_a_bool_and_absence_means_off() {
2866 let f = SwitchField;
2867 assert_eq!(
2870 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2871 .unwrap(),
2872 Some(AttrValue::Bool(false))
2873 );
2874 assert_eq!(
2875 f.to_attr(
2876 &SubmittedField::new("f", &one(Some("on"))),
2877 &none(),
2878 Mode::Create
2879 )
2880 .unwrap(),
2881 Some(AttrValue::Bool(true))
2882 );
2883 }
2884
2885 #[test]
2886 fn a_switch_reads_back_whatever_the_backend_gave() {
2887 let f = SwitchField;
2888 for on in ["1", "true", "t", "on"] {
2890 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2891 }
2892 for off in ["0", "false", "f", ""] {
2893 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2894 }
2895 assert!(f.to_control(None, &none()).is_empty());
2896 }
2897
2898 #[test]
2899 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2900 let f = DateField;
2901 assert_eq!(
2903 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2904 "2026-09-09"
2905 );
2906 assert_eq!(
2907 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2908 "2026-09-09"
2909 );
2910 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2911 assert_eq!(f.to_control(None, &none()), "");
2912 }
2913
2914 #[test]
2915 fn a_cleared_date_stores_null_not_an_empty_string() {
2916 let f = DateField;
2917 assert_eq!(
2918 f.to_attr(
2919 &SubmittedField::new("f", &one(Some(""))),
2920 &none(),
2921 Mode::Update
2922 )
2923 .unwrap(),
2924 Some(AttrValue::Null)
2925 );
2926 assert_eq!(
2927 f.to_attr(
2928 &SubmittedField::new("f", &one(Some("2026-09-09"))),
2929 &none(),
2930 Mode::Create
2931 )
2932 .unwrap(),
2933 Some(AttrValue::Text("2026-09-09".into()))
2934 );
2935 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2936 }
2937
2938 #[test]
2939 fn a_password_hashes_and_never_echoes() {
2940 let f = PasswordField;
2941 let stored = f
2942 .to_attr(
2943 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2944 &none(),
2945 Mode::Create,
2946 )
2947 .unwrap()
2948 .unwrap();
2949 let hash = stored.as_str().unwrap();
2950 assert!(
2951 hash.starts_with("$argon2"),
2952 "stored as a hash, not plaintext"
2953 );
2954 assert_ne!(hash, "hunter2hunter2");
2955 assert_eq!(f.to_control(Some(hash), &none()), "");
2957 }
2958
2959 #[test]
2960 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2961 let f = PasswordField;
2962 assert_eq!(
2964 f.to_attr(
2965 &SubmittedField::new("f", &one(Some(""))),
2966 &none(),
2967 Mode::Update
2968 )
2969 .unwrap(),
2970 None
2971 );
2972 assert_eq!(
2973 f.to_attr(
2974 &SubmittedField::new("f", &one(Some(" "))),
2975 &none(),
2976 Mode::Update
2977 )
2978 .unwrap(),
2979 None
2980 );
2981 assert_eq!(
2982 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2983 .unwrap(),
2984 None
2985 );
2986 }
2987
2988 #[test]
2989 fn a_plain_field_stores_and_shows_the_text_unchanged() {
2990 let f = TextareaField;
2991 assert_eq!(
2992 f.to_attr(
2993 &SubmittedField::new("f", &one(Some("hello"))),
2994 &none(),
2995 Mode::Create
2996 )
2997 .unwrap(),
2998 Some(AttrValue::Text("hello".into()))
2999 );
3000 assert_eq!(
3002 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3003 .unwrap(),
3004 None
3005 );
3006 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3007 }
3008}