1use std::any::Any;
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use askama::Template;
16use laterite_core::validation::{Mode, Rule};
17use laterite_core::{AttrValue, Translator};
18use serde::{Deserialize, Serialize};
19
20use crate::html::Markup;
21use crate::picker::PickerRegistry;
22
23#[derive(Debug, Clone, Serialize)]
26#[serde(untagged)]
27pub enum FieldValue {
28 Null,
29 Text(String),
30 Json(serde_json::Value),
31}
32
33impl FieldValue {
34 pub fn as_text(&self) -> &str {
36 match self {
37 FieldValue::Text(s) => s,
38 _ => "",
39 }
40 }
41}
42
43#[derive(Default, Clone)]
47pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
48
49impl ResolvedOptions {
50 pub fn none() -> Self {
52 Self(None)
53 }
54 pub fn new<T: Any + Send + Sync>(value: T) -> Self {
56 Self(Some(Arc::new(value)))
57 }
58 pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
60 self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Chrome {
68 Wrapped,
69 Bare,
70}
71
72#[non_exhaustive]
74pub struct FieldCx<'a> {
75 pub name: &'a str,
77 pub id: &'a str,
79 pub label: &'a str,
80 pub value: &'a FieldValue,
81 pub required: bool,
83 pub max_length: Option<usize>,
85 pub opts: &'a ResolvedOptions,
86 pub base: &'a str,
89 pub i18n: &'a Translator,
97 pub(crate) icons: crate::icons::Icons<'a>,
99}
100
101impl FieldCx<'_> {
102 pub fn icon(&self, name: &str) -> String {
105 self.icons.render(Some(name))
106 }
107}
108
109#[derive(Debug, Clone, Serialize)]
112pub struct FieldVm {
113 pub view_key: String,
114 pub name: String,
115 pub id: String,
116 pub label: String,
117 pub required: bool,
118 pub value: FieldValue,
119 pub data: serde_json::Value,
120}
121
122pub struct SubmittedField<'a> {
130 name: &'a str,
131 data: &'a HashMap<String, String>,
132}
133
134impl<'a> SubmittedField<'a> {
135 pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
136 Self { name, data }
137 }
138
139 pub fn name(&self) -> &str {
142 self.name
143 }
144
145 pub fn value(&self) -> Option<&str> {
150 self.data.get(self.name).map(String::as_str)
151 }
152
153 pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
158 let prefix = format!("{}[", self.name);
159 self.data.iter().filter_map(move |(key, value)| {
160 key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
161 })
162 }
163
164 pub fn all(&self) -> &'a HashMap<String, String> {
167 self.data
168 }
169}
170
171pub trait FieldType: Send + Sync + 'static {
172 fn view_key(&self) -> &'static str;
175
176 fn resolve_options(
183 &self,
184 _raw: &serde_json::Value,
185 _types: &FieldRegistry,
186 ) -> Result<ResolvedOptions, OptionsError> {
187 Ok(ResolvedOptions::none())
188 }
189 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
195 None
196 }
197
198 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
201 Vec::new()
202 }
203
204 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
206
207 fn render_default(&self, vm: &FieldVm) -> Markup;
210
211 fn to_attr(
225 &self,
226 field: &SubmittedField<'_>,
227 opts: &ResolvedOptions,
228 mode: Mode,
229 ) -> Result<Option<AttrValue>, String> {
230 let _ = (opts, mode);
231 Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
232 }
233
234 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
243 let _ = opts;
244 stored.unwrap_or_default().to_string()
245 }
246
247 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
258 let _ = opts;
259 field.value().map(str::to_string)
260 }
261
262 fn chrome(&self) -> Chrome {
264 Chrome::Wrapped
265 }
266
267 fn linked_media(&self, submitted: Option<&str>, opts: &ResolvedOptions) -> Option<Vec<i64>> {
272 let _ = (submitted, opts);
273 None
274 }
275
276 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
281 Vec::new()
282 }
283}
284
285#[derive(Debug, thiserror::Error)]
287#[error("invalid field options: {0}")]
288pub struct OptionsError(pub String);
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum Surface {
293 Field,
294 Column,
295}
296
297pub struct OverrideScope<'a> {
300 pub surface: Surface,
301 pub view_key: &'a str,
302 pub resource: Option<&'a str>,
303 pub field: Option<&'a str>,
304}
305
306#[derive(Debug, thiserror::Error)]
308#[error("override render failed: {0}")]
309pub struct OverrideError(pub String);
310
311pub trait OverrideResolver: Send + Sync {
315 fn render_override(
318 &self,
319 scope: &OverrideScope<'_>,
320 vm: &serde_json::Value,
321 ) -> Option<Result<String, OverrideError>>;
322}
323
324pub struct NoOverrides;
327
328impl OverrideResolver for NoOverrides {
329 fn render_override(
330 &self,
331 _scope: &OverrideScope<'_>,
332 _vm: &serde_json::Value,
333 ) -> Option<Result<String, OverrideError>> {
334 None
335 }
336}
337
338pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
340
341pub struct FieldTypeReg {
348 pub field_type: Arc<dyn FieldType>,
349}
350
351impl FieldTypeReg {
352 pub fn new(field_type: Arc<dyn FieldType>) -> Self {
353 Self { field_type }
354 }
355
356 pub fn name(&self) -> &'static str {
358 self.field_type.view_key()
359 }
360}
361
362pub(crate) fn render_field(
365 ft: &dyn FieldType,
366 resolver: &dyn OverrideResolver,
367 scope: &OverrideScope<'_>,
368 cx: &FieldCx<'_>,
369) -> Markup {
370 let vm = ft.view_model(cx);
371 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
372 Some(Ok(html)) => Markup::from_override(html),
373 Some(Err(_)) | None => ft.render_default(&vm),
375 }
376}
377
378pub(crate) struct RepeaterOptions {
381 rows: Vec<RepeaterSub>,
382 min_items: usize,
383 max_items: Option<usize>,
384 display: Option<RepeaterDisplay>,
387 summary_field: Option<String>,
389 reorder: bool,
391 duplicate: bool,
393}
394
395pub const REPEATER_LIST_PAST: usize = 3;
397
398impl RepeaterOptions {
399 fn list(&self) -> bool {
400 match self.display {
401 Some(RepeaterDisplay::List) => true,
402 Some(RepeaterDisplay::Inline) => false,
403 None => self.rows.len() > REPEATER_LIST_PAST,
404 }
405 }
406}
407
408fn auto_or_display<'de, D: serde::Deserializer<'de>>(
410 de: D,
411) -> Result<Option<RepeaterDisplay>, D::Error> {
412 #[derive(Deserialize)]
413 #[serde(rename_all = "snake_case")]
414 enum Raw {
415 Auto,
416 Inline,
417 List,
418 }
419 Ok(match Raw::deserialize(de)? {
420 Raw::Auto => None,
421 Raw::Inline => Some(RepeaterDisplay::Inline),
422 Raw::List => Some(RepeaterDisplay::List),
423 })
424}
425
426fn on() -> bool {
427 true
428}
429
430#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
437#[serde(rename_all = "snake_case")]
438pub enum RepeaterDisplay {
439 #[default]
442 Inline,
443 List,
446}
447
448pub(crate) struct RepeaterSub {
450 field: crate::form::FormField,
451 field_type: Arc<dyn FieldType>,
452 opts: ResolvedOptions,
453}
454
455#[derive(Deserialize)]
457struct RepeaterOptionsRaw {
458 #[serde(deserialize_with = "named_or_listed")]
460 fields: Vec<crate::form::FormField>,
461 #[serde(default)]
462 min_items: usize,
463 #[serde(default)]
464 max_items: Option<usize>,
465 #[serde(default, deserialize_with = "auto_or_display")]
466 display: Option<RepeaterDisplay>,
467 #[serde(default)]
468 summary_field: Option<String>,
469 #[serde(default = "on")]
470 reorder: bool,
471 #[serde(default = "on")]
472 duplicate: bool,
473}
474
475pub(crate) fn check_option_keys(
477 raw: &serde_json::Value,
478 accepted: &[String],
479) -> Result<(), OptionsError> {
480 let serde_json::Value::Object(entries) = raw else {
481 return Ok(());
482 };
483 for key in entries.keys() {
484 if !accepted.iter().any(|k| k == key) {
485 return Err(OptionsError(if accepted.is_empty() {
486 format!("unknown key `{key}`: this type takes no keys of its own")
487 } else {
488 format!(
489 "unknown key `{key}`, expected one of: {}",
490 accepted.join(", ")
491 )
492 }));
493 }
494 }
495 Ok(())
496}
497
498pub(crate) fn check_field_keys(
501 field: &crate::form::FormField,
502 field_type: &dyn FieldType,
503) -> Result<(), OptionsError> {
504 match field_type.option_keys(&field.options) {
505 Some(keys) => check_option_keys(&field.options, &keys),
506 None => Ok(()),
507 }
508}
509
510fn keys(names: &[&str]) -> Option<Vec<String>> {
511 Some(names.iter().map(|k| k.to_string()).collect())
512}
513
514fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
517where
518 D: serde::Deserializer<'de>,
519{
520 use crate::keyed::Keyed;
521 #[derive(Deserialize)]
522 #[serde(untagged)]
523 enum Shape {
524 Listed(Vec<crate::form::FormField>),
525 Named(serde_json::Map<String, serde_json::Value>),
526 }
527 match Shape::deserialize(de)? {
528 Shape::Listed(fields) => Ok(fields),
529 Shape::Named(entries) => entries
530 .into_iter()
531 .map(|(name, entry)| {
532 let mut field: crate::form::FormField =
533 serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
534 field.set_key(name);
535 Ok(field)
536 })
537 .collect(),
538 }
539}
540
541pub(crate) struct RepeaterField;
550
551impl RepeaterField {
552 const BLANK: &'static str = "__index__";
554}
555
556impl FieldType for RepeaterField {
557 fn view_key(&self) -> &'static str {
558 "repeater"
559 }
560
561 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
562 keys(&[
563 "fields",
564 "min_items",
565 "max_items",
566 "display",
567 "summary_field",
568 "reorder",
569 "duplicate",
570 ])
571 }
572
573 fn resolve_options(
574 &self,
575 raw: &serde_json::Value,
576 types: &FieldRegistry,
577 ) -> Result<ResolvedOptions, OptionsError> {
578 let parsed: RepeaterOptionsRaw =
579 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
580 let mut rows = Vec::new();
581 for field in parsed.fields {
582 if field.field_type == "repeater" {
583 return Err(OptionsError(
584 "a repeater cannot hold a repeater".to_string(),
585 ));
586 }
587 let field_type = types
588 .get(&field.field_type)
589 .cloned()
590 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
591 check_field_keys(&field, field_type.as_ref())
592 .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
593 let opts = field_type.resolve_options(&field.options, types)?;
594 rows.push(RepeaterSub {
595 field,
596 field_type,
597 opts,
598 });
599 }
600 Ok(ResolvedOptions::new(RepeaterOptions {
601 rows,
602 min_items: parsed.min_items,
603 max_items: parsed.max_items,
604 display: parsed.display,
605 summary_field: parsed.summary_field,
606 reorder: parsed.reorder,
607 duplicate: parsed.duplicate,
608 }))
609 }
610
611 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
612 let opts = cx.opts.get::<RepeaterOptions>();
613 let stored = match cx.value {
616 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
617 FieldValue::Text(text) => serde_json::from_str::<serde_json::Value>(text)
618 .ok()
619 .and_then(|v| v.as_array().cloned())
620 .unwrap_or_default(),
621 _ => Vec::new(),
622 };
623 let summary_index = opts
626 .and_then(|o| {
627 o.summary_field
628 .as_ref()
629 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
630 })
631 .unwrap_or(0);
632 let summaries = stored
633 .iter()
634 .map(|row| {
635 opts.and_then(|o| o.rows.get(summary_index))
636 .and_then(|sub| row.get(&sub.field.name))
637 .map(summary_text)
638 .unwrap_or_default()
639 })
640 .collect();
641 let data = RepeaterData {
642 rows: opts
643 .map(|o| {
644 stored
645 .iter()
646 .enumerate()
647 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
648 .collect()
649 })
650 .unwrap_or_default(),
651 blank: opts
652 .map(|o| render_row(o, cx, Self::BLANK, None))
653 .unwrap_or_default(),
654 list: opts.is_some_and(RepeaterOptions::list),
655 summary_index,
656 summaries,
657 reorder: opts.is_none_or(|o| o.reorder),
658 duplicate: opts.is_none_or(|o| o.duplicate),
659 text: RepeaterText {
660 add: cx.i18n.t(&laterite_core::t!("Add")),
661 remove: cx.i18n.t(&laterite_core::t!("Remove")),
662 duplicate: cx.i18n.t(&laterite_core::t!("Duplicate")),
663 up: cx.i18n.t(&laterite_core::t!("Move up")),
664 down: cx.i18n.t(&laterite_core::t!("Move down")),
665 untitled: cx.i18n.t(&laterite_core::t!("Untitled")),
666 },
667 };
668
669 FieldVm {
670 view_key: self.view_key().to_string(),
671 name: cx.name.to_string(),
672 id: cx.id.to_string(),
673 label: cx.label.to_string(),
674 required: cx.required,
675 value: cx.value.clone(),
676 data: serde_json::to_value(data).unwrap_or_default(),
677 }
678 }
679
680 fn render_default(&self, vm: &FieldVm) -> Markup {
681 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
682 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
683 }
684
685 fn to_attr(
686 &self,
687 field: &SubmittedField<'_>,
688 opts: &ResolvedOptions,
689 mode: Mode,
690 ) -> Result<Option<AttrValue>, String> {
691 let Some(options) = opts.get::<RepeaterOptions>() else {
692 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
693 };
694
695 let mut indices: Vec<usize> = field
698 .nested()
699 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
700 .collect();
701 indices.sort_unstable();
702 indices.dedup();
703
704 let mut rows = Vec::new();
705 for index in indices {
706 let mut object = serde_json::Map::new();
707 for sub in &options.rows {
708 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
709 let scoped = SubmittedField::new(&key, field.all());
710 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
711 object.insert(sub.field.name.clone(), attr_to_json(value));
712 }
713 }
714 let row = serde_json::Value::Object(object);
715 if !is_blank_row(&row) {
717 rows.push(row);
718 }
719 }
720
721 if rows.len() < options.min_items {
722 return Err(format!("at least {} required", options.min_items));
723 }
724 if let Some(max) = options.max_items {
725 if rows.len() > max {
726 return Err(format!("at most {max} allowed"));
727 }
728 }
729 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
730 }
731
732 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
733 stored.unwrap_or("[]").to_string()
734 }
735
736 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
739 let options = opts.get::<RepeaterOptions>()?;
740 let mut indices: Vec<usize> = field
741 .nested()
742 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
743 .collect();
744 indices.sort_unstable();
745 indices.dedup();
746 let rows: Vec<serde_json::Value> = indices
747 .into_iter()
748 .map(|index| {
749 let mut object = serde_json::Map::new();
750 for sub in &options.rows {
751 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
752 if let Some(value) = field.all().get(&key) {
753 object.insert(
754 sub.field.name.clone(),
755 serde_json::Value::String(value.clone()),
756 );
757 }
758 }
759 serde_json::Value::Object(object)
760 })
761 .filter(|row| !is_blank_row(row))
762 .collect();
763 if rows.is_empty() {
764 return None;
765 }
766 serde_json::to_string(&rows).ok()
767 }
768}
769
770fn summary_text(value: &serde_json::Value) -> String {
774 let raw = match value {
775 serde_json::Value::String(s) => s.clone(),
776 serde_json::Value::Null => String::new(),
777 other => other.to_string(),
778 };
779 raw.lines().next().unwrap_or_default().trim().to_string()
780}
781
782fn render_row(
783 options: &RepeaterOptions,
784 cx: &FieldCx<'_>,
785 index: &str,
786 row: Option<&serde_json::Value>,
787) -> Vec<RepeaterCell> {
788 options
789 .rows
790 .iter()
791 .map(|sub| {
792 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
793 let stored = row.and_then(|r| r.get(&sub.field.name));
794 let value = match stored {
795 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
796 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
797 Some(other) => FieldValue::Text(
798 sub.field_type
799 .to_control(Some(&other.to_string()), &sub.opts),
800 ),
801 };
802 let sub_cx = FieldCx {
803 name: &name,
804 id: &name,
805 label: cx.label,
806 value: &value,
807 required: false,
808 max_length: crate::form::max_length(&sub.field.rules),
809 opts: &sub.opts,
810 base: cx.base,
811 i18n: cx.i18n,
812 icons: cx.icons,
813 };
814 let vm = sub.field_type.view_model(&sub_cx);
815 RepeaterCell {
816 label: cx.i18n.t(&sub.field.label),
817 control: sub.field_type.render_default(&vm).into_string(),
818 help: sub
819 .field
820 .help
821 .as_ref()
822 .map(|h| cx.i18n.t(h))
823 .unwrap_or_default(),
824 }
825 })
826 .collect()
827}
828
829fn attr_to_json(value: AttrValue) -> serde_json::Value {
831 match value {
832 AttrValue::Null => serde_json::Value::Null,
833 AttrValue::Bool(b) => serde_json::Value::Bool(b),
834 AttrValue::Int(i) => serde_json::Value::from(i),
835 AttrValue::Float(f) => serde_json::Value::from(f),
836 AttrValue::Json(j) => j,
837 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
838 }
839}
840
841fn is_blank_row(row: &serde_json::Value) -> bool {
843 row.as_object().is_none_or(|object| {
844 object.values().all(|v| match v {
845 serde_json::Value::String(s) => s.trim().is_empty(),
846 serde_json::Value::Bool(b) => !b,
847 serde_json::Value::Array(items) => items.is_empty(),
848 serde_json::Value::Null => true,
849 _ => false,
850 })
851 })
852}
853
854#[derive(Serialize, Deserialize)]
856struct RepeaterCell {
857 label: String,
858 control: String,
859 help: String,
863}
864
865#[derive(Serialize, Deserialize, Default)]
868struct RepeaterData {
869 rows: Vec<Vec<RepeaterCell>>,
870 blank: Vec<RepeaterCell>,
871 list: bool,
873 summary_index: usize,
875 summaries: Vec<String>,
878 #[serde(default = "on")]
880 reorder: bool,
881 #[serde(default = "on")]
883 duplicate: bool,
884 #[serde(default)]
886 text: RepeaterText,
887}
888
889#[derive(Debug, Clone, Default, Serialize, Deserialize)]
891struct RepeaterText {
892 add: String,
893 remove: String,
894 duplicate: String,
895 up: String,
896 down: String,
897 untitled: String,
898}
899
900#[derive(Template)]
901#[template(path = "fields/repeater.html")]
902struct RepeaterTmpl {
903 data: RepeaterData,
904}
905
906#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
908#[serde(rename_all = "lowercase")]
909pub enum CodeLanguage {
910 #[default]
911 Text,
912 Html,
913 Css,
914 Javascript,
915 Json,
916 Yaml,
917 Markdown,
918 Jinja,
919}
920
921impl CodeLanguage {
922 fn as_str(self) -> &'static str {
923 match self {
924 Self::Text => "text",
925 Self::Html => "html",
926 Self::Css => "css",
927 Self::Javascript => "javascript",
928 Self::Json => "json",
929 Self::Yaml => "yaml",
930 Self::Markdown => "markdown",
931 Self::Jinja => "jinja",
932 }
933 }
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize)]
939struct CodeOptions {
940 #[serde(default)]
941 language: CodeLanguage,
942 #[serde(default)]
943 rows: Option<u16>,
944 #[serde(default = "yes")]
945 line_numbers: bool,
946 #[serde(default)]
947 wrap: bool,
948 #[serde(default)]
949 placeholder: Option<String>,
950}
951
952impl Default for CodeOptions {
953 fn default() -> Self {
954 Self {
955 language: CodeLanguage::Text,
956 rows: None,
957 line_numbers: true,
958 wrap: false,
959 placeholder: None,
960 }
961 }
962}
963
964#[derive(Template)]
965#[template(path = "fields/codeeditor.html")]
966struct CodeTmpl<'a> {
967 name: &'a str,
968 id: &'a str,
969 value: &'a str,
970 required: bool,
971 language: &'a str,
972 rows: Option<u16>,
973 line_numbers: bool,
974 wrap: bool,
975 placeholder: Option<&'a str>,
976}
977
978pub(crate) struct CodeEditorField;
982
983impl FieldType for CodeEditorField {
984 fn view_key(&self) -> &'static str {
985 "codeeditor"
986 }
987 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
988 keys(&["language", "rows", "line_numbers", "wrap", "placeholder"])
989 }
990 fn resolve_options(
991 &self,
992 raw: &serde_json::Value,
993 _types: &FieldRegistry,
994 ) -> Result<ResolvedOptions, OptionsError> {
995 let opts: CodeOptions = if raw.is_null() {
996 CodeOptions::default()
997 } else {
998 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
999 };
1000 Ok(ResolvedOptions::new(opts))
1001 }
1002 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1003 let mut vm = scalar_vm("codeeditor", cx);
1004 let opts = cx.opts.get::<CodeOptions>().cloned().unwrap_or_default();
1005 vm.data = serde_json::to_value(opts).unwrap_or_default();
1006 vm
1007 }
1008 fn render_default(&self, vm: &FieldVm) -> Markup {
1009 let opts: CodeOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1010 Markup::from_template(&CodeTmpl {
1011 name: &vm.name,
1012 id: &vm.id,
1013 value: vm.value.as_text(),
1014 required: vm.required,
1015 language: opts.language.as_str(),
1016 rows: opts.rows,
1017 line_numbers: opts.line_numbers,
1018 wrap: opts.wrap,
1019 placeholder: opts.placeholder.as_deref(),
1020 })
1021 .unwrap_or_default()
1022 }
1023 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1024 crate::CODE_EDITOR_ASSETS.to_vec()
1025 }
1026}
1027
1028const RICH_TOOLBAR_DEFAULT: &[&str] = &[
1030 "format",
1031 "bold",
1032 "italic",
1033 "underline",
1034 "strike",
1035 "|",
1036 "bullet_list",
1037 "ordered_list",
1038 "|",
1039 "link",
1040 "image",
1041 "table",
1042 "rule",
1043 "insert",
1044 "|",
1045 "undo",
1046 "redo",
1047];
1048
1049#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1054struct RichOptions {
1055 #[serde(default)]
1056 rows: Option<u16>,
1057 #[serde(default)]
1058 placeholder: Option<String>,
1059 #[serde(default)]
1060 toolbar: Option<Vec<String>>,
1061}
1062
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064struct RichButton {
1065 kind: String,
1067 cmd: String,
1068 label: String,
1069 icon: String,
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1075struct RichData {
1076 pick_url: String,
1077 menu_url: String,
1078 rows: Option<u16>,
1079 placeholder: Option<String>,
1080 toolbar_label: String,
1081 buttons: Vec<RichButton>,
1082 formats: Vec<(String, String)>,
1083 table_actions: Vec<(String, String)>,
1084 words: Vec<(String, String)>,
1085}
1086
1087#[derive(Template)]
1088#[template(path = "fields/richeditor.html")]
1089struct RichTmpl<'a> {
1090 name: &'a str,
1091 id: &'a str,
1092 value: &'a str,
1093 required: bool,
1094 data: &'a RichData,
1095}
1096
1097pub(crate) struct RichEditorField;
1101
1102impl RichEditorField {
1103 fn button(cx: &FieldCx<'_>, name: &str) -> Option<RichButton> {
1104 let (label, icon) = match name {
1105 "bold" => (laterite_core::t!("Bold"), "bold"),
1106 "italic" => (laterite_core::t!("Italic"), "italic"),
1107 "underline" => (laterite_core::t!("Underline"), "underline"),
1108 "strike" => (laterite_core::t!("Strikethrough"), "strikethrough"),
1109 "bullet_list" => (laterite_core::t!("Bulleted list"), "list"),
1110 "ordered_list" => (laterite_core::t!("Numbered list"), "list-ordered"),
1111 "link" => (laterite_core::t!("Link"), "link"),
1112 "image" => (laterite_core::t!("Image"), "image"),
1113 "table" => (laterite_core::t!("Table"), "table"),
1114 "rule" => (laterite_core::t!("Horizontal rule"), "minus"),
1115 "insert" => (laterite_core::t!("Insert"), "blocks"),
1116 "undo" => (laterite_core::t!("Undo"), "undo-2"),
1117 "redo" => (laterite_core::t!("Redo"), "redo-2"),
1118 "format" => {
1119 return Some(RichButton {
1120 kind: "format".into(),
1121 cmd: "format".into(),
1122 label: cx.i18n.t(&laterite_core::t!("Format")),
1123 icon: String::new(),
1124 })
1125 }
1126 "|" => {
1127 return Some(RichButton {
1128 kind: "sep".into(),
1129 cmd: String::new(),
1130 label: String::new(),
1131 icon: String::new(),
1132 })
1133 }
1134 _ => return None,
1135 };
1136 Some(RichButton {
1137 kind: "button".into(),
1138 cmd: name.to_string(),
1139 label: cx.i18n.t(&label),
1140 icon: cx.icon(icon),
1141 })
1142 }
1143}
1144
1145impl FieldType for RichEditorField {
1146 fn view_key(&self) -> &'static str {
1147 "richeditor"
1148 }
1149 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1150 keys(&["rows", "placeholder", "toolbar"])
1151 }
1152 fn resolve_options(
1153 &self,
1154 raw: &serde_json::Value,
1155 _types: &FieldRegistry,
1156 ) -> Result<ResolvedOptions, OptionsError> {
1157 let opts: RichOptions = if raw.is_null() {
1158 RichOptions::default()
1159 } else {
1160 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1161 };
1162 if let Some(names) = &opts.toolbar {
1163 if let Some(bad) = names
1164 .iter()
1165 .find(|n| !RICH_TOOLBAR_DEFAULT.contains(&n.as_str()))
1166 {
1167 return Err(OptionsError(format!(
1168 "unknown toolbar entry `{bad}`; expected one of {}",
1169 RICH_TOOLBAR_DEFAULT.join(", ")
1170 )));
1171 }
1172 }
1173 Ok(ResolvedOptions::new(opts))
1174 }
1175 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1176 let mut vm = scalar_vm("richeditor", cx);
1177 let opts = cx.opts.get::<RichOptions>().cloned().unwrap_or_default();
1178 let t = |s: &str| cx.i18n.t(&laterite_core::Text::dynamic(s));
1179 let names: Vec<String> = opts
1180 .toolbar
1181 .clone()
1182 .unwrap_or_else(|| RICH_TOOLBAR_DEFAULT.iter().map(|s| s.to_string()).collect());
1183 let data = RichData {
1184 pick_url: format!("{}/media/pick", cx.base),
1185 menu_url: format!("{}/editor/menu?surface=richeditor", cx.base),
1186 rows: opts.rows,
1187 placeholder: opts.placeholder,
1188 toolbar_label: cx.i18n.t(&laterite_core::t!("Formatting")),
1189 buttons: names.iter().filter_map(|n| Self::button(cx, n)).collect(),
1190 formats: vec![
1191 (
1192 "paragraph".into(),
1193 cx.i18n.t(&laterite_core::t!("Paragraph")),
1194 ),
1195 ("h1".into(), cx.i18n.t(&laterite_core::t!("Heading 1"))),
1196 ("h2".into(), cx.i18n.t(&laterite_core::t!("Heading 2"))),
1197 ("h3".into(), cx.i18n.t(&laterite_core::t!("Heading 3"))),
1198 ("h4".into(), cx.i18n.t(&laterite_core::t!("Heading 4"))),
1199 ("blockquote".into(), cx.i18n.t(&laterite_core::t!("Quote"))),
1200 (
1201 "code_block".into(),
1202 cx.i18n.t(&laterite_core::t!("Code block")),
1203 ),
1204 ],
1205 table_actions: vec![
1206 ("row_after".into(), cx.i18n.t(&laterite_core::t!("Add row"))),
1207 (
1208 "col_after".into(),
1209 cx.i18n.t(&laterite_core::t!("Add column")),
1210 ),
1211 (
1212 "row_delete".into(),
1213 cx.i18n.t(&laterite_core::t!("Delete row")),
1214 ),
1215 (
1216 "col_delete".into(),
1217 cx.i18n.t(&laterite_core::t!("Delete column")),
1218 ),
1219 (
1220 "header_row".into(),
1221 cx.i18n.t(&laterite_core::t!("Header row")),
1222 ),
1223 (
1224 "table_delete".into(),
1225 cx.i18n.t(&laterite_core::t!("Delete table")),
1226 ),
1227 ],
1228 words: vec![
1229 (
1230 "insert-title".into(),
1231 cx.i18n.t(&laterite_core::t!("Insert")),
1232 ),
1233 ("snippets".into(), cx.i18n.t(&laterite_core::t!("Snippets"))),
1234 (
1235 "nothing-to-insert".into(),
1236 cx.i18n.t(&laterite_core::t!("Nothing to insert.")),
1237 ),
1238 ("edit".into(), cx.i18n.t(&laterite_core::t!("Edit"))),
1239 ("link-title".into(), cx.i18n.t(&laterite_core::t!("Link"))),
1240 ("url".into(), cx.i18n.t(&laterite_core::t!("URL"))),
1241 (
1242 "new-tab".into(),
1243 cx.i18n.t(&laterite_core::t!("Open in a new tab")),
1244 ),
1245 (
1246 "remove-link".into(),
1247 cx.i18n.t(&laterite_core::t!("Remove link")),
1248 ),
1249 ("apply".into(), cx.i18n.t(&laterite_core::t!("Apply"))),
1250 ("cancel".into(), cx.i18n.t(&laterite_core::t!("Cancel"))),
1251 (
1252 "library-title".into(),
1253 cx.i18n.t(&laterite_core::t!("Choose from the library")),
1254 ),
1255 (
1256 "library-use".into(),
1257 cx.i18n.t(&laterite_core::t!("Use selected")),
1258 ),
1259 ],
1260 };
1261 let _ = t;
1262 vm.data = serde_json::to_value(data).unwrap_or_default();
1263 vm
1264 }
1265 fn render_default(&self, vm: &FieldVm) -> Markup {
1266 let data: RichData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1267 Markup::from_template(&RichTmpl {
1268 name: &vm.name,
1269 id: &vm.id,
1270 value: vm.value.as_text(),
1271 required: vm.required,
1272 data: &data,
1273 })
1274 .unwrap_or_default()
1275 }
1276 fn submitted(&self, field: &SubmittedField<'_>, _opts: &ResolvedOptions) -> Option<String> {
1278 field.value().map(|v| {
1279 if crate::richtext::is_blank(v) {
1280 String::new()
1281 } else {
1282 v.to_string()
1283 }
1284 })
1285 }
1286 fn to_attr(
1288 &self,
1289 field: &SubmittedField<'_>,
1290 _opts: &ResolvedOptions,
1291 _mode: Mode,
1292 ) -> Result<Option<AttrValue>, String> {
1293 Ok(field.value().map(|v| {
1294 let clean = crate::richtext::clean(v);
1295 AttrValue::Text(if crate::richtext::is_blank(&clean) {
1296 String::new()
1297 } else {
1298 clean
1299 })
1300 }))
1301 }
1302 fn linked_media(&self, submitted: Option<&str>, _opts: &ResolvedOptions) -> Option<Vec<i64>> {
1303 submitted.map(crate::richtext::media_ids)
1304 }
1305 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1306 crate::RICH_EDITOR_ASSETS.to_vec()
1307 }
1308}
1309
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1313struct MarkdownOptions {
1314 #[serde(default)]
1315 rows: Option<u16>,
1316 #[serde(default)]
1317 placeholder: Option<String>,
1318 #[serde(default = "yes")]
1319 preview: bool,
1320}
1321
1322impl Default for MarkdownOptions {
1323 fn default() -> Self {
1324 Self {
1325 rows: None,
1326 placeholder: None,
1327 preview: true,
1328 }
1329 }
1330}
1331
1332#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1335struct MarkdownData {
1336 preview_url: Option<String>,
1337 pick_url: String,
1338 menu_url: String,
1339 rows: Option<u16>,
1340 placeholder: Option<String>,
1341 toolbar_label: String,
1342 write: String,
1343 preview: String,
1344 buttons: Vec<RichButton>,
1345 words: Vec<(String, String)>,
1346}
1347
1348#[derive(Template)]
1349#[template(path = "fields/markdown.html")]
1350struct MarkdownTmpl<'a> {
1351 name: &'a str,
1352 id: &'a str,
1353 value: &'a str,
1354 required: bool,
1355 data: &'a MarkdownData,
1356}
1357
1358pub(crate) struct MarkdownField;
1361
1362impl FieldType for MarkdownField {
1363 fn view_key(&self) -> &'static str {
1364 "markdown"
1365 }
1366 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1367 keys(&["rows", "placeholder", "preview"])
1368 }
1369 fn resolve_options(
1370 &self,
1371 raw: &serde_json::Value,
1372 _types: &FieldRegistry,
1373 ) -> Result<ResolvedOptions, OptionsError> {
1374 let opts: MarkdownOptions = if raw.is_null() {
1375 MarkdownOptions::default()
1376 } else {
1377 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1378 };
1379 Ok(ResolvedOptions::new(opts))
1380 }
1381 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1382 let mut vm = scalar_vm("markdown", cx);
1383 let opts = cx
1384 .opts
1385 .get::<MarkdownOptions>()
1386 .cloned()
1387 .unwrap_or_default();
1388 let button = |cmd: &str, label: laterite_core::Text, icon: &str| RichButton {
1389 kind: "button".into(),
1390 cmd: cmd.into(),
1391 label: cx.i18n.t(&label),
1392 icon: cx.icon(icon),
1393 };
1394 let data = MarkdownData {
1395 preview_url: opts
1396 .preview
1397 .then(|| format!("{}/markdown/preview", cx.base)),
1398 pick_url: format!("{}/media/pick", cx.base),
1399 menu_url: format!("{}/editor/menu?surface=markdown", cx.base),
1400 rows: opts.rows,
1401 placeholder: opts.placeholder,
1402 toolbar_label: cx.i18n.t(&laterite_core::t!("Formatting")),
1403 write: cx.i18n.t(&laterite_core::t!("Write")),
1404 preview: cx.i18n.t(&laterite_core::t!("Preview")),
1405 buttons: vec![
1406 button("heading", laterite_core::t!("Heading"), "heading"),
1407 button("bold", laterite_core::t!("Bold"), "bold"),
1408 button("italic", laterite_core::t!("Italic"), "italic"),
1409 button("quote", laterite_core::t!("Quote"), "quote"),
1410 button("code", laterite_core::t!("Code"), "code"),
1411 button("link", laterite_core::t!("Link"), "link"),
1412 button("image", laterite_core::t!("Image"), "image"),
1413 button("bullet_list", laterite_core::t!("Bulleted list"), "list"),
1414 button(
1415 "ordered_list",
1416 laterite_core::t!("Numbered list"),
1417 "list-ordered",
1418 ),
1419 button("table", laterite_core::t!("Table"), "table"),
1420 button("insert", laterite_core::t!("Insert"), "blocks"),
1421 ],
1422 words: vec![
1423 (
1424 "insert-title".into(),
1425 cx.i18n.t(&laterite_core::t!("Insert")),
1426 ),
1427 ("snippets".into(), cx.i18n.t(&laterite_core::t!("Snippets"))),
1428 (
1429 "nothing-to-insert".into(),
1430 cx.i18n.t(&laterite_core::t!("Nothing to insert.")),
1431 ),
1432 (
1433 "bold-text".into(),
1434 cx.i18n.t(&laterite_core::t!("bold text")),
1435 ),
1436 (
1437 "italic-text".into(),
1438 cx.i18n.t(&laterite_core::t!("italic text")),
1439 ),
1440 (
1441 "link-text".into(),
1442 cx.i18n.t(&laterite_core::t!("link text")),
1443 ),
1444 ("column".into(), cx.i18n.t(&laterite_core::t!("Column"))),
1445 (
1446 "nothing".into(),
1447 cx.i18n.t(&laterite_core::t!("Nothing to preview.")),
1448 ),
1449 (
1450 "preview-failed".into(),
1451 cx.i18n
1452 .t(&laterite_core::t!("The preview could not be rendered.")),
1453 ),
1454 (
1455 "library-title".into(),
1456 cx.i18n.t(&laterite_core::t!("Choose from the library")),
1457 ),
1458 (
1459 "library-use".into(),
1460 cx.i18n.t(&laterite_core::t!("Use selected")),
1461 ),
1462 ("cancel".into(), cx.i18n.t(&laterite_core::t!("Cancel"))),
1463 ],
1464 };
1465 vm.data = serde_json::to_value(data).unwrap_or_default();
1466 vm
1467 }
1468 fn render_default(&self, vm: &FieldVm) -> Markup {
1469 let data: MarkdownData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1470 Markup::from_template(&MarkdownTmpl {
1471 name: &vm.name,
1472 id: &vm.id,
1473 value: vm.value.as_text(),
1474 required: vm.required,
1475 data: &data,
1476 })
1477 .unwrap_or_default()
1478 }
1479 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1480 crate::MARKDOWN_ASSETS.to_vec()
1481 }
1482}
1483
1484pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
1487 let inputs = Arc::new(builtin_input_registry());
1488 vec![
1489 Arc::new(TextField::new(inputs)),
1490 Arc::new(TextareaField),
1491 Arc::new(CodeEditorField),
1492 Arc::new(RichEditorField),
1493 Arc::new(MarkdownField),
1494 Arc::new(IdField),
1495 Arc::new(RepeaterField),
1496 Arc::new(SelectField),
1497 Arc::new(ChecklistField),
1498 Arc::new(SwitchField),
1499 Arc::new(DateField),
1500 Arc::new(PasswordField),
1501 Arc::new(RadioField),
1502 Arc::new(crate::medialib::field::MediaField),
1503 ]
1504}
1505
1506pub(crate) fn builtin_registry() -> FieldRegistry {
1508 builtin_field_types()
1509 .into_iter()
1510 .map(|ft| (ft.view_key().to_string(), ft))
1511 .collect()
1512}
1513
1514#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1516#[serde(rename_all = "snake_case")]
1517pub enum Placement {
1518 Leading,
1519 Trailing,
1520}
1521
1522#[derive(Debug, Clone, Serialize, Deserialize)]
1526pub struct Adornment {
1527 pub placement: Placement,
1528 pub button: bool,
1530 pub label: String,
1532 pub widget: Option<String>,
1535}
1536
1537pub trait InputType: Send + Sync + 'static {
1543 fn key(&self) -> &'static str;
1545 fn html_type(&self) -> &'static str {
1547 "text"
1548 }
1549 fn resolve_options(
1552 &self,
1553 _raw: &serde_json::Value,
1554 _types: &FieldRegistry,
1555 ) -> Result<ResolvedOptions, OptionsError> {
1556 Ok(ResolvedOptions::none())
1557 }
1558 fn option_keys(&self) -> Vec<&'static str> {
1561 Vec::new()
1562 }
1563 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1565 Vec::new()
1566 }
1567 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1570 BTreeMap::new()
1571 }
1572 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
1574 Vec::new()
1575 }
1576 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1579 Vec::new()
1580 }
1581}
1582
1583pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
1585
1586struct TextInput;
1587impl InputType for TextInput {
1588 fn key(&self) -> &'static str {
1589 "text"
1590 }
1591}
1592
1593struct EmailInput;
1594impl InputType for EmailInput {
1595 fn key(&self) -> &'static str {
1596 "email"
1597 }
1598 fn html_type(&self) -> &'static str {
1599 "email"
1600 }
1601 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1602 vec![Rule::Email]
1603 }
1604}
1605
1606struct TelInput;
1607impl InputType for TelInput {
1608 fn key(&self) -> &'static str {
1609 "tel"
1610 }
1611 fn html_type(&self) -> &'static str {
1612 "tel"
1613 }
1614}
1615
1616#[derive(Default, Deserialize)]
1618struct NumberOptions {
1619 #[serde(default)]
1620 min: Option<f64>,
1621 #[serde(default)]
1622 max: Option<f64>,
1623 #[serde(default)]
1624 step: Option<f64>,
1625}
1626
1627struct NumberInput;
1628impl InputType for NumberInput {
1629 fn key(&self) -> &'static str {
1630 "number"
1631 }
1632 fn html_type(&self) -> &'static str {
1633 "number"
1634 }
1635 fn option_keys(&self) -> Vec<&'static str> {
1636 vec!["min", "max", "step"]
1637 }
1638 fn resolve_options(
1639 &self,
1640 raw: &serde_json::Value,
1641 _types: &FieldRegistry,
1642 ) -> Result<ResolvedOptions, OptionsError> {
1643 let opts: NumberOptions = if raw.is_null() {
1644 NumberOptions::default()
1645 } else {
1646 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1647 };
1648 Ok(ResolvedOptions::new(opts))
1649 }
1650 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1651 vec![Rule::Numeric]
1652 }
1653 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
1654 let mut attrs = BTreeMap::new();
1655 if let Some(o) = opts.get::<NumberOptions>() {
1656 if let Some(min) = o.min {
1657 attrs.insert("min".to_string(), min.to_string());
1658 }
1659 if let Some(max) = o.max {
1660 attrs.insert("max".to_string(), max.to_string());
1661 }
1662 if let Some(step) = o.step {
1663 attrs.insert("step".to_string(), step.to_string());
1664 }
1665 }
1666 attrs
1667 }
1668}
1669
1670#[derive(Deserialize)]
1672struct UrlOptions {
1673 #[serde(default = "default_true")]
1674 copy: bool,
1675}
1676
1677impl Default for UrlOptions {
1678 fn default() -> Self {
1679 Self { copy: true }
1680 }
1681}
1682
1683fn default_true() -> bool {
1684 true
1685}
1686
1687struct UrlInput;
1688impl InputType for UrlInput {
1689 fn key(&self) -> &'static str {
1690 "url"
1691 }
1692 fn html_type(&self) -> &'static str {
1693 "url"
1694 }
1695 fn option_keys(&self) -> Vec<&'static str> {
1696 vec!["copy"]
1697 }
1698 fn resolve_options(
1699 &self,
1700 raw: &serde_json::Value,
1701 _types: &FieldRegistry,
1702 ) -> Result<ResolvedOptions, OptionsError> {
1703 let opts: UrlOptions = if raw.is_null() {
1704 UrlOptions::default()
1705 } else {
1706 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1707 };
1708 Ok(ResolvedOptions::new(opts))
1709 }
1710 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1711 vec![Rule::Url]
1712 }
1713 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
1714 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
1715 vec![Adornment {
1716 placement: Placement::Trailing,
1717 button: true,
1718 label: "Copy".to_string(),
1719 widget: Some("copy".to_string()),
1720 }]
1721 } else {
1722 Vec::new()
1723 }
1724 }
1725}
1726
1727struct ColorInput;
1729impl InputType for ColorInput {
1730 fn key(&self) -> &'static str {
1731 "color"
1732 }
1733 fn html_type(&self) -> &'static str {
1734 "color"
1735 }
1736}
1737
1738pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1739 vec![
1740 Arc::new(TextInput),
1741 Arc::new(EmailInput),
1742 Arc::new(TelInput),
1743 Arc::new(NumberInput),
1744 Arc::new(UrlInput),
1745 Arc::new(ColorInput),
1746 ]
1747}
1748
1749pub(crate) fn builtin_input_registry() -> InputRegistry {
1751 builtin_input_types()
1752 .into_iter()
1753 .map(|i| (i.key().to_string(), i))
1754 .collect()
1755}
1756
1757const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1760
1761fn validate_contributions(
1766 attrs: &BTreeMap<String, String>,
1767 adornments: &[Adornment],
1768) -> Result<(), OptionsError> {
1769 for name in attrs.keys() {
1770 if !is_name(name, false) {
1771 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1772 }
1773 if RESERVED_ATTRS.contains(&name.as_str()) {
1774 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1775 }
1776 }
1777 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1778 if !is_name(widget, true) {
1779 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1780 }
1781 }
1782 Ok(())
1783}
1784
1785pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1787 let mut chars = s.chars();
1788 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1789 && chars.all(|c| {
1790 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1791 })
1792}
1793
1794#[derive(Deserialize)]
1796struct TextOptions {
1797 #[serde(default = "default_input")]
1798 input: String,
1799 #[serde(default)]
1801 placeholder: Option<String>,
1802 #[serde(default)]
1804 counter: crate::checklist::Offer,
1805}
1806
1807fn default_input() -> String {
1808 "text".to_string()
1809}
1810
1811struct TextResolved {
1814 html_type: &'static str,
1815 attrs: BTreeMap<String, String>,
1816 adornments: Vec<Adornment>,
1817 rules: Vec<Rule>,
1818 assets: Vec<&'static str>,
1819 counter: crate::checklist::Offer,
1820}
1821
1822fn length_attrs(
1825 attrs: &mut BTreeMap<String, String>,
1826 max_length: Option<usize>,
1827 counter: crate::checklist::Offer,
1828) {
1829 use crate::checklist::Offer;
1830 let Some(max) = max_length else {
1831 return;
1832 };
1833 attrs.insert("maxlength".to_string(), max.to_string());
1834 match counter {
1835 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1836 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1837 Offer::Never => None,
1838 };
1839}
1840
1841#[derive(Default, Serialize, Deserialize)]
1844struct TextData {
1845 input_type: String,
1846 #[serde(default)]
1847 attrs: BTreeMap<String, String>,
1848 #[serde(default)]
1849 adornments: Vec<Adornment>,
1850}
1851
1852#[derive(Template)]
1853#[template(path = "fields/text.html")]
1854struct TextTmpl<'a> {
1855 name: &'a str,
1856 id: &'a str,
1857 value: &'a str,
1858 required: bool,
1859 input_type: &'a str,
1860 attrs: &'a BTreeMap<String, String>,
1861 leading: &'a [Adornment],
1862 trailing: &'a [Adornment],
1863}
1864
1865pub(crate) struct TextField {
1869 inputs: Arc<InputRegistry>,
1870}
1871
1872impl TextField {
1873 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1874 Self { inputs }
1875 }
1876}
1877
1878impl FieldType for TextField {
1879 fn view_key(&self) -> &'static str {
1880 "text"
1881 }
1882 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1883 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1884 let input = self.inputs.get(input)?;
1887 let mut all = vec![
1888 "input".to_string(),
1889 "placeholder".to_string(),
1890 "counter".to_string(),
1891 ];
1892 all.extend(input.option_keys().into_iter().map(str::to_string));
1893 Some(all)
1894 }
1895 fn resolve_options(
1896 &self,
1897 raw: &serde_json::Value,
1898 _types: &FieldRegistry,
1899 ) -> Result<ResolvedOptions, OptionsError> {
1900 let opts: TextOptions = if raw.is_null() {
1901 TextOptions {
1902 input: default_input(),
1903 placeholder: None,
1904 counter: Default::default(),
1905 }
1906 } else {
1907 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1908 };
1909 let input = self
1910 .inputs
1911 .get(&opts.input)
1912 .cloned()
1913 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1914 let input_opts = input.resolve_options(raw, _types)?;
1917 let mut attrs = input.attributes(&input_opts);
1918 let adornments = input.adornments(&input_opts);
1919 validate_contributions(&attrs, &adornments)?;
1920 if let Some(placeholder) = opts.placeholder {
1921 attrs.insert("placeholder".to_string(), placeholder);
1922 }
1923 Ok(ResolvedOptions::new(TextResolved {
1924 html_type: input.html_type(),
1925 rules: input.rules(&input_opts),
1926 assets: input.assets(&input_opts),
1927 attrs,
1928 adornments,
1929 counter: opts.counter,
1930 }))
1931 }
1932 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1933 opts.get::<TextResolved>()
1934 .map(|r| r.rules.clone())
1935 .unwrap_or_default()
1936 }
1937 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1938 opts.get::<TextResolved>()
1939 .map(|r| r.assets.clone())
1940 .unwrap_or_default()
1941 }
1942 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1943 let mut data = match cx.opts.get::<TextResolved>() {
1944 Some(r) => TextData {
1945 input_type: r.html_type.to_string(),
1946 attrs: r.attrs.clone(),
1947 adornments: r.adornments.clone(),
1948 },
1949 None => TextData {
1950 input_type: "text".to_string(),
1951 ..TextData::default()
1952 },
1953 };
1954 if matches!(
1956 data.input_type.as_str(),
1957 "text" | "email" | "tel" | "url" | "search"
1958 ) {
1959 let counter = cx
1960 .opts
1961 .get::<TextResolved>()
1962 .map(|r| r.counter)
1963 .unwrap_or_default();
1964 length_attrs(&mut data.attrs, cx.max_length, counter);
1965 }
1966 FieldVm {
1967 view_key: "text".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(data).unwrap_or_default(),
1974 }
1975 }
1976 fn render_default(&self, vm: &FieldVm) -> Markup {
1977 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1978 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1979 .adornments
1980 .into_iter()
1981 .partition(|a| matches!(a.placement, Placement::Leading));
1982 Markup::from_template(&TextTmpl {
1983 name: &vm.name,
1984 id: &vm.id,
1985 value: vm.value.as_text(),
1986 required: vm.required,
1987 input_type: &data.input_type,
1988 attrs: &data.attrs,
1989 leading: &leading,
1990 trailing: &trailing,
1991 })
1992 .unwrap_or_default()
1993 }
1994}
1995
1996#[derive(Template)]
1997#[template(path = "fields/textarea.html")]
1998struct TextareaTmpl<'a> {
1999 name: &'a str,
2000 id: &'a str,
2001 value: &'a str,
2002 required: bool,
2003 rows: Option<u16>,
2004 placeholder: Option<&'a str>,
2005 grow: bool,
2006 attrs: &'a BTreeMap<String, String>,
2007}
2008
2009fn yes() -> bool {
2010 true
2011}
2012
2013#[derive(Debug, Clone, Serialize, Deserialize)]
2016struct TextareaOptions {
2017 #[serde(default)]
2018 rows: Option<u16>,
2019 #[serde(default)]
2020 placeholder: Option<String>,
2021 #[serde(default = "yes")]
2022 grow: bool,
2023 #[serde(default)]
2024 counter: crate::checklist::Offer,
2025}
2026
2027impl Default for TextareaOptions {
2028 fn default() -> Self {
2029 Self {
2030 rows: None,
2031 placeholder: None,
2032 grow: true,
2033 counter: Default::default(),
2034 }
2035 }
2036}
2037
2038#[derive(Debug, Clone, Serialize, Deserialize)]
2040struct TextareaData {
2041 #[serde(default)]
2042 rows: Option<u16>,
2043 #[serde(default)]
2044 placeholder: Option<String>,
2045 #[serde(default = "yes")]
2046 grow: bool,
2047 #[serde(default)]
2049 attrs: BTreeMap<String, String>,
2050}
2051
2052impl Default for TextareaData {
2053 fn default() -> Self {
2054 Self {
2055 rows: None,
2056 placeholder: None,
2057 grow: true,
2058 attrs: BTreeMap::new(),
2059 }
2060 }
2061}
2062
2063pub(crate) struct TextareaField;
2065
2066impl FieldType for TextareaField {
2067 fn view_key(&self) -> &'static str {
2068 "textarea"
2069 }
2070 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2071 keys(&["rows", "placeholder", "grow", "counter"])
2072 }
2073 fn resolve_options(
2074 &self,
2075 raw: &serde_json::Value,
2076 _types: &FieldRegistry,
2077 ) -> Result<ResolvedOptions, OptionsError> {
2078 let opts: TextareaOptions = if raw.is_null() {
2079 TextareaOptions::default()
2080 } else {
2081 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2082 };
2083 Ok(ResolvedOptions::new(opts))
2084 }
2085 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2086 let mut vm = scalar_vm("textarea", cx);
2087 let opts = cx
2088 .opts
2089 .get::<TextareaOptions>()
2090 .cloned()
2091 .unwrap_or_default();
2092 let mut data = TextareaData {
2093 rows: opts.rows,
2094 placeholder: opts.placeholder,
2095 grow: opts.grow,
2096 attrs: BTreeMap::new(),
2097 };
2098 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
2099 vm.data = serde_json::to_value(data).unwrap_or_default();
2100 vm
2101 }
2102 fn render_default(&self, vm: &FieldVm) -> Markup {
2103 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2104 Markup::from_template(&TextareaTmpl {
2105 name: &vm.name,
2106 id: &vm.id,
2107 value: vm.value.as_text(),
2108 required: vm.required,
2109 rows: data.rows,
2110 placeholder: data.placeholder.as_deref(),
2111 grow: data.grow,
2112 attrs: &data.attrs,
2113 })
2114 .unwrap_or_default()
2115 }
2116}
2117
2118pub const SELECT_SEARCH_PAST: usize = 10;
2120
2121#[derive(Debug, Default, Deserialize)]
2124struct SelectOptions {
2125 #[serde(default)]
2126 options: Vec<SelectOption>,
2127 #[serde(default)]
2128 search: crate::checklist::Offer,
2129}
2130
2131impl SelectOptions {
2132 fn searchable(&self) -> bool {
2133 use crate::checklist::Offer;
2134 match self.search {
2135 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
2136 Offer::Always => true,
2137 Offer::Never => false,
2138 }
2139 }
2140}
2141
2142#[derive(Debug, Default, Serialize, Deserialize)]
2144struct SelectData {
2145 options: Vec<OptionView>,
2146 #[serde(default)]
2147 search: bool,
2148}
2149
2150#[derive(Debug, Deserialize)]
2151struct SelectOption {
2152 value: String,
2153 #[serde(default)]
2155 label: Option<String>,
2156}
2157
2158#[derive(Debug, Serialize, Deserialize)]
2161struct OptionView {
2162 value: String,
2163 label: String,
2164 selected: bool,
2165}
2166
2167#[derive(Template)]
2168#[template(path = "fields/select.html")]
2169struct SelectTmpl<'a> {
2170 name: &'a str,
2171 id: &'a str,
2172 required: bool,
2173 options: &'a [OptionView],
2174 search: bool,
2175}
2176
2177pub(crate) struct SelectField;
2179
2180impl FieldType for SelectField {
2181 fn view_key(&self) -> &'static str {
2182 "select"
2183 }
2184 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2185 keys(&["options", "search"])
2186 }
2187 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
2190 match opts.get::<SelectOptions>() {
2191 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
2192 _ => Vec::new(),
2193 }
2194 }
2195 fn resolve_options(
2196 &self,
2197 raw: &serde_json::Value,
2198 _types: &FieldRegistry,
2199 ) -> Result<ResolvedOptions, OptionsError> {
2200 let opts: SelectOptions =
2201 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2202 Ok(ResolvedOptions::new(opts))
2203 }
2204 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2205 let current = cx.value.as_text();
2206 let opts = cx.opts.get::<SelectOptions>();
2207 let data = SelectData {
2208 options: opts
2209 .map(|o| {
2210 o.options
2211 .iter()
2212 .map(|so| OptionView {
2213 value: so.value.clone(),
2214 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2215 selected: so.value == current,
2216 })
2217 .collect()
2218 })
2219 .unwrap_or_default(),
2220 search: opts.is_some_and(SelectOptions::searchable),
2221 };
2222 FieldVm {
2223 view_key: "select".to_string(),
2224 name: cx.name.to_string(),
2225 id: cx.id.to_string(),
2226 label: cx.label.to_string(),
2227 required: cx.required,
2228 value: cx.value.clone(),
2229 data: serde_json::to_value(&data).unwrap_or_default(),
2230 }
2231 }
2232 fn render_default(&self, vm: &FieldVm) -> Markup {
2233 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2234 Markup::from_template(&SelectTmpl {
2235 name: &vm.name,
2236 id: &vm.id,
2237 required: vm.required,
2238 options: &data.options,
2239 search: data.search,
2240 })
2241 .unwrap_or_default()
2242 }
2243}
2244
2245#[derive(Debug, Default, Deserialize)]
2247struct ChecklistOptions {
2248 #[serde(default)]
2249 options: Vec<ChecklistEntry>,
2250 #[serde(default)]
2251 select_all: crate::checklist::Offer,
2252 #[serde(default)]
2253 search: crate::checklist::Offer,
2254 #[serde(default)]
2255 expand: crate::checklist::Expand,
2256}
2257
2258#[derive(Debug, Deserialize)]
2260#[serde(untagged)]
2261enum ChecklistEntry {
2262 Group {
2263 group: String,
2264 #[serde(default)]
2265 options: Vec<ChecklistEntry>,
2266 },
2267 Choice {
2268 value: String,
2269 #[serde(default)]
2271 label: Option<String>,
2272 #[serde(default)]
2274 note: Option<String>,
2275 },
2276}
2277
2278impl ChecklistOptions {
2279 fn values(&self) -> Vec<&str> {
2281 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
2282 for entry in entries {
2283 match entry {
2284 ChecklistEntry::Choice { value, .. } => out.push(value),
2285 ChecklistEntry::Group { options, .. } => walk(options, out),
2286 }
2287 }
2288 }
2289 let mut out = Vec::new();
2290 walk(&self.options, &mut out);
2291 out
2292 }
2293
2294 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
2297 self.values()
2298 .into_iter()
2299 .filter(|value| submitted.contains(value))
2300 .collect()
2301 }
2302}
2303
2304fn checklist_values(text: &str) -> Vec<String> {
2306 serde_json::from_str(text).unwrap_or_default()
2307}
2308
2309pub(crate) struct ChecklistField;
2314
2315impl ChecklistField {
2316 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
2317 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
2318 match opts.get::<ChecklistOptions>() {
2319 Some(options) => options
2320 .ticked(&submitted)
2321 .into_iter()
2322 .map(str::to_string)
2323 .collect(),
2324 None => Vec::new(),
2325 }
2326 }
2327}
2328
2329impl FieldType for ChecklistField {
2330 fn view_key(&self) -> &'static str {
2331 "checklist"
2332 }
2333 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2334 keys(&["options", "select_all", "search", "expand"])
2335 }
2336 fn resolve_options(
2337 &self,
2338 raw: &serde_json::Value,
2339 _types: &FieldRegistry,
2340 ) -> Result<ResolvedOptions, OptionsError> {
2341 let opts: ChecklistOptions = if raw.is_null() {
2342 ChecklistOptions::default()
2343 } else {
2344 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2345 };
2346 let mut seen = std::collections::HashSet::new();
2347 for value in opts.values() {
2348 if !seen.insert(value) {
2349 return Err(OptionsError(format!("option `{value}` is listed twice")));
2350 }
2351 }
2352 Ok(ResolvedOptions::new(opts))
2353 }
2354
2355 fn to_attr(
2356 &self,
2357 field: &SubmittedField<'_>,
2358 opts: &ResolvedOptions,
2359 _mode: Mode,
2360 ) -> Result<Option<AttrValue>, String> {
2361 let values = Self::gathered(field, opts)
2364 .into_iter()
2365 .map(serde_json::Value::String)
2366 .collect();
2367 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
2368 }
2369
2370 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2371 stored.unwrap_or("[]").to_string()
2372 }
2373
2374 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
2375 let values = Self::gathered(field, opts);
2376 if values.is_empty() {
2377 return None;
2378 }
2379 serde_json::to_string(&values).ok()
2380 }
2381
2382 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2383 use crate::checklist::{Checklist, Choice, Entry, Group};
2384
2385 let ticked: Vec<String> = match cx.value {
2386 FieldValue::Json(serde_json::Value::Array(values)) => values
2387 .iter()
2388 .filter_map(|v| v.as_str().map(str::to_string))
2389 .collect(),
2390 FieldValue::Text(text) => checklist_values(text),
2391 _ => Vec::new(),
2392 };
2393
2394 fn build(
2395 entries: &[ChecklistEntry],
2396 name: &str,
2397 ticked: &[String],
2398 position: &mut usize,
2399 ) -> Vec<Entry> {
2400 entries
2401 .iter()
2402 .map(|entry| match entry {
2403 ChecklistEntry::Choice { value, label, note } => {
2404 let mut choice = Choice::new(
2405 format!("{name}[{position}]"),
2406 value.clone(),
2407 label.clone().unwrap_or_else(|| value.clone()),
2408 )
2409 .checked(ticked.contains(value));
2410 if let Some(note) = note {
2411 choice = choice.note(note.clone());
2412 }
2413 *position += 1;
2414 Entry::from(choice)
2415 }
2416 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
2417 group.clone(),
2418 build(options, name, ticked, position),
2419 )),
2420 })
2421 .collect()
2422 }
2423
2424 let mut list = Checklist::new(cx.label).id(cx.id);
2425 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
2426 let mut position = 0;
2427 for entry in build(&options.options, cx.name, &ticked, &mut position) {
2428 list = list.entry(entry);
2429 }
2430 list = list
2431 .select_all(options.select_all)
2432 .search(options.search)
2433 .expand(options.expand);
2434 }
2435 FieldVm {
2436 view_key: "checklist".to_string(),
2437 name: cx.name.to_string(),
2438 id: cx.id.to_string(),
2439 label: cx.label.to_string(),
2440 required: cx.required,
2441 value: cx.value.clone(),
2442 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
2443 }
2444 }
2445
2446 fn render_default(&self, vm: &FieldVm) -> Markup {
2447 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
2448 .map(|view| view.render_markup())
2449 .unwrap_or_default()
2450 }
2451}
2452
2453#[derive(Template)]
2454#[template(path = "fields/switch.html")]
2455struct SwitchTmpl<'a> {
2456 name: &'a str,
2457 id: &'a str,
2458 on: bool,
2459}
2460
2461pub(crate) struct SwitchField;
2467
2468fn stored_is_on(stored: Option<&str>) -> bool {
2471 matches!(
2472 stored.map(str::trim).unwrap_or_default(),
2473 "1" | "true" | "TRUE" | "t" | "yes" | "on"
2474 )
2475}
2476
2477impl FieldType for SwitchField {
2478 fn view_key(&self) -> &'static str {
2479 "switch"
2480 }
2481 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2482 keys(&[])
2483 }
2484
2485 fn to_attr(
2486 &self,
2487 field: &SubmittedField<'_>,
2488 _opts: &ResolvedOptions,
2489 _mode: Mode,
2490 ) -> Result<Option<AttrValue>, String> {
2491 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
2494 }
2495
2496 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2497 if stored_is_on(stored) {
2498 "1".to_string()
2499 } else {
2500 String::new()
2501 }
2502 }
2503
2504 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2505 scalar_vm("switch", cx)
2506 }
2507
2508 fn render_default(&self, vm: &FieldVm) -> Markup {
2509 Markup::from_template(&SwitchTmpl {
2510 name: &vm.name,
2511 id: &vm.id,
2512 on: stored_is_on(Some(vm.value.as_text())),
2513 })
2514 .unwrap_or_default()
2515 }
2516}
2517
2518#[derive(Template)]
2519#[template(path = "fields/date.html")]
2520struct DateTmpl<'a> {
2521 name: &'a str,
2522 id: &'a str,
2523 value: &'a str,
2524 required: bool,
2525}
2526
2527pub(crate) struct DateField;
2533
2534impl FieldType for DateField {
2535 fn view_key(&self) -> &'static str {
2536 "date"
2537 }
2538 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2539 keys(&[])
2540 }
2541
2542 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
2543 vec![Rule::Date]
2544 }
2545
2546 fn to_attr(
2547 &self,
2548 field: &SubmittedField<'_>,
2549 _opts: &ResolvedOptions,
2550 _mode: Mode,
2551 ) -> Result<Option<AttrValue>, String> {
2552 Ok(field.value().map(|v| match v.trim() {
2553 "" => AttrValue::Null,
2554 date => AttrValue::Text(date.to_string()),
2555 }))
2556 }
2557
2558 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2559 let stored = stored.unwrap_or_default().trim();
2560 stored
2563 .split(['T', ' '])
2564 .next()
2565 .unwrap_or_default()
2566 .to_string()
2567 }
2568
2569 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2570 scalar_vm("date", cx)
2571 }
2572
2573 fn render_default(&self, vm: &FieldVm) -> Markup {
2574 Markup::from_template(&DateTmpl {
2575 name: &vm.name,
2576 id: &vm.id,
2577 value: vm.value.as_text(),
2578 required: vm.required,
2579 })
2580 .unwrap_or_default()
2581 }
2582}
2583
2584#[derive(Template)]
2585#[template(path = "fields/password.html")]
2586struct PasswordTmpl<'a> {
2587 name: &'a str,
2588 id: &'a str,
2589 required: bool,
2590 reveal: bool,
2591 show: &'a str,
2592}
2593
2594#[derive(Debug, Clone, Serialize, Deserialize)]
2596struct PasswordOptions {
2597 #[serde(default = "yes")]
2598 reveal: bool,
2599 #[serde(default)]
2601 show: String,
2602}
2603
2604impl Default for PasswordOptions {
2605 fn default() -> Self {
2606 Self {
2607 reveal: true,
2608 show: String::new(),
2609 }
2610 }
2611}
2612
2613pub(crate) struct PasswordField;
2621
2622impl FieldType for PasswordField {
2623 fn view_key(&self) -> &'static str {
2624 "password"
2625 }
2626 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2627 keys(&["reveal"])
2628 }
2629 fn resolve_options(
2630 &self,
2631 raw: &serde_json::Value,
2632 _types: &FieldRegistry,
2633 ) -> Result<ResolvedOptions, OptionsError> {
2634 let opts: PasswordOptions = if raw.is_null() {
2635 PasswordOptions::default()
2636 } else {
2637 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2638 };
2639 Ok(ResolvedOptions::new(opts))
2640 }
2641
2642 fn to_attr(
2643 &self,
2644 field: &SubmittedField<'_>,
2645 _opts: &ResolvedOptions,
2646 _mode: Mode,
2647 ) -> Result<Option<AttrValue>, String> {
2648 match field.value().map(str::trim).unwrap_or_default() {
2649 "" => Ok(None),
2651 plain => laterite_auth::password::hash_password(plain)
2652 .map(|hash| Some(AttrValue::Text(hash)))
2653 .map_err(|e| e.to_string()),
2654 }
2655 }
2656
2657 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2660 String::new()
2661 }
2662
2663 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2664 let mut vm = scalar_vm("password", cx);
2665 let mut opts = cx
2666 .opts
2667 .get::<PasswordOptions>()
2668 .cloned()
2669 .unwrap_or_default();
2670 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2671 vm.data = serde_json::to_value(opts).unwrap_or_default();
2672 vm
2673 }
2674
2675 fn render_default(&self, vm: &FieldVm) -> Markup {
2676 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2677 Markup::from_template(&PasswordTmpl {
2678 name: &vm.name,
2679 id: &vm.id,
2680 required: vm.required,
2681 reveal: opts.reveal,
2682 show: &opts.show,
2683 })
2684 .unwrap_or_default()
2685 }
2686}
2687
2688#[derive(Template)]
2689#[template(path = "fields/radio.html")]
2690struct RadioTmpl<'a> {
2691 name: &'a str,
2692 options: &'a [OptionView],
2693 required: bool,
2694}
2695
2696pub(crate) struct RadioField;
2701
2702impl FieldType for RadioField {
2703 fn view_key(&self) -> &'static str {
2704 "radio"
2705 }
2706 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2707 keys(&["options"])
2708 }
2709
2710 fn resolve_options(
2711 &self,
2712 raw: &serde_json::Value,
2713 _types: &FieldRegistry,
2714 ) -> Result<ResolvedOptions, OptionsError> {
2715 let opts: SelectOptions =
2716 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2717 Ok(ResolvedOptions::new(opts))
2718 }
2719
2720 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2721 let current = cx.value.as_text();
2722 let views: Vec<OptionView> = cx
2723 .opts
2724 .get::<SelectOptions>()
2725 .map(|o| {
2726 o.options
2727 .iter()
2728 .map(|so| OptionView {
2729 value: so.value.clone(),
2730 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2731 selected: so.value == current,
2732 })
2733 .collect()
2734 })
2735 .unwrap_or_default();
2736 FieldVm {
2737 view_key: "radio".to_string(),
2738 name: cx.name.to_string(),
2739 id: cx.id.to_string(),
2740 label: cx.label.to_string(),
2741 required: cx.required,
2742 value: cx.value.clone(),
2743 data: serde_json::to_value(&views).unwrap_or_default(),
2744 }
2745 }
2746
2747 fn render_default(&self, vm: &FieldVm) -> Markup {
2748 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2749 Markup::from_template(&RadioTmpl {
2750 name: &vm.name,
2751 options: &options,
2752 required: vm.required,
2753 })
2754 .unwrap_or_default()
2755 }
2756}
2757
2758fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2760 FieldVm {
2761 view_key: view_key.to_string(),
2762 name: cx.name.to_string(),
2763 id: cx.id.to_string(),
2764 label: cx.label.to_string(),
2765 required: cx.required,
2766 value: cx.value.clone(),
2767 data: serde_json::Value::Null,
2768 }
2769}
2770
2771#[derive(Deserialize)]
2773struct RefOptions {
2774 source: String,
2775}
2776
2777struct RefResolved {
2779 source: String,
2780}
2781
2782#[derive(Default, Serialize, Deserialize)]
2785struct RefData {
2786 source: String,
2787 search_url: String,
2788 resolve_url: String,
2789 value: String,
2790 placeholder: String,
2791}
2792
2793#[derive(Template)]
2794#[template(path = "fields/ref_picker.html")]
2795struct RefPickerTmpl<'a> {
2796 name: &'a str,
2797 id: &'a str,
2798 value: &'a str,
2799 search_url: &'a str,
2800 resolve_url: &'a str,
2801 placeholder: &'a str,
2802}
2803
2804pub(crate) struct RefPickerField {
2809 pickers: Arc<PickerRegistry>,
2810}
2811
2812impl RefPickerField {
2813 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2814 Self { pickers }
2815 }
2816}
2817
2818pub(crate) struct IdField;
2823
2824impl FieldType for IdField {
2825 fn view_key(&self) -> &'static str {
2826 "id"
2827 }
2828 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2829 keys(&[])
2830 }
2831 fn resolve_options(
2832 &self,
2833 _raw: &serde_json::Value,
2834 _types: &FieldRegistry,
2835 ) -> Result<ResolvedOptions, OptionsError> {
2836 Ok(ResolvedOptions::new(()))
2837 }
2838 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2839 FieldVm {
2840 view_key: "id".to_string(),
2841 name: cx.name.to_string(),
2842 id: cx.id.to_string(),
2843 label: cx.label.to_string(),
2844 required: cx.required,
2845 value: cx.value.clone(),
2846 data: serde_json::Value::Null,
2847 }
2848 }
2849 fn render_default(&self, vm: &FieldVm) -> Markup {
2850 Markup::from_override(format!(
2851 r#"<input type="hidden" name="{}" value="{}">"#,
2852 crate::form::escape_attr(&vm.name),
2853 crate::form::escape_attr(vm.value.as_text())
2854 ))
2855 }
2856 fn to_attr(
2857 &self,
2858 field: &SubmittedField<'_>,
2859 _opts: &ResolvedOptions,
2860 _mode: Mode,
2861 ) -> Result<Option<AttrValue>, String> {
2862 Ok(field.value().map(id_attr))
2863 }
2864}
2865
2866fn id_attr(value: &str) -> AttrValue {
2869 let value = value.trim();
2870 if value.is_empty() {
2871 AttrValue::Null
2872 } else if let Ok(id) = value.parse::<i64>() {
2873 AttrValue::Int(id)
2874 } else {
2875 AttrValue::Text(value.to_string())
2876 }
2877}
2878
2879impl FieldType for RefPickerField {
2880 fn to_attr(
2883 &self,
2884 field: &SubmittedField<'_>,
2885 _opts: &ResolvedOptions,
2886 _mode: Mode,
2887 ) -> Result<Option<AttrValue>, String> {
2888 Ok(field.value().map(id_attr))
2889 }
2890 fn view_key(&self) -> &'static str {
2891 "reference"
2892 }
2893 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2894 keys(&["source"])
2895 }
2896 fn resolve_options(
2897 &self,
2898 raw: &serde_json::Value,
2899 _types: &FieldRegistry,
2900 ) -> Result<ResolvedOptions, OptionsError> {
2901 let opts: RefOptions =
2902 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2903 if !self.pickers.contains_key(&opts.source) {
2904 return Err(OptionsError(format!(
2905 "unknown picker source `{}`",
2906 opts.source
2907 )));
2908 }
2909 Ok(ResolvedOptions::new(RefResolved {
2910 source: opts.source,
2911 }))
2912 }
2913 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2914 let source = cx
2915 .opts
2916 .get::<RefResolved>()
2917 .map(|r| r.source.as_str())
2918 .unwrap_or_default();
2919 let data = RefData {
2920 search_url: format!("{}/pickers/{}/search", cx.base, source),
2921 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2922 source: source.to_string(),
2923 value: cx.value.as_text().to_string(),
2924 placeholder: "Search…".to_string(),
2925 };
2926 FieldVm {
2927 view_key: "reference".to_string(),
2928 name: cx.name.to_string(),
2929 id: cx.id.to_string(),
2930 label: cx.label.to_string(),
2931 required: cx.required,
2932 value: cx.value.clone(),
2933 data: serde_json::to_value(data).unwrap_or_default(),
2934 }
2935 }
2936 fn render_default(&self, vm: &FieldVm) -> Markup {
2937 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2938 Markup::from_template(&RefPickerTmpl {
2939 name: &vm.name,
2940 id: &vm.id,
2941 value: vm.value.as_text(),
2942 search_url: &data.search_url,
2943 resolve_url: &data.resolve_url,
2944 placeholder: &data.placeholder,
2945 })
2946 .unwrap_or_default()
2947 }
2948 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2949 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2950 }
2951}
2952
2953#[cfg(test)]
2956fn test_icons() -> crate::icons::Icons<'static> {
2957 crate::icons::Icons::new(
2958 Box::leak(Box::new(laterite_core::icons::IconSet::new())),
2959 "/admin/assets/icons.svg",
2960 )
2961}
2962
2963#[cfg(test)]
2964mod tests {
2965 use super::*;
2966
2967 #[test]
2968 fn an_id_is_stored_as_a_number_or_null() {
2969 assert!(matches!(id_attr("7"), AttrValue::Int(7)));
2970 assert!(matches!(id_attr(" 12 "), AttrValue::Int(12)));
2971 assert!(matches!(id_attr(""), AttrValue::Null));
2972 assert!(matches!(id_attr("x1"), AttrValue::Text(t) if t == "x1"));
2973 }
2974
2975 fn untranslated() -> Translator {
2978 Translator::new("en")
2979 }
2980
2981 fn cx_with<'a>(
2982 name: &'a str,
2983 value: &'a FieldValue,
2984 opts: &'a ResolvedOptions,
2985 i18n: &'a Translator,
2986 ) -> FieldCx<'a> {
2987 FieldCx {
2988 name,
2989 id: name,
2990 label: "Label",
2991 value,
2992 required: true,
2993 max_length: None,
2994 opts,
2995 base: "/admin",
2996 i18n,
2997 icons: test_icons(),
2998 }
2999 }
3000
3001 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
3002 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
3005 }
3006
3007 fn text_field() -> TextField {
3008 TextField::new(Arc::new(builtin_input_registry()))
3009 }
3010
3011 struct BadAttrInput;
3013 impl InputType for BadAttrInput {
3014 fn key(&self) -> &'static str {
3015 "bad"
3016 }
3017 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
3018 BTreeMap::from([("class".to_string(), "x".to_string())])
3019 }
3020 }
3021
3022 #[test]
3023 fn text_renders_its_value_escaped() {
3024 let opts = ResolvedOptions::none();
3025 let value = FieldValue::Text("a<b>&\"c".to_string());
3026 let markup = render_field(
3027 &text_field(),
3028 &NoOverrides,
3029 &scope(),
3030 &cx("title", &value, &opts),
3031 );
3032 let html = markup.as_str();
3033 assert!(html.contains(r#"name="title""#));
3034 assert!(html.contains("required"));
3035 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
3039 assert!(
3040 html.contains("<") || html.contains("<"),
3041 "angle escaped: {html}"
3042 );
3043 assert!(
3044 html.contains("&") || html.contains("&"),
3045 "ampersand escaped: {html}"
3046 );
3047 }
3048
3049 #[test]
3050 fn textarea_uses_its_own_control() {
3051 let opts = ResolvedOptions::none();
3052 let value = FieldValue::Text("body".to_string());
3053 let markup = render_field(
3054 &TextareaField,
3055 &NoOverrides,
3056 &scope(),
3057 &cx("body", &value, &opts),
3058 );
3059 assert!(markup.as_str().contains("<textarea"));
3060 }
3061
3062 #[test]
3063 fn code_editor_wraps_a_textarea_in_its_island_and_declares_the_engine() {
3064 let types = FieldRegistry::default();
3065 let opts = CodeEditorField
3066 .resolve_options(
3067 &serde_json::json!({ "language": "jinja", "rows": 20 }),
3068 &types,
3069 )
3070 .unwrap();
3071 let value = FieldValue::Text("{{ page.title }}".to_string());
3072 let markup = render_field(
3073 &CodeEditorField,
3074 &NoOverrides,
3075 &scope(),
3076 &cx("markup", &value, &opts),
3077 );
3078 let html = markup.as_str();
3079 assert!(html.contains(r#"data-lat-widget="codeeditor""#), "{html}");
3080 assert!(html.contains(r#"data-language="jinja""#), "{html}");
3081 assert!(html.contains(r#"rows="20""#), "{html}");
3082 assert!(html.contains("<textarea"), "{html}");
3083 assert_eq!(
3084 CodeEditorField.assets(&opts),
3085 crate::CODE_EDITOR_ASSETS.to_vec()
3086 );
3087 assert!(CodeEditorField
3089 .resolve_options(&serde_json::json!({ "language": "cobol" }), &types)
3090 .is_err());
3091 }
3092
3093 #[test]
3094 fn rich_editor_renders_its_toolbar_and_cleans_what_it_stores() {
3095 let types = FieldRegistry::default();
3096 let opts = RichEditorField
3097 .resolve_options(
3098 &serde_json::json!({ "toolbar": ["bold", "|", "link"] }),
3099 &types,
3100 )
3101 .unwrap();
3102 let value = FieldValue::Text("<p>Hi</p>".to_string());
3103 let markup = render_field(
3104 &RichEditorField,
3105 &NoOverrides,
3106 &scope(),
3107 &cx("body", &value, &opts),
3108 );
3109 let html = markup.as_str();
3110 assert!(html.contains(r#"data-lat-widget="richeditor""#), "{html}");
3111 assert!(html.contains(r#"data-rich-cmd="bold""#), "{html}");
3112 assert!(html.contains(r#"data-rich-cmd="link""#), "{html}");
3113 assert!(!html.contains(r#"data-rich-cmd="image""#), "{html}");
3114 assert!(html.contains("icons.svg#lat-bold"), "{html}");
3115 assert!(html.contains("<textarea"), "{html}");
3117 assert!(!html.contains("<p>Hi</p>"), "{html}");
3118 assert!(RichEditorField
3119 .resolve_options(&serde_json::json!({ "toolbar": ["blink"] }), &types)
3120 .is_err());
3121 }
3122
3123 #[test]
3124 fn markdown_renders_its_tabs_and_offers_no_preview_when_told() {
3125 let types = FieldRegistry::default();
3126 let value = FieldValue::Text("# Hi".to_string());
3127 let opts = MarkdownField
3128 .resolve_options(&serde_json::Value::Null, &types)
3129 .unwrap();
3130 let html = render_field(
3131 &MarkdownField,
3132 &NoOverrides,
3133 &scope(),
3134 &cx("body", &value, &opts),
3135 );
3136 let html = html.as_str();
3137 assert!(html.contains(r#"data-lat-widget="markdown""#), "{html}");
3138 assert!(
3139 html.contains(r#"data-preview="/admin/markdown/preview""#),
3140 "{html}"
3141 );
3142 assert!(html.contains(r#"data-md-tab="preview""#), "{html}");
3143 assert!(html.contains("icons.svg#lat-heading"), "{html}");
3144 assert_eq!(MarkdownField.assets(&opts), crate::MARKDOWN_ASSETS.to_vec());
3145 let opts = MarkdownField
3146 .resolve_options(&serde_json::json!({ "preview": false }), &types)
3147 .unwrap();
3148 let html = render_field(
3149 &MarkdownField,
3150 &NoOverrides,
3151 &scope(),
3152 &cx("body", &value, &opts),
3153 );
3154 assert!(!html.as_str().contains("data-md-tab"), "{}", html.as_str());
3155 }
3156
3157 #[test]
3158 fn no_overrides_uses_the_compiled_default() {
3159 let opts = ResolvedOptions::none();
3161 let value = FieldValue::Text("x".to_string());
3162 let ft = text_field();
3163 let vm = ft.view_model(&cx("t", &value, &opts));
3164 let direct = ft.render_default(&vm);
3165 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
3166 assert_eq!(direct.as_str(), routed.as_str());
3167 }
3168
3169 #[test]
3170 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
3171 let field = text_field();
3172 let opts = field
3173 .resolve_options(
3174 &serde_json::json!({ "input": "email" }),
3175 &builtin_registry(),
3176 )
3177 .unwrap();
3178 assert!(matches!(
3180 field.intrinsic_rules(&opts).as_slice(),
3181 [Rule::Email]
3182 ));
3183 let value = FieldValue::Text("a@b.test".to_string());
3184 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
3185 assert!(markup.as_str().contains(r#"type="email""#));
3186 }
3187
3188 #[test]
3189 fn text_default_input_is_plain_text_with_no_extra_rule() {
3190 let field = text_field();
3191 let opts = field
3192 .resolve_options(&serde_json::Value::Null, &builtin_registry())
3193 .unwrap();
3194 assert!(field.intrinsic_rules(&opts).is_empty());
3195 let value = FieldValue::Text("hi".to_string());
3196 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
3197 assert!(markup.as_str().contains(r#"type="text""#));
3198 }
3199
3200 #[test]
3201 fn text_number_input_sets_the_type_and_numeric_rule() {
3202 let field = text_field();
3203 let opts = field
3204 .resolve_options(
3205 &serde_json::json!({ "input": "number" }),
3206 &builtin_registry(),
3207 )
3208 .unwrap();
3209 assert!(matches!(
3210 field.intrinsic_rules(&opts).as_slice(),
3211 [Rule::Numeric]
3212 ));
3213 let value = FieldValue::Text("42".to_string());
3214 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3215 assert!(markup.as_str().contains(r#"type="number""#));
3216 }
3217
3218 #[test]
3219 fn text_tel_input_sets_the_type_with_no_rule() {
3220 let field = text_field();
3221 let opts = field
3222 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
3223 .unwrap();
3224 assert!(field.intrinsic_rules(&opts).is_empty());
3225 let value = FieldValue::Text(String::new());
3226 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
3227 assert!(markup.as_str().contains(r#"type="tel""#));
3228 }
3229
3230 #[test]
3231 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
3232 let field = text_field();
3233 let opts = field
3234 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
3235 .unwrap();
3236 assert!(matches!(
3237 field.intrinsic_rules(&opts).as_slice(),
3238 [Rule::Url]
3239 ));
3240 let value = FieldValue::Text("https://example.com".to_string());
3241 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3242 let html = markup.as_str();
3243 assert!(html.contains(r#"type="url""#), "{html}");
3244 assert!(html.contains("lat-input-group"), "{html}");
3245 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
3246 }
3247
3248 #[test]
3249 fn text_url_input_copy_false_omits_the_button() {
3250 let field = text_field();
3251 let opts = field
3252 .resolve_options(
3253 &serde_json::json!({ "input": "url", "copy": false }),
3254 &builtin_registry(),
3255 )
3256 .unwrap();
3257 let value = FieldValue::Text("https://example.com".to_string());
3258 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3259 let html = markup.as_str();
3260 assert!(html.contains(r#"type="url""#), "{html}");
3261 assert!(!html.contains("lat-input-group"), "{html}");
3263 assert!(!html.contains("data-lat-widget"), "{html}");
3264 }
3265
3266 #[test]
3267 fn text_number_input_emits_min_max_step_attributes() {
3268 let field = text_field();
3269 let opts = field
3270 .resolve_options(
3271 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
3272 &builtin_registry(),
3273 )
3274 .unwrap();
3275 let value = FieldValue::Text("5".to_string());
3278 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3279 let html = markup.as_str();
3280 assert!(html.contains(r#"min="0""#), "{html}");
3281 assert!(html.contains(r#"max="10""#), "{html}");
3282 assert!(html.contains(r#"step="2""#), "{html}");
3283 }
3284
3285 #[test]
3286 fn text_input_reserved_attribute_name_aborts_resolve() {
3287 let mut inputs = builtin_input_registry();
3288 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
3289 let field = TextField::new(Arc::new(inputs));
3290 let Err(e) =
3291 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
3292 else {
3293 panic!("expected a reserved-attribute rejection");
3294 };
3295 assert!(e.0.contains("class"), "{e}");
3296 }
3297
3298 #[test]
3299 fn attribute_and_widget_name_rules() {
3300 assert!(is_name("min", false));
3301 assert!(is_name("data-x", false));
3302 assert!(!is_name("Min", false));
3303 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
3306 }
3307
3308 #[test]
3309 fn select_renders_options_with_the_current_value_selected() {
3310 let raw = serde_json::json!({
3311 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3312 });
3313 let opts = SelectField
3314 .resolve_options(&raw, &builtin_registry())
3315 .unwrap();
3316 let value = FieldValue::Text("closed".to_string());
3317 let markup = render_field(
3318 &SelectField,
3319 &NoOverrides,
3320 &scope(),
3321 &cx("status", &value, &opts),
3322 );
3323 let html = markup.as_str();
3324 assert!(html.contains(r#"<option value="open">Open</option>"#));
3325 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
3327 }
3328
3329 #[test]
3330 fn radio_renders_the_same_options_with_the_current_one_checked() {
3331 let raw = serde_json::json!({
3332 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3333 });
3334 let opts = RadioField
3335 .resolve_options(&raw, &builtin_registry())
3336 .unwrap();
3337 let value = FieldValue::Text("closed".to_string());
3338 let markup = render_field(
3339 &RadioField,
3340 &NoOverrides,
3341 &scope(),
3342 &cx("status", &value, &opts),
3343 );
3344 let html = markup.as_str();
3345 assert!(html.contains(r#"value="open""#));
3346 assert!(
3347 html.contains(r#"value="closed" checked"#),
3348 "current is checked"
3349 );
3350 assert_eq!(html.matches(r#"name="status""#).count(), 2);
3353 assert!(html.contains("closed</label>"));
3355 }
3356
3357 fn scope<'a>() -> OverrideScope<'a> {
3358 OverrideScope {
3359 surface: Surface::Field,
3360 view_key: "text",
3361 resource: None,
3362 field: None,
3363 }
3364 }
3365
3366 fn ref_field() -> RefPickerField {
3367 let mut pickers = crate::picker::PickerRegistry::new();
3368 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
3369 pickers.insert(
3370 "acme.place".to_string(),
3371 crate::picker::PickerSourceReg::new("acme.place", source),
3372 );
3373 RefPickerField::new(Arc::new(pickers))
3374 }
3375
3376 #[test]
3377 fn reference_rejects_an_unknown_source() {
3378 let field = ref_field();
3379 let Err(e) = field.resolve_options(
3380 &serde_json::json!({ "source": "no.such" }),
3381 &builtin_registry(),
3382 ) else {
3383 panic!("expected an unknown-source rejection");
3384 };
3385 assert!(e.0.contains("no.such"), "{e}");
3386 }
3387
3388 #[test]
3389 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
3390 let field = ref_field();
3391 let opts = field
3392 .resolve_options(
3393 &serde_json::json!({ "source": "acme.place" }),
3394 &builtin_registry(),
3395 )
3396 .unwrap();
3397 let value = FieldValue::Text("42".to_string());
3398 let markup = render_field(
3399 &field,
3400 &NoOverrides,
3401 &scope(),
3402 &cx("place_id", &value, &opts),
3403 );
3404 let html = markup.as_str();
3405 assert!(html.contains(r#"type="hidden""#), "{html}");
3408 assert!(html.contains(r#"name="place_id""#), "{html}");
3409 assert!(html.contains(r#"value="42""#), "{html}");
3410 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
3412 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
3413 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
3414 }
3415
3416 #[test]
3417 fn reference_declares_its_widget_assets() {
3418 let field = ref_field();
3419 let opts = field
3420 .resolve_options(
3421 &serde_json::json!({ "source": "acme.place" }),
3422 &builtin_registry(),
3423 )
3424 .unwrap();
3425 assert_eq!(
3426 field.assets(&opts),
3427 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
3428 );
3429 }
3430}
3431
3432#[cfg(test)]
3433mod repeater_tests {
3434 use super::*;
3435
3436 fn options() -> ResolvedOptions {
3437 RepeaterField
3438 .resolve_options(
3439 &serde_json::json!({
3440 "fields": [
3441 { "name": "path", "label": "Path", "type": "text" },
3442 { "name": "allow", "label": "Allow", "type": "switch" },
3443 ]
3444 }),
3445 &builtin_registry(),
3446 )
3447 .unwrap()
3448 }
3449
3450 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3451 pairs
3452 .iter()
3453 .map(|(k, v)| (k.to_string(), v.to_string()))
3454 .collect()
3455 }
3456
3457 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
3458 let field = SubmittedField::new("rules", data);
3459 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
3460 Some(AttrValue::Json(value)) => value,
3461 other => panic!("expected a JSON array, got {other:?}"),
3462 }
3463 }
3464
3465 #[test]
3469 fn a_row_is_typed_by_its_sub_field_types() {
3470 let rows = stored(
3471 &submitted(&[
3472 ("rules[0][path]", "/private"),
3473 ("rules[1][path]", "/public"),
3474 ("rules[1][allow]", "on"),
3475 ]),
3476 &options(),
3477 );
3478
3479 assert_eq!(rows[0]["path"], "/private");
3480 assert_eq!(rows[0]["allow"], serde_json::json!(false));
3481 assert_eq!(rows[1]["allow"], serde_json::json!(true));
3482 }
3483
3484 #[test]
3487 fn rows_are_ordered_by_index_not_submission_order() {
3488 let rows = stored(
3489 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
3490 &options(),
3491 );
3492 assert_eq!(rows.as_array().unwrap().len(), 2);
3493 assert_eq!(rows[0]["path"], "/first");
3494 assert_eq!(rows[1]["path"], "/last");
3495 }
3496
3497 #[test]
3498 fn a_row_left_blank_is_dropped() {
3499 let rows = stored(
3500 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
3501 &options(),
3502 );
3503 assert_eq!(rows.as_array().unwrap().len(), 1);
3504 }
3505
3506 #[test]
3507 fn nothing_submitted_stores_an_empty_list() {
3508 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
3509 }
3510
3511 #[test]
3512 fn a_repeater_refuses_to_hold_a_repeater() {
3513 let err = RepeaterField
3514 .resolve_options(
3515 &serde_json::json!({
3516 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
3517 }),
3518 &builtin_registry(),
3519 )
3520 .err()
3521 .expect("a nested repeater is refused");
3522 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
3523 }
3524
3525 #[test]
3526 fn an_unregistered_sub_field_type_is_refused_at_boot() {
3527 let err = RepeaterField
3528 .resolve_options(
3529 &serde_json::json!({
3530 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
3531 }),
3532 &builtin_registry(),
3533 )
3534 .err()
3535 .expect("an unregistered type is refused");
3536 assert!(err.0.contains("acme.nope"), "{}", err.0);
3537 }
3538
3539 #[test]
3540 fn row_counts_are_bounded_when_asked() {
3541 let opts = RepeaterField
3542 .resolve_options(
3543 &serde_json::json!({
3544 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
3545 "min_items": 1,
3546 "max_items": 2
3547 }),
3548 &builtin_registry(),
3549 )
3550 .unwrap();
3551
3552 let empty = HashMap::new();
3553 let too_few = SubmittedField::new("rules", &empty);
3554 assert!(RepeaterField
3555 .to_attr(&too_few, &opts, Mode::Create)
3556 .is_err());
3557
3558 let three = submitted(&[
3559 ("rules[0][path]", "/a"),
3560 ("rules[1][path]", "/b"),
3561 ("rules[2][path]", "/c"),
3562 ]);
3563 let field = SubmittedField::new("rules", &three);
3564 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
3565 }
3566
3567 #[test]
3568 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
3569 let value = FieldValue::Json(serde_json::json!([
3570 { "path": "/private", "allow": false },
3571 ]));
3572 let cx = FieldCx {
3573 name: "rules",
3574 id: "rules",
3575 label: "Rules",
3576 value: &value,
3577 required: false,
3578 max_length: None,
3579 opts: &options(),
3580 base: "/admin",
3581 i18n: &Translator::new("en"),
3582 icons: test_icons(),
3583 };
3584 let html = RepeaterField
3585 .render_default(&RepeaterField.view_model(&cx))
3586 .into_string();
3587
3588 assert!(html.contains(r#"name="rules[0][path]""#));
3589 assert!(html.contains(r#"value="/private""#));
3590 assert!(html.contains("lat-repeater__blank"));
3592 assert!(html.contains(r#"name="rules[__index__][path]""#));
3593 }
3594
3595 fn list_options() -> ResolvedOptions {
3597 RepeaterField
3598 .resolve_options(
3599 &serde_json::json!({
3600 "fields": [
3601 { "name": "agent", "label": "Agent", "type": "text" },
3602 { "name": "disallow", "label": "Disallow", "type": "textarea" },
3603 ],
3604 "display": "list",
3605 "summary_field": "agent",
3606 }),
3607 &builtin_registry(),
3608 )
3609 .unwrap()
3610 }
3611
3612 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
3613 let cx = FieldCx {
3614 name: "rules",
3615 id: "rules",
3616 label: "Rules",
3617 value,
3618 required: false,
3619 max_length: None,
3620 opts,
3621 base: "/admin",
3622 i18n: &Translator::new("en"),
3623 icons: test_icons(),
3624 };
3625 RepeaterField
3626 .render_default(&RepeaterField.view_model(&cx))
3627 .into_string()
3628 }
3629
3630 #[test]
3631 fn a_list_repeater_collapses_its_rows_and_names_them() {
3632 let value = FieldValue::Json(serde_json::json!([
3633 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
3634 ]));
3635 let html = render_with(&list_options(), &value);
3636 assert!(html.contains("lat-repeater--list"), "list mode is declared");
3637 assert!(html.contains("<details"), "a row collapses");
3638 assert!(
3639 html.contains("Googlebot"),
3640 "the naming field titles the row"
3641 );
3642 assert!(html.contains(r#"name="rules[0][disallow]""#));
3644 }
3645
3646 #[test]
3647 fn a_row_with_no_name_yet_reads_as_untitled() {
3648 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
3649 let html = render_with(&list_options(), &value);
3650 assert!(html.contains("lat-repeater__untitled"));
3651 }
3652
3653 #[test]
3654 fn the_default_layout_stays_inline() {
3655 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
3657 let html = render_with(&options(), &value);
3658 assert!(!html.contains("lat-repeater--list"));
3659 assert!(!html.contains("<details"));
3660 }
3661
3662 #[test]
3663 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
3664 assert_eq!(
3666 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
3667 "/private"
3668 );
3669 assert_eq!(summary_text(&serde_json::json!("")), "");
3670 }
3671
3672 #[test]
3679 fn a_contributed_field_type_names_itself_and_converts() {
3680 struct Money;
3681 impl FieldType for Money {
3682 fn view_key(&self) -> &'static str {
3683 "acme.money"
3684 }
3685 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
3686 scalar_vm("acme.money", cx)
3687 }
3688 fn render_default(&self, _vm: &FieldVm) -> Markup {
3689 Markup::empty()
3690 }
3691 fn to_attr(
3692 &self,
3693 field: &SubmittedField<'_>,
3694 _opts: &ResolvedOptions,
3695 _mode: Mode,
3696 ) -> Result<Option<AttrValue>, String> {
3697 let raw = field.value().unwrap_or("0");
3699 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
3700 Ok(Some(AttrValue::Int(minor as i64)))
3701 }
3702 }
3703
3704 let reg = FieldTypeReg::new(Arc::new(Money));
3705 assert_eq!(reg.name(), "acme.money");
3708
3709 let mut registry = builtin_registry();
3710 let name = reg.name().to_string();
3711 assert!(
3712 registry.insert(name.clone(), reg.field_type).is_none(),
3713 "a dotted key does not collide with a built-in"
3714 );
3715
3716 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
3717 let submitted = SubmittedField::new("price", &data);
3718 let converted = registry[&name]
3719 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
3720 .unwrap();
3721 assert!(
3722 matches!(converted, Some(AttrValue::Int(1250))),
3723 "{converted:?}"
3724 );
3725 }
3726
3727 #[test]
3728 fn a_sub_field_label_is_localized_like_any_other() {
3729 use laterite_core::i18n::CatalogStore;
3730 use std::sync::Arc;
3731
3732 let po = concat!(
3733 "msgid \"\"\n",
3734 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
3735 "\n",
3736 "msgid \"Path\"\n",
3737 "msgstr \"Path-kn\"\n",
3738 );
3739 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
3740 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
3741
3742 let value = FieldValue::Text(String::new());
3743 let cx = FieldCx {
3744 name: "rules",
3745 id: "rules",
3746 label: "Rules",
3747 value: &value,
3748 required: false,
3749 max_length: None,
3750 opts: &options(),
3751 base: "/admin",
3752 i18n: &i18n,
3753 icons: test_icons(),
3754 };
3755 let html = RepeaterField
3756 .render_default(&RepeaterField.view_model(&cx))
3757 .into_string();
3758
3759 assert!(
3760 html.contains("Path-kn"),
3761 "sub-label was not localized: {html}"
3762 );
3763 assert!(
3764 !html.contains(">Path<"),
3765 "the source string still leaked: {html}"
3766 );
3767 }
3768}
3769
3770#[cfg(test)]
3771mod save_contract_tests {
3772 use super::*;
3773
3774 fn one(raw: Option<&str>) -> HashMap<String, String> {
3776 raw.into_iter()
3777 .map(|v| ("f".to_string(), v.to_string()))
3778 .collect()
3779 }
3780
3781 fn none() -> ResolvedOptions {
3782 ResolvedOptions::none()
3783 }
3784
3785 #[test]
3786 fn a_switch_stores_a_bool_and_absence_means_off() {
3787 let f = SwitchField;
3788 assert_eq!(
3791 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3792 .unwrap(),
3793 Some(AttrValue::Bool(false))
3794 );
3795 assert_eq!(
3796 f.to_attr(
3797 &SubmittedField::new("f", &one(Some("on"))),
3798 &none(),
3799 Mode::Create
3800 )
3801 .unwrap(),
3802 Some(AttrValue::Bool(true))
3803 );
3804 }
3805
3806 #[test]
3807 fn a_switch_reads_back_whatever_the_backend_gave() {
3808 let f = SwitchField;
3809 for on in ["1", "true", "t", "on"] {
3811 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3812 }
3813 for off in ["0", "false", "f", ""] {
3814 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3815 }
3816 assert!(f.to_control(None, &none()).is_empty());
3817 }
3818
3819 #[test]
3820 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3821 let f = DateField;
3822 assert_eq!(
3824 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3825 "2026-09-09"
3826 );
3827 assert_eq!(
3828 f.to_control(Some("2026-09-09 12:30:45"), &none()),
3829 "2026-09-09"
3830 );
3831 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3832 assert_eq!(f.to_control(None, &none()), "");
3833 }
3834
3835 #[test]
3836 fn a_cleared_date_stores_null_not_an_empty_string() {
3837 let f = DateField;
3838 assert_eq!(
3839 f.to_attr(
3840 &SubmittedField::new("f", &one(Some(""))),
3841 &none(),
3842 Mode::Update
3843 )
3844 .unwrap(),
3845 Some(AttrValue::Null)
3846 );
3847 assert_eq!(
3848 f.to_attr(
3849 &SubmittedField::new("f", &one(Some("2026-09-09"))),
3850 &none(),
3851 Mode::Create
3852 )
3853 .unwrap(),
3854 Some(AttrValue::Text("2026-09-09".into()))
3855 );
3856 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3857 }
3858
3859 #[test]
3860 fn a_password_hashes_and_never_echoes() {
3861 let f = PasswordField;
3862 let stored = f
3863 .to_attr(
3864 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3865 &none(),
3866 Mode::Create,
3867 )
3868 .unwrap()
3869 .unwrap();
3870 let hash = stored.as_str().unwrap();
3871 assert!(
3872 hash.starts_with("$argon2"),
3873 "stored as a hash, not plaintext"
3874 );
3875 assert_ne!(hash, "hunter2hunter2");
3876 assert_eq!(f.to_control(Some(hash), &none()), "");
3878 }
3879
3880 #[test]
3881 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3882 let f = PasswordField;
3883 assert_eq!(
3885 f.to_attr(
3886 &SubmittedField::new("f", &one(Some(""))),
3887 &none(),
3888 Mode::Update
3889 )
3890 .unwrap(),
3891 None
3892 );
3893 assert_eq!(
3894 f.to_attr(
3895 &SubmittedField::new("f", &one(Some(" "))),
3896 &none(),
3897 Mode::Update
3898 )
3899 .unwrap(),
3900 None
3901 );
3902 assert_eq!(
3903 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3904 .unwrap(),
3905 None
3906 );
3907 }
3908
3909 #[test]
3910 fn rich_text_is_cleaned_on_save_and_blank_when_empty() {
3911 let f = RichEditorField;
3912 let dirty = one(Some(
3913 r#"<p onclick="x()">Hi</p><script>1</script><img src="/a.png" data-media="4">"#,
3914 ));
3915 assert_eq!(
3916 f.to_attr(&SubmittedField::new("f", &dirty), &none(), Mode::Create)
3917 .unwrap(),
3918 Some(AttrValue::Text(
3919 r#"<p>Hi</p><img src="/a.png" data-media="4">"#.into()
3920 ))
3921 );
3922 assert_eq!(
3923 f.linked_media(Some(r#"<img data-media="4">"#), &none()),
3924 Some(vec![4])
3925 );
3926 let blank = one(Some("<p></p>"));
3928 assert_eq!(
3929 f.submitted(&SubmittedField::new("f", &blank), &none()),
3930 Some(String::new())
3931 );
3932 assert_eq!(
3933 f.to_attr(&SubmittedField::new("f", &blank), &none(), Mode::Create)
3934 .unwrap(),
3935 Some(AttrValue::Text(String::new()))
3936 );
3937 }
3938
3939 #[test]
3940 fn a_plain_field_stores_and_shows_the_text_unchanged() {
3941 let f = TextareaField;
3942 assert_eq!(
3943 f.to_attr(
3944 &SubmittedField::new("f", &one(Some("hello"))),
3945 &none(),
3946 Mode::Create
3947 )
3948 .unwrap(),
3949 Some(AttrValue::Text("hello".into()))
3950 );
3951 assert_eq!(
3953 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3954 .unwrap(),
3955 None
3956 );
3957 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3958 }
3959}