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 opts: &'a ResolvedOptions,
84 pub base: &'a str,
87 pub i18n: &'a Translator,
95}
96
97#[derive(Debug, Clone, Serialize)]
100pub struct FieldVm {
101 pub view_key: String,
102 pub name: String,
103 pub id: String,
104 pub label: String,
105 pub required: bool,
106 pub value: FieldValue,
107 pub data: serde_json::Value,
108}
109
110pub struct SubmittedField<'a> {
118 name: &'a str,
119 data: &'a HashMap<String, String>,
120}
121
122impl<'a> SubmittedField<'a> {
123 pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
124 Self { name, data }
125 }
126
127 pub fn name(&self) -> &str {
130 self.name
131 }
132
133 pub fn value(&self) -> Option<&str> {
138 self.data.get(self.name).map(String::as_str)
139 }
140
141 pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
146 let prefix = format!("{}[", self.name);
147 self.data.iter().filter_map(move |(key, value)| {
148 key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
149 })
150 }
151
152 pub fn all(&self) -> &'a HashMap<String, String> {
155 self.data
156 }
157}
158
159pub trait FieldType: Send + Sync + 'static {
160 fn view_key(&self) -> &'static str;
163
164 fn resolve_options(
171 &self,
172 _raw: &serde_json::Value,
173 _types: &FieldRegistry,
174 ) -> Result<ResolvedOptions, OptionsError> {
175 Ok(ResolvedOptions::none())
176 }
177 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
183 None
184 }
185
186 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
189 Vec::new()
190 }
191
192 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
194
195 fn render_default(&self, vm: &FieldVm) -> Markup;
198
199 fn to_attr(
213 &self,
214 field: &SubmittedField<'_>,
215 opts: &ResolvedOptions,
216 mode: Mode,
217 ) -> Result<Option<AttrValue>, String> {
218 let _ = (opts, mode);
219 Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
220 }
221
222 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
231 let _ = opts;
232 stored.unwrap_or_default().to_string()
233 }
234
235 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
246 let _ = opts;
247 field.value().map(str::to_string)
248 }
249
250 fn chrome(&self) -> Chrome {
252 Chrome::Wrapped
253 }
254
255 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
260 Vec::new()
261 }
262}
263
264#[derive(Debug, thiserror::Error)]
266#[error("invalid field options: {0}")]
267pub struct OptionsError(pub String);
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum Surface {
272 Field,
273 Column,
274}
275
276pub struct OverrideScope<'a> {
279 pub surface: Surface,
280 pub view_key: &'a str,
281 pub resource: Option<&'a str>,
282 pub field: Option<&'a str>,
283}
284
285#[derive(Debug, thiserror::Error)]
287#[error("override render failed: {0}")]
288pub struct OverrideError(pub String);
289
290pub trait OverrideResolver: Send + Sync {
294 fn render_override(
297 &self,
298 scope: &OverrideScope<'_>,
299 vm: &serde_json::Value,
300 ) -> Option<Result<String, OverrideError>>;
301}
302
303pub struct NoOverrides;
306
307impl OverrideResolver for NoOverrides {
308 fn render_override(
309 &self,
310 _scope: &OverrideScope<'_>,
311 _vm: &serde_json::Value,
312 ) -> Option<Result<String, OverrideError>> {
313 None
314 }
315}
316
317pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
319
320pub struct FieldTypeReg {
327 pub field_type: Arc<dyn FieldType>,
328}
329
330impl FieldTypeReg {
331 pub fn new(field_type: Arc<dyn FieldType>) -> Self {
332 Self { field_type }
333 }
334
335 pub fn name(&self) -> &'static str {
337 self.field_type.view_key()
338 }
339}
340
341pub(crate) fn render_field(
344 ft: &dyn FieldType,
345 resolver: &dyn OverrideResolver,
346 scope: &OverrideScope<'_>,
347 cx: &FieldCx<'_>,
348) -> Markup {
349 let vm = ft.view_model(cx);
350 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
351 Some(Ok(html)) => Markup::from_override(html),
352 Some(Err(_)) | None => ft.render_default(&vm),
354 }
355}
356
357pub(crate) struct RepeaterOptions {
360 rows: Vec<RepeaterSub>,
361 min_items: usize,
362 max_items: Option<usize>,
363 display: RepeaterDisplay,
364 summary_field: Option<String>,
366}
367
368#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
375#[serde(rename_all = "snake_case")]
376pub enum RepeaterDisplay {
377 #[default]
380 Inline,
381 List,
384}
385
386pub(crate) struct RepeaterSub {
388 field: crate::form::FormField,
389 field_type: Arc<dyn FieldType>,
390 opts: ResolvedOptions,
391}
392
393#[derive(Deserialize)]
395struct RepeaterOptionsRaw {
396 #[serde(deserialize_with = "named_or_listed")]
398 fields: Vec<crate::form::FormField>,
399 #[serde(default)]
400 min_items: usize,
401 #[serde(default)]
402 max_items: Option<usize>,
403 #[serde(default)]
404 display: RepeaterDisplay,
405 #[serde(default)]
406 summary_field: Option<String>,
407}
408
409pub(crate) fn check_option_keys(
411 raw: &serde_json::Value,
412 accepted: &[String],
413) -> Result<(), OptionsError> {
414 let serde_json::Value::Object(entries) = raw else {
415 return Ok(());
416 };
417 for key in entries.keys() {
418 if !accepted.iter().any(|k| k == key) {
419 return Err(OptionsError(if accepted.is_empty() {
420 format!("unknown key `{key}`: this type takes no keys of its own")
421 } else {
422 format!(
423 "unknown key `{key}`, expected one of: {}",
424 accepted.join(", ")
425 )
426 }));
427 }
428 }
429 Ok(())
430}
431
432pub(crate) fn check_field_keys(
435 field: &crate::form::FormField,
436 field_type: &dyn FieldType,
437) -> Result<(), OptionsError> {
438 match field_type.option_keys(&field.options) {
439 Some(keys) => check_option_keys(&field.options, &keys),
440 None => Ok(()),
441 }
442}
443
444fn keys(names: &[&str]) -> Option<Vec<String>> {
445 Some(names.iter().map(|k| k.to_string()).collect())
446}
447
448fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
451where
452 D: serde::Deserializer<'de>,
453{
454 use crate::keyed::Keyed;
455 #[derive(Deserialize)]
456 #[serde(untagged)]
457 enum Shape {
458 Listed(Vec<crate::form::FormField>),
459 Named(serde_json::Map<String, serde_json::Value>),
460 }
461 match Shape::deserialize(de)? {
462 Shape::Listed(fields) => Ok(fields),
463 Shape::Named(entries) => entries
464 .into_iter()
465 .map(|(name, entry)| {
466 let mut field: crate::form::FormField =
467 serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
468 field.set_key(name);
469 Ok(field)
470 })
471 .collect(),
472 }
473}
474
475pub(crate) struct RepeaterField;
484
485impl RepeaterField {
486 const BLANK: &'static str = "__index__";
488}
489
490impl FieldType for RepeaterField {
491 fn view_key(&self) -> &'static str {
492 "repeater"
493 }
494
495 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
496 keys(&[
497 "fields",
498 "min_items",
499 "max_items",
500 "display",
501 "summary_field",
502 ])
503 }
504
505 fn resolve_options(
506 &self,
507 raw: &serde_json::Value,
508 types: &FieldRegistry,
509 ) -> Result<ResolvedOptions, OptionsError> {
510 let parsed: RepeaterOptionsRaw =
511 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
512 let mut rows = Vec::new();
513 for field in parsed.fields {
514 if field.field_type == "repeater" {
515 return Err(OptionsError(
516 "a repeater cannot hold a repeater".to_string(),
517 ));
518 }
519 let field_type = types
520 .get(&field.field_type)
521 .cloned()
522 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
523 check_field_keys(&field, field_type.as_ref())
524 .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
525 let opts = field_type.resolve_options(&field.options, types)?;
526 rows.push(RepeaterSub {
527 field,
528 field_type,
529 opts,
530 });
531 }
532 Ok(ResolvedOptions::new(RepeaterOptions {
533 rows,
534 min_items: parsed.min_items,
535 max_items: parsed.max_items,
536 display: parsed.display,
537 summary_field: parsed.summary_field,
538 }))
539 }
540
541 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
542 let opts = cx.opts.get::<RepeaterOptions>();
543 let stored = match cx.value {
544 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
545 _ => Vec::new(),
546 };
547 let summary_index = opts
550 .and_then(|o| {
551 o.summary_field
552 .as_ref()
553 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
554 })
555 .unwrap_or(0);
556 let summaries = stored
557 .iter()
558 .map(|row| {
559 opts.and_then(|o| o.rows.get(summary_index))
560 .and_then(|sub| row.get(&sub.field.name))
561 .map(summary_text)
562 .unwrap_or_default()
563 })
564 .collect();
565 let data = RepeaterData {
566 rows: opts
567 .map(|o| {
568 stored
569 .iter()
570 .enumerate()
571 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
572 .collect()
573 })
574 .unwrap_or_default(),
575 blank: opts
576 .map(|o| render_row(o, cx, Self::BLANK, None))
577 .unwrap_or_default(),
578 list: opts.is_some_and(|o| o.display == RepeaterDisplay::List),
579 summary_index,
580 summaries,
581 };
582
583 FieldVm {
584 view_key: self.view_key().to_string(),
585 name: cx.name.to_string(),
586 id: cx.id.to_string(),
587 label: cx.label.to_string(),
588 required: cx.required,
589 value: cx.value.clone(),
590 data: serde_json::to_value(data).unwrap_or_default(),
591 }
592 }
593
594 fn render_default(&self, vm: &FieldVm) -> Markup {
595 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
596 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
597 }
598
599 fn to_attr(
600 &self,
601 field: &SubmittedField<'_>,
602 opts: &ResolvedOptions,
603 mode: Mode,
604 ) -> Result<Option<AttrValue>, String> {
605 let Some(options) = opts.get::<RepeaterOptions>() else {
606 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
607 };
608
609 let mut indices: Vec<usize> = field
612 .nested()
613 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
614 .collect();
615 indices.sort_unstable();
616 indices.dedup();
617
618 let mut rows = Vec::new();
619 for index in indices {
620 let mut object = serde_json::Map::new();
621 for sub in &options.rows {
622 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
623 let scoped = SubmittedField::new(&key, field.all());
624 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
625 object.insert(sub.field.name.clone(), attr_to_json(value));
626 }
627 }
628 let row = serde_json::Value::Object(object);
629 if !is_blank_row(&row) {
631 rows.push(row);
632 }
633 }
634
635 if rows.len() < options.min_items {
636 return Err(format!("at least {} required", options.min_items));
637 }
638 if let Some(max) = options.max_items {
639 if rows.len() > max {
640 return Err(format!("at most {max} allowed"));
641 }
642 }
643 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
644 }
645
646 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
647 stored.unwrap_or("[]").to_string()
648 }
649}
650
651fn summary_text(value: &serde_json::Value) -> String {
655 let raw = match value {
656 serde_json::Value::String(s) => s.clone(),
657 serde_json::Value::Null => String::new(),
658 other => other.to_string(),
659 };
660 raw.lines().next().unwrap_or_default().trim().to_string()
661}
662
663fn render_row(
664 options: &RepeaterOptions,
665 cx: &FieldCx<'_>,
666 index: &str,
667 row: Option<&serde_json::Value>,
668) -> Vec<RepeaterCell> {
669 options
670 .rows
671 .iter()
672 .map(|sub| {
673 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
674 let stored = row.and_then(|r| r.get(&sub.field.name));
675 let value = match stored {
676 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
677 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
678 Some(other) => FieldValue::Text(
679 sub.field_type
680 .to_control(Some(&other.to_string()), &sub.opts),
681 ),
682 };
683 let sub_cx = FieldCx {
684 name: &name,
685 id: &name,
686 label: cx.label,
687 value: &value,
688 required: false,
689 opts: &sub.opts,
690 base: cx.base,
691 i18n: cx.i18n,
692 };
693 let vm = sub.field_type.view_model(&sub_cx);
694 RepeaterCell {
695 label: cx.i18n.t(&sub.field.label),
696 control: sub.field_type.render_default(&vm).into_string(),
697 help: sub
698 .field
699 .help
700 .as_ref()
701 .map(|h| cx.i18n.t(h))
702 .unwrap_or_default(),
703 }
704 })
705 .collect()
706}
707
708fn attr_to_json(value: AttrValue) -> serde_json::Value {
710 match value {
711 AttrValue::Null => serde_json::Value::Null,
712 AttrValue::Bool(b) => serde_json::Value::Bool(b),
713 AttrValue::Int(i) => serde_json::Value::from(i),
714 AttrValue::Float(f) => serde_json::Value::from(f),
715 AttrValue::Json(j) => j,
716 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
717 }
718}
719
720fn is_blank_row(row: &serde_json::Value) -> bool {
722 row.as_object().is_none_or(|object| {
723 object.values().all(|v| match v {
724 serde_json::Value::String(s) => s.trim().is_empty(),
725 serde_json::Value::Bool(b) => !b,
726 serde_json::Value::Array(items) => items.is_empty(),
727 serde_json::Value::Null => true,
728 _ => false,
729 })
730 })
731}
732
733#[derive(Serialize, Deserialize)]
735struct RepeaterCell {
736 label: String,
737 control: String,
738 help: String,
742}
743
744#[derive(Serialize, Deserialize, Default)]
747struct RepeaterData {
748 rows: Vec<Vec<RepeaterCell>>,
749 blank: Vec<RepeaterCell>,
750 list: bool,
752 summary_index: usize,
754 summaries: Vec<String>,
757}
758
759#[derive(Template)]
760#[template(path = "fields/repeater.html")]
761struct RepeaterTmpl {
762 data: RepeaterData,
763}
764
765pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
768 let inputs = Arc::new(builtin_input_registry());
769 vec![
770 Arc::new(TextField::new(inputs)),
771 Arc::new(TextareaField),
772 Arc::new(RepeaterField),
773 Arc::new(SelectField),
774 Arc::new(ChecklistField),
775 Arc::new(SwitchField),
776 Arc::new(DateField),
777 Arc::new(PasswordField),
778 Arc::new(RadioField),
779 ]
780}
781
782pub(crate) fn builtin_registry() -> FieldRegistry {
784 builtin_field_types()
785 .into_iter()
786 .map(|ft| (ft.view_key().to_string(), ft))
787 .collect()
788}
789
790#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum Placement {
794 Leading,
795 Trailing,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct Adornment {
803 pub placement: Placement,
804 pub button: bool,
806 pub label: String,
808 pub widget: Option<String>,
811}
812
813pub trait InputType: Send + Sync + 'static {
819 fn key(&self) -> &'static str;
821 fn html_type(&self) -> &'static str {
823 "text"
824 }
825 fn resolve_options(
828 &self,
829 _raw: &serde_json::Value,
830 _types: &FieldRegistry,
831 ) -> Result<ResolvedOptions, OptionsError> {
832 Ok(ResolvedOptions::none())
833 }
834 fn option_keys(&self) -> Vec<&'static str> {
837 Vec::new()
838 }
839 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
841 Vec::new()
842 }
843 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
846 BTreeMap::new()
847 }
848 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
850 Vec::new()
851 }
852 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
855 Vec::new()
856 }
857}
858
859pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
861
862struct TextInput;
863impl InputType for TextInput {
864 fn key(&self) -> &'static str {
865 "text"
866 }
867}
868
869struct EmailInput;
870impl InputType for EmailInput {
871 fn key(&self) -> &'static str {
872 "email"
873 }
874 fn html_type(&self) -> &'static str {
875 "email"
876 }
877 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
878 vec![Rule::Email]
879 }
880}
881
882struct TelInput;
883impl InputType for TelInput {
884 fn key(&self) -> &'static str {
885 "tel"
886 }
887 fn html_type(&self) -> &'static str {
888 "tel"
889 }
890}
891
892#[derive(Default, Deserialize)]
894struct NumberOptions {
895 #[serde(default)]
896 min: Option<f64>,
897 #[serde(default)]
898 max: Option<f64>,
899 #[serde(default)]
900 step: Option<f64>,
901}
902
903struct NumberInput;
904impl InputType for NumberInput {
905 fn key(&self) -> &'static str {
906 "number"
907 }
908 fn html_type(&self) -> &'static str {
909 "number"
910 }
911 fn option_keys(&self) -> Vec<&'static str> {
912 vec!["min", "max", "step"]
913 }
914 fn resolve_options(
915 &self,
916 raw: &serde_json::Value,
917 _types: &FieldRegistry,
918 ) -> Result<ResolvedOptions, OptionsError> {
919 let opts: NumberOptions = if raw.is_null() {
920 NumberOptions::default()
921 } else {
922 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
923 };
924 Ok(ResolvedOptions::new(opts))
925 }
926 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
927 vec![Rule::Numeric]
928 }
929 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
930 let mut attrs = BTreeMap::new();
931 if let Some(o) = opts.get::<NumberOptions>() {
932 if let Some(min) = o.min {
933 attrs.insert("min".to_string(), min.to_string());
934 }
935 if let Some(max) = o.max {
936 attrs.insert("max".to_string(), max.to_string());
937 }
938 if let Some(step) = o.step {
939 attrs.insert("step".to_string(), step.to_string());
940 }
941 }
942 attrs
943 }
944}
945
946#[derive(Deserialize)]
948struct UrlOptions {
949 #[serde(default = "default_true")]
950 copy: bool,
951}
952
953impl Default for UrlOptions {
954 fn default() -> Self {
955 Self { copy: true }
956 }
957}
958
959fn default_true() -> bool {
960 true
961}
962
963struct UrlInput;
964impl InputType for UrlInput {
965 fn key(&self) -> &'static str {
966 "url"
967 }
968 fn html_type(&self) -> &'static str {
969 "url"
970 }
971 fn option_keys(&self) -> Vec<&'static str> {
972 vec!["copy"]
973 }
974 fn resolve_options(
975 &self,
976 raw: &serde_json::Value,
977 _types: &FieldRegistry,
978 ) -> Result<ResolvedOptions, OptionsError> {
979 let opts: UrlOptions = if raw.is_null() {
980 UrlOptions::default()
981 } else {
982 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
983 };
984 Ok(ResolvedOptions::new(opts))
985 }
986 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
987 vec![Rule::Url]
988 }
989 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
990 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
991 vec![Adornment {
992 placement: Placement::Trailing,
993 button: true,
994 label: "Copy".to_string(),
995 widget: Some("copy".to_string()),
996 }]
997 } else {
998 Vec::new()
999 }
1000 }
1001}
1002
1003pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1004 vec![
1005 Arc::new(TextInput),
1006 Arc::new(EmailInput),
1007 Arc::new(TelInput),
1008 Arc::new(NumberInput),
1009 Arc::new(UrlInput),
1010 ]
1011}
1012
1013pub(crate) fn builtin_input_registry() -> InputRegistry {
1015 builtin_input_types()
1016 .into_iter()
1017 .map(|i| (i.key().to_string(), i))
1018 .collect()
1019}
1020
1021const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1024
1025fn validate_contributions(
1030 attrs: &BTreeMap<String, String>,
1031 adornments: &[Adornment],
1032) -> Result<(), OptionsError> {
1033 for name in attrs.keys() {
1034 if !is_name(name, false) {
1035 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1036 }
1037 if RESERVED_ATTRS.contains(&name.as_str()) {
1038 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1039 }
1040 }
1041 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1042 if !is_name(widget, true) {
1043 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1044 }
1045 }
1046 Ok(())
1047}
1048
1049pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1051 let mut chars = s.chars();
1052 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1053 && chars.all(|c| {
1054 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1055 })
1056}
1057
1058#[derive(Deserialize)]
1060struct TextOptions {
1061 #[serde(default = "default_input")]
1062 input: String,
1063 #[serde(default)]
1065 placeholder: Option<String>,
1066}
1067
1068fn default_input() -> String {
1069 "text".to_string()
1070}
1071
1072struct TextResolved {
1075 html_type: &'static str,
1076 attrs: BTreeMap<String, String>,
1077 adornments: Vec<Adornment>,
1078 rules: Vec<Rule>,
1079 assets: Vec<&'static str>,
1080}
1081
1082#[derive(Default, Serialize, Deserialize)]
1085struct TextData {
1086 input_type: String,
1087 #[serde(default)]
1088 attrs: BTreeMap<String, String>,
1089 #[serde(default)]
1090 adornments: Vec<Adornment>,
1091}
1092
1093#[derive(Template)]
1094#[template(path = "fields/text.html")]
1095struct TextTmpl<'a> {
1096 name: &'a str,
1097 id: &'a str,
1098 value: &'a str,
1099 required: bool,
1100 input_type: &'a str,
1101 attrs: &'a BTreeMap<String, String>,
1102 leading: &'a [Adornment],
1103 trailing: &'a [Adornment],
1104}
1105
1106pub(crate) struct TextField {
1110 inputs: Arc<InputRegistry>,
1111}
1112
1113impl TextField {
1114 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1115 Self { inputs }
1116 }
1117}
1118
1119impl FieldType for TextField {
1120 fn view_key(&self) -> &'static str {
1121 "text"
1122 }
1123 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1124 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1125 let input = self.inputs.get(input)?;
1128 let mut all = vec!["input".to_string(), "placeholder".to_string()];
1129 all.extend(input.option_keys().into_iter().map(str::to_string));
1130 Some(all)
1131 }
1132 fn resolve_options(
1133 &self,
1134 raw: &serde_json::Value,
1135 _types: &FieldRegistry,
1136 ) -> Result<ResolvedOptions, OptionsError> {
1137 let opts: TextOptions = if raw.is_null() {
1138 TextOptions {
1139 input: default_input(),
1140 placeholder: None,
1141 }
1142 } else {
1143 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1144 };
1145 let input = self
1146 .inputs
1147 .get(&opts.input)
1148 .cloned()
1149 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1150 let input_opts = input.resolve_options(raw, _types)?;
1153 let mut attrs = input.attributes(&input_opts);
1154 let adornments = input.adornments(&input_opts);
1155 validate_contributions(&attrs, &adornments)?;
1156 if let Some(placeholder) = opts.placeholder {
1157 attrs.insert("placeholder".to_string(), placeholder);
1158 }
1159 Ok(ResolvedOptions::new(TextResolved {
1160 html_type: input.html_type(),
1161 rules: input.rules(&input_opts),
1162 assets: input.assets(&input_opts),
1163 attrs,
1164 adornments,
1165 }))
1166 }
1167 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1168 opts.get::<TextResolved>()
1169 .map(|r| r.rules.clone())
1170 .unwrap_or_default()
1171 }
1172 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1173 opts.get::<TextResolved>()
1174 .map(|r| r.assets.clone())
1175 .unwrap_or_default()
1176 }
1177 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1178 let data = match cx.opts.get::<TextResolved>() {
1179 Some(r) => TextData {
1180 input_type: r.html_type.to_string(),
1181 attrs: r.attrs.clone(),
1182 adornments: r.adornments.clone(),
1183 },
1184 None => TextData {
1185 input_type: "text".to_string(),
1186 ..TextData::default()
1187 },
1188 };
1189 FieldVm {
1190 view_key: "text".to_string(),
1191 name: cx.name.to_string(),
1192 id: cx.id.to_string(),
1193 label: cx.label.to_string(),
1194 required: cx.required,
1195 value: cx.value.clone(),
1196 data: serde_json::to_value(data).unwrap_or_default(),
1197 }
1198 }
1199 fn render_default(&self, vm: &FieldVm) -> Markup {
1200 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1201 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1202 .adornments
1203 .into_iter()
1204 .partition(|a| matches!(a.placement, Placement::Leading));
1205 Markup::from_template(&TextTmpl {
1206 name: &vm.name,
1207 id: &vm.id,
1208 value: vm.value.as_text(),
1209 required: vm.required,
1210 input_type: &data.input_type,
1211 attrs: &data.attrs,
1212 leading: &leading,
1213 trailing: &trailing,
1214 })
1215 .unwrap_or_default()
1216 }
1217}
1218
1219#[derive(Template)]
1220#[template(path = "fields/textarea.html")]
1221struct TextareaTmpl<'a> {
1222 name: &'a str,
1223 id: &'a str,
1224 value: &'a str,
1225 required: bool,
1226 rows: Option<u16>,
1227 placeholder: Option<&'a str>,
1228}
1229
1230#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1232struct TextareaOptions {
1233 #[serde(default)]
1234 rows: Option<u16>,
1235 #[serde(default)]
1236 placeholder: Option<String>,
1237}
1238
1239pub(crate) struct TextareaField;
1241
1242impl FieldType for TextareaField {
1243 fn view_key(&self) -> &'static str {
1244 "textarea"
1245 }
1246 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1247 keys(&["rows", "placeholder"])
1248 }
1249 fn resolve_options(
1250 &self,
1251 raw: &serde_json::Value,
1252 _types: &FieldRegistry,
1253 ) -> Result<ResolvedOptions, OptionsError> {
1254 let opts: TextareaOptions = if raw.is_null() {
1255 TextareaOptions::default()
1256 } else {
1257 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1258 };
1259 Ok(ResolvedOptions::new(opts))
1260 }
1261 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1262 let mut vm = scalar_vm("textarea", cx);
1263 if let Some(opts) = cx.opts.get::<TextareaOptions>() {
1264 vm.data = serde_json::to_value(opts).unwrap_or_default();
1265 }
1266 vm
1267 }
1268 fn render_default(&self, vm: &FieldVm) -> Markup {
1269 let opts: TextareaOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1270 Markup::from_template(&TextareaTmpl {
1271 name: &vm.name,
1272 id: &vm.id,
1273 value: vm.value.as_text(),
1274 required: vm.required,
1275 rows: opts.rows,
1276 placeholder: opts.placeholder.as_deref(),
1277 })
1278 .unwrap_or_default()
1279 }
1280}
1281
1282#[derive(Debug, Default, Deserialize)]
1284struct SelectOptions {
1285 #[serde(default)]
1286 options: Vec<SelectOption>,
1287}
1288
1289#[derive(Debug, Deserialize)]
1290struct SelectOption {
1291 value: String,
1292 #[serde(default)]
1294 label: Option<String>,
1295}
1296
1297#[derive(Debug, Serialize, Deserialize)]
1300struct OptionView {
1301 value: String,
1302 label: String,
1303 selected: bool,
1304}
1305
1306#[derive(Template)]
1307#[template(path = "fields/select.html")]
1308struct SelectTmpl<'a> {
1309 name: &'a str,
1310 id: &'a str,
1311 required: bool,
1312 options: &'a [OptionView],
1313}
1314
1315pub(crate) struct SelectField;
1317
1318impl FieldType for SelectField {
1319 fn view_key(&self) -> &'static str {
1320 "select"
1321 }
1322 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1323 keys(&["options"])
1324 }
1325 fn resolve_options(
1326 &self,
1327 raw: &serde_json::Value,
1328 _types: &FieldRegistry,
1329 ) -> Result<ResolvedOptions, OptionsError> {
1330 let opts: SelectOptions =
1331 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1332 Ok(ResolvedOptions::new(opts))
1333 }
1334 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1335 let current = cx.value.as_text();
1336 let views: Vec<OptionView> = cx
1337 .opts
1338 .get::<SelectOptions>()
1339 .map(|o| {
1340 o.options
1341 .iter()
1342 .map(|so| OptionView {
1343 value: so.value.clone(),
1344 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1345 selected: so.value == current,
1346 })
1347 .collect()
1348 })
1349 .unwrap_or_default();
1350 FieldVm {
1351 view_key: "select".to_string(),
1352 name: cx.name.to_string(),
1353 id: cx.id.to_string(),
1354 label: cx.label.to_string(),
1355 required: cx.required,
1356 value: cx.value.clone(),
1357 data: serde_json::to_value(&views).unwrap_or_default(),
1358 }
1359 }
1360 fn render_default(&self, vm: &FieldVm) -> Markup {
1361 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1362 Markup::from_template(&SelectTmpl {
1363 name: &vm.name,
1364 id: &vm.id,
1365 required: vm.required,
1366 options: &options,
1367 })
1368 .unwrap_or_default()
1369 }
1370}
1371
1372#[derive(Debug, Default, Deserialize)]
1374struct ChecklistOptions {
1375 #[serde(default)]
1376 options: Vec<ChecklistEntry>,
1377 #[serde(default)]
1378 select_all: crate::checklist::Offer,
1379 #[serde(default)]
1380 search: crate::checklist::Offer,
1381 #[serde(default)]
1382 expand: crate::checklist::Expand,
1383}
1384
1385#[derive(Debug, Deserialize)]
1387#[serde(untagged)]
1388enum ChecklistEntry {
1389 Group {
1390 group: String,
1391 #[serde(default)]
1392 options: Vec<ChecklistEntry>,
1393 },
1394 Choice {
1395 value: String,
1396 #[serde(default)]
1398 label: Option<String>,
1399 #[serde(default)]
1401 note: Option<String>,
1402 },
1403}
1404
1405impl ChecklistOptions {
1406 fn values(&self) -> Vec<&str> {
1408 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
1409 for entry in entries {
1410 match entry {
1411 ChecklistEntry::Choice { value, .. } => out.push(value),
1412 ChecklistEntry::Group { options, .. } => walk(options, out),
1413 }
1414 }
1415 }
1416 let mut out = Vec::new();
1417 walk(&self.options, &mut out);
1418 out
1419 }
1420
1421 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
1424 self.values()
1425 .into_iter()
1426 .filter(|value| submitted.contains(value))
1427 .collect()
1428 }
1429}
1430
1431fn checklist_values(text: &str) -> Vec<String> {
1433 serde_json::from_str(text).unwrap_or_default()
1434}
1435
1436pub(crate) struct ChecklistField;
1441
1442impl ChecklistField {
1443 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
1444 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
1445 match opts.get::<ChecklistOptions>() {
1446 Some(options) => options
1447 .ticked(&submitted)
1448 .into_iter()
1449 .map(str::to_string)
1450 .collect(),
1451 None => Vec::new(),
1452 }
1453 }
1454}
1455
1456impl FieldType for ChecklistField {
1457 fn view_key(&self) -> &'static str {
1458 "checklist"
1459 }
1460 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1461 keys(&["options", "select_all", "search", "expand"])
1462 }
1463 fn resolve_options(
1464 &self,
1465 raw: &serde_json::Value,
1466 _types: &FieldRegistry,
1467 ) -> Result<ResolvedOptions, OptionsError> {
1468 let opts: ChecklistOptions = if raw.is_null() {
1469 ChecklistOptions::default()
1470 } else {
1471 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1472 };
1473 let mut seen = std::collections::HashSet::new();
1474 for value in opts.values() {
1475 if !seen.insert(value) {
1476 return Err(OptionsError(format!("option `{value}` is listed twice")));
1477 }
1478 }
1479 Ok(ResolvedOptions::new(opts))
1480 }
1481
1482 fn to_attr(
1483 &self,
1484 field: &SubmittedField<'_>,
1485 opts: &ResolvedOptions,
1486 _mode: Mode,
1487 ) -> Result<Option<AttrValue>, String> {
1488 let values = Self::gathered(field, opts)
1491 .into_iter()
1492 .map(serde_json::Value::String)
1493 .collect();
1494 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
1495 }
1496
1497 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1498 stored.unwrap_or("[]").to_string()
1499 }
1500
1501 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
1502 let values = Self::gathered(field, opts);
1503 if values.is_empty() {
1504 return None;
1505 }
1506 serde_json::to_string(&values).ok()
1507 }
1508
1509 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1510 use crate::checklist::{Checklist, Choice, Entry, Group};
1511
1512 let ticked: Vec<String> = match cx.value {
1513 FieldValue::Json(serde_json::Value::Array(values)) => values
1514 .iter()
1515 .filter_map(|v| v.as_str().map(str::to_string))
1516 .collect(),
1517 FieldValue::Text(text) => checklist_values(text),
1518 _ => Vec::new(),
1519 };
1520
1521 fn build(
1522 entries: &[ChecklistEntry],
1523 name: &str,
1524 ticked: &[String],
1525 position: &mut usize,
1526 ) -> Vec<Entry> {
1527 entries
1528 .iter()
1529 .map(|entry| match entry {
1530 ChecklistEntry::Choice { value, label, note } => {
1531 let mut choice = Choice::new(
1532 format!("{name}[{position}]"),
1533 value.clone(),
1534 label.clone().unwrap_or_else(|| value.clone()),
1535 )
1536 .checked(ticked.contains(value));
1537 if let Some(note) = note {
1538 choice = choice.note(note.clone());
1539 }
1540 *position += 1;
1541 Entry::from(choice)
1542 }
1543 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
1544 group.clone(),
1545 build(options, name, ticked, position),
1546 )),
1547 })
1548 .collect()
1549 }
1550
1551 let mut list = Checklist::new(cx.label).id(cx.id);
1552 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
1553 let mut position = 0;
1554 for entry in build(&options.options, cx.name, &ticked, &mut position) {
1555 list = list.entry(entry);
1556 }
1557 list = list
1558 .select_all(options.select_all)
1559 .search(options.search)
1560 .expand(options.expand);
1561 }
1562 FieldVm {
1563 view_key: "checklist".to_string(),
1564 name: cx.name.to_string(),
1565 id: cx.id.to_string(),
1566 label: cx.label.to_string(),
1567 required: cx.required,
1568 value: cx.value.clone(),
1569 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
1570 }
1571 }
1572
1573 fn render_default(&self, vm: &FieldVm) -> Markup {
1574 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
1575 .map(|view| view.render_markup())
1576 .unwrap_or_default()
1577 }
1578}
1579
1580#[derive(Template)]
1581#[template(path = "fields/switch.html")]
1582struct SwitchTmpl<'a> {
1583 name: &'a str,
1584 id: &'a str,
1585 on: bool,
1586}
1587
1588pub(crate) struct SwitchField;
1594
1595fn stored_is_on(stored: Option<&str>) -> bool {
1598 matches!(
1599 stored.map(str::trim).unwrap_or_default(),
1600 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1601 )
1602}
1603
1604impl FieldType for SwitchField {
1605 fn view_key(&self) -> &'static str {
1606 "switch"
1607 }
1608 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1609 keys(&[])
1610 }
1611
1612 fn to_attr(
1613 &self,
1614 field: &SubmittedField<'_>,
1615 _opts: &ResolvedOptions,
1616 _mode: Mode,
1617 ) -> Result<Option<AttrValue>, String> {
1618 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1621 }
1622
1623 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1624 if stored_is_on(stored) {
1625 "1".to_string()
1626 } else {
1627 String::new()
1628 }
1629 }
1630
1631 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1632 scalar_vm("switch", cx)
1633 }
1634
1635 fn render_default(&self, vm: &FieldVm) -> Markup {
1636 Markup::from_template(&SwitchTmpl {
1637 name: &vm.name,
1638 id: &vm.id,
1639 on: stored_is_on(Some(vm.value.as_text())),
1640 })
1641 .unwrap_or_default()
1642 }
1643}
1644
1645#[derive(Template)]
1646#[template(path = "fields/date.html")]
1647struct DateTmpl<'a> {
1648 name: &'a str,
1649 id: &'a str,
1650 value: &'a str,
1651 required: bool,
1652}
1653
1654pub(crate) struct DateField;
1660
1661impl FieldType for DateField {
1662 fn view_key(&self) -> &'static str {
1663 "date"
1664 }
1665 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1666 keys(&[])
1667 }
1668
1669 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1670 vec![Rule::Date]
1671 }
1672
1673 fn to_attr(
1674 &self,
1675 field: &SubmittedField<'_>,
1676 _opts: &ResolvedOptions,
1677 _mode: Mode,
1678 ) -> Result<Option<AttrValue>, String> {
1679 Ok(field.value().map(|v| match v.trim() {
1680 "" => AttrValue::Null,
1681 date => AttrValue::Text(date.to_string()),
1682 }))
1683 }
1684
1685 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1686 let stored = stored.unwrap_or_default().trim();
1687 stored
1690 .split(['T', ' '])
1691 .next()
1692 .unwrap_or_default()
1693 .to_string()
1694 }
1695
1696 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1697 scalar_vm("date", cx)
1698 }
1699
1700 fn render_default(&self, vm: &FieldVm) -> Markup {
1701 Markup::from_template(&DateTmpl {
1702 name: &vm.name,
1703 id: &vm.id,
1704 value: vm.value.as_text(),
1705 required: vm.required,
1706 })
1707 .unwrap_or_default()
1708 }
1709}
1710
1711#[derive(Template)]
1712#[template(path = "fields/password.html")]
1713struct PasswordTmpl<'a> {
1714 name: &'a str,
1715 id: &'a str,
1716 required: bool,
1717}
1718
1719pub(crate) struct PasswordField;
1727
1728impl FieldType for PasswordField {
1729 fn view_key(&self) -> &'static str {
1730 "password"
1731 }
1732 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1733 keys(&[])
1734 }
1735
1736 fn to_attr(
1737 &self,
1738 field: &SubmittedField<'_>,
1739 _opts: &ResolvedOptions,
1740 _mode: Mode,
1741 ) -> Result<Option<AttrValue>, String> {
1742 match field.value().map(str::trim).unwrap_or_default() {
1743 "" => Ok(None),
1745 plain => laterite_auth::password::hash_password(plain)
1746 .map(|hash| Some(AttrValue::Text(hash)))
1747 .map_err(|e| e.to_string()),
1748 }
1749 }
1750
1751 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1754 String::new()
1755 }
1756
1757 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1758 scalar_vm("password", cx)
1759 }
1760
1761 fn render_default(&self, vm: &FieldVm) -> Markup {
1762 Markup::from_template(&PasswordTmpl {
1763 name: &vm.name,
1764 id: &vm.id,
1765 required: vm.required,
1766 })
1767 .unwrap_or_default()
1768 }
1769}
1770
1771#[derive(Template)]
1772#[template(path = "fields/radio.html")]
1773struct RadioTmpl<'a> {
1774 name: &'a str,
1775 options: &'a [OptionView],
1776 required: bool,
1777}
1778
1779pub(crate) struct RadioField;
1784
1785impl FieldType for RadioField {
1786 fn view_key(&self) -> &'static str {
1787 "radio"
1788 }
1789 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1790 keys(&["options"])
1791 }
1792
1793 fn resolve_options(
1794 &self,
1795 raw: &serde_json::Value,
1796 _types: &FieldRegistry,
1797 ) -> Result<ResolvedOptions, OptionsError> {
1798 let opts: SelectOptions =
1799 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1800 Ok(ResolvedOptions::new(opts))
1801 }
1802
1803 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1804 let current = cx.value.as_text();
1805 let views: Vec<OptionView> = cx
1806 .opts
1807 .get::<SelectOptions>()
1808 .map(|o| {
1809 o.options
1810 .iter()
1811 .map(|so| OptionView {
1812 value: so.value.clone(),
1813 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1814 selected: so.value == current,
1815 })
1816 .collect()
1817 })
1818 .unwrap_or_default();
1819 FieldVm {
1820 view_key: "radio".to_string(),
1821 name: cx.name.to_string(),
1822 id: cx.id.to_string(),
1823 label: cx.label.to_string(),
1824 required: cx.required,
1825 value: cx.value.clone(),
1826 data: serde_json::to_value(&views).unwrap_or_default(),
1827 }
1828 }
1829
1830 fn render_default(&self, vm: &FieldVm) -> Markup {
1831 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1832 Markup::from_template(&RadioTmpl {
1833 name: &vm.name,
1834 options: &options,
1835 required: vm.required,
1836 })
1837 .unwrap_or_default()
1838 }
1839}
1840
1841fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1843 FieldVm {
1844 view_key: view_key.to_string(),
1845 name: cx.name.to_string(),
1846 id: cx.id.to_string(),
1847 label: cx.label.to_string(),
1848 required: cx.required,
1849 value: cx.value.clone(),
1850 data: serde_json::Value::Null,
1851 }
1852}
1853
1854#[derive(Deserialize)]
1856struct RefOptions {
1857 source: String,
1858}
1859
1860struct RefResolved {
1862 source: String,
1863}
1864
1865#[derive(Default, Serialize, Deserialize)]
1868struct RefData {
1869 source: String,
1870 search_url: String,
1871 resolve_url: String,
1872 value: String,
1873 placeholder: String,
1874}
1875
1876#[derive(Template)]
1877#[template(path = "fields/ref_picker.html")]
1878struct RefPickerTmpl<'a> {
1879 name: &'a str,
1880 id: &'a str,
1881 value: &'a str,
1882 search_url: &'a str,
1883 resolve_url: &'a str,
1884 placeholder: &'a str,
1885}
1886
1887pub(crate) struct RefPickerField {
1892 pickers: Arc<PickerRegistry>,
1893}
1894
1895impl RefPickerField {
1896 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1897 Self { pickers }
1898 }
1899}
1900
1901impl FieldType for RefPickerField {
1902 fn view_key(&self) -> &'static str {
1903 "reference"
1904 }
1905 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1906 keys(&["source"])
1907 }
1908 fn resolve_options(
1909 &self,
1910 raw: &serde_json::Value,
1911 _types: &FieldRegistry,
1912 ) -> Result<ResolvedOptions, OptionsError> {
1913 let opts: RefOptions =
1914 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1915 if !self.pickers.contains_key(&opts.source) {
1916 return Err(OptionsError(format!(
1917 "unknown picker source `{}`",
1918 opts.source
1919 )));
1920 }
1921 Ok(ResolvedOptions::new(RefResolved {
1922 source: opts.source,
1923 }))
1924 }
1925 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1926 let source = cx
1927 .opts
1928 .get::<RefResolved>()
1929 .map(|r| r.source.as_str())
1930 .unwrap_or_default();
1931 let data = RefData {
1932 search_url: format!("{}/pickers/{}/search", cx.base, source),
1933 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1934 source: source.to_string(),
1935 value: cx.value.as_text().to_string(),
1936 placeholder: "Search…".to_string(),
1937 };
1938 FieldVm {
1939 view_key: "reference".to_string(),
1940 name: cx.name.to_string(),
1941 id: cx.id.to_string(),
1942 label: cx.label.to_string(),
1943 required: cx.required,
1944 value: cx.value.clone(),
1945 data: serde_json::to_value(data).unwrap_or_default(),
1946 }
1947 }
1948 fn render_default(&self, vm: &FieldVm) -> Markup {
1949 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1950 Markup::from_template(&RefPickerTmpl {
1951 name: &vm.name,
1952 id: &vm.id,
1953 value: vm.value.as_text(),
1954 search_url: &data.search_url,
1955 resolve_url: &data.resolve_url,
1956 placeholder: &data.placeholder,
1957 })
1958 .unwrap_or_default()
1959 }
1960 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1961 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1962 }
1963}
1964
1965#[cfg(test)]
1966mod tests {
1967 use super::*;
1968
1969 fn untranslated() -> Translator {
1972 Translator::new("en")
1973 }
1974
1975 fn cx_with<'a>(
1976 name: &'a str,
1977 value: &'a FieldValue,
1978 opts: &'a ResolvedOptions,
1979 i18n: &'a Translator,
1980 ) -> FieldCx<'a> {
1981 FieldCx {
1982 name,
1983 id: name,
1984 label: "Label",
1985 value,
1986 required: true,
1987 opts,
1988 base: "/admin",
1989 i18n,
1990 }
1991 }
1992
1993 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1994 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
1997 }
1998
1999 fn text_field() -> TextField {
2000 TextField::new(Arc::new(builtin_input_registry()))
2001 }
2002
2003 struct BadAttrInput;
2005 impl InputType for BadAttrInput {
2006 fn key(&self) -> &'static str {
2007 "bad"
2008 }
2009 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
2010 BTreeMap::from([("class".to_string(), "x".to_string())])
2011 }
2012 }
2013
2014 #[test]
2015 fn text_renders_its_value_escaped() {
2016 let opts = ResolvedOptions::none();
2017 let value = FieldValue::Text("a<b>&\"c".to_string());
2018 let markup = render_field(
2019 &text_field(),
2020 &NoOverrides,
2021 &scope(),
2022 &cx("title", &value, &opts),
2023 );
2024 let html = markup.as_str();
2025 assert!(html.contains(r#"name="title""#));
2026 assert!(html.contains("required"));
2027 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
2031 assert!(
2032 html.contains("<") || html.contains("<"),
2033 "angle escaped: {html}"
2034 );
2035 assert!(
2036 html.contains("&") || html.contains("&"),
2037 "ampersand escaped: {html}"
2038 );
2039 }
2040
2041 #[test]
2042 fn textarea_uses_its_own_control() {
2043 let opts = ResolvedOptions::none();
2044 let value = FieldValue::Text("body".to_string());
2045 let markup = render_field(
2046 &TextareaField,
2047 &NoOverrides,
2048 &scope(),
2049 &cx("body", &value, &opts),
2050 );
2051 assert!(markup.as_str().contains("<textarea"));
2052 }
2053
2054 #[test]
2055 fn no_overrides_uses_the_compiled_default() {
2056 let opts = ResolvedOptions::none();
2058 let value = FieldValue::Text("x".to_string());
2059 let ft = text_field();
2060 let vm = ft.view_model(&cx("t", &value, &opts));
2061 let direct = ft.render_default(&vm);
2062 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
2063 assert_eq!(direct.as_str(), routed.as_str());
2064 }
2065
2066 #[test]
2067 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
2068 let field = text_field();
2069 let opts = field
2070 .resolve_options(
2071 &serde_json::json!({ "input": "email" }),
2072 &builtin_registry(),
2073 )
2074 .unwrap();
2075 assert!(matches!(
2077 field.intrinsic_rules(&opts).as_slice(),
2078 [Rule::Email]
2079 ));
2080 let value = FieldValue::Text("a@b.test".to_string());
2081 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
2082 assert!(markup.as_str().contains(r#"type="email""#));
2083 }
2084
2085 #[test]
2086 fn text_default_input_is_plain_text_with_no_extra_rule() {
2087 let field = text_field();
2088 let opts = field
2089 .resolve_options(&serde_json::Value::Null, &builtin_registry())
2090 .unwrap();
2091 assert!(field.intrinsic_rules(&opts).is_empty());
2092 let value = FieldValue::Text("hi".to_string());
2093 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
2094 assert!(markup.as_str().contains(r#"type="text""#));
2095 }
2096
2097 #[test]
2098 fn text_number_input_sets_the_type_and_numeric_rule() {
2099 let field = text_field();
2100 let opts = field
2101 .resolve_options(
2102 &serde_json::json!({ "input": "number" }),
2103 &builtin_registry(),
2104 )
2105 .unwrap();
2106 assert!(matches!(
2107 field.intrinsic_rules(&opts).as_slice(),
2108 [Rule::Numeric]
2109 ));
2110 let value = FieldValue::Text("42".to_string());
2111 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2112 assert!(markup.as_str().contains(r#"type="number""#));
2113 }
2114
2115 #[test]
2116 fn text_tel_input_sets_the_type_with_no_rule() {
2117 let field = text_field();
2118 let opts = field
2119 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
2120 .unwrap();
2121 assert!(field.intrinsic_rules(&opts).is_empty());
2122 let value = FieldValue::Text(String::new());
2123 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
2124 assert!(markup.as_str().contains(r#"type="tel""#));
2125 }
2126
2127 #[test]
2128 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
2129 let field = text_field();
2130 let opts = field
2131 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
2132 .unwrap();
2133 assert!(matches!(
2134 field.intrinsic_rules(&opts).as_slice(),
2135 [Rule::Url]
2136 ));
2137 let value = FieldValue::Text("https://example.com".to_string());
2138 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2139 let html = markup.as_str();
2140 assert!(html.contains(r#"type="url""#), "{html}");
2141 assert!(html.contains("lat-input-group"), "{html}");
2142 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
2143 }
2144
2145 #[test]
2146 fn text_url_input_copy_false_omits_the_button() {
2147 let field = text_field();
2148 let opts = field
2149 .resolve_options(
2150 &serde_json::json!({ "input": "url", "copy": false }),
2151 &builtin_registry(),
2152 )
2153 .unwrap();
2154 let value = FieldValue::Text("https://example.com".to_string());
2155 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
2156 let html = markup.as_str();
2157 assert!(html.contains(r#"type="url""#), "{html}");
2158 assert!(!html.contains("lat-input-group"), "{html}");
2160 assert!(!html.contains("data-lat-widget"), "{html}");
2161 }
2162
2163 #[test]
2164 fn text_number_input_emits_min_max_step_attributes() {
2165 let field = text_field();
2166 let opts = field
2167 .resolve_options(
2168 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
2169 &builtin_registry(),
2170 )
2171 .unwrap();
2172 let value = FieldValue::Text("5".to_string());
2175 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
2176 let html = markup.as_str();
2177 assert!(html.contains(r#"min="0""#), "{html}");
2178 assert!(html.contains(r#"max="10""#), "{html}");
2179 assert!(html.contains(r#"step="2""#), "{html}");
2180 }
2181
2182 #[test]
2183 fn text_input_reserved_attribute_name_aborts_resolve() {
2184 let mut inputs = builtin_input_registry();
2185 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
2186 let field = TextField::new(Arc::new(inputs));
2187 let Err(e) =
2188 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
2189 else {
2190 panic!("expected a reserved-attribute rejection");
2191 };
2192 assert!(e.0.contains("class"), "{e}");
2193 }
2194
2195 #[test]
2196 fn attribute_and_widget_name_rules() {
2197 assert!(is_name("min", false));
2198 assert!(is_name("data-x", false));
2199 assert!(!is_name("Min", false));
2200 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
2203 }
2204
2205 #[test]
2206 fn select_renders_options_with_the_current_value_selected() {
2207 let raw = serde_json::json!({
2208 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2209 });
2210 let opts = SelectField
2211 .resolve_options(&raw, &builtin_registry())
2212 .unwrap();
2213 let value = FieldValue::Text("closed".to_string());
2214 let markup = render_field(
2215 &SelectField,
2216 &NoOverrides,
2217 &scope(),
2218 &cx("status", &value, &opts),
2219 );
2220 let html = markup.as_str();
2221 assert!(html.contains(r#"<option value="open">Open</option>"#));
2222 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2224 }
2225
2226 #[test]
2227 fn radio_renders_the_same_options_with_the_current_one_checked() {
2228 let raw = serde_json::json!({
2229 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2230 });
2231 let opts = RadioField
2232 .resolve_options(&raw, &builtin_registry())
2233 .unwrap();
2234 let value = FieldValue::Text("closed".to_string());
2235 let markup = render_field(
2236 &RadioField,
2237 &NoOverrides,
2238 &scope(),
2239 &cx("status", &value, &opts),
2240 );
2241 let html = markup.as_str();
2242 assert!(html.contains(r#"value="open""#));
2243 assert!(
2244 html.contains(r#"value="closed" checked"#),
2245 "current is checked"
2246 );
2247 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2250 assert!(html.contains("closed</label>"));
2252 }
2253
2254 fn scope<'a>() -> OverrideScope<'a> {
2255 OverrideScope {
2256 surface: Surface::Field,
2257 view_key: "text",
2258 resource: None,
2259 field: None,
2260 }
2261 }
2262
2263 fn ref_field() -> RefPickerField {
2264 let mut pickers = crate::picker::PickerRegistry::new();
2265 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2266 pickers.insert(
2267 "acme.place".to_string(),
2268 crate::picker::PickerSourceReg::new("acme.place", source),
2269 );
2270 RefPickerField::new(Arc::new(pickers))
2271 }
2272
2273 #[test]
2274 fn reference_rejects_an_unknown_source() {
2275 let field = ref_field();
2276 let Err(e) = field.resolve_options(
2277 &serde_json::json!({ "source": "no.such" }),
2278 &builtin_registry(),
2279 ) else {
2280 panic!("expected an unknown-source rejection");
2281 };
2282 assert!(e.0.contains("no.such"), "{e}");
2283 }
2284
2285 #[test]
2286 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2287 let field = ref_field();
2288 let opts = field
2289 .resolve_options(
2290 &serde_json::json!({ "source": "acme.place" }),
2291 &builtin_registry(),
2292 )
2293 .unwrap();
2294 let value = FieldValue::Text("42".to_string());
2295 let markup = render_field(
2296 &field,
2297 &NoOverrides,
2298 &scope(),
2299 &cx("place_id", &value, &opts),
2300 );
2301 let html = markup.as_str();
2302 assert!(html.contains(r#"type="hidden""#), "{html}");
2305 assert!(html.contains(r#"name="place_id""#), "{html}");
2306 assert!(html.contains(r#"value="42""#), "{html}");
2307 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2309 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2310 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2311 }
2312
2313 #[test]
2314 fn reference_declares_its_widget_assets() {
2315 let field = ref_field();
2316 let opts = field
2317 .resolve_options(
2318 &serde_json::json!({ "source": "acme.place" }),
2319 &builtin_registry(),
2320 )
2321 .unwrap();
2322 assert_eq!(
2323 field.assets(&opts),
2324 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2325 );
2326 }
2327}
2328
2329#[cfg(test)]
2330mod repeater_tests {
2331 use super::*;
2332
2333 fn options() -> ResolvedOptions {
2334 RepeaterField
2335 .resolve_options(
2336 &serde_json::json!({
2337 "fields": [
2338 { "name": "path", "label": "Path", "type": "text" },
2339 { "name": "allow", "label": "Allow", "type": "switch" },
2340 ]
2341 }),
2342 &builtin_registry(),
2343 )
2344 .unwrap()
2345 }
2346
2347 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2348 pairs
2349 .iter()
2350 .map(|(k, v)| (k.to_string(), v.to_string()))
2351 .collect()
2352 }
2353
2354 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2355 let field = SubmittedField::new("rules", data);
2356 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2357 Some(AttrValue::Json(value)) => value,
2358 other => panic!("expected a JSON array, got {other:?}"),
2359 }
2360 }
2361
2362 #[test]
2366 fn a_row_is_typed_by_its_sub_field_types() {
2367 let rows = stored(
2368 &submitted(&[
2369 ("rules[0][path]", "/private"),
2370 ("rules[1][path]", "/public"),
2371 ("rules[1][allow]", "on"),
2372 ]),
2373 &options(),
2374 );
2375
2376 assert_eq!(rows[0]["path"], "/private");
2377 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2378 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2379 }
2380
2381 #[test]
2384 fn rows_are_ordered_by_index_not_submission_order() {
2385 let rows = stored(
2386 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2387 &options(),
2388 );
2389 assert_eq!(rows.as_array().unwrap().len(), 2);
2390 assert_eq!(rows[0]["path"], "/first");
2391 assert_eq!(rows[1]["path"], "/last");
2392 }
2393
2394 #[test]
2395 fn a_row_left_blank_is_dropped() {
2396 let rows = stored(
2397 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2398 &options(),
2399 );
2400 assert_eq!(rows.as_array().unwrap().len(), 1);
2401 }
2402
2403 #[test]
2404 fn nothing_submitted_stores_an_empty_list() {
2405 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2406 }
2407
2408 #[test]
2409 fn a_repeater_refuses_to_hold_a_repeater() {
2410 let err = RepeaterField
2411 .resolve_options(
2412 &serde_json::json!({
2413 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2414 }),
2415 &builtin_registry(),
2416 )
2417 .err()
2418 .expect("a nested repeater is refused");
2419 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2420 }
2421
2422 #[test]
2423 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2424 let err = RepeaterField
2425 .resolve_options(
2426 &serde_json::json!({
2427 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2428 }),
2429 &builtin_registry(),
2430 )
2431 .err()
2432 .expect("an unregistered type is refused");
2433 assert!(err.0.contains("acme.nope"), "{}", err.0);
2434 }
2435
2436 #[test]
2437 fn row_counts_are_bounded_when_asked() {
2438 let opts = RepeaterField
2439 .resolve_options(
2440 &serde_json::json!({
2441 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2442 "min_items": 1,
2443 "max_items": 2
2444 }),
2445 &builtin_registry(),
2446 )
2447 .unwrap();
2448
2449 let empty = HashMap::new();
2450 let too_few = SubmittedField::new("rules", &empty);
2451 assert!(RepeaterField
2452 .to_attr(&too_few, &opts, Mode::Create)
2453 .is_err());
2454
2455 let three = submitted(&[
2456 ("rules[0][path]", "/a"),
2457 ("rules[1][path]", "/b"),
2458 ("rules[2][path]", "/c"),
2459 ]);
2460 let field = SubmittedField::new("rules", &three);
2461 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2462 }
2463
2464 #[test]
2465 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2466 let value = FieldValue::Json(serde_json::json!([
2467 { "path": "/private", "allow": false },
2468 ]));
2469 let cx = FieldCx {
2470 name: "rules",
2471 id: "rules",
2472 label: "Rules",
2473 value: &value,
2474 required: false,
2475 opts: &options(),
2476 base: "/admin",
2477 i18n: &Translator::new("en"),
2478 };
2479 let html = RepeaterField
2480 .render_default(&RepeaterField.view_model(&cx))
2481 .into_string();
2482
2483 assert!(html.contains(r#"name="rules[0][path]""#));
2484 assert!(html.contains(r#"value="/private""#));
2485 assert!(html.contains("lat-repeater__blank"));
2487 assert!(html.contains(r#"name="rules[__index__][path]""#));
2488 }
2489
2490 fn list_options() -> ResolvedOptions {
2492 RepeaterField
2493 .resolve_options(
2494 &serde_json::json!({
2495 "fields": [
2496 { "name": "agent", "label": "Agent", "type": "text" },
2497 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2498 ],
2499 "display": "list",
2500 "summary_field": "agent",
2501 }),
2502 &builtin_registry(),
2503 )
2504 .unwrap()
2505 }
2506
2507 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2508 let cx = FieldCx {
2509 name: "rules",
2510 id: "rules",
2511 label: "Rules",
2512 value,
2513 required: false,
2514 opts,
2515 base: "/admin",
2516 i18n: &Translator::new("en"),
2517 };
2518 RepeaterField
2519 .render_default(&RepeaterField.view_model(&cx))
2520 .into_string()
2521 }
2522
2523 #[test]
2524 fn a_list_repeater_collapses_its_rows_and_names_them() {
2525 let value = FieldValue::Json(serde_json::json!([
2526 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2527 ]));
2528 let html = render_with(&list_options(), &value);
2529 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2530 assert!(html.contains("<details"), "a row collapses");
2531 assert!(
2532 html.contains("Googlebot"),
2533 "the naming field titles the row"
2534 );
2535 assert!(html.contains(r#"name="rules[0][disallow]""#));
2537 }
2538
2539 #[test]
2540 fn a_row_with_no_name_yet_reads_as_untitled() {
2541 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2542 let html = render_with(&list_options(), &value);
2543 assert!(html.contains("lat-repeater__untitled"));
2544 }
2545
2546 #[test]
2547 fn the_default_layout_stays_inline() {
2548 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2550 let html = render_with(&options(), &value);
2551 assert!(!html.contains("lat-repeater--list"));
2552 assert!(!html.contains("<details"));
2553 }
2554
2555 #[test]
2556 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2557 assert_eq!(
2559 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2560 "/private"
2561 );
2562 assert_eq!(summary_text(&serde_json::json!("")), "");
2563 }
2564
2565 #[test]
2572 fn a_contributed_field_type_names_itself_and_converts() {
2573 struct Money;
2574 impl FieldType for Money {
2575 fn view_key(&self) -> &'static str {
2576 "acme.money"
2577 }
2578 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2579 scalar_vm("acme.money", cx)
2580 }
2581 fn render_default(&self, _vm: &FieldVm) -> Markup {
2582 Markup::empty()
2583 }
2584 fn to_attr(
2585 &self,
2586 field: &SubmittedField<'_>,
2587 _opts: &ResolvedOptions,
2588 _mode: Mode,
2589 ) -> Result<Option<AttrValue>, String> {
2590 let raw = field.value().unwrap_or("0");
2592 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2593 Ok(Some(AttrValue::Int(minor as i64)))
2594 }
2595 }
2596
2597 let reg = FieldTypeReg::new(Arc::new(Money));
2598 assert_eq!(reg.name(), "acme.money");
2601
2602 let mut registry = builtin_registry();
2603 let name = reg.name().to_string();
2604 assert!(
2605 registry.insert(name.clone(), reg.field_type).is_none(),
2606 "a dotted key does not collide with a built-in"
2607 );
2608
2609 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2610 let submitted = SubmittedField::new("price", &data);
2611 let converted = registry[&name]
2612 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2613 .unwrap();
2614 assert!(
2615 matches!(converted, Some(AttrValue::Int(1250))),
2616 "{converted:?}"
2617 );
2618 }
2619
2620 #[test]
2621 fn a_sub_field_label_is_localized_like_any_other() {
2622 use laterite_core::i18n::CatalogStore;
2623 use std::sync::Arc;
2624
2625 let po = concat!(
2626 "msgid \"\"\n",
2627 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2628 "\n",
2629 "msgid \"Path\"\n",
2630 "msgstr \"Path-kn\"\n",
2631 );
2632 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2633 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2634
2635 let value = FieldValue::Text(String::new());
2636 let cx = FieldCx {
2637 name: "rules",
2638 id: "rules",
2639 label: "Rules",
2640 value: &value,
2641 required: false,
2642 opts: &options(),
2643 base: "/admin",
2644 i18n: &i18n,
2645 };
2646 let html = RepeaterField
2647 .render_default(&RepeaterField.view_model(&cx))
2648 .into_string();
2649
2650 assert!(
2651 html.contains("Path-kn"),
2652 "sub-label was not localized: {html}"
2653 );
2654 assert!(
2655 !html.contains(">Path<"),
2656 "the source string still leaked: {html}"
2657 );
2658 }
2659}
2660
2661#[cfg(test)]
2662mod save_contract_tests {
2663 use super::*;
2664
2665 fn one(raw: Option<&str>) -> HashMap<String, String> {
2667 raw.into_iter()
2668 .map(|v| ("f".to_string(), v.to_string()))
2669 .collect()
2670 }
2671
2672 fn none() -> ResolvedOptions {
2673 ResolvedOptions::none()
2674 }
2675
2676 #[test]
2677 fn a_switch_stores_a_bool_and_absence_means_off() {
2678 let f = SwitchField;
2679 assert_eq!(
2682 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2683 .unwrap(),
2684 Some(AttrValue::Bool(false))
2685 );
2686 assert_eq!(
2687 f.to_attr(
2688 &SubmittedField::new("f", &one(Some("on"))),
2689 &none(),
2690 Mode::Create
2691 )
2692 .unwrap(),
2693 Some(AttrValue::Bool(true))
2694 );
2695 }
2696
2697 #[test]
2698 fn a_switch_reads_back_whatever_the_backend_gave() {
2699 let f = SwitchField;
2700 for on in ["1", "true", "t", "on"] {
2702 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2703 }
2704 for off in ["0", "false", "f", ""] {
2705 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2706 }
2707 assert!(f.to_control(None, &none()).is_empty());
2708 }
2709
2710 #[test]
2711 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2712 let f = DateField;
2713 assert_eq!(
2715 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2716 "2026-09-09"
2717 );
2718 assert_eq!(
2719 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2720 "2026-09-09"
2721 );
2722 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2723 assert_eq!(f.to_control(None, &none()), "");
2724 }
2725
2726 #[test]
2727 fn a_cleared_date_stores_null_not_an_empty_string() {
2728 let f = DateField;
2729 assert_eq!(
2730 f.to_attr(
2731 &SubmittedField::new("f", &one(Some(""))),
2732 &none(),
2733 Mode::Update
2734 )
2735 .unwrap(),
2736 Some(AttrValue::Null)
2737 );
2738 assert_eq!(
2739 f.to_attr(
2740 &SubmittedField::new("f", &one(Some("2026-09-09"))),
2741 &none(),
2742 Mode::Create
2743 )
2744 .unwrap(),
2745 Some(AttrValue::Text("2026-09-09".into()))
2746 );
2747 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2748 }
2749
2750 #[test]
2751 fn a_password_hashes_and_never_echoes() {
2752 let f = PasswordField;
2753 let stored = f
2754 .to_attr(
2755 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2756 &none(),
2757 Mode::Create,
2758 )
2759 .unwrap()
2760 .unwrap();
2761 let hash = stored.as_str().unwrap();
2762 assert!(
2763 hash.starts_with("$argon2"),
2764 "stored as a hash, not plaintext"
2765 );
2766 assert_ne!(hash, "hunter2hunter2");
2767 assert_eq!(f.to_control(Some(hash), &none()), "");
2769 }
2770
2771 #[test]
2772 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2773 let f = PasswordField;
2774 assert_eq!(
2776 f.to_attr(
2777 &SubmittedField::new("f", &one(Some(""))),
2778 &none(),
2779 Mode::Update
2780 )
2781 .unwrap(),
2782 None
2783 );
2784 assert_eq!(
2785 f.to_attr(
2786 &SubmittedField::new("f", &one(Some(" "))),
2787 &none(),
2788 Mode::Update
2789 )
2790 .unwrap(),
2791 None
2792 );
2793 assert_eq!(
2794 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2795 .unwrap(),
2796 None
2797 );
2798 }
2799
2800 #[test]
2801 fn a_plain_field_stores_and_shows_the_text_unchanged() {
2802 let f = TextareaField;
2803 assert_eq!(
2804 f.to_attr(
2805 &SubmittedField::new("f", &one(Some("hello"))),
2806 &none(),
2807 Mode::Create
2808 )
2809 .unwrap(),
2810 Some(AttrValue::Text("hello".into()))
2811 );
2812 assert_eq!(
2814 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2815 .unwrap(),
2816 None
2817 );
2818 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
2819 }
2820}