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
1385#[derive(Debug, Default, Deserialize)]
1387struct SelectOptions {
1388 #[serde(default)]
1389 options: Vec<SelectOption>,
1390}
1391
1392#[derive(Debug, Deserialize)]
1393struct SelectOption {
1394 value: String,
1395 #[serde(default)]
1397 label: Option<String>,
1398}
1399
1400#[derive(Debug, Serialize, Deserialize)]
1403struct OptionView {
1404 value: String,
1405 label: String,
1406 selected: bool,
1407}
1408
1409#[derive(Template)]
1410#[template(path = "fields/select.html")]
1411struct SelectTmpl<'a> {
1412 name: &'a str,
1413 id: &'a str,
1414 required: bool,
1415 options: &'a [OptionView],
1416}
1417
1418pub(crate) struct SelectField;
1420
1421impl FieldType for SelectField {
1422 fn view_key(&self) -> &'static str {
1423 "select"
1424 }
1425 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1426 keys(&["options"])
1427 }
1428 fn resolve_options(
1429 &self,
1430 raw: &serde_json::Value,
1431 _types: &FieldRegistry,
1432 ) -> Result<ResolvedOptions, OptionsError> {
1433 let opts: SelectOptions =
1434 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1435 Ok(ResolvedOptions::new(opts))
1436 }
1437 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1438 let current = cx.value.as_text();
1439 let views: Vec<OptionView> = cx
1440 .opts
1441 .get::<SelectOptions>()
1442 .map(|o| {
1443 o.options
1444 .iter()
1445 .map(|so| OptionView {
1446 value: so.value.clone(),
1447 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1448 selected: so.value == current,
1449 })
1450 .collect()
1451 })
1452 .unwrap_or_default();
1453 FieldVm {
1454 view_key: "select".to_string(),
1455 name: cx.name.to_string(),
1456 id: cx.id.to_string(),
1457 label: cx.label.to_string(),
1458 required: cx.required,
1459 value: cx.value.clone(),
1460 data: serde_json::to_value(&views).unwrap_or_default(),
1461 }
1462 }
1463 fn render_default(&self, vm: &FieldVm) -> Markup {
1464 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1465 Markup::from_template(&SelectTmpl {
1466 name: &vm.name,
1467 id: &vm.id,
1468 required: vm.required,
1469 options: &options,
1470 })
1471 .unwrap_or_default()
1472 }
1473}
1474
1475#[derive(Debug, Default, Deserialize)]
1477struct ChecklistOptions {
1478 #[serde(default)]
1479 options: Vec<ChecklistEntry>,
1480 #[serde(default)]
1481 select_all: crate::checklist::Offer,
1482 #[serde(default)]
1483 search: crate::checklist::Offer,
1484 #[serde(default)]
1485 expand: crate::checklist::Expand,
1486}
1487
1488#[derive(Debug, Deserialize)]
1490#[serde(untagged)]
1491enum ChecklistEntry {
1492 Group {
1493 group: String,
1494 #[serde(default)]
1495 options: Vec<ChecklistEntry>,
1496 },
1497 Choice {
1498 value: String,
1499 #[serde(default)]
1501 label: Option<String>,
1502 #[serde(default)]
1504 note: Option<String>,
1505 },
1506}
1507
1508impl ChecklistOptions {
1509 fn values(&self) -> Vec<&str> {
1511 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1512 for entry in entries {
1513 match entry {
1514 ChecklistEntry::Choice { value, .. } => out.push(value),
1515 ChecklistEntry::Group { options, .. } => walk(options, out),
1516 }
1517 }
1518 }
1519 let mut out = Vec::new();
1520 walk(&self.options, &mut out);
1521 out
1522 }
1523
1524 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1527 self.values()
1528 .into_iter()
1529 .filter(|value| submitted.contains(value))
1530 .collect()
1531 }
1532}
1533
1534fn checklist_values(text: &str) -> Vec<String> {
1536 serde_json::from_str(text).unwrap_or_default()
1537}
1538
1539pub(crate) struct ChecklistField;
1544
1545impl ChecklistField {
1546 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1547 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1548 match opts.get::<ChecklistOptions>() {
1549 Some(options) => options
1550 .ticked(&submitted)
1551 .into_iter()
1552 .map(str::to_string)
1553 .collect(),
1554 None => Vec::new(),
1555 }
1556 }
1557}
1558
1559impl FieldType for ChecklistField {
1560 fn view_key(&self) -> &'static str {
1561 "checklist"
1562 }
1563 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1564 keys(&["options", "select_all", "search", "expand"])
1565 }
1566 fn resolve_options(
1567 &self,
1568 raw: &serde_json::Value,
1569 _types: &FieldRegistry,
1570 ) -> Result<ResolvedOptions, OptionsError> {
1571 let opts: ChecklistOptions = if raw.is_null() {
1572 ChecklistOptions::default()
1573 } else {
1574 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1575 };
1576 let mut seen = std::collections::HashSet::new();
1577 for value in opts.values() {
1578 if !seen.insert(value) {
1579 return Err(OptionsError(format!("option `{value}` is listed twice")));
1580 }
1581 }
1582 Ok(ResolvedOptions::new(opts))
1583 }
1584
1585 fn to_attr(
1586 &self,
1587 field: &SubmittedField<'_>,
1588 opts: &ResolvedOptions,
1589 _mode: Mode,
1590 ) -> Result<Option<AttrValue>, String> {
1591 let values = Self::gathered(field, opts)
1594 .into_iter()
1595 .map(serde_json::Value::String)
1596 .collect();
1597 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1598 }
1599
1600 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1601 stored.unwrap_or("[]").to_string()
1602 }
1603
1604 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1605 let values = Self::gathered(field, opts);
1606 if values.is_empty() {
1607 return None;
1608 }
1609 serde_json::to_string(&values).ok()
1610 }
1611
1612 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1613 use crate::checklist::{Checklist, Choice, Entry, Group};
1614
1615 let ticked: Vec<String> = match cx.value {
1616 FieldValue::Json(serde_json::Value::Array(values)) => values
1617 .iter()
1618 .filter_map(|v| v.as_str().map(str::to_string))
1619 .collect(),
1620 FieldValue::Text(text) => checklist_values(text),
1621 _ => Vec::new(),
1622 };
1623
1624 fn build(
1625 entries: &[ChecklistEntry],
1626 name: &str,
1627 ticked: &[String],
1628 position: &mut usize,
1629 ) -> Vec<Entry> {
1630 entries
1631 .iter()
1632 .map(|entry| match entry {
1633 ChecklistEntry::Choice { value, label, note } => {
1634 let mut choice = Choice::new(
1635 format!("{name}[{position}]"),
1636 value.clone(),
1637 label.clone().unwrap_or_else(|| value.clone()),
1638 )
1639 .checked(ticked.contains(value));
1640 if let Some(note) = note {
1641 choice = choice.note(note.clone());
1642 }
1643 *position += 1;
1644 Entry::from(choice)
1645 }
1646 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1647 group.clone(),
1648 build(options, name, ticked, position),
1649 )),
1650 })
1651 .collect()
1652 }
1653
1654 let mut list = Checklist::new(cx.label).id(cx.id);
1655 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1656 let mut position = 0;
1657 for entry in build(&options.options, cx.name, &ticked, &mut position) {
1658 list = list.entry(entry);
1659 }
1660 list = list
1661 .select_all(options.select_all)
1662 .search(options.search)
1663 .expand(options.expand);
1664 }
1665 FieldVm {
1666 view_key: "checklist".to_string(),
1667 name: cx.name.to_string(),
1668 id: cx.id.to_string(),
1669 label: cx.label.to_string(),
1670 required: cx.required,
1671 value: cx.value.clone(),
1672 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1673 }
1674 }
1675
1676 fn render_default(&self, vm: &FieldVm) -> Markup {
1677 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1678 .map(|view| view.render_markup())
1679 .unwrap_or_default()
1680 }
1681}
1682
1683#[derive(Template)]
1684#[template(path = "fields/switch.html")]
1685struct SwitchTmpl<'a> {
1686 name: &'a str,
1687 id: &'a str,
1688 on: bool,
1689}
1690
1691pub(crate) struct SwitchField;
1697
1698fn stored_is_on(stored: Option<&str>) -> bool {
1701 matches!(
1702 stored.map(str::trim).unwrap_or_default(),
1703 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1704 )
1705}
1706
1707impl FieldType for SwitchField {
1708 fn view_key(&self) -> &'static str {
1709 "switch"
1710 }
1711 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1712 keys(&[])
1713 }
1714
1715 fn to_attr(
1716 &self,
1717 field: &SubmittedField<'_>,
1718 _opts: &ResolvedOptions,
1719 _mode: Mode,
1720 ) -> Result<Option<AttrValue>, String> {
1721 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1724 }
1725
1726 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1727 if stored_is_on(stored) {
1728 "1".to_string()
1729 } else {
1730 String::new()
1731 }
1732 }
1733
1734 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1735 scalar_vm("switch", cx)
1736 }
1737
1738 fn render_default(&self, vm: &FieldVm) -> Markup {
1739 Markup::from_template(&SwitchTmpl {
1740 name: &vm.name,
1741 id: &vm.id,
1742 on: stored_is_on(Some(vm.value.as_text())),
1743 })
1744 .unwrap_or_default()
1745 }
1746}
1747
1748#[derive(Template)]
1749#[template(path = "fields/date.html")]
1750struct DateTmpl<'a> {
1751 name: &'a str,
1752 id: &'a str,
1753 value: &'a str,
1754 required: bool,
1755}
1756
1757pub(crate) struct DateField;
1763
1764impl FieldType for DateField {
1765 fn view_key(&self) -> &'static str {
1766 "date"
1767 }
1768 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1769 keys(&[])
1770 }
1771
1772 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1773 vec![Rule::Date]
1774 }
1775
1776 fn to_attr(
1777 &self,
1778 field: &SubmittedField<'_>,
1779 _opts: &ResolvedOptions,
1780 _mode: Mode,
1781 ) -> Result<Option<AttrValue>, String> {
1782 Ok(field.value().map(|v| match v.trim() {
1783 "" => AttrValue::Null,
1784 date => AttrValue::Text(date.to_string()),
1785 }))
1786 }
1787
1788 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1789 let stored = stored.unwrap_or_default().trim();
1790 stored
1793 .split(['T', ' '])
1794 .next()
1795 .unwrap_or_default()
1796 .to_string()
1797 }
1798
1799 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1800 scalar_vm("date", cx)
1801 }
1802
1803 fn render_default(&self, vm: &FieldVm) -> Markup {
1804 Markup::from_template(&DateTmpl {
1805 name: &vm.name,
1806 id: &vm.id,
1807 value: vm.value.as_text(),
1808 required: vm.required,
1809 })
1810 .unwrap_or_default()
1811 }
1812}
1813
1814#[derive(Template)]
1815#[template(path = "fields/password.html")]
1816struct PasswordTmpl<'a> {
1817 name: &'a str,
1818 id: &'a str,
1819 required: bool,
1820 reveal: bool,
1821 show: &'a str,
1822}
1823
1824#[derive(Debug, Clone, Serialize, Deserialize)]
1826struct PasswordOptions {
1827 #[serde(default = "yes")]
1828 reveal: bool,
1829 #[serde(default)]
1831 show: String,
1832}
1833
1834impl Default for PasswordOptions {
1835 fn default() -> Self {
1836 Self {
1837 reveal: true,
1838 show: String::new(),
1839 }
1840 }
1841}
1842
1843pub(crate) struct PasswordField;
1851
1852impl FieldType for PasswordField {
1853 fn view_key(&self) -> &'static str {
1854 "password"
1855 }
1856 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1857 keys(&["reveal"])
1858 }
1859 fn resolve_options(
1860 &self,
1861 raw: &serde_json::Value,
1862 _types: &FieldRegistry,
1863 ) -> Result<ResolvedOptions, OptionsError> {
1864 let opts: PasswordOptions = if raw.is_null() {
1865 PasswordOptions::default()
1866 } else {
1867 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1868 };
1869 Ok(ResolvedOptions::new(opts))
1870 }
1871
1872 fn to_attr(
1873 &self,
1874 field: &SubmittedField<'_>,
1875 _opts: &ResolvedOptions,
1876 _mode: Mode,
1877 ) -> Result<Option<AttrValue>, String> {
1878 match field.value().map(str::trim).unwrap_or_default() {
1879 "" => Ok(None),
1881 plain => laterite_auth::password::hash_password(plain)
1882 .map(|hash| Some(AttrValue::Text(hash)))
1883 .map_err(|e| e.to_string()),
1884 }
1885 }
1886
1887 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1890 String::new()
1891 }
1892
1893 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1894 let mut vm = scalar_vm("password", cx);
1895 let mut opts = cx
1896 .opts
1897 .get::<PasswordOptions>()
1898 .cloned()
1899 .unwrap_or_default();
1900 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
1901 vm.data = serde_json::to_value(opts).unwrap_or_default();
1902 vm
1903 }
1904
1905 fn render_default(&self, vm: &FieldVm) -> Markup {
1906 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1907 Markup::from_template(&PasswordTmpl {
1908 name: &vm.name,
1909 id: &vm.id,
1910 required: vm.required,
1911 reveal: opts.reveal,
1912 show: &opts.show,
1913 })
1914 .unwrap_or_default()
1915 }
1916}
1917
1918#[derive(Template)]
1919#[template(path = "fields/radio.html")]
1920struct RadioTmpl<'a> {
1921 name: &'a str,
1922 options: &'a [OptionView],
1923 required: bool,
1924}
1925
1926pub(crate) struct RadioField;
1931
1932impl FieldType for RadioField {
1933 fn view_key(&self) -> &'static str {
1934 "radio"
1935 }
1936 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1937 keys(&["options"])
1938 }
1939
1940 fn resolve_options(
1941 &self,
1942 raw: &serde_json::Value,
1943 _types: &FieldRegistry,
1944 ) -> Result<ResolvedOptions, OptionsError> {
1945 let opts: SelectOptions =
1946 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1947 Ok(ResolvedOptions::new(opts))
1948 }
1949
1950 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1951 let current = cx.value.as_text();
1952 let views: Vec<OptionView> = cx
1953 .opts
1954 .get::<SelectOptions>()
1955 .map(|o| {
1956 o.options
1957 .iter()
1958 .map(|so| OptionView {
1959 value: so.value.clone(),
1960 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1961 selected: so.value == current,
1962 })
1963 .collect()
1964 })
1965 .unwrap_or_default();
1966 FieldVm {
1967 view_key: "radio".to_string(),
1968 name: cx.name.to_string(),
1969 id: cx.id.to_string(),
1970 label: cx.label.to_string(),
1971 required: cx.required,
1972 value: cx.value.clone(),
1973 data: serde_json::to_value(&views).unwrap_or_default(),
1974 }
1975 }
1976
1977 fn render_default(&self, vm: &FieldVm) -> Markup {
1978 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1979 Markup::from_template(&RadioTmpl {
1980 name: &vm.name,
1981 options: &options,
1982 required: vm.required,
1983 })
1984 .unwrap_or_default()
1985 }
1986}
1987
1988fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1990 FieldVm {
1991 view_key: view_key.to_string(),
1992 name: cx.name.to_string(),
1993 id: cx.id.to_string(),
1994 label: cx.label.to_string(),
1995 required: cx.required,
1996 value: cx.value.clone(),
1997 data: serde_json::Value::Null,
1998 }
1999}
2000
2001#[derive(Deserialize)]
2003struct RefOptions {
2004 source: String,
2005}
2006
2007struct RefResolved {
2009 source: String,
2010}
2011
2012#[derive(Default, Serialize, Deserialize)]
2015struct RefData {
2016 source: String,
2017 search_url: String,
2018 resolve_url: String,
2019 value: String,
2020 placeholder: String,
2021}
2022
2023#[derive(Template)]
2024#[template(path = "fields/ref_picker.html")]
2025struct RefPickerTmpl<'a> {
2026 name: &'a str,
2027 id: &'a str,
2028 value: &'a str,
2029 search_url: &'a str,
2030 resolve_url: &'a str,
2031 placeholder: &'a str,
2032}
2033
2034pub(crate) struct RefPickerField {
2039 pickers: Arc<PickerRegistry>,
2040}
2041
2042impl RefPickerField {
2043 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2044 Self { pickers }
2045 }
2046}
2047
2048impl FieldType for RefPickerField {
2049 fn view_key(&self) -> &'static str {
2050 "reference"
2051 }
2052 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2053 keys(&["source"])
2054 }
2055 fn resolve_options(
2056 &self,
2057 raw: &serde_json::Value,
2058 _types: &FieldRegistry,
2059 ) -> Result<ResolvedOptions, OptionsError> {
2060 let opts: RefOptions =
2061 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2062 if !self.pickers.contains_key(&opts.source) {
2063 return Err(OptionsError(format!(
2064 "unknown picker source `{}`",
2065 opts.source
2066 )));
2067 }
2068 Ok(ResolvedOptions::new(RefResolved {
2069 source: opts.source,
2070 }))
2071 }
2072 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2073 let source = cx
2074 .opts
2075 .get::<RefResolved>()
2076 .map(|r| r.source.as_str())
2077 .unwrap_or_default();
2078 let data = RefData {
2079 search_url: format!("{}/pickers/{}/search", cx.base, source),
2080 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2081 source: source.to_string(),
2082 value: cx.value.as_text().to_string(),
2083 placeholder: "Search…".to_string(),
2084 };
2085 FieldVm {
2086 view_key: "reference".to_string(),
2087 name: cx.name.to_string(),
2088 id: cx.id.to_string(),
2089 label: cx.label.to_string(),
2090 required: cx.required,
2091 value: cx.value.clone(),
2092 data: serde_json::to_value(data).unwrap_or_default(),
2093 }
2094 }
2095 fn render_default(&self, vm: &FieldVm) -> Markup {
2096 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2097 Markup::from_template(&RefPickerTmpl {
2098 name: &vm.name,
2099 id: &vm.id,
2100 value: vm.value.as_text(),
2101 search_url: &data.search_url,
2102 resolve_url: &data.resolve_url,
2103 placeholder: &data.placeholder,
2104 })
2105 .unwrap_or_default()
2106 }
2107 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2108 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2109 }
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114 use super::*;
2115
2116 fn untranslated() -> Translator {
2119 Translator::new("en")
2120 }
2121
2122 fn cx_with<'a>(
2123 name: &'a str,
2124 value: &'a FieldValue,
2125 opts: &'a ResolvedOptions,
2126 i18n: &'a Translator,
2127 ) -> FieldCx<'a> {
2128 FieldCx {
2129 name,
2130 id: name,
2131 label: "Label",
2132 value,
2133 required: true,
2134 max_length: None,
2135 opts,
2136 base: "/admin",
2137 i18n,
2138 }
2139 }
2140
2141 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2142 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2145 }
2146
2147 fn text_field() -> TextField {
2148 TextField::new(Arc::new(builtin_input_registry()))
2149 }
2150
2151 struct BadAttrInput;
2153 impl InputType for BadAttrInput {
2154 fn key(&self) -> &'static str {
2155 "bad"
2156 }
2157 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2158 BTreeMap::from([("class".to_string(), "x".to_string())])
2159 }
2160 }
2161
2162 #[test]
2163 fn text_renders_its_value_escaped() {
2164 let opts = ResolvedOptions::none();
2165 let value = FieldValue::Text("a<b>&\"c".to_string());
2166 let markup = render_field(
2167 &text_field(),
2168 &NoOverrides,
2169 &scope(),
2170 &cx("title", &value, &opts),
2171 );
2172 let html = markup.as_str();
2173 assert!(html.contains(r#"name="title""#));
2174 assert!(html.contains("required"));
2175 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2179 assert!(
2180 html.contains("<") || html.contains("<"),
2181 "angle escaped: {html}"
2182 );
2183 assert!(
2184 html.contains("&") || html.contains("&"),
2185 "ampersand escaped: {html}"
2186 );
2187 }
2188
2189 #[test]
2190 fn textarea_uses_its_own_control() {
2191 let opts = ResolvedOptions::none();
2192 let value = FieldValue::Text("body".to_string());
2193 let markup = render_field(
2194 &TextareaField,
2195 &NoOverrides,
2196 &scope(),
2197 &cx("body", &value, &opts),
2198 );
2199 assert!(markup.as_str().contains("<textarea"));
2200 }
2201
2202 #[test]
2203 fn no_overrides_uses_the_compiled_default() {
2204 let opts = ResolvedOptions::none();
2206 let value = FieldValue::Text("x".to_string());
2207 let ft = text_field();
2208 let vm = ft.view_model(&cx("t", &value, &opts));
2209 let direct = ft.render_default(&vm);
2210 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2211 assert_eq!(direct.as_str(), routed.as_str());
2212 }
2213
2214 #[test]
2215 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2216 let field = text_field();
2217 let opts = field
2218 .resolve_options(
2219 &serde_json::json!({ "input": "email" }),
2220 &builtin_registry(),
2221 )
2222 .unwrap();
2223 assert!(matches!(
2225 field.intrinsic_rules(&opts).as_slice(),
2226 [Rule::Email]
2227 ));
2228 let value = FieldValue::Text("a@b.test".to_string());
2229 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2230 assert!(markup.as_str().contains(r#"type="email""#));
2231 }
2232
2233 #[test]
2234 fn text_default_input_is_plain_text_with_no_extra_rule() {
2235 let field = text_field();
2236 let opts = field
2237 .resolve_options(&serde_json::Value::Null, &builtin_registry())
2238 .unwrap();
2239 assert!(field.intrinsic_rules(&opts).is_empty());
2240 let value = FieldValue::Text("hi".to_string());
2241 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2242 assert!(markup.as_str().contains(r#"type="text""#));
2243 }
2244
2245 #[test]
2246 fn text_number_input_sets_the_type_and_numeric_rule() {
2247 let field = text_field();
2248 let opts = field
2249 .resolve_options(
2250 &serde_json::json!({ "input": "number" }),
2251 &builtin_registry(),
2252 )
2253 .unwrap();
2254 assert!(matches!(
2255 field.intrinsic_rules(&opts).as_slice(),
2256 [Rule::Numeric]
2257 ));
2258 let value = FieldValue::Text("42".to_string());
2259 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2260 assert!(markup.as_str().contains(r#"type="number""#));
2261 }
2262
2263 #[test]
2264 fn text_tel_input_sets_the_type_with_no_rule() {
2265 let field = text_field();
2266 let opts = field
2267 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2268 .unwrap();
2269 assert!(field.intrinsic_rules(&opts).is_empty());
2270 let value = FieldValue::Text(String::new());
2271 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2272 assert!(markup.as_str().contains(r#"type="tel""#));
2273 }
2274
2275 #[test]
2276 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2277 let field = text_field();
2278 let opts = field
2279 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2280 .unwrap();
2281 assert!(matches!(
2282 field.intrinsic_rules(&opts).as_slice(),
2283 [Rule::Url]
2284 ));
2285 let value = FieldValue::Text("https://example.com".to_string());
2286 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2287 let html = markup.as_str();
2288 assert!(html.contains(r#"type="url""#), "{html}");
2289 assert!(html.contains("lat-input-group"), "{html}");
2290 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2291 }
2292
2293 #[test]
2294 fn text_url_input_copy_false_omits_the_button() {
2295 let field = text_field();
2296 let opts = field
2297 .resolve_options(
2298 &serde_json::json!({ "input": "url", "copy": false }),
2299 &builtin_registry(),
2300 )
2301 .unwrap();
2302 let value = FieldValue::Text("https://example.com".to_string());
2303 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2304 let html = markup.as_str();
2305 assert!(html.contains(r#"type="url""#), "{html}");
2306 assert!(!html.contains("lat-input-group"), "{html}");
2308 assert!(!html.contains("data-lat-widget"), "{html}");
2309 }
2310
2311 #[test]
2312 fn text_number_input_emits_min_max_step_attributes() {
2313 let field = text_field();
2314 let opts = field
2315 .resolve_options(
2316 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2317 &builtin_registry(),
2318 )
2319 .unwrap();
2320 let value = FieldValue::Text("5".to_string());
2323 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2324 let html = markup.as_str();
2325 assert!(html.contains(r#"min="0""#), "{html}");
2326 assert!(html.contains(r#"max="10""#), "{html}");
2327 assert!(html.contains(r#"step="2""#), "{html}");
2328 }
2329
2330 #[test]
2331 fn text_input_reserved_attribute_name_aborts_resolve() {
2332 let mut inputs = builtin_input_registry();
2333 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2334 let field = TextField::new(Arc::new(inputs));
2335 let Err(e) =
2336 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2337 else {
2338 panic!("expected a reserved-attribute rejection");
2339 };
2340 assert!(e.0.contains("class"), "{e}");
2341 }
2342
2343 #[test]
2344 fn attribute_and_widget_name_rules() {
2345 assert!(is_name("min", false));
2346 assert!(is_name("data-x", false));
2347 assert!(!is_name("Min", false));
2348 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
2351 }
2352
2353 #[test]
2354 fn select_renders_options_with_the_current_value_selected() {
2355 let raw = serde_json::json!({
2356 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2357 });
2358 let opts = SelectField
2359 .resolve_options(&raw, &builtin_registry())
2360 .unwrap();
2361 let value = FieldValue::Text("closed".to_string());
2362 let markup = render_field(
2363 &SelectField,
2364 &NoOverrides,
2365 &scope(),
2366 &cx("status", &value, &opts),
2367 );
2368 let html = markup.as_str();
2369 assert!(html.contains(r#"<option value="open">Open</option>"#));
2370 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2372 }
2373
2374 #[test]
2375 fn radio_renders_the_same_options_with_the_current_one_checked() {
2376 let raw = serde_json::json!({
2377 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2378 });
2379 let opts = RadioField
2380 .resolve_options(&raw, &builtin_registry())
2381 .unwrap();
2382 let value = FieldValue::Text("closed".to_string());
2383 let markup = render_field(
2384 &RadioField,
2385 &NoOverrides,
2386 &scope(),
2387 &cx("status", &value, &opts),
2388 );
2389 let html = markup.as_str();
2390 assert!(html.contains(r#"value="open""#));
2391 assert!(
2392 html.contains(r#"value="closed" checked"#),
2393 "current is checked"
2394 );
2395 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2398 assert!(html.contains("closed</label>"));
2400 }
2401
2402 fn scope<'a>() -> OverrideScope<'a> {
2403 OverrideScope {
2404 surface: Surface::Field,
2405 view_key: "text",
2406 resource: None,
2407 field: None,
2408 }
2409 }
2410
2411 fn ref_field() -> RefPickerField {
2412 let mut pickers = crate::picker::PickerRegistry::new();
2413 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2414 pickers.insert(
2415 "acme.place".to_string(),
2416 crate::picker::PickerSourceReg::new("acme.place", source),
2417 );
2418 RefPickerField::new(Arc::new(pickers))
2419 }
2420
2421 #[test]
2422 fn reference_rejects_an_unknown_source() {
2423 let field = ref_field();
2424 let Err(e) = field.resolve_options(
2425 &serde_json::json!({ "source": "no.such" }),
2426 &builtin_registry(),
2427 ) else {
2428 panic!("expected an unknown-source rejection");
2429 };
2430 assert!(e.0.contains("no.such"), "{e}");
2431 }
2432
2433 #[test]
2434 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2435 let field = ref_field();
2436 let opts = field
2437 .resolve_options(
2438 &serde_json::json!({ "source": "acme.place" }),
2439 &builtin_registry(),
2440 )
2441 .unwrap();
2442 let value = FieldValue::Text("42".to_string());
2443 let markup = render_field(
2444 &field,
2445 &NoOverrides,
2446 &scope(),
2447 &cx("place_id", &value, &opts),
2448 );
2449 let html = markup.as_str();
2450 assert!(html.contains(r#"type="hidden""#), "{html}");
2453 assert!(html.contains(r#"name="place_id""#), "{html}");
2454 assert!(html.contains(r#"value="42""#), "{html}");
2455 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2457 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2458 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2459 }
2460
2461 #[test]
2462 fn reference_declares_its_widget_assets() {
2463 let field = ref_field();
2464 let opts = field
2465 .resolve_options(
2466 &serde_json::json!({ "source": "acme.place" }),
2467 &builtin_registry(),
2468 )
2469 .unwrap();
2470 assert_eq!(
2471 field.assets(&opts),
2472 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2473 );
2474 }
2475}
2476
2477#[cfg(test)]
2478mod repeater_tests {
2479 use super::*;
2480
2481 fn options() -> ResolvedOptions {
2482 RepeaterField
2483 .resolve_options(
2484 &serde_json::json!({
2485 "fields": [
2486 { "name": "path", "label": "Path", "type": "text" },
2487 { "name": "allow", "label": "Allow", "type": "switch" },
2488 ]
2489 }),
2490 &builtin_registry(),
2491 )
2492 .unwrap()
2493 }
2494
2495 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2496 pairs
2497 .iter()
2498 .map(|(k, v)| (k.to_string(), v.to_string()))
2499 .collect()
2500 }
2501
2502 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2503 let field = SubmittedField::new("rules", data);
2504 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2505 Some(AttrValue::Json(value)) => value,
2506 other => panic!("expected a JSON array, got {other:?}"),
2507 }
2508 }
2509
2510 #[test]
2514 fn a_row_is_typed_by_its_sub_field_types() {
2515 let rows = stored(
2516 &submitted(&[
2517 ("rules[0][path]", "/private"),
2518 ("rules[1][path]", "/public"),
2519 ("rules[1][allow]", "on"),
2520 ]),
2521 &options(),
2522 );
2523
2524 assert_eq!(rows[0]["path"], "/private");
2525 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2526 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2527 }
2528
2529 #[test]
2532 fn rows_are_ordered_by_index_not_submission_order() {
2533 let rows = stored(
2534 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2535 &options(),
2536 );
2537 assert_eq!(rows.as_array().unwrap().len(), 2);
2538 assert_eq!(rows[0]["path"], "/first");
2539 assert_eq!(rows[1]["path"], "/last");
2540 }
2541
2542 #[test]
2543 fn a_row_left_blank_is_dropped() {
2544 let rows = stored(
2545 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2546 &options(),
2547 );
2548 assert_eq!(rows.as_array().unwrap().len(), 1);
2549 }
2550
2551 #[test]
2552 fn nothing_submitted_stores_an_empty_list() {
2553 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2554 }
2555
2556 #[test]
2557 fn a_repeater_refuses_to_hold_a_repeater() {
2558 let err = RepeaterField
2559 .resolve_options(
2560 &serde_json::json!({
2561 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2562 }),
2563 &builtin_registry(),
2564 )
2565 .err()
2566 .expect("a nested repeater is refused");
2567 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2568 }
2569
2570 #[test]
2571 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2572 let err = RepeaterField
2573 .resolve_options(
2574 &serde_json::json!({
2575 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2576 }),
2577 &builtin_registry(),
2578 )
2579 .err()
2580 .expect("an unregistered type is refused");
2581 assert!(err.0.contains("acme.nope"), "{}", err.0);
2582 }
2583
2584 #[test]
2585 fn row_counts_are_bounded_when_asked() {
2586 let opts = RepeaterField
2587 .resolve_options(
2588 &serde_json::json!({
2589 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2590 "min_items": 1,
2591 "max_items": 2
2592 }),
2593 &builtin_registry(),
2594 )
2595 .unwrap();
2596
2597 let empty = HashMap::new();
2598 let too_few = SubmittedField::new("rules", &empty);
2599 assert!(RepeaterField
2600 .to_attr(&too_few, &opts, Mode::Create)
2601 .is_err());
2602
2603 let three = submitted(&[
2604 ("rules[0][path]", "/a"),
2605 ("rules[1][path]", "/b"),
2606 ("rules[2][path]", "/c"),
2607 ]);
2608 let field = SubmittedField::new("rules", &three);
2609 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2610 }
2611
2612 #[test]
2613 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2614 let value = FieldValue::Json(serde_json::json!([
2615 { "path": "/private", "allow": false },
2616 ]));
2617 let cx = FieldCx {
2618 name: "rules",
2619 id: "rules",
2620 label: "Rules",
2621 value: &value,
2622 required: false,
2623 max_length: None,
2624 opts: &options(),
2625 base: "/admin",
2626 i18n: &Translator::new("en"),
2627 };
2628 let html = RepeaterField
2629 .render_default(&RepeaterField.view_model(&cx))
2630 .into_string();
2631
2632 assert!(html.contains(r#"name="rules[0][path]""#));
2633 assert!(html.contains(r#"value="/private""#));
2634 assert!(html.contains("lat-repeater__blank"));
2636 assert!(html.contains(r#"name="rules[__index__][path]""#));
2637 }
2638
2639 fn list_options() -> ResolvedOptions {
2641 RepeaterField
2642 .resolve_options(
2643 &serde_json::json!({
2644 "fields": [
2645 { "name": "agent", "label": "Agent", "type": "text" },
2646 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2647 ],
2648 "display": "list",
2649 "summary_field": "agent",
2650 }),
2651 &builtin_registry(),
2652 )
2653 .unwrap()
2654 }
2655
2656 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2657 let cx = FieldCx {
2658 name: "rules",
2659 id: "rules",
2660 label: "Rules",
2661 value,
2662 required: false,
2663 max_length: None,
2664 opts,
2665 base: "/admin",
2666 i18n: &Translator::new("en"),
2667 };
2668 RepeaterField
2669 .render_default(&RepeaterField.view_model(&cx))
2670 .into_string()
2671 }
2672
2673 #[test]
2674 fn a_list_repeater_collapses_its_rows_and_names_them() {
2675 let value = FieldValue::Json(serde_json::json!([
2676 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2677 ]));
2678 let html = render_with(&list_options(), &value);
2679 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2680 assert!(html.contains("<details"), "a row collapses");
2681 assert!(
2682 html.contains("Googlebot"),
2683 "the naming field titles the row"
2684 );
2685 assert!(html.contains(r#"name="rules[0][disallow]""#));
2687 }
2688
2689 #[test]
2690 fn a_row_with_no_name_yet_reads_as_untitled() {
2691 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2692 let html = render_with(&list_options(), &value);
2693 assert!(html.contains("lat-repeater__untitled"));
2694 }
2695
2696 #[test]
2697 fn the_default_layout_stays_inline() {
2698 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2700 let html = render_with(&options(), &value);
2701 assert!(!html.contains("lat-repeater--list"));
2702 assert!(!html.contains("<details"));
2703 }
2704
2705 #[test]
2706 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2707 assert_eq!(
2709 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2710 "/private"
2711 );
2712 assert_eq!(summary_text(&serde_json::json!("")), "");
2713 }
2714
2715 #[test]
2722 fn a_contributed_field_type_names_itself_and_converts() {
2723 struct Money;
2724 impl FieldType for Money {
2725 fn view_key(&self) -> &'static str {
2726 "acme.money"
2727 }
2728 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2729 scalar_vm("acme.money", cx)
2730 }
2731 fn render_default(&self, _vm: &FieldVm) -> Markup {
2732 Markup::empty()
2733 }
2734 fn to_attr(
2735 &self,
2736 field: &SubmittedField<'_>,
2737 _opts: &ResolvedOptions,
2738 _mode: Mode,
2739 ) -> Result<Option<AttrValue>, String> {
2740 let raw = field.value().unwrap_or("0");
2742 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2743 Ok(Some(AttrValue::Int(minor as i64)))
2744 }
2745 }
2746
2747 let reg = FieldTypeReg::new(Arc::new(Money));
2748 assert_eq!(reg.name(), "acme.money");
2751
2752 let mut registry = builtin_registry();
2753 let name = reg.name().to_string();
2754 assert!(
2755 registry.insert(name.clone(), reg.field_type).is_none(),
2756 "a dotted key does not collide with a built-in"
2757 );
2758
2759 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2760 let submitted = SubmittedField::new("price", &data);
2761 let converted = registry[&name]
2762 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2763 .unwrap();
2764 assert!(
2765 matches!(converted, Some(AttrValue::Int(1250))),
2766 "{converted:?}"
2767 );
2768 }
2769
2770 #[test]
2771 fn a_sub_field_label_is_localized_like_any_other() {
2772 use laterite_core::i18n::CatalogStore;
2773 use std::sync::Arc;
2774
2775 let po = concat!(
2776 "msgid \"\"\n",
2777 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2778 "\n",
2779 "msgid \"Path\"\n",
2780 "msgstr \"Path-kn\"\n",
2781 );
2782 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2783 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2784
2785 let value = FieldValue::Text(String::new());
2786 let cx = FieldCx {
2787 name: "rules",
2788 id: "rules",
2789 label: "Rules",
2790 value: &value,
2791 required: false,
2792 max_length: None,
2793 opts: &options(),
2794 base: "/admin",
2795 i18n: &i18n,
2796 };
2797 let html = RepeaterField
2798 .render_default(&RepeaterField.view_model(&cx))
2799 .into_string();
2800
2801 assert!(
2802 html.contains("Path-kn"),
2803 "sub-label was not localized: {html}"
2804 );
2805 assert!(
2806 !html.contains(">Path<"),
2807 "the source string still leaked: {html}"
2808 );
2809 }
2810}
2811
2812#[cfg(test)]
2813mod save_contract_tests {
2814 use super::*;
2815
2816 fn one(raw: Option<&str>) -> HashMap<String, String> {
2818 raw.into_iter()
2819 .map(|v| ("f".to_string(), v.to_string()))
2820 .collect()
2821 }
2822
2823 fn none() -> ResolvedOptions {
2824 ResolvedOptions::none()
2825 }
2826
2827 #[test]
2828 fn a_switch_stores_a_bool_and_absence_means_off() {
2829 let f = SwitchField;
2830 assert_eq!(
2833 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2834 .unwrap(),
2835 Some(AttrValue::Bool(false))
2836 );
2837 assert_eq!(
2838 f.to_attr(
2839 &SubmittedField::new("f", &one(Some("on"))),
2840 &none(),
2841 Mode::Create
2842 )
2843 .unwrap(),
2844 Some(AttrValue::Bool(true))
2845 );
2846 }
2847
2848 #[test]
2849 fn a_switch_reads_back_whatever_the_backend_gave() {
2850 let f = SwitchField;
2851 for on in ["1", "true", "t", "on"] {
2853 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2854 }
2855 for off in ["0", "false", "f", ""] {
2856 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2857 }
2858 assert!(f.to_control(None, &none()).is_empty());
2859 }
2860
2861 #[test]
2862 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2863 let f = DateField;
2864 assert_eq!(
2866 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2867 "2026-09-09"
2868 );
2869 assert_eq!(
2870 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2871 "2026-09-09"
2872 );
2873 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2874 assert_eq!(f.to_control(None, &none()), "");
2875 }
2876
2877 #[test]
2878 fn a_cleared_date_stores_null_not_an_empty_string() {
2879 let f = DateField;
2880 assert_eq!(
2881 f.to_attr(
2882 &SubmittedField::new("f", &one(Some(""))),
2883 &none(),
2884 Mode::Update
2885 )
2886 .unwrap(),
2887 Some(AttrValue::Null)
2888 );
2889 assert_eq!(
2890 f.to_attr(
2891 &SubmittedField::new("f", &one(Some("2026-09-09"))),
2892 &none(),
2893 Mode::Create
2894 )
2895 .unwrap(),
2896 Some(AttrValue::Text("2026-09-09".into()))
2897 );
2898 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2899 }
2900
2901 #[test]
2902 fn a_password_hashes_and_never_echoes() {
2903 let f = PasswordField;
2904 let stored = f
2905 .to_attr(
2906 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2907 &none(),
2908 Mode::Create,
2909 )
2910 .unwrap()
2911 .unwrap();
2912 let hash = stored.as_str().unwrap();
2913 assert!(
2914 hash.starts_with("$argon2"),
2915 "stored as a hash, not plaintext"
2916 );
2917 assert_ne!(hash, "hunter2hunter2");
2918 assert_eq!(f.to_control(Some(hash), &none()), "");
2920 }
2921
2922 #[test]
2923 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2924 let f = PasswordField;
2925 assert_eq!(
2927 f.to_attr(
2928 &SubmittedField::new("f", &one(Some(""))),
2929 &none(),
2930 Mode::Update
2931 )
2932 .unwrap(),
2933 None
2934 );
2935 assert_eq!(
2936 f.to_attr(
2937 &SubmittedField::new("f", &one(Some(" "))),
2938 &none(),
2939 Mode::Update
2940 )
2941 .unwrap(),
2942 None
2943 );
2944 assert_eq!(
2945 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2946 .unwrap(),
2947 None
2948 );
2949 }
2950
2951 #[test]
2952 fn a_plain_field_stores_and_shows_the_text_unchanged() {
2953 let f = TextareaField;
2954 assert_eq!(
2955 f.to_attr(
2956 &SubmittedField::new("f", &one(Some("hello"))),
2957 &none(),
2958 Mode::Create
2959 )
2960 .unwrap(),
2961 Some(AttrValue::Text("hello".into()))
2962 );
2963 assert_eq!(
2965 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2966 .unwrap(),
2967 None
2968 );
2969 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
2970 }
2971}