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
1727pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1728 vec![
1729 Arc::new(TextInput),
1730 Arc::new(EmailInput),
1731 Arc::new(TelInput),
1732 Arc::new(NumberInput),
1733 Arc::new(UrlInput),
1734 ]
1735}
1736
1737pub(crate) fn builtin_input_registry() -> InputRegistry {
1739 builtin_input_types()
1740 .into_iter()
1741 .map(|i| (i.key().to_string(), i))
1742 .collect()
1743}
1744
1745const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1748
1749fn validate_contributions(
1754 attrs: &BTreeMap<String, String>,
1755 adornments: &[Adornment],
1756) -> Result<(), OptionsError> {
1757 for name in attrs.keys() {
1758 if !is_name(name, false) {
1759 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1760 }
1761 if RESERVED_ATTRS.contains(&name.as_str()) {
1762 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1763 }
1764 }
1765 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1766 if !is_name(widget, true) {
1767 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1768 }
1769 }
1770 Ok(())
1771}
1772
1773pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1775 let mut chars = s.chars();
1776 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1777 && chars.all(|c| {
1778 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1779 })
1780}
1781
1782#[derive(Deserialize)]
1784struct TextOptions {
1785 #[serde(default = "default_input")]
1786 input: String,
1787 #[serde(default)]
1789 placeholder: Option<String>,
1790 #[serde(default)]
1792 counter: crate::checklist::Offer,
1793}
1794
1795fn default_input() -> String {
1796 "text".to_string()
1797}
1798
1799struct TextResolved {
1802 html_type: &'static str,
1803 attrs: BTreeMap<String, String>,
1804 adornments: Vec<Adornment>,
1805 rules: Vec<Rule>,
1806 assets: Vec<&'static str>,
1807 counter: crate::checklist::Offer,
1808}
1809
1810fn length_attrs(
1813 attrs: &mut BTreeMap<String, String>,
1814 max_length: Option<usize>,
1815 counter: crate::checklist::Offer,
1816) {
1817 use crate::checklist::Offer;
1818 let Some(max) = max_length else {
1819 return;
1820 };
1821 attrs.insert("maxlength".to_string(), max.to_string());
1822 match counter {
1823 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1824 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1825 Offer::Never => None,
1826 };
1827}
1828
1829#[derive(Default, Serialize, Deserialize)]
1832struct TextData {
1833 input_type: String,
1834 #[serde(default)]
1835 attrs: BTreeMap<String, String>,
1836 #[serde(default)]
1837 adornments: Vec<Adornment>,
1838}
1839
1840#[derive(Template)]
1841#[template(path = "fields/text.html")]
1842struct TextTmpl<'a> {
1843 name: &'a str,
1844 id: &'a str,
1845 value: &'a str,
1846 required: bool,
1847 input_type: &'a str,
1848 attrs: &'a BTreeMap<String, String>,
1849 leading: &'a [Adornment],
1850 trailing: &'a [Adornment],
1851}
1852
1853pub(crate) struct TextField {
1857 inputs: Arc<InputRegistry>,
1858}
1859
1860impl TextField {
1861 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1862 Self { inputs }
1863 }
1864}
1865
1866impl FieldType for TextField {
1867 fn view_key(&self) -> &'static str {
1868 "text"
1869 }
1870 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1871 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1872 let input = self.inputs.get(input)?;
1875 let mut all = vec![
1876 "input".to_string(),
1877 "placeholder".to_string(),
1878 "counter".to_string(),
1879 ];
1880 all.extend(input.option_keys().into_iter().map(str::to_string));
1881 Some(all)
1882 }
1883 fn resolve_options(
1884 &self,
1885 raw: &serde_json::Value,
1886 _types: &FieldRegistry,
1887 ) -> Result<ResolvedOptions, OptionsError> {
1888 let opts: TextOptions = if raw.is_null() {
1889 TextOptions {
1890 input: default_input(),
1891 placeholder: None,
1892 counter: Default::default(),
1893 }
1894 } else {
1895 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1896 };
1897 let input = self
1898 .inputs
1899 .get(&opts.input)
1900 .cloned()
1901 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1902 let input_opts = input.resolve_options(raw, _types)?;
1905 let mut attrs = input.attributes(&input_opts);
1906 let adornments = input.adornments(&input_opts);
1907 validate_contributions(&attrs, &adornments)?;
1908 if let Some(placeholder) = opts.placeholder {
1909 attrs.insert("placeholder".to_string(), placeholder);
1910 }
1911 Ok(ResolvedOptions::new(TextResolved {
1912 html_type: input.html_type(),
1913 rules: input.rules(&input_opts),
1914 assets: input.assets(&input_opts),
1915 attrs,
1916 adornments,
1917 counter: opts.counter,
1918 }))
1919 }
1920 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1921 opts.get::<TextResolved>()
1922 .map(|r| r.rules.clone())
1923 .unwrap_or_default()
1924 }
1925 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1926 opts.get::<TextResolved>()
1927 .map(|r| r.assets.clone())
1928 .unwrap_or_default()
1929 }
1930 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1931 let mut data = match cx.opts.get::<TextResolved>() {
1932 Some(r) => TextData {
1933 input_type: r.html_type.to_string(),
1934 attrs: r.attrs.clone(),
1935 adornments: r.adornments.clone(),
1936 },
1937 None => TextData {
1938 input_type: "text".to_string(),
1939 ..TextData::default()
1940 },
1941 };
1942 if matches!(
1944 data.input_type.as_str(),
1945 "text" | "email" | "tel" | "url" | "search"
1946 ) {
1947 let counter = cx
1948 .opts
1949 .get::<TextResolved>()
1950 .map(|r| r.counter)
1951 .unwrap_or_default();
1952 length_attrs(&mut data.attrs, cx.max_length, counter);
1953 }
1954 FieldVm {
1955 view_key: "text".to_string(),
1956 name: cx.name.to_string(),
1957 id: cx.id.to_string(),
1958 label: cx.label.to_string(),
1959 required: cx.required,
1960 value: cx.value.clone(),
1961 data: serde_json::to_value(data).unwrap_or_default(),
1962 }
1963 }
1964 fn render_default(&self, vm: &FieldVm) -> Markup {
1965 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1966 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1967 .adornments
1968 .into_iter()
1969 .partition(|a| matches!(a.placement, Placement::Leading));
1970 Markup::from_template(&TextTmpl {
1971 name: &vm.name,
1972 id: &vm.id,
1973 value: vm.value.as_text(),
1974 required: vm.required,
1975 input_type: &data.input_type,
1976 attrs: &data.attrs,
1977 leading: &leading,
1978 trailing: &trailing,
1979 })
1980 .unwrap_or_default()
1981 }
1982}
1983
1984#[derive(Template)]
1985#[template(path = "fields/textarea.html")]
1986struct TextareaTmpl<'a> {
1987 name: &'a str,
1988 id: &'a str,
1989 value: &'a str,
1990 required: bool,
1991 rows: Option<u16>,
1992 placeholder: Option<&'a str>,
1993 grow: bool,
1994 attrs: &'a BTreeMap<String, String>,
1995}
1996
1997fn yes() -> bool {
1998 true
1999}
2000
2001#[derive(Debug, Clone, Serialize, Deserialize)]
2004struct TextareaOptions {
2005 #[serde(default)]
2006 rows: Option<u16>,
2007 #[serde(default)]
2008 placeholder: Option<String>,
2009 #[serde(default = "yes")]
2010 grow: bool,
2011 #[serde(default)]
2012 counter: crate::checklist::Offer,
2013}
2014
2015impl Default for TextareaOptions {
2016 fn default() -> Self {
2017 Self {
2018 rows: None,
2019 placeholder: None,
2020 grow: true,
2021 counter: Default::default(),
2022 }
2023 }
2024}
2025
2026#[derive(Debug, Clone, Serialize, Deserialize)]
2028struct TextareaData {
2029 #[serde(default)]
2030 rows: Option<u16>,
2031 #[serde(default)]
2032 placeholder: Option<String>,
2033 #[serde(default = "yes")]
2034 grow: bool,
2035 #[serde(default)]
2037 attrs: BTreeMap<String, String>,
2038}
2039
2040impl Default for TextareaData {
2041 fn default() -> Self {
2042 Self {
2043 rows: None,
2044 placeholder: None,
2045 grow: true,
2046 attrs: BTreeMap::new(),
2047 }
2048 }
2049}
2050
2051pub(crate) struct TextareaField;
2053
2054impl FieldType for TextareaField {
2055 fn view_key(&self) -> &'static str {
2056 "textarea"
2057 }
2058 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2059 keys(&["rows", "placeholder", "grow", "counter"])
2060 }
2061 fn resolve_options(
2062 &self,
2063 raw: &serde_json::Value,
2064 _types: &FieldRegistry,
2065 ) -> Result<ResolvedOptions, OptionsError> {
2066 let opts: TextareaOptions = if raw.is_null() {
2067 TextareaOptions::default()
2068 } else {
2069 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2070 };
2071 Ok(ResolvedOptions::new(opts))
2072 }
2073 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2074 let mut vm = scalar_vm("textarea", cx);
2075 let opts = cx
2076 .opts
2077 .get::<TextareaOptions>()
2078 .cloned()
2079 .unwrap_or_default();
2080 let mut data = TextareaData {
2081 rows: opts.rows,
2082 placeholder: opts.placeholder,
2083 grow: opts.grow,
2084 attrs: BTreeMap::new(),
2085 };
2086 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
2087 vm.data = serde_json::to_value(data).unwrap_or_default();
2088 vm
2089 }
2090 fn render_default(&self, vm: &FieldVm) -> Markup {
2091 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2092 Markup::from_template(&TextareaTmpl {
2093 name: &vm.name,
2094 id: &vm.id,
2095 value: vm.value.as_text(),
2096 required: vm.required,
2097 rows: data.rows,
2098 placeholder: data.placeholder.as_deref(),
2099 grow: data.grow,
2100 attrs: &data.attrs,
2101 })
2102 .unwrap_or_default()
2103 }
2104}
2105
2106pub const SELECT_SEARCH_PAST: usize = 10;
2108
2109#[derive(Debug, Default, Deserialize)]
2112struct SelectOptions {
2113 #[serde(default)]
2114 options: Vec<SelectOption>,
2115 #[serde(default)]
2116 search: crate::checklist::Offer,
2117}
2118
2119impl SelectOptions {
2120 fn searchable(&self) -> bool {
2121 use crate::checklist::Offer;
2122 match self.search {
2123 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
2124 Offer::Always => true,
2125 Offer::Never => false,
2126 }
2127 }
2128}
2129
2130#[derive(Debug, Default, Serialize, Deserialize)]
2132struct SelectData {
2133 options: Vec<OptionView>,
2134 #[serde(default)]
2135 search: bool,
2136}
2137
2138#[derive(Debug, Deserialize)]
2139struct SelectOption {
2140 value: String,
2141 #[serde(default)]
2143 label: Option<String>,
2144}
2145
2146#[derive(Debug, Serialize, Deserialize)]
2149struct OptionView {
2150 value: String,
2151 label: String,
2152 selected: bool,
2153}
2154
2155#[derive(Template)]
2156#[template(path = "fields/select.html")]
2157struct SelectTmpl<'a> {
2158 name: &'a str,
2159 id: &'a str,
2160 required: bool,
2161 options: &'a [OptionView],
2162 search: bool,
2163}
2164
2165pub(crate) struct SelectField;
2167
2168impl FieldType for SelectField {
2169 fn view_key(&self) -> &'static str {
2170 "select"
2171 }
2172 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2173 keys(&["options", "search"])
2174 }
2175 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
2178 match opts.get::<SelectOptions>() {
2179 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
2180 _ => Vec::new(),
2181 }
2182 }
2183 fn resolve_options(
2184 &self,
2185 raw: &serde_json::Value,
2186 _types: &FieldRegistry,
2187 ) -> Result<ResolvedOptions, OptionsError> {
2188 let opts: SelectOptions =
2189 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2190 Ok(ResolvedOptions::new(opts))
2191 }
2192 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2193 let current = cx.value.as_text();
2194 let opts = cx.opts.get::<SelectOptions>();
2195 let data = SelectData {
2196 options: opts
2197 .map(|o| {
2198 o.options
2199 .iter()
2200 .map(|so| OptionView {
2201 value: so.value.clone(),
2202 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2203 selected: so.value == current,
2204 })
2205 .collect()
2206 })
2207 .unwrap_or_default(),
2208 search: opts.is_some_and(SelectOptions::searchable),
2209 };
2210 FieldVm {
2211 view_key: "select".to_string(),
2212 name: cx.name.to_string(),
2213 id: cx.id.to_string(),
2214 label: cx.label.to_string(),
2215 required: cx.required,
2216 value: cx.value.clone(),
2217 data: serde_json::to_value(&data).unwrap_or_default(),
2218 }
2219 }
2220 fn render_default(&self, vm: &FieldVm) -> Markup {
2221 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2222 Markup::from_template(&SelectTmpl {
2223 name: &vm.name,
2224 id: &vm.id,
2225 required: vm.required,
2226 options: &data.options,
2227 search: data.search,
2228 })
2229 .unwrap_or_default()
2230 }
2231}
2232
2233#[derive(Debug, Default, Deserialize)]
2235struct ChecklistOptions {
2236 #[serde(default)]
2237 options: Vec<ChecklistEntry>,
2238 #[serde(default)]
2239 select_all: crate::checklist::Offer,
2240 #[serde(default)]
2241 search: crate::checklist::Offer,
2242 #[serde(default)]
2243 expand: crate::checklist::Expand,
2244}
2245
2246#[derive(Debug, Deserialize)]
2248#[serde(untagged)]
2249enum ChecklistEntry {
2250 Group {
2251 group: String,
2252 #[serde(default)]
2253 options: Vec<ChecklistEntry>,
2254 },
2255 Choice {
2256 value: String,
2257 #[serde(default)]
2259 label: Option<String>,
2260 #[serde(default)]
2262 note: Option<String>,
2263 },
2264}
2265
2266impl ChecklistOptions {
2267 fn values(&self) -> Vec<&str> {
2269 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
2270 for entry in entries {
2271 match entry {
2272 ChecklistEntry::Choice { value, .. } => out.push(value),
2273 ChecklistEntry::Group { options, .. } => walk(options, out),
2274 }
2275 }
2276 }
2277 let mut out = Vec::new();
2278 walk(&self.options, &mut out);
2279 out
2280 }
2281
2282 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
2285 self.values()
2286 .into_iter()
2287 .filter(|value| submitted.contains(value))
2288 .collect()
2289 }
2290}
2291
2292fn checklist_values(text: &str) -> Vec<String> {
2294 serde_json::from_str(text).unwrap_or_default()
2295}
2296
2297pub(crate) struct ChecklistField;
2302
2303impl ChecklistField {
2304 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
2305 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
2306 match opts.get::<ChecklistOptions>() {
2307 Some(options) => options
2308 .ticked(&submitted)
2309 .into_iter()
2310 .map(str::to_string)
2311 .collect(),
2312 None => Vec::new(),
2313 }
2314 }
2315}
2316
2317impl FieldType for ChecklistField {
2318 fn view_key(&self) -> &'static str {
2319 "checklist"
2320 }
2321 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2322 keys(&["options", "select_all", "search", "expand"])
2323 }
2324 fn resolve_options(
2325 &self,
2326 raw: &serde_json::Value,
2327 _types: &FieldRegistry,
2328 ) -> Result<ResolvedOptions, OptionsError> {
2329 let opts: ChecklistOptions = if raw.is_null() {
2330 ChecklistOptions::default()
2331 } else {
2332 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2333 };
2334 let mut seen = std::collections::HashSet::new();
2335 for value in opts.values() {
2336 if !seen.insert(value) {
2337 return Err(OptionsError(format!("option `{value}` is listed twice")));
2338 }
2339 }
2340 Ok(ResolvedOptions::new(opts))
2341 }
2342
2343 fn to_attr(
2344 &self,
2345 field: &SubmittedField<'_>,
2346 opts: &ResolvedOptions,
2347 _mode: Mode,
2348 ) -> Result<Option<AttrValue>, String> {
2349 let values = Self::gathered(field, opts)
2352 .into_iter()
2353 .map(serde_json::Value::String)
2354 .collect();
2355 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
2356 }
2357
2358 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2359 stored.unwrap_or("[]").to_string()
2360 }
2361
2362 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
2363 let values = Self::gathered(field, opts);
2364 if values.is_empty() {
2365 return None;
2366 }
2367 serde_json::to_string(&values).ok()
2368 }
2369
2370 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2371 use crate::checklist::{Checklist, Choice, Entry, Group};
2372
2373 let ticked: Vec<String> = match cx.value {
2374 FieldValue::Json(serde_json::Value::Array(values)) => values
2375 .iter()
2376 .filter_map(|v| v.as_str().map(str::to_string))
2377 .collect(),
2378 FieldValue::Text(text) => checklist_values(text),
2379 _ => Vec::new(),
2380 };
2381
2382 fn build(
2383 entries: &[ChecklistEntry],
2384 name: &str,
2385 ticked: &[String],
2386 position: &mut usize,
2387 ) -> Vec<Entry> {
2388 entries
2389 .iter()
2390 .map(|entry| match entry {
2391 ChecklistEntry::Choice { value, label, note } => {
2392 let mut choice = Choice::new(
2393 format!("{name}[{position}]"),
2394 value.clone(),
2395 label.clone().unwrap_or_else(|| value.clone()),
2396 )
2397 .checked(ticked.contains(value));
2398 if let Some(note) = note {
2399 choice = choice.note(note.clone());
2400 }
2401 *position += 1;
2402 Entry::from(choice)
2403 }
2404 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
2405 group.clone(),
2406 build(options, name, ticked, position),
2407 )),
2408 })
2409 .collect()
2410 }
2411
2412 let mut list = Checklist::new(cx.label).id(cx.id);
2413 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
2414 let mut position = 0;
2415 for entry in build(&options.options, cx.name, &ticked, &mut position) {
2416 list = list.entry(entry);
2417 }
2418 list = list
2419 .select_all(options.select_all)
2420 .search(options.search)
2421 .expand(options.expand);
2422 }
2423 FieldVm {
2424 view_key: "checklist".to_string(),
2425 name: cx.name.to_string(),
2426 id: cx.id.to_string(),
2427 label: cx.label.to_string(),
2428 required: cx.required,
2429 value: cx.value.clone(),
2430 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
2431 }
2432 }
2433
2434 fn render_default(&self, vm: &FieldVm) -> Markup {
2435 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
2436 .map(|view| view.render_markup())
2437 .unwrap_or_default()
2438 }
2439}
2440
2441#[derive(Template)]
2442#[template(path = "fields/switch.html")]
2443struct SwitchTmpl<'a> {
2444 name: &'a str,
2445 id: &'a str,
2446 on: bool,
2447}
2448
2449pub(crate) struct SwitchField;
2455
2456fn stored_is_on(stored: Option<&str>) -> bool {
2459 matches!(
2460 stored.map(str::trim).unwrap_or_default(),
2461 "1" | "true" | "TRUE" | "t" | "yes" | "on"
2462 )
2463}
2464
2465impl FieldType for SwitchField {
2466 fn view_key(&self) -> &'static str {
2467 "switch"
2468 }
2469 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2470 keys(&[])
2471 }
2472
2473 fn to_attr(
2474 &self,
2475 field: &SubmittedField<'_>,
2476 _opts: &ResolvedOptions,
2477 _mode: Mode,
2478 ) -> Result<Option<AttrValue>, String> {
2479 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
2482 }
2483
2484 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2485 if stored_is_on(stored) {
2486 "1".to_string()
2487 } else {
2488 String::new()
2489 }
2490 }
2491
2492 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2493 scalar_vm("switch", cx)
2494 }
2495
2496 fn render_default(&self, vm: &FieldVm) -> Markup {
2497 Markup::from_template(&SwitchTmpl {
2498 name: &vm.name,
2499 id: &vm.id,
2500 on: stored_is_on(Some(vm.value.as_text())),
2501 })
2502 .unwrap_or_default()
2503 }
2504}
2505
2506#[derive(Template)]
2507#[template(path = "fields/date.html")]
2508struct DateTmpl<'a> {
2509 name: &'a str,
2510 id: &'a str,
2511 value: &'a str,
2512 required: bool,
2513}
2514
2515pub(crate) struct DateField;
2521
2522impl FieldType for DateField {
2523 fn view_key(&self) -> &'static str {
2524 "date"
2525 }
2526 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2527 keys(&[])
2528 }
2529
2530 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
2531 vec![Rule::Date]
2532 }
2533
2534 fn to_attr(
2535 &self,
2536 field: &SubmittedField<'_>,
2537 _opts: &ResolvedOptions,
2538 _mode: Mode,
2539 ) -> Result<Option<AttrValue>, String> {
2540 Ok(field.value().map(|v| match v.trim() {
2541 "" => AttrValue::Null,
2542 date => AttrValue::Text(date.to_string()),
2543 }))
2544 }
2545
2546 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2547 let stored = stored.unwrap_or_default().trim();
2548 stored
2551 .split(['T', ' '])
2552 .next()
2553 .unwrap_or_default()
2554 .to_string()
2555 }
2556
2557 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2558 scalar_vm("date", cx)
2559 }
2560
2561 fn render_default(&self, vm: &FieldVm) -> Markup {
2562 Markup::from_template(&DateTmpl {
2563 name: &vm.name,
2564 id: &vm.id,
2565 value: vm.value.as_text(),
2566 required: vm.required,
2567 })
2568 .unwrap_or_default()
2569 }
2570}
2571
2572#[derive(Template)]
2573#[template(path = "fields/password.html")]
2574struct PasswordTmpl<'a> {
2575 name: &'a str,
2576 id: &'a str,
2577 required: bool,
2578 reveal: bool,
2579 show: &'a str,
2580}
2581
2582#[derive(Debug, Clone, Serialize, Deserialize)]
2584struct PasswordOptions {
2585 #[serde(default = "yes")]
2586 reveal: bool,
2587 #[serde(default)]
2589 show: String,
2590}
2591
2592impl Default for PasswordOptions {
2593 fn default() -> Self {
2594 Self {
2595 reveal: true,
2596 show: String::new(),
2597 }
2598 }
2599}
2600
2601pub(crate) struct PasswordField;
2609
2610impl FieldType for PasswordField {
2611 fn view_key(&self) -> &'static str {
2612 "password"
2613 }
2614 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2615 keys(&["reveal"])
2616 }
2617 fn resolve_options(
2618 &self,
2619 raw: &serde_json::Value,
2620 _types: &FieldRegistry,
2621 ) -> Result<ResolvedOptions, OptionsError> {
2622 let opts: PasswordOptions = if raw.is_null() {
2623 PasswordOptions::default()
2624 } else {
2625 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2626 };
2627 Ok(ResolvedOptions::new(opts))
2628 }
2629
2630 fn to_attr(
2631 &self,
2632 field: &SubmittedField<'_>,
2633 _opts: &ResolvedOptions,
2634 _mode: Mode,
2635 ) -> Result<Option<AttrValue>, String> {
2636 match field.value().map(str::trim).unwrap_or_default() {
2637 "" => Ok(None),
2639 plain => laterite_auth::password::hash_password(plain)
2640 .map(|hash| Some(AttrValue::Text(hash)))
2641 .map_err(|e| e.to_string()),
2642 }
2643 }
2644
2645 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2648 String::new()
2649 }
2650
2651 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2652 let mut vm = scalar_vm("password", cx);
2653 let mut opts = cx
2654 .opts
2655 .get::<PasswordOptions>()
2656 .cloned()
2657 .unwrap_or_default();
2658 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2659 vm.data = serde_json::to_value(opts).unwrap_or_default();
2660 vm
2661 }
2662
2663 fn render_default(&self, vm: &FieldVm) -> Markup {
2664 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2665 Markup::from_template(&PasswordTmpl {
2666 name: &vm.name,
2667 id: &vm.id,
2668 required: vm.required,
2669 reveal: opts.reveal,
2670 show: &opts.show,
2671 })
2672 .unwrap_or_default()
2673 }
2674}
2675
2676#[derive(Template)]
2677#[template(path = "fields/radio.html")]
2678struct RadioTmpl<'a> {
2679 name: &'a str,
2680 options: &'a [OptionView],
2681 required: bool,
2682}
2683
2684pub(crate) struct RadioField;
2689
2690impl FieldType for RadioField {
2691 fn view_key(&self) -> &'static str {
2692 "radio"
2693 }
2694 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2695 keys(&["options"])
2696 }
2697
2698 fn resolve_options(
2699 &self,
2700 raw: &serde_json::Value,
2701 _types: &FieldRegistry,
2702 ) -> Result<ResolvedOptions, OptionsError> {
2703 let opts: SelectOptions =
2704 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2705 Ok(ResolvedOptions::new(opts))
2706 }
2707
2708 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2709 let current = cx.value.as_text();
2710 let views: Vec<OptionView> = cx
2711 .opts
2712 .get::<SelectOptions>()
2713 .map(|o| {
2714 o.options
2715 .iter()
2716 .map(|so| OptionView {
2717 value: so.value.clone(),
2718 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2719 selected: so.value == current,
2720 })
2721 .collect()
2722 })
2723 .unwrap_or_default();
2724 FieldVm {
2725 view_key: "radio".to_string(),
2726 name: cx.name.to_string(),
2727 id: cx.id.to_string(),
2728 label: cx.label.to_string(),
2729 required: cx.required,
2730 value: cx.value.clone(),
2731 data: serde_json::to_value(&views).unwrap_or_default(),
2732 }
2733 }
2734
2735 fn render_default(&self, vm: &FieldVm) -> Markup {
2736 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2737 Markup::from_template(&RadioTmpl {
2738 name: &vm.name,
2739 options: &options,
2740 required: vm.required,
2741 })
2742 .unwrap_or_default()
2743 }
2744}
2745
2746fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2748 FieldVm {
2749 view_key: view_key.to_string(),
2750 name: cx.name.to_string(),
2751 id: cx.id.to_string(),
2752 label: cx.label.to_string(),
2753 required: cx.required,
2754 value: cx.value.clone(),
2755 data: serde_json::Value::Null,
2756 }
2757}
2758
2759#[derive(Deserialize)]
2761struct RefOptions {
2762 source: String,
2763}
2764
2765struct RefResolved {
2767 source: String,
2768}
2769
2770#[derive(Default, Serialize, Deserialize)]
2773struct RefData {
2774 source: String,
2775 search_url: String,
2776 resolve_url: String,
2777 value: String,
2778 placeholder: String,
2779}
2780
2781#[derive(Template)]
2782#[template(path = "fields/ref_picker.html")]
2783struct RefPickerTmpl<'a> {
2784 name: &'a str,
2785 id: &'a str,
2786 value: &'a str,
2787 search_url: &'a str,
2788 resolve_url: &'a str,
2789 placeholder: &'a str,
2790}
2791
2792pub(crate) struct RefPickerField {
2797 pickers: Arc<PickerRegistry>,
2798}
2799
2800impl RefPickerField {
2801 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2802 Self { pickers }
2803 }
2804}
2805
2806pub(crate) struct IdField;
2811
2812impl FieldType for IdField {
2813 fn view_key(&self) -> &'static str {
2814 "id"
2815 }
2816 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2817 keys(&[])
2818 }
2819 fn resolve_options(
2820 &self,
2821 _raw: &serde_json::Value,
2822 _types: &FieldRegistry,
2823 ) -> Result<ResolvedOptions, OptionsError> {
2824 Ok(ResolvedOptions::new(()))
2825 }
2826 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2827 FieldVm {
2828 view_key: "id".to_string(),
2829 name: cx.name.to_string(),
2830 id: cx.id.to_string(),
2831 label: cx.label.to_string(),
2832 required: cx.required,
2833 value: cx.value.clone(),
2834 data: serde_json::Value::Null,
2835 }
2836 }
2837 fn render_default(&self, vm: &FieldVm) -> Markup {
2838 Markup::from_override(format!(
2839 r#"<input type="hidden" name="{}" value="{}">"#,
2840 crate::form::escape_attr(&vm.name),
2841 crate::form::escape_attr(vm.value.as_text())
2842 ))
2843 }
2844 fn to_attr(
2845 &self,
2846 field: &SubmittedField<'_>,
2847 _opts: &ResolvedOptions,
2848 _mode: Mode,
2849 ) -> Result<Option<AttrValue>, String> {
2850 Ok(field.value().map(id_attr))
2851 }
2852}
2853
2854fn id_attr(value: &str) -> AttrValue {
2857 let value = value.trim();
2858 if value.is_empty() {
2859 AttrValue::Null
2860 } else if let Ok(id) = value.parse::<i64>() {
2861 AttrValue::Int(id)
2862 } else {
2863 AttrValue::Text(value.to_string())
2864 }
2865}
2866
2867impl FieldType for RefPickerField {
2868 fn to_attr(
2871 &self,
2872 field: &SubmittedField<'_>,
2873 _opts: &ResolvedOptions,
2874 _mode: Mode,
2875 ) -> Result<Option<AttrValue>, String> {
2876 Ok(field.value().map(id_attr))
2877 }
2878 fn view_key(&self) -> &'static str {
2879 "reference"
2880 }
2881 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2882 keys(&["source"])
2883 }
2884 fn resolve_options(
2885 &self,
2886 raw: &serde_json::Value,
2887 _types: &FieldRegistry,
2888 ) -> Result<ResolvedOptions, OptionsError> {
2889 let opts: RefOptions =
2890 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2891 if !self.pickers.contains_key(&opts.source) {
2892 return Err(OptionsError(format!(
2893 "unknown picker source `{}`",
2894 opts.source
2895 )));
2896 }
2897 Ok(ResolvedOptions::new(RefResolved {
2898 source: opts.source,
2899 }))
2900 }
2901 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2902 let source = cx
2903 .opts
2904 .get::<RefResolved>()
2905 .map(|r| r.source.as_str())
2906 .unwrap_or_default();
2907 let data = RefData {
2908 search_url: format!("{}/pickers/{}/search", cx.base, source),
2909 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
2910 source: source.to_string(),
2911 value: cx.value.as_text().to_string(),
2912 placeholder: "Search…".to_string(),
2913 };
2914 FieldVm {
2915 view_key: "reference".to_string(),
2916 name: cx.name.to_string(),
2917 id: cx.id.to_string(),
2918 label: cx.label.to_string(),
2919 required: cx.required,
2920 value: cx.value.clone(),
2921 data: serde_json::to_value(data).unwrap_or_default(),
2922 }
2923 }
2924 fn render_default(&self, vm: &FieldVm) -> Markup {
2925 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2926 Markup::from_template(&RefPickerTmpl {
2927 name: &vm.name,
2928 id: &vm.id,
2929 value: vm.value.as_text(),
2930 search_url: &data.search_url,
2931 resolve_url: &data.resolve_url,
2932 placeholder: &data.placeholder,
2933 })
2934 .unwrap_or_default()
2935 }
2936 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
2937 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2938 }
2939}
2940
2941#[cfg(test)]
2944fn test_icons() -> crate::icons::Icons<'static> {
2945 crate::icons::Icons::new(
2946 Box::leak(Box::new(laterite_core::icons::IconSet::new())),
2947 "/admin/assets/icons.svg",
2948 )
2949}
2950
2951#[cfg(test)]
2952mod tests {
2953 use super::*;
2954
2955 #[test]
2956 fn an_id_is_stored_as_a_number_or_null() {
2957 assert!(matches!(id_attr("7"), AttrValue::Int(7)));
2958 assert!(matches!(id_attr(" 12 "), AttrValue::Int(12)));
2959 assert!(matches!(id_attr(""), AttrValue::Null));
2960 assert!(matches!(id_attr("x1"), AttrValue::Text(t) if t == "x1"));
2961 }
2962
2963 fn untranslated() -> Translator {
2966 Translator::new("en")
2967 }
2968
2969 fn cx_with<'a>(
2970 name: &'a str,
2971 value: &'a FieldValue,
2972 opts: &'a ResolvedOptions,
2973 i18n: &'a Translator,
2974 ) -> FieldCx<'a> {
2975 FieldCx {
2976 name,
2977 id: name,
2978 label: "Label",
2979 value,
2980 required: true,
2981 max_length: None,
2982 opts,
2983 base: "/admin",
2984 i18n,
2985 icons: test_icons(),
2986 }
2987 }
2988
2989 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
2990 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
2993 }
2994
2995 fn text_field() -> TextField {
2996 TextField::new(Arc::new(builtin_input_registry()))
2997 }
2998
2999 struct BadAttrInput;
3001 impl InputType for BadAttrInput {
3002 fn key(&self) -> &'static str {
3003 "bad"
3004 }
3005 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
3006 BTreeMap::from([("class".to_string(), "x".to_string())])
3007 }
3008 }
3009
3010 #[test]
3011 fn text_renders_its_value_escaped() {
3012 let opts = ResolvedOptions::none();
3013 let value = FieldValue::Text("a<b>&\"c".to_string());
3014 let markup = render_field(
3015 &text_field(),
3016 &NoOverrides,
3017 &scope(),
3018 &cx("title", &value, &opts),
3019 );
3020 let html = markup.as_str();
3021 assert!(html.contains(r#"name="title""#));
3022 assert!(html.contains("required"));
3023 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
3027 assert!(
3028 html.contains("<") || html.contains("<"),
3029 "angle escaped: {html}"
3030 );
3031 assert!(
3032 html.contains("&") || html.contains("&"),
3033 "ampersand escaped: {html}"
3034 );
3035 }
3036
3037 #[test]
3038 fn textarea_uses_its_own_control() {
3039 let opts = ResolvedOptions::none();
3040 let value = FieldValue::Text("body".to_string());
3041 let markup = render_field(
3042 &TextareaField,
3043 &NoOverrides,
3044 &scope(),
3045 &cx("body", &value, &opts),
3046 );
3047 assert!(markup.as_str().contains("<textarea"));
3048 }
3049
3050 #[test]
3051 fn code_editor_wraps_a_textarea_in_its_island_and_declares_the_engine() {
3052 let types = FieldRegistry::default();
3053 let opts = CodeEditorField
3054 .resolve_options(
3055 &serde_json::json!({ "language": "jinja", "rows": 20 }),
3056 &types,
3057 )
3058 .unwrap();
3059 let value = FieldValue::Text("{{ page.title }}".to_string());
3060 let markup = render_field(
3061 &CodeEditorField,
3062 &NoOverrides,
3063 &scope(),
3064 &cx("markup", &value, &opts),
3065 );
3066 let html = markup.as_str();
3067 assert!(html.contains(r#"data-lat-widget="codeeditor""#), "{html}");
3068 assert!(html.contains(r#"data-language="jinja""#), "{html}");
3069 assert!(html.contains(r#"rows="20""#), "{html}");
3070 assert!(html.contains("<textarea"), "{html}");
3071 assert_eq!(
3072 CodeEditorField.assets(&opts),
3073 crate::CODE_EDITOR_ASSETS.to_vec()
3074 );
3075 assert!(CodeEditorField
3077 .resolve_options(&serde_json::json!({ "language": "cobol" }), &types)
3078 .is_err());
3079 }
3080
3081 #[test]
3082 fn rich_editor_renders_its_toolbar_and_cleans_what_it_stores() {
3083 let types = FieldRegistry::default();
3084 let opts = RichEditorField
3085 .resolve_options(
3086 &serde_json::json!({ "toolbar": ["bold", "|", "link"] }),
3087 &types,
3088 )
3089 .unwrap();
3090 let value = FieldValue::Text("<p>Hi</p>".to_string());
3091 let markup = render_field(
3092 &RichEditorField,
3093 &NoOverrides,
3094 &scope(),
3095 &cx("body", &value, &opts),
3096 );
3097 let html = markup.as_str();
3098 assert!(html.contains(r#"data-lat-widget="richeditor""#), "{html}");
3099 assert!(html.contains(r#"data-rich-cmd="bold""#), "{html}");
3100 assert!(html.contains(r#"data-rich-cmd="link""#), "{html}");
3101 assert!(!html.contains(r#"data-rich-cmd="image""#), "{html}");
3102 assert!(html.contains("icons.svg#lat-bold"), "{html}");
3103 assert!(html.contains("<textarea"), "{html}");
3105 assert!(!html.contains("<p>Hi</p>"), "{html}");
3106 assert!(RichEditorField
3107 .resolve_options(&serde_json::json!({ "toolbar": ["blink"] }), &types)
3108 .is_err());
3109 }
3110
3111 #[test]
3112 fn markdown_renders_its_tabs_and_offers_no_preview_when_told() {
3113 let types = FieldRegistry::default();
3114 let value = FieldValue::Text("# Hi".to_string());
3115 let opts = MarkdownField
3116 .resolve_options(&serde_json::Value::Null, &types)
3117 .unwrap();
3118 let html = render_field(
3119 &MarkdownField,
3120 &NoOverrides,
3121 &scope(),
3122 &cx("body", &value, &opts),
3123 );
3124 let html = html.as_str();
3125 assert!(html.contains(r#"data-lat-widget="markdown""#), "{html}");
3126 assert!(
3127 html.contains(r#"data-preview="/admin/markdown/preview""#),
3128 "{html}"
3129 );
3130 assert!(html.contains(r#"data-md-tab="preview""#), "{html}");
3131 assert!(html.contains("icons.svg#lat-heading"), "{html}");
3132 assert_eq!(MarkdownField.assets(&opts), crate::MARKDOWN_ASSETS.to_vec());
3133 let opts = MarkdownField
3134 .resolve_options(&serde_json::json!({ "preview": false }), &types)
3135 .unwrap();
3136 let html = render_field(
3137 &MarkdownField,
3138 &NoOverrides,
3139 &scope(),
3140 &cx("body", &value, &opts),
3141 );
3142 assert!(!html.as_str().contains("data-md-tab"), "{}", html.as_str());
3143 }
3144
3145 #[test]
3146 fn no_overrides_uses_the_compiled_default() {
3147 let opts = ResolvedOptions::none();
3149 let value = FieldValue::Text("x".to_string());
3150 let ft = text_field();
3151 let vm = ft.view_model(&cx("t", &value, &opts));
3152 let direct = ft.render_default(&vm);
3153 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
3154 assert_eq!(direct.as_str(), routed.as_str());
3155 }
3156
3157 #[test]
3158 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
3159 let field = text_field();
3160 let opts = field
3161 .resolve_options(
3162 &serde_json::json!({ "input": "email" }),
3163 &builtin_registry(),
3164 )
3165 .unwrap();
3166 assert!(matches!(
3168 field.intrinsic_rules(&opts).as_slice(),
3169 [Rule::Email]
3170 ));
3171 let value = FieldValue::Text("a@b.test".to_string());
3172 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
3173 assert!(markup.as_str().contains(r#"type="email""#));
3174 }
3175
3176 #[test]
3177 fn text_default_input_is_plain_text_with_no_extra_rule() {
3178 let field = text_field();
3179 let opts = field
3180 .resolve_options(&serde_json::Value::Null, &builtin_registry())
3181 .unwrap();
3182 assert!(field.intrinsic_rules(&opts).is_empty());
3183 let value = FieldValue::Text("hi".to_string());
3184 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
3185 assert!(markup.as_str().contains(r#"type="text""#));
3186 }
3187
3188 #[test]
3189 fn text_number_input_sets_the_type_and_numeric_rule() {
3190 let field = text_field();
3191 let opts = field
3192 .resolve_options(
3193 &serde_json::json!({ "input": "number" }),
3194 &builtin_registry(),
3195 )
3196 .unwrap();
3197 assert!(matches!(
3198 field.intrinsic_rules(&opts).as_slice(),
3199 [Rule::Numeric]
3200 ));
3201 let value = FieldValue::Text("42".to_string());
3202 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3203 assert!(markup.as_str().contains(r#"type="number""#));
3204 }
3205
3206 #[test]
3207 fn text_tel_input_sets_the_type_with_no_rule() {
3208 let field = text_field();
3209 let opts = field
3210 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
3211 .unwrap();
3212 assert!(field.intrinsic_rules(&opts).is_empty());
3213 let value = FieldValue::Text(String::new());
3214 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
3215 assert!(markup.as_str().contains(r#"type="tel""#));
3216 }
3217
3218 #[test]
3219 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
3220 let field = text_field();
3221 let opts = field
3222 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
3223 .unwrap();
3224 assert!(matches!(
3225 field.intrinsic_rules(&opts).as_slice(),
3226 [Rule::Url]
3227 ));
3228 let value = FieldValue::Text("https://example.com".to_string());
3229 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3230 let html = markup.as_str();
3231 assert!(html.contains(r#"type="url""#), "{html}");
3232 assert!(html.contains("lat-input-group"), "{html}");
3233 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
3234 }
3235
3236 #[test]
3237 fn text_url_input_copy_false_omits_the_button() {
3238 let field = text_field();
3239 let opts = field
3240 .resolve_options(
3241 &serde_json::json!({ "input": "url", "copy": false }),
3242 &builtin_registry(),
3243 )
3244 .unwrap();
3245 let value = FieldValue::Text("https://example.com".to_string());
3246 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3247 let html = markup.as_str();
3248 assert!(html.contains(r#"type="url""#), "{html}");
3249 assert!(!html.contains("lat-input-group"), "{html}");
3251 assert!(!html.contains("data-lat-widget"), "{html}");
3252 }
3253
3254 #[test]
3255 fn text_number_input_emits_min_max_step_attributes() {
3256 let field = text_field();
3257 let opts = field
3258 .resolve_options(
3259 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
3260 &builtin_registry(),
3261 )
3262 .unwrap();
3263 let value = FieldValue::Text("5".to_string());
3266 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3267 let html = markup.as_str();
3268 assert!(html.contains(r#"min="0""#), "{html}");
3269 assert!(html.contains(r#"max="10""#), "{html}");
3270 assert!(html.contains(r#"step="2""#), "{html}");
3271 }
3272
3273 #[test]
3274 fn text_input_reserved_attribute_name_aborts_resolve() {
3275 let mut inputs = builtin_input_registry();
3276 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
3277 let field = TextField::new(Arc::new(inputs));
3278 let Err(e) =
3279 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
3280 else {
3281 panic!("expected a reserved-attribute rejection");
3282 };
3283 assert!(e.0.contains("class"), "{e}");
3284 }
3285
3286 #[test]
3287 fn attribute_and_widget_name_rules() {
3288 assert!(is_name("min", false));
3289 assert!(is_name("data-x", false));
3290 assert!(!is_name("Min", false));
3291 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
3294 }
3295
3296 #[test]
3297 fn select_renders_options_with_the_current_value_selected() {
3298 let raw = serde_json::json!({
3299 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3300 });
3301 let opts = SelectField
3302 .resolve_options(&raw, &builtin_registry())
3303 .unwrap();
3304 let value = FieldValue::Text("closed".to_string());
3305 let markup = render_field(
3306 &SelectField,
3307 &NoOverrides,
3308 &scope(),
3309 &cx("status", &value, &opts),
3310 );
3311 let html = markup.as_str();
3312 assert!(html.contains(r#"<option value="open">Open</option>"#));
3313 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
3315 }
3316
3317 #[test]
3318 fn radio_renders_the_same_options_with_the_current_one_checked() {
3319 let raw = serde_json::json!({
3320 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3321 });
3322 let opts = RadioField
3323 .resolve_options(&raw, &builtin_registry())
3324 .unwrap();
3325 let value = FieldValue::Text("closed".to_string());
3326 let markup = render_field(
3327 &RadioField,
3328 &NoOverrides,
3329 &scope(),
3330 &cx("status", &value, &opts),
3331 );
3332 let html = markup.as_str();
3333 assert!(html.contains(r#"value="open""#));
3334 assert!(
3335 html.contains(r#"value="closed" checked"#),
3336 "current is checked"
3337 );
3338 assert_eq!(html.matches(r#"name="status""#).count(), 2);
3341 assert!(html.contains("closed</label>"));
3343 }
3344
3345 fn scope<'a>() -> OverrideScope<'a> {
3346 OverrideScope {
3347 surface: Surface::Field,
3348 view_key: "text",
3349 resource: None,
3350 field: None,
3351 }
3352 }
3353
3354 fn ref_field() -> RefPickerField {
3355 let mut pickers = crate::picker::PickerRegistry::new();
3356 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
3357 pickers.insert(
3358 "acme.place".to_string(),
3359 crate::picker::PickerSourceReg::new("acme.place", source),
3360 );
3361 RefPickerField::new(Arc::new(pickers))
3362 }
3363
3364 #[test]
3365 fn reference_rejects_an_unknown_source() {
3366 let field = ref_field();
3367 let Err(e) = field.resolve_options(
3368 &serde_json::json!({ "source": "no.such" }),
3369 &builtin_registry(),
3370 ) else {
3371 panic!("expected an unknown-source rejection");
3372 };
3373 assert!(e.0.contains("no.such"), "{e}");
3374 }
3375
3376 #[test]
3377 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
3378 let field = ref_field();
3379 let opts = field
3380 .resolve_options(
3381 &serde_json::json!({ "source": "acme.place" }),
3382 &builtin_registry(),
3383 )
3384 .unwrap();
3385 let value = FieldValue::Text("42".to_string());
3386 let markup = render_field(
3387 &field,
3388 &NoOverrides,
3389 &scope(),
3390 &cx("place_id", &value, &opts),
3391 );
3392 let html = markup.as_str();
3393 assert!(html.contains(r#"type="hidden""#), "{html}");
3396 assert!(html.contains(r#"name="place_id""#), "{html}");
3397 assert!(html.contains(r#"value="42""#), "{html}");
3398 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
3400 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
3401 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
3402 }
3403
3404 #[test]
3405 fn reference_declares_its_widget_assets() {
3406 let field = ref_field();
3407 let opts = field
3408 .resolve_options(
3409 &serde_json::json!({ "source": "acme.place" }),
3410 &builtin_registry(),
3411 )
3412 .unwrap();
3413 assert_eq!(
3414 field.assets(&opts),
3415 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
3416 );
3417 }
3418}
3419
3420#[cfg(test)]
3421mod repeater_tests {
3422 use super::*;
3423
3424 fn options() -> ResolvedOptions {
3425 RepeaterField
3426 .resolve_options(
3427 &serde_json::json!({
3428 "fields": [
3429 { "name": "path", "label": "Path", "type": "text" },
3430 { "name": "allow", "label": "Allow", "type": "switch" },
3431 ]
3432 }),
3433 &builtin_registry(),
3434 )
3435 .unwrap()
3436 }
3437
3438 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3439 pairs
3440 .iter()
3441 .map(|(k, v)| (k.to_string(), v.to_string()))
3442 .collect()
3443 }
3444
3445 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
3446 let field = SubmittedField::new("rules", data);
3447 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
3448 Some(AttrValue::Json(value)) => value,
3449 other => panic!("expected a JSON array, got {other:?}"),
3450 }
3451 }
3452
3453 #[test]
3457 fn a_row_is_typed_by_its_sub_field_types() {
3458 let rows = stored(
3459 &submitted(&[
3460 ("rules[0][path]", "/private"),
3461 ("rules[1][path]", "/public"),
3462 ("rules[1][allow]", "on"),
3463 ]),
3464 &options(),
3465 );
3466
3467 assert_eq!(rows[0]["path"], "/private");
3468 assert_eq!(rows[0]["allow"], serde_json::json!(false));
3469 assert_eq!(rows[1]["allow"], serde_json::json!(true));
3470 }
3471
3472 #[test]
3475 fn rows_are_ordered_by_index_not_submission_order() {
3476 let rows = stored(
3477 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
3478 &options(),
3479 );
3480 assert_eq!(rows.as_array().unwrap().len(), 2);
3481 assert_eq!(rows[0]["path"], "/first");
3482 assert_eq!(rows[1]["path"], "/last");
3483 }
3484
3485 #[test]
3486 fn a_row_left_blank_is_dropped() {
3487 let rows = stored(
3488 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
3489 &options(),
3490 );
3491 assert_eq!(rows.as_array().unwrap().len(), 1);
3492 }
3493
3494 #[test]
3495 fn nothing_submitted_stores_an_empty_list() {
3496 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
3497 }
3498
3499 #[test]
3500 fn a_repeater_refuses_to_hold_a_repeater() {
3501 let err = RepeaterField
3502 .resolve_options(
3503 &serde_json::json!({
3504 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
3505 }),
3506 &builtin_registry(),
3507 )
3508 .err()
3509 .expect("a nested repeater is refused");
3510 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
3511 }
3512
3513 #[test]
3514 fn an_unregistered_sub_field_type_is_refused_at_boot() {
3515 let err = RepeaterField
3516 .resolve_options(
3517 &serde_json::json!({
3518 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
3519 }),
3520 &builtin_registry(),
3521 )
3522 .err()
3523 .expect("an unregistered type is refused");
3524 assert!(err.0.contains("acme.nope"), "{}", err.0);
3525 }
3526
3527 #[test]
3528 fn row_counts_are_bounded_when_asked() {
3529 let opts = RepeaterField
3530 .resolve_options(
3531 &serde_json::json!({
3532 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
3533 "min_items": 1,
3534 "max_items": 2
3535 }),
3536 &builtin_registry(),
3537 )
3538 .unwrap();
3539
3540 let empty = HashMap::new();
3541 let too_few = SubmittedField::new("rules", &empty);
3542 assert!(RepeaterField
3543 .to_attr(&too_few, &opts, Mode::Create)
3544 .is_err());
3545
3546 let three = submitted(&[
3547 ("rules[0][path]", "/a"),
3548 ("rules[1][path]", "/b"),
3549 ("rules[2][path]", "/c"),
3550 ]);
3551 let field = SubmittedField::new("rules", &three);
3552 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
3553 }
3554
3555 #[test]
3556 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
3557 let value = FieldValue::Json(serde_json::json!([
3558 { "path": "/private", "allow": false },
3559 ]));
3560 let cx = FieldCx {
3561 name: "rules",
3562 id: "rules",
3563 label: "Rules",
3564 value: &value,
3565 required: false,
3566 max_length: None,
3567 opts: &options(),
3568 base: "/admin",
3569 i18n: &Translator::new("en"),
3570 icons: test_icons(),
3571 };
3572 let html = RepeaterField
3573 .render_default(&RepeaterField.view_model(&cx))
3574 .into_string();
3575
3576 assert!(html.contains(r#"name="rules[0][path]""#));
3577 assert!(html.contains(r#"value="/private""#));
3578 assert!(html.contains("lat-repeater__blank"));
3580 assert!(html.contains(r#"name="rules[__index__][path]""#));
3581 }
3582
3583 fn list_options() -> ResolvedOptions {
3585 RepeaterField
3586 .resolve_options(
3587 &serde_json::json!({
3588 "fields": [
3589 { "name": "agent", "label": "Agent", "type": "text" },
3590 { "name": "disallow", "label": "Disallow", "type": "textarea" },
3591 ],
3592 "display": "list",
3593 "summary_field": "agent",
3594 }),
3595 &builtin_registry(),
3596 )
3597 .unwrap()
3598 }
3599
3600 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
3601 let cx = FieldCx {
3602 name: "rules",
3603 id: "rules",
3604 label: "Rules",
3605 value,
3606 required: false,
3607 max_length: None,
3608 opts,
3609 base: "/admin",
3610 i18n: &Translator::new("en"),
3611 icons: test_icons(),
3612 };
3613 RepeaterField
3614 .render_default(&RepeaterField.view_model(&cx))
3615 .into_string()
3616 }
3617
3618 #[test]
3619 fn a_list_repeater_collapses_its_rows_and_names_them() {
3620 let value = FieldValue::Json(serde_json::json!([
3621 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
3622 ]));
3623 let html = render_with(&list_options(), &value);
3624 assert!(html.contains("lat-repeater--list"), "list mode is declared");
3625 assert!(html.contains("<details"), "a row collapses");
3626 assert!(
3627 html.contains("Googlebot"),
3628 "the naming field titles the row"
3629 );
3630 assert!(html.contains(r#"name="rules[0][disallow]""#));
3632 }
3633
3634 #[test]
3635 fn a_row_with_no_name_yet_reads_as_untitled() {
3636 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
3637 let html = render_with(&list_options(), &value);
3638 assert!(html.contains("lat-repeater__untitled"));
3639 }
3640
3641 #[test]
3642 fn the_default_layout_stays_inline() {
3643 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
3645 let html = render_with(&options(), &value);
3646 assert!(!html.contains("lat-repeater--list"));
3647 assert!(!html.contains("<details"));
3648 }
3649
3650 #[test]
3651 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
3652 assert_eq!(
3654 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
3655 "/private"
3656 );
3657 assert_eq!(summary_text(&serde_json::json!("")), "");
3658 }
3659
3660 #[test]
3667 fn a_contributed_field_type_names_itself_and_converts() {
3668 struct Money;
3669 impl FieldType for Money {
3670 fn view_key(&self) -> &'static str {
3671 "acme.money"
3672 }
3673 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
3674 scalar_vm("acme.money", cx)
3675 }
3676 fn render_default(&self, _vm: &FieldVm) -> Markup {
3677 Markup::empty()
3678 }
3679 fn to_attr(
3680 &self,
3681 field: &SubmittedField<'_>,
3682 _opts: &ResolvedOptions,
3683 _mode: Mode,
3684 ) -> Result<Option<AttrValue>, String> {
3685 let raw = field.value().unwrap_or("0");
3687 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
3688 Ok(Some(AttrValue::Int(minor as i64)))
3689 }
3690 }
3691
3692 let reg = FieldTypeReg::new(Arc::new(Money));
3693 assert_eq!(reg.name(), "acme.money");
3696
3697 let mut registry = builtin_registry();
3698 let name = reg.name().to_string();
3699 assert!(
3700 registry.insert(name.clone(), reg.field_type).is_none(),
3701 "a dotted key does not collide with a built-in"
3702 );
3703
3704 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
3705 let submitted = SubmittedField::new("price", &data);
3706 let converted = registry[&name]
3707 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
3708 .unwrap();
3709 assert!(
3710 matches!(converted, Some(AttrValue::Int(1250))),
3711 "{converted:?}"
3712 );
3713 }
3714
3715 #[test]
3716 fn a_sub_field_label_is_localized_like_any_other() {
3717 use laterite_core::i18n::CatalogStore;
3718 use std::sync::Arc;
3719
3720 let po = concat!(
3721 "msgid \"\"\n",
3722 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
3723 "\n",
3724 "msgid \"Path\"\n",
3725 "msgstr \"Path-kn\"\n",
3726 );
3727 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
3728 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
3729
3730 let value = FieldValue::Text(String::new());
3731 let cx = FieldCx {
3732 name: "rules",
3733 id: "rules",
3734 label: "Rules",
3735 value: &value,
3736 required: false,
3737 max_length: None,
3738 opts: &options(),
3739 base: "/admin",
3740 i18n: &i18n,
3741 icons: test_icons(),
3742 };
3743 let html = RepeaterField
3744 .render_default(&RepeaterField.view_model(&cx))
3745 .into_string();
3746
3747 assert!(
3748 html.contains("Path-kn"),
3749 "sub-label was not localized: {html}"
3750 );
3751 assert!(
3752 !html.contains(">Path<"),
3753 "the source string still leaked: {html}"
3754 );
3755 }
3756}
3757
3758#[cfg(test)]
3759mod save_contract_tests {
3760 use super::*;
3761
3762 fn one(raw: Option<&str>) -> HashMap<String, String> {
3764 raw.into_iter()
3765 .map(|v| ("f".to_string(), v.to_string()))
3766 .collect()
3767 }
3768
3769 fn none() -> ResolvedOptions {
3770 ResolvedOptions::none()
3771 }
3772
3773 #[test]
3774 fn a_switch_stores_a_bool_and_absence_means_off() {
3775 let f = SwitchField;
3776 assert_eq!(
3779 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3780 .unwrap(),
3781 Some(AttrValue::Bool(false))
3782 );
3783 assert_eq!(
3784 f.to_attr(
3785 &SubmittedField::new("f", &one(Some("on"))),
3786 &none(),
3787 Mode::Create
3788 )
3789 .unwrap(),
3790 Some(AttrValue::Bool(true))
3791 );
3792 }
3793
3794 #[test]
3795 fn a_switch_reads_back_whatever_the_backend_gave() {
3796 let f = SwitchField;
3797 for on in ["1", "true", "t", "on"] {
3799 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3800 }
3801 for off in ["0", "false", "f", ""] {
3802 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3803 }
3804 assert!(f.to_control(None, &none()).is_empty());
3805 }
3806
3807 #[test]
3808 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3809 let f = DateField;
3810 assert_eq!(
3812 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3813 "2026-09-09"
3814 );
3815 assert_eq!(
3816 f.to_control(Some("2026-09-09 12:30:45"), &none()),
3817 "2026-09-09"
3818 );
3819 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3820 assert_eq!(f.to_control(None, &none()), "");
3821 }
3822
3823 #[test]
3824 fn a_cleared_date_stores_null_not_an_empty_string() {
3825 let f = DateField;
3826 assert_eq!(
3827 f.to_attr(
3828 &SubmittedField::new("f", &one(Some(""))),
3829 &none(),
3830 Mode::Update
3831 )
3832 .unwrap(),
3833 Some(AttrValue::Null)
3834 );
3835 assert_eq!(
3836 f.to_attr(
3837 &SubmittedField::new("f", &one(Some("2026-09-09"))),
3838 &none(),
3839 Mode::Create
3840 )
3841 .unwrap(),
3842 Some(AttrValue::Text("2026-09-09".into()))
3843 );
3844 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3845 }
3846
3847 #[test]
3848 fn a_password_hashes_and_never_echoes() {
3849 let f = PasswordField;
3850 let stored = f
3851 .to_attr(
3852 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3853 &none(),
3854 Mode::Create,
3855 )
3856 .unwrap()
3857 .unwrap();
3858 let hash = stored.as_str().unwrap();
3859 assert!(
3860 hash.starts_with("$argon2"),
3861 "stored as a hash, not plaintext"
3862 );
3863 assert_ne!(hash, "hunter2hunter2");
3864 assert_eq!(f.to_control(Some(hash), &none()), "");
3866 }
3867
3868 #[test]
3869 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3870 let f = PasswordField;
3871 assert_eq!(
3873 f.to_attr(
3874 &SubmittedField::new("f", &one(Some(""))),
3875 &none(),
3876 Mode::Update
3877 )
3878 .unwrap(),
3879 None
3880 );
3881 assert_eq!(
3882 f.to_attr(
3883 &SubmittedField::new("f", &one(Some(" "))),
3884 &none(),
3885 Mode::Update
3886 )
3887 .unwrap(),
3888 None
3889 );
3890 assert_eq!(
3891 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3892 .unwrap(),
3893 None
3894 );
3895 }
3896
3897 #[test]
3898 fn rich_text_is_cleaned_on_save_and_blank_when_empty() {
3899 let f = RichEditorField;
3900 let dirty = one(Some(
3901 r#"<p onclick="x()">Hi</p><script>1</script><img src="/a.png" data-media="4">"#,
3902 ));
3903 assert_eq!(
3904 f.to_attr(&SubmittedField::new("f", &dirty), &none(), Mode::Create)
3905 .unwrap(),
3906 Some(AttrValue::Text(
3907 r#"<p>Hi</p><img src="/a.png" data-media="4">"#.into()
3908 ))
3909 );
3910 assert_eq!(
3911 f.linked_media(Some(r#"<img data-media="4">"#), &none()),
3912 Some(vec![4])
3913 );
3914 let blank = one(Some("<p></p>"));
3916 assert_eq!(
3917 f.submitted(&SubmittedField::new("f", &blank), &none()),
3918 Some(String::new())
3919 );
3920 assert_eq!(
3921 f.to_attr(&SubmittedField::new("f", &blank), &none(), Mode::Create)
3922 .unwrap(),
3923 Some(AttrValue::Text(String::new()))
3924 );
3925 }
3926
3927 #[test]
3928 fn a_plain_field_stores_and_shows_the_text_unchanged() {
3929 let f = TextareaField;
3930 assert_eq!(
3931 f.to_attr(
3932 &SubmittedField::new("f", &one(Some("hello"))),
3933 &none(),
3934 Mode::Create
3935 )
3936 .unwrap(),
3937 Some(AttrValue::Text("hello".into()))
3938 );
3939 assert_eq!(
3941 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3942 .unwrap(),
3943 None
3944 );
3945 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
3946 }
3947}