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(SensitiveField::new(Arc::new(
1502 laterite_core::Crypt::ephemeral(),
1503 ))),
1504 Arc::new(RadioField),
1505 Arc::new(crate::medialib::field::MediaField),
1506 ]
1507}
1508
1509pub(crate) fn builtin_registry() -> FieldRegistry {
1511 builtin_field_types()
1512 .into_iter()
1513 .map(|ft| (ft.view_key().to_string(), ft))
1514 .collect()
1515}
1516
1517#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1519#[serde(rename_all = "snake_case")]
1520pub enum Placement {
1521 Leading,
1522 Trailing,
1523}
1524
1525#[derive(Debug, Clone, Serialize, Deserialize)]
1529pub struct Adornment {
1530 pub placement: Placement,
1531 pub button: bool,
1533 pub label: String,
1535 pub widget: Option<String>,
1538}
1539
1540pub trait InputType: Send + Sync + 'static {
1546 fn key(&self) -> &'static str;
1548 fn html_type(&self) -> &'static str {
1550 "text"
1551 }
1552 fn resolve_options(
1555 &self,
1556 _raw: &serde_json::Value,
1557 _types: &FieldRegistry,
1558 ) -> Result<ResolvedOptions, OptionsError> {
1559 Ok(ResolvedOptions::none())
1560 }
1561 fn option_keys(&self) -> Vec<&'static str> {
1564 Vec::new()
1565 }
1566 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1568 Vec::new()
1569 }
1570 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1573 BTreeMap::new()
1574 }
1575 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
1577 Vec::new()
1578 }
1579 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1582 Vec::new()
1583 }
1584}
1585
1586pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
1588
1589struct TextInput;
1590impl InputType for TextInput {
1591 fn key(&self) -> &'static str {
1592 "text"
1593 }
1594}
1595
1596struct EmailInput;
1597impl InputType for EmailInput {
1598 fn key(&self) -> &'static str {
1599 "email"
1600 }
1601 fn html_type(&self) -> &'static str {
1602 "email"
1603 }
1604 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1605 vec![Rule::Email]
1606 }
1607}
1608
1609struct TelInput;
1610impl InputType for TelInput {
1611 fn key(&self) -> &'static str {
1612 "tel"
1613 }
1614 fn html_type(&self) -> &'static str {
1615 "tel"
1616 }
1617}
1618
1619#[derive(Default, Deserialize)]
1621struct NumberOptions {
1622 #[serde(default)]
1623 min: Option<f64>,
1624 #[serde(default)]
1625 max: Option<f64>,
1626 #[serde(default)]
1627 step: Option<f64>,
1628}
1629
1630struct NumberInput;
1631impl InputType for NumberInput {
1632 fn key(&self) -> &'static str {
1633 "number"
1634 }
1635 fn html_type(&self) -> &'static str {
1636 "number"
1637 }
1638 fn option_keys(&self) -> Vec<&'static str> {
1639 vec!["min", "max", "step"]
1640 }
1641 fn resolve_options(
1642 &self,
1643 raw: &serde_json::Value,
1644 _types: &FieldRegistry,
1645 ) -> Result<ResolvedOptions, OptionsError> {
1646 let opts: NumberOptions = if raw.is_null() {
1647 NumberOptions::default()
1648 } else {
1649 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1650 };
1651 Ok(ResolvedOptions::new(opts))
1652 }
1653 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1654 vec![Rule::Numeric]
1655 }
1656 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
1657 let mut attrs = BTreeMap::new();
1658 if let Some(o) = opts.get::<NumberOptions>() {
1659 if let Some(min) = o.min {
1660 attrs.insert("min".to_string(), min.to_string());
1661 }
1662 if let Some(max) = o.max {
1663 attrs.insert("max".to_string(), max.to_string());
1664 }
1665 if let Some(step) = o.step {
1666 attrs.insert("step".to_string(), step.to_string());
1667 }
1668 }
1669 attrs
1670 }
1671}
1672
1673#[derive(Deserialize)]
1675struct UrlOptions {
1676 #[serde(default = "default_true")]
1677 copy: bool,
1678}
1679
1680impl Default for UrlOptions {
1681 fn default() -> Self {
1682 Self { copy: true }
1683 }
1684}
1685
1686fn default_true() -> bool {
1687 true
1688}
1689
1690struct UrlInput;
1691impl InputType for UrlInput {
1692 fn key(&self) -> &'static str {
1693 "url"
1694 }
1695 fn html_type(&self) -> &'static str {
1696 "url"
1697 }
1698 fn option_keys(&self) -> Vec<&'static str> {
1699 vec!["copy"]
1700 }
1701 fn resolve_options(
1702 &self,
1703 raw: &serde_json::Value,
1704 _types: &FieldRegistry,
1705 ) -> Result<ResolvedOptions, OptionsError> {
1706 let opts: UrlOptions = if raw.is_null() {
1707 UrlOptions::default()
1708 } else {
1709 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1710 };
1711 Ok(ResolvedOptions::new(opts))
1712 }
1713 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1714 vec![Rule::Url]
1715 }
1716 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
1717 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
1718 vec![Adornment {
1719 placement: Placement::Trailing,
1720 button: true,
1721 label: "Copy".to_string(),
1722 widget: Some("copy".to_string()),
1723 }]
1724 } else {
1725 Vec::new()
1726 }
1727 }
1728}
1729
1730struct ColorInput;
1732impl InputType for ColorInput {
1733 fn key(&self) -> &'static str {
1734 "color"
1735 }
1736 fn html_type(&self) -> &'static str {
1737 "color"
1738 }
1739}
1740
1741pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
1742 vec![
1743 Arc::new(TextInput),
1744 Arc::new(EmailInput),
1745 Arc::new(TelInput),
1746 Arc::new(NumberInput),
1747 Arc::new(UrlInput),
1748 Arc::new(ColorInput),
1749 ]
1750}
1751
1752pub(crate) fn builtin_input_registry() -> InputRegistry {
1754 builtin_input_types()
1755 .into_iter()
1756 .map(|i| (i.key().to_string(), i))
1757 .collect()
1758}
1759
1760const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1763
1764fn validate_contributions(
1769 attrs: &BTreeMap<String, String>,
1770 adornments: &[Adornment],
1771) -> Result<(), OptionsError> {
1772 for name in attrs.keys() {
1773 if !is_name(name, false) {
1774 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1775 }
1776 if RESERVED_ATTRS.contains(&name.as_str()) {
1777 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1778 }
1779 }
1780 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1781 if !is_name(widget, true) {
1782 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1783 }
1784 }
1785 Ok(())
1786}
1787
1788pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1790 let mut chars = s.chars();
1791 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1792 && chars.all(|c| {
1793 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1794 })
1795}
1796
1797#[derive(Deserialize)]
1799struct TextOptions {
1800 #[serde(default = "default_input")]
1801 input: String,
1802 #[serde(default)]
1804 placeholder: Option<String>,
1805 #[serde(default)]
1807 counter: crate::checklist::Offer,
1808}
1809
1810fn default_input() -> String {
1811 "text".to_string()
1812}
1813
1814struct TextResolved {
1817 html_type: &'static str,
1818 attrs: BTreeMap<String, String>,
1819 adornments: Vec<Adornment>,
1820 rules: Vec<Rule>,
1821 assets: Vec<&'static str>,
1822 counter: crate::checklist::Offer,
1823}
1824
1825fn length_attrs(
1828 attrs: &mut BTreeMap<String, String>,
1829 max_length: Option<usize>,
1830 counter: crate::checklist::Offer,
1831) {
1832 use crate::checklist::Offer;
1833 let Some(max) = max_length else {
1834 return;
1835 };
1836 attrs.insert("maxlength".to_string(), max.to_string());
1837 match counter {
1838 Offer::Auto => attrs.insert("data-lat-counter".to_string(), "auto".to_string()),
1839 Offer::Always => attrs.insert("data-lat-counter".to_string(), "on".to_string()),
1840 Offer::Never => None,
1841 };
1842}
1843
1844#[derive(Default, Serialize, Deserialize)]
1847struct TextData {
1848 input_type: String,
1849 #[serde(default)]
1850 attrs: BTreeMap<String, String>,
1851 #[serde(default)]
1852 adornments: Vec<Adornment>,
1853}
1854
1855#[derive(Template)]
1856#[template(path = "fields/text.html")]
1857struct TextTmpl<'a> {
1858 name: &'a str,
1859 id: &'a str,
1860 value: &'a str,
1861 required: bool,
1862 input_type: &'a str,
1863 attrs: &'a BTreeMap<String, String>,
1864 leading: &'a [Adornment],
1865 trailing: &'a [Adornment],
1866}
1867
1868pub(crate) struct TextField {
1872 inputs: Arc<InputRegistry>,
1873}
1874
1875impl TextField {
1876 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1877 Self { inputs }
1878 }
1879}
1880
1881impl FieldType for TextField {
1882 fn view_key(&self) -> &'static str {
1883 "text"
1884 }
1885 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1886 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1887 let input = self.inputs.get(input)?;
1890 let mut all = vec![
1891 "input".to_string(),
1892 "placeholder".to_string(),
1893 "counter".to_string(),
1894 ];
1895 all.extend(input.option_keys().into_iter().map(str::to_string));
1896 Some(all)
1897 }
1898 fn resolve_options(
1899 &self,
1900 raw: &serde_json::Value,
1901 _types: &FieldRegistry,
1902 ) -> Result<ResolvedOptions, OptionsError> {
1903 let opts: TextOptions = if raw.is_null() {
1904 TextOptions {
1905 input: default_input(),
1906 placeholder: None,
1907 counter: Default::default(),
1908 }
1909 } else {
1910 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1911 };
1912 let input = self
1913 .inputs
1914 .get(&opts.input)
1915 .cloned()
1916 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1917 let input_opts = input.resolve_options(raw, _types)?;
1920 let mut attrs = input.attributes(&input_opts);
1921 let adornments = input.adornments(&input_opts);
1922 validate_contributions(&attrs, &adornments)?;
1923 if let Some(placeholder) = opts.placeholder {
1924 attrs.insert("placeholder".to_string(), placeholder);
1925 }
1926 Ok(ResolvedOptions::new(TextResolved {
1927 html_type: input.html_type(),
1928 rules: input.rules(&input_opts),
1929 assets: input.assets(&input_opts),
1930 attrs,
1931 adornments,
1932 counter: opts.counter,
1933 }))
1934 }
1935 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1936 opts.get::<TextResolved>()
1937 .map(|r| r.rules.clone())
1938 .unwrap_or_default()
1939 }
1940 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1941 opts.get::<TextResolved>()
1942 .map(|r| r.assets.clone())
1943 .unwrap_or_default()
1944 }
1945 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1946 let mut data = match cx.opts.get::<TextResolved>() {
1947 Some(r) => TextData {
1948 input_type: r.html_type.to_string(),
1949 attrs: r.attrs.clone(),
1950 adornments: r.adornments.clone(),
1951 },
1952 None => TextData {
1953 input_type: "text".to_string(),
1954 ..TextData::default()
1955 },
1956 };
1957 if matches!(
1959 data.input_type.as_str(),
1960 "text" | "email" | "tel" | "url" | "search"
1961 ) {
1962 let counter = cx
1963 .opts
1964 .get::<TextResolved>()
1965 .map(|r| r.counter)
1966 .unwrap_or_default();
1967 length_attrs(&mut data.attrs, cx.max_length, counter);
1968 }
1969 FieldVm {
1970 view_key: "text".to_string(),
1971 name: cx.name.to_string(),
1972 id: cx.id.to_string(),
1973 label: cx.label.to_string(),
1974 required: cx.required,
1975 value: cx.value.clone(),
1976 data: serde_json::to_value(data).unwrap_or_default(),
1977 }
1978 }
1979 fn render_default(&self, vm: &FieldVm) -> Markup {
1980 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1981 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1982 .adornments
1983 .into_iter()
1984 .partition(|a| matches!(a.placement, Placement::Leading));
1985 Markup::from_template(&TextTmpl {
1986 name: &vm.name,
1987 id: &vm.id,
1988 value: vm.value.as_text(),
1989 required: vm.required,
1990 input_type: &data.input_type,
1991 attrs: &data.attrs,
1992 leading: &leading,
1993 trailing: &trailing,
1994 })
1995 .unwrap_or_default()
1996 }
1997}
1998
1999#[derive(Template)]
2000#[template(path = "fields/textarea.html")]
2001struct TextareaTmpl<'a> {
2002 name: &'a str,
2003 id: &'a str,
2004 value: &'a str,
2005 required: bool,
2006 rows: Option<u16>,
2007 placeholder: Option<&'a str>,
2008 grow: bool,
2009 attrs: &'a BTreeMap<String, String>,
2010}
2011
2012fn yes() -> bool {
2013 true
2014}
2015
2016#[derive(Debug, Clone, Serialize, Deserialize)]
2019struct TextareaOptions {
2020 #[serde(default)]
2021 rows: Option<u16>,
2022 #[serde(default)]
2023 placeholder: Option<String>,
2024 #[serde(default = "yes")]
2025 grow: bool,
2026 #[serde(default)]
2027 counter: crate::checklist::Offer,
2028}
2029
2030impl Default for TextareaOptions {
2031 fn default() -> Self {
2032 Self {
2033 rows: None,
2034 placeholder: None,
2035 grow: true,
2036 counter: Default::default(),
2037 }
2038 }
2039}
2040
2041#[derive(Debug, Clone, Serialize, Deserialize)]
2043struct TextareaData {
2044 #[serde(default)]
2045 rows: Option<u16>,
2046 #[serde(default)]
2047 placeholder: Option<String>,
2048 #[serde(default = "yes")]
2049 grow: bool,
2050 #[serde(default)]
2052 attrs: BTreeMap<String, String>,
2053}
2054
2055impl Default for TextareaData {
2056 fn default() -> Self {
2057 Self {
2058 rows: None,
2059 placeholder: None,
2060 grow: true,
2061 attrs: BTreeMap::new(),
2062 }
2063 }
2064}
2065
2066pub(crate) struct TextareaField;
2068
2069impl FieldType for TextareaField {
2070 fn view_key(&self) -> &'static str {
2071 "textarea"
2072 }
2073 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2074 keys(&["rows", "placeholder", "grow", "counter"])
2075 }
2076 fn resolve_options(
2077 &self,
2078 raw: &serde_json::Value,
2079 _types: &FieldRegistry,
2080 ) -> Result<ResolvedOptions, OptionsError> {
2081 let opts: TextareaOptions = if raw.is_null() {
2082 TextareaOptions::default()
2083 } else {
2084 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2085 };
2086 Ok(ResolvedOptions::new(opts))
2087 }
2088 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2089 let mut vm = scalar_vm("textarea", cx);
2090 let opts = cx
2091 .opts
2092 .get::<TextareaOptions>()
2093 .cloned()
2094 .unwrap_or_default();
2095 let mut data = TextareaData {
2096 rows: opts.rows,
2097 placeholder: opts.placeholder,
2098 grow: opts.grow,
2099 attrs: BTreeMap::new(),
2100 };
2101 length_attrs(&mut data.attrs, cx.max_length, opts.counter);
2102 vm.data = serde_json::to_value(data).unwrap_or_default();
2103 vm
2104 }
2105 fn render_default(&self, vm: &FieldVm) -> Markup {
2106 let data: TextareaData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2107 Markup::from_template(&TextareaTmpl {
2108 name: &vm.name,
2109 id: &vm.id,
2110 value: vm.value.as_text(),
2111 required: vm.required,
2112 rows: data.rows,
2113 placeholder: data.placeholder.as_deref(),
2114 grow: data.grow,
2115 attrs: &data.attrs,
2116 })
2117 .unwrap_or_default()
2118 }
2119}
2120
2121pub const SELECT_SEARCH_PAST: usize = 10;
2123
2124#[derive(Debug, Default, Deserialize)]
2127struct SelectOptions {
2128 #[serde(default)]
2129 options: Vec<SelectOption>,
2130 #[serde(default)]
2131 search: crate::checklist::Offer,
2132}
2133
2134impl SelectOptions {
2135 fn searchable(&self) -> bool {
2136 use crate::checklist::Offer;
2137 match self.search {
2138 Offer::Auto => self.options.len() > SELECT_SEARCH_PAST,
2139 Offer::Always => true,
2140 Offer::Never => false,
2141 }
2142 }
2143}
2144
2145#[derive(Debug, Default, Serialize, Deserialize)]
2147struct SelectData {
2148 options: Vec<OptionView>,
2149 #[serde(default)]
2150 search: bool,
2151}
2152
2153#[derive(Debug, Deserialize)]
2154struct SelectOption {
2155 value: String,
2156 #[serde(default)]
2158 label: Option<String>,
2159}
2160
2161#[derive(Debug, Serialize, Deserialize)]
2164struct OptionView {
2165 value: String,
2166 label: String,
2167 selected: bool,
2168}
2169
2170#[derive(Template)]
2171#[template(path = "fields/select.html")]
2172struct SelectTmpl<'a> {
2173 name: &'a str,
2174 id: &'a str,
2175 required: bool,
2176 options: &'a [OptionView],
2177 search: bool,
2178}
2179
2180pub(crate) struct SelectField;
2182
2183impl FieldType for SelectField {
2184 fn view_key(&self) -> &'static str {
2185 "select"
2186 }
2187 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2188 keys(&["options", "search"])
2189 }
2190 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
2193 match opts.get::<SelectOptions>() {
2194 Some(o) if o.searchable() => vec!["fields/ref-picker.js", "fields/ref-picker.css"],
2195 _ => Vec::new(),
2196 }
2197 }
2198 fn resolve_options(
2199 &self,
2200 raw: &serde_json::Value,
2201 _types: &FieldRegistry,
2202 ) -> Result<ResolvedOptions, OptionsError> {
2203 let opts: SelectOptions =
2204 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2205 Ok(ResolvedOptions::new(opts))
2206 }
2207 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2208 let current = cx.value.as_text();
2209 let opts = cx.opts.get::<SelectOptions>();
2210 let data = SelectData {
2211 options: opts
2212 .map(|o| {
2213 o.options
2214 .iter()
2215 .map(|so| OptionView {
2216 value: so.value.clone(),
2217 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2218 selected: so.value == current,
2219 })
2220 .collect()
2221 })
2222 .unwrap_or_default(),
2223 search: opts.is_some_and(SelectOptions::searchable),
2224 };
2225 FieldVm {
2226 view_key: "select".to_string(),
2227 name: cx.name.to_string(),
2228 id: cx.id.to_string(),
2229 label: cx.label.to_string(),
2230 required: cx.required,
2231 value: cx.value.clone(),
2232 data: serde_json::to_value(&data).unwrap_or_default(),
2233 }
2234 }
2235 fn render_default(&self, vm: &FieldVm) -> Markup {
2236 let data: SelectData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2237 Markup::from_template(&SelectTmpl {
2238 name: &vm.name,
2239 id: &vm.id,
2240 required: vm.required,
2241 options: &data.options,
2242 search: data.search,
2243 })
2244 .unwrap_or_default()
2245 }
2246}
2247
2248#[derive(Debug, Default, Deserialize)]
2250struct ChecklistOptions {
2251 #[serde(default)]
2252 options: Vec<ChecklistEntry>,
2253 #[serde(default)]
2254 select_all: crate::checklist::Offer,
2255 #[serde(default)]
2256 search: crate::checklist::Offer,
2257 #[serde(default)]
2258 expand: crate::checklist::Expand,
2259}
2260
2261#[derive(Debug, Deserialize)]
2263#[serde(untagged)]
2264enum ChecklistEntry {
2265 Group {
2266 group: String,
2267 #[serde(default)]
2268 options: Vec<ChecklistEntry>,
2269 },
2270 Choice {
2271 value: String,
2272 #[serde(default)]
2274 label: Option<String>,
2275 #[serde(default)]
2277 note: Option<String>,
2278 },
2279}
2280
2281impl ChecklistOptions {
2282 fn values(&self) -> Vec<&str> {
2284 fn walk<'a>(entries: &'a [ChecklistEntry], out: &mut Vec<&'a str>) {
2285 for entry in entries {
2286 match entry {
2287 ChecklistEntry::Choice { value, .. } => out.push(value),
2288 ChecklistEntry::Group { options, .. } => walk(options, out),
2289 }
2290 }
2291 }
2292 let mut out = Vec::new();
2293 walk(&self.options, &mut out);
2294 out
2295 }
2296
2297 fn ticked<'a>(&'a self, submitted: &[&str]) -> Vec<&'a str> {
2300 self.values()
2301 .into_iter()
2302 .filter(|value| submitted.contains(value))
2303 .collect()
2304 }
2305}
2306
2307fn checklist_values(text: &str) -> Vec<String> {
2309 serde_json::from_str(text).unwrap_or_default()
2310}
2311
2312pub(crate) struct ChecklistField;
2317
2318impl ChecklistField {
2319 fn gathered(field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Vec<String> {
2320 let submitted: Vec<&str> = field.nested().map(|(_, value)| value).collect();
2321 match opts.get::<ChecklistOptions>() {
2322 Some(options) => options
2323 .ticked(&submitted)
2324 .into_iter()
2325 .map(str::to_string)
2326 .collect(),
2327 None => Vec::new(),
2328 }
2329 }
2330}
2331
2332impl FieldType for ChecklistField {
2333 fn view_key(&self) -> &'static str {
2334 "checklist"
2335 }
2336 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2337 keys(&["options", "select_all", "search", "expand"])
2338 }
2339 fn resolve_options(
2340 &self,
2341 raw: &serde_json::Value,
2342 _types: &FieldRegistry,
2343 ) -> Result<ResolvedOptions, OptionsError> {
2344 let opts: ChecklistOptions = if raw.is_null() {
2345 ChecklistOptions::default()
2346 } else {
2347 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2348 };
2349 let mut seen = std::collections::HashSet::new();
2350 for value in opts.values() {
2351 if !seen.insert(value) {
2352 return Err(OptionsError(format!("option `{value}` is listed twice")));
2353 }
2354 }
2355 Ok(ResolvedOptions::new(opts))
2356 }
2357
2358 fn to_attr(
2359 &self,
2360 field: &SubmittedField<'_>,
2361 opts: &ResolvedOptions,
2362 _mode: Mode,
2363 ) -> Result<Option<AttrValue>, String> {
2364 let values = Self::gathered(field, opts)
2367 .into_iter()
2368 .map(serde_json::Value::String)
2369 .collect();
2370 Ok(Some(AttrValue::Json(serde_json::Value::Array(values))))
2371 }
2372
2373 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2374 stored.unwrap_or("[]").to_string()
2375 }
2376
2377 fn submitted(&self, field: &SubmittedField<'_>, opts: &ResolvedOptions) -> Option<String> {
2378 let values = Self::gathered(field, opts);
2379 if values.is_empty() {
2380 return None;
2381 }
2382 serde_json::to_string(&values).ok()
2383 }
2384
2385 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2386 use crate::checklist::{Checklist, Choice, Entry, Group};
2387
2388 let ticked: Vec<String> = match cx.value {
2389 FieldValue::Json(serde_json::Value::Array(values)) => values
2390 .iter()
2391 .filter_map(|v| v.as_str().map(str::to_string))
2392 .collect(),
2393 FieldValue::Text(text) => checklist_values(text),
2394 _ => Vec::new(),
2395 };
2396
2397 fn build(
2398 entries: &[ChecklistEntry],
2399 name: &str,
2400 ticked: &[String],
2401 position: &mut usize,
2402 ) -> Vec<Entry> {
2403 entries
2404 .iter()
2405 .map(|entry| match entry {
2406 ChecklistEntry::Choice { value, label, note } => {
2407 let mut choice = Choice::new(
2408 format!("{name}[{position}]"),
2409 value.clone(),
2410 label.clone().unwrap_or_else(|| value.clone()),
2411 )
2412 .checked(ticked.contains(value));
2413 if let Some(note) = note {
2414 choice = choice.note(note.clone());
2415 }
2416 *position += 1;
2417 Entry::from(choice)
2418 }
2419 ChecklistEntry::Group { group, options } => Entry::from(Group::new(
2420 group.clone(),
2421 build(options, name, ticked, position),
2422 )),
2423 })
2424 .collect()
2425 }
2426
2427 let mut list = Checklist::new(cx.label).id(cx.id);
2428 if let Some(options) = cx.opts.get::<ChecklistOptions>() {
2429 let mut position = 0;
2430 for entry in build(&options.options, cx.name, &ticked, &mut position) {
2431 list = list.entry(entry);
2432 }
2433 list = list
2434 .select_all(options.select_all)
2435 .search(options.search)
2436 .expand(options.expand);
2437 }
2438 FieldVm {
2439 view_key: "checklist".to_string(),
2440 name: cx.name.to_string(),
2441 id: cx.id.to_string(),
2442 label: cx.label.to_string(),
2443 required: cx.required,
2444 value: cx.value.clone(),
2445 data: serde_json::to_value(list.view(cx.i18n)).unwrap_or_default(),
2446 }
2447 }
2448
2449 fn render_default(&self, vm: &FieldVm) -> Markup {
2450 serde_json::from_value::<crate::checklist::ChecklistView>(vm.data.clone())
2451 .map(|view| view.render_markup())
2452 .unwrap_or_default()
2453 }
2454}
2455
2456#[derive(Template)]
2457#[template(path = "fields/switch.html")]
2458struct SwitchTmpl<'a> {
2459 name: &'a str,
2460 id: &'a str,
2461 on: bool,
2462}
2463
2464pub(crate) struct SwitchField;
2470
2471fn stored_is_on(stored: Option<&str>) -> bool {
2474 matches!(
2475 stored.map(str::trim).unwrap_or_default(),
2476 "1" | "true" | "TRUE" | "t" | "yes" | "on"
2477 )
2478}
2479
2480impl FieldType for SwitchField {
2481 fn view_key(&self) -> &'static str {
2482 "switch"
2483 }
2484 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2485 keys(&[])
2486 }
2487
2488 fn to_attr(
2489 &self,
2490 field: &SubmittedField<'_>,
2491 _opts: &ResolvedOptions,
2492 _mode: Mode,
2493 ) -> Result<Option<AttrValue>, String> {
2494 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
2497 }
2498
2499 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2500 if stored_is_on(stored) {
2501 "1".to_string()
2502 } else {
2503 String::new()
2504 }
2505 }
2506
2507 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2508 scalar_vm("switch", cx)
2509 }
2510
2511 fn render_default(&self, vm: &FieldVm) -> Markup {
2512 Markup::from_template(&SwitchTmpl {
2513 name: &vm.name,
2514 id: &vm.id,
2515 on: stored_is_on(Some(vm.value.as_text())),
2516 })
2517 .unwrap_or_default()
2518 }
2519}
2520
2521#[derive(Template)]
2522#[template(path = "fields/date.html")]
2523struct DateTmpl<'a> {
2524 name: &'a str,
2525 id: &'a str,
2526 value: &'a str,
2527 required: bool,
2528}
2529
2530pub(crate) struct DateField;
2536
2537impl FieldType for DateField {
2538 fn view_key(&self) -> &'static str {
2539 "date"
2540 }
2541 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2542 keys(&[])
2543 }
2544
2545 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
2546 vec![Rule::Date]
2547 }
2548
2549 fn to_attr(
2550 &self,
2551 field: &SubmittedField<'_>,
2552 _opts: &ResolvedOptions,
2553 _mode: Mode,
2554 ) -> Result<Option<AttrValue>, String> {
2555 Ok(field.value().map(|v| match v.trim() {
2556 "" => AttrValue::Null,
2557 date => AttrValue::Text(date.to_string()),
2558 }))
2559 }
2560
2561 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2562 let stored = stored.unwrap_or_default().trim();
2563 stored
2566 .split(['T', ' '])
2567 .next()
2568 .unwrap_or_default()
2569 .to_string()
2570 }
2571
2572 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2573 scalar_vm("date", cx)
2574 }
2575
2576 fn render_default(&self, vm: &FieldVm) -> Markup {
2577 Markup::from_template(&DateTmpl {
2578 name: &vm.name,
2579 id: &vm.id,
2580 value: vm.value.as_text(),
2581 required: vm.required,
2582 })
2583 .unwrap_or_default()
2584 }
2585}
2586
2587#[derive(Template)]
2588#[template(path = "fields/password.html")]
2589struct PasswordTmpl<'a> {
2590 name: &'a str,
2591 id: &'a str,
2592 required: bool,
2593 reveal: bool,
2594 show: &'a str,
2595}
2596
2597#[derive(Debug, Clone, Serialize, Deserialize)]
2599struct PasswordOptions {
2600 #[serde(default = "yes")]
2601 reveal: bool,
2602 #[serde(default)]
2604 show: String,
2605}
2606
2607impl Default for PasswordOptions {
2608 fn default() -> Self {
2609 Self {
2610 reveal: true,
2611 show: String::new(),
2612 }
2613 }
2614}
2615
2616pub(crate) struct PasswordField;
2624
2625impl FieldType for PasswordField {
2626 fn view_key(&self) -> &'static str {
2627 "password"
2628 }
2629 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2630 keys(&["reveal"])
2631 }
2632 fn resolve_options(
2633 &self,
2634 raw: &serde_json::Value,
2635 _types: &FieldRegistry,
2636 ) -> Result<ResolvedOptions, OptionsError> {
2637 let opts: PasswordOptions = if raw.is_null() {
2638 PasswordOptions::default()
2639 } else {
2640 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
2641 };
2642 Ok(ResolvedOptions::new(opts))
2643 }
2644
2645 fn to_attr(
2646 &self,
2647 field: &SubmittedField<'_>,
2648 _opts: &ResolvedOptions,
2649 _mode: Mode,
2650 ) -> Result<Option<AttrValue>, String> {
2651 match field.value().map(str::trim).unwrap_or_default() {
2652 "" => Ok(None),
2654 plain => laterite_auth::password::hash_password(plain)
2655 .map(|hash| Some(AttrValue::Text(hash)))
2656 .map_err(|e| e.to_string()),
2657 }
2658 }
2659
2660 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2663 String::new()
2664 }
2665
2666 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2667 let mut vm = scalar_vm("password", cx);
2668 let mut opts = cx
2669 .opts
2670 .get::<PasswordOptions>()
2671 .cloned()
2672 .unwrap_or_default();
2673 opts.show = cx.i18n.t(&laterite_core::t!("Show password"));
2674 vm.data = serde_json::to_value(opts).unwrap_or_default();
2675 vm
2676 }
2677
2678 fn render_default(&self, vm: &FieldVm) -> Markup {
2679 let opts: PasswordOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2680 Markup::from_template(&PasswordTmpl {
2681 name: &vm.name,
2682 id: &vm.id,
2683 required: vm.required,
2684 reveal: opts.reveal,
2685 show: &opts.show,
2686 })
2687 .unwrap_or_default()
2688 }
2689}
2690
2691#[derive(Template)]
2692#[template(path = "fields/sensitive.html")]
2693struct SensitiveTmpl<'a> {
2694 name: &'a str,
2695 id: &'a str,
2696 value: &'a str,
2697 required: bool,
2698 has_value: bool,
2699 reveal: &'a str,
2700 failed: &'a str,
2701}
2702
2703pub(crate) struct SensitiveField {
2708 crypt: Arc<laterite_core::Crypt>,
2709}
2710
2711impl SensitiveField {
2712 pub(crate) fn new(crypt: Arc<laterite_core::Crypt>) -> Self {
2713 Self { crypt }
2714 }
2715
2716 pub(crate) fn open(crypt: &laterite_core::Crypt, field: &str, sealed: &str) -> Option<String> {
2718 if sealed.is_empty() {
2719 return Some(String::new());
2720 }
2721 crypt
2722 .decrypt(
2723 laterite_core::crypt::FIELD_PURPOSE,
2724 field.as_bytes(),
2725 sealed,
2726 )
2727 .ok()
2728 .and_then(|bytes| String::from_utf8(bytes).ok())
2729 }
2730}
2731
2732impl FieldType for SensitiveField {
2733 fn view_key(&self) -> &'static str {
2734 "sensitive"
2735 }
2736 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2737 keys(&[])
2738 }
2739
2740 fn to_attr(
2741 &self,
2742 field: &SubmittedField<'_>,
2743 _opts: &ResolvedOptions,
2744 _mode: Mode,
2745 ) -> Result<Option<AttrValue>, String> {
2746 match field.value().map(str::trim).unwrap_or_default() {
2747 "" | laterite_core::crypt::MASK => Ok(None),
2749 plain => self
2750 .crypt
2751 .encrypt(
2752 laterite_core::crypt::FIELD_PURPOSE,
2753 field.name().as_bytes(),
2754 plain.as_bytes(),
2755 )
2756 .map(|sealed| Some(AttrValue::Text(sealed)))
2757 .map_err(|e| e.to_string()),
2758 }
2759 }
2760
2761 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
2763 match stored {
2764 Some(s) if !s.is_empty() => laterite_core::crypt::MASK.to_string(),
2765 _ => String::new(),
2766 }
2767 }
2768
2769 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2770 let mut vm = scalar_vm("sensitive", cx);
2771 vm.data = serde_json::json!({
2772 "has_value": cx.value.as_text() == laterite_core::crypt::MASK,
2773 "reveal": cx.i18n.t(&laterite_core::t!("Reveal")),
2774 "failed": cx.i18n.t(&laterite_core::t!("Could not reveal the value.")),
2775 });
2776 vm
2777 }
2778
2779 fn render_default(&self, vm: &FieldVm) -> Markup {
2780 let has_value = vm.data["has_value"].as_bool().unwrap_or(false);
2781 let reveal = vm.data["reveal"].as_str().unwrap_or("Reveal");
2782 let failed = vm.data["failed"].as_str().unwrap_or_default();
2783 Markup::from_template(&SensitiveTmpl {
2784 name: &vm.name,
2785 id: &vm.id,
2786 value: vm.value.as_text(),
2787 required: vm.required,
2788 has_value,
2789 reveal,
2790 failed,
2791 })
2792 .unwrap_or_default()
2793 }
2794}
2795
2796#[derive(Template)]
2797#[template(path = "fields/radio.html")]
2798struct RadioTmpl<'a> {
2799 name: &'a str,
2800 options: &'a [OptionView],
2801 required: bool,
2802}
2803
2804pub(crate) struct RadioField;
2809
2810impl FieldType for RadioField {
2811 fn view_key(&self) -> &'static str {
2812 "radio"
2813 }
2814 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2815 keys(&["options"])
2816 }
2817
2818 fn resolve_options(
2819 &self,
2820 raw: &serde_json::Value,
2821 _types: &FieldRegistry,
2822 ) -> Result<ResolvedOptions, OptionsError> {
2823 let opts: SelectOptions =
2824 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
2825 Ok(ResolvedOptions::new(opts))
2826 }
2827
2828 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2829 let current = cx.value.as_text();
2830 let views: Vec<OptionView> = cx
2831 .opts
2832 .get::<SelectOptions>()
2833 .map(|o| {
2834 o.options
2835 .iter()
2836 .map(|so| OptionView {
2837 value: so.value.clone(),
2838 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
2839 selected: so.value == current,
2840 })
2841 .collect()
2842 })
2843 .unwrap_or_default();
2844 FieldVm {
2845 view_key: "radio".to_string(),
2846 name: cx.name.to_string(),
2847 id: cx.id.to_string(),
2848 label: cx.label.to_string(),
2849 required: cx.required,
2850 value: cx.value.clone(),
2851 data: serde_json::to_value(&views).unwrap_or_default(),
2852 }
2853 }
2854
2855 fn render_default(&self, vm: &FieldVm) -> Markup {
2856 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
2857 Markup::from_template(&RadioTmpl {
2858 name: &vm.name,
2859 options: &options,
2860 required: vm.required,
2861 })
2862 .unwrap_or_default()
2863 }
2864}
2865
2866fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
2868 FieldVm {
2869 view_key: view_key.to_string(),
2870 name: cx.name.to_string(),
2871 id: cx.id.to_string(),
2872 label: cx.label.to_string(),
2873 required: cx.required,
2874 value: cx.value.clone(),
2875 data: serde_json::Value::Null,
2876 }
2877}
2878
2879#[derive(Deserialize)]
2881struct RefOptions {
2882 source: String,
2883}
2884
2885struct RefResolved {
2887 source: String,
2888}
2889
2890#[derive(Default, Serialize, Deserialize)]
2893struct RefData {
2894 source: String,
2895 search_url: String,
2896 resolve_url: String,
2897 value: String,
2898 placeholder: String,
2899}
2900
2901#[derive(Template)]
2902#[template(path = "fields/ref_picker.html")]
2903struct RefPickerTmpl<'a> {
2904 name: &'a str,
2905 id: &'a str,
2906 value: &'a str,
2907 search_url: &'a str,
2908 resolve_url: &'a str,
2909 placeholder: &'a str,
2910}
2911
2912pub(crate) struct RefPickerField {
2917 pickers: Arc<PickerRegistry>,
2918}
2919
2920impl RefPickerField {
2921 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
2922 Self { pickers }
2923 }
2924}
2925
2926pub(crate) struct IdField;
2931
2932impl FieldType for IdField {
2933 fn view_key(&self) -> &'static str {
2934 "id"
2935 }
2936 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
2937 keys(&[])
2938 }
2939 fn resolve_options(
2940 &self,
2941 _raw: &serde_json::Value,
2942 _types: &FieldRegistry,
2943 ) -> Result<ResolvedOptions, OptionsError> {
2944 Ok(ResolvedOptions::new(()))
2945 }
2946 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2947 FieldVm {
2948 view_key: "id".to_string(),
2949 name: cx.name.to_string(),
2950 id: cx.id.to_string(),
2951 label: cx.label.to_string(),
2952 required: cx.required,
2953 value: cx.value.clone(),
2954 data: serde_json::Value::Null,
2955 }
2956 }
2957 fn render_default(&self, vm: &FieldVm) -> Markup {
2958 Markup::from_override(format!(
2959 r#"<input type="hidden" name="{}" value="{}">"#,
2960 crate::form::escape_attr(&vm.name),
2961 crate::form::escape_attr(vm.value.as_text())
2962 ))
2963 }
2964 fn to_attr(
2965 &self,
2966 field: &SubmittedField<'_>,
2967 _opts: &ResolvedOptions,
2968 _mode: Mode,
2969 ) -> Result<Option<AttrValue>, String> {
2970 Ok(field.value().map(id_attr))
2971 }
2972}
2973
2974fn id_attr(value: &str) -> AttrValue {
2977 let value = value.trim();
2978 if value.is_empty() {
2979 AttrValue::Null
2980 } else if let Ok(id) = value.parse::<i64>() {
2981 AttrValue::Int(id)
2982 } else {
2983 AttrValue::Text(value.to_string())
2984 }
2985}
2986
2987impl FieldType for RefPickerField {
2988 fn to_attr(
2991 &self,
2992 field: &SubmittedField<'_>,
2993 _opts: &ResolvedOptions,
2994 _mode: Mode,
2995 ) -> Result<Option<AttrValue>, String> {
2996 Ok(field.value().map(id_attr))
2997 }
2998 fn view_key(&self) -> &'static str {
2999 "reference"
3000 }
3001 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
3002 keys(&["source"])
3003 }
3004 fn resolve_options(
3005 &self,
3006 raw: &serde_json::Value,
3007 _types: &FieldRegistry,
3008 ) -> Result<ResolvedOptions, OptionsError> {
3009 let opts: RefOptions =
3010 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
3011 if !self.pickers.contains_key(&opts.source) {
3012 return Err(OptionsError(format!(
3013 "unknown picker source `{}`",
3014 opts.source
3015 )));
3016 }
3017 Ok(ResolvedOptions::new(RefResolved {
3018 source: opts.source,
3019 }))
3020 }
3021 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
3022 let source = cx
3023 .opts
3024 .get::<RefResolved>()
3025 .map(|r| r.source.as_str())
3026 .unwrap_or_default();
3027 let data = RefData {
3028 search_url: format!("{}/pickers/{}/search", cx.base, source),
3029 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
3030 source: source.to_string(),
3031 value: cx.value.as_text().to_string(),
3032 placeholder: "Search…".to_string(),
3033 };
3034 FieldVm {
3035 view_key: "reference".to_string(),
3036 name: cx.name.to_string(),
3037 id: cx.id.to_string(),
3038 label: cx.label.to_string(),
3039 required: cx.required,
3040 value: cx.value.clone(),
3041 data: serde_json::to_value(data).unwrap_or_default(),
3042 }
3043 }
3044 fn render_default(&self, vm: &FieldVm) -> Markup {
3045 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
3046 Markup::from_template(&RefPickerTmpl {
3047 name: &vm.name,
3048 id: &vm.id,
3049 value: vm.value.as_text(),
3050 search_url: &data.search_url,
3051 resolve_url: &data.resolve_url,
3052 placeholder: &data.placeholder,
3053 })
3054 .unwrap_or_default()
3055 }
3056 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
3057 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
3058 }
3059}
3060
3061#[cfg(test)]
3064fn test_icons() -> crate::icons::Icons<'static> {
3065 crate::icons::Icons::new(
3066 Box::leak(Box::new(laterite_core::icons::IconSet::new())),
3067 "/admin/assets/icons.svg",
3068 )
3069}
3070
3071#[cfg(test)]
3072mod tests {
3073 use super::*;
3074
3075 #[test]
3076 fn an_id_is_stored_as_a_number_or_null() {
3077 assert!(matches!(id_attr("7"), AttrValue::Int(7)));
3078 assert!(matches!(id_attr(" 12 "), AttrValue::Int(12)));
3079 assert!(matches!(id_attr(""), AttrValue::Null));
3080 assert!(matches!(id_attr("x1"), AttrValue::Text(t) if t == "x1"));
3081 }
3082
3083 fn untranslated() -> Translator {
3086 Translator::new("en")
3087 }
3088
3089 fn cx_with<'a>(
3090 name: &'a str,
3091 value: &'a FieldValue,
3092 opts: &'a ResolvedOptions,
3093 i18n: &'a Translator,
3094 ) -> FieldCx<'a> {
3095 FieldCx {
3096 name,
3097 id: name,
3098 label: "Label",
3099 value,
3100 required: true,
3101 max_length: None,
3102 opts,
3103 base: "/admin",
3104 i18n,
3105 icons: test_icons(),
3106 }
3107 }
3108
3109 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
3110 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
3113 }
3114
3115 fn text_field() -> TextField {
3116 TextField::new(Arc::new(builtin_input_registry()))
3117 }
3118
3119 struct BadAttrInput;
3121 impl InputType for BadAttrInput {
3122 fn key(&self) -> &'static str {
3123 "bad"
3124 }
3125 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
3126 BTreeMap::from([("class".to_string(), "x".to_string())])
3127 }
3128 }
3129
3130 #[test]
3131 fn text_renders_its_value_escaped() {
3132 let opts = ResolvedOptions::none();
3133 let value = FieldValue::Text("a<b>&\"c".to_string());
3134 let markup = render_field(
3135 &text_field(),
3136 &NoOverrides,
3137 &scope(),
3138 &cx("title", &value, &opts),
3139 );
3140 let html = markup.as_str();
3141 assert!(html.contains(r#"name="title""#));
3142 assert!(html.contains("required"));
3143 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
3147 assert!(
3148 html.contains("<") || html.contains("<"),
3149 "angle escaped: {html}"
3150 );
3151 assert!(
3152 html.contains("&") || html.contains("&"),
3153 "ampersand escaped: {html}"
3154 );
3155 }
3156
3157 #[test]
3158 fn textarea_uses_its_own_control() {
3159 let opts = ResolvedOptions::none();
3160 let value = FieldValue::Text("body".to_string());
3161 let markup = render_field(
3162 &TextareaField,
3163 &NoOverrides,
3164 &scope(),
3165 &cx("body", &value, &opts),
3166 );
3167 assert!(markup.as_str().contains("<textarea"));
3168 }
3169
3170 #[test]
3171 fn code_editor_wraps_a_textarea_in_its_island_and_declares_the_engine() {
3172 let types = FieldRegistry::default();
3173 let opts = CodeEditorField
3174 .resolve_options(
3175 &serde_json::json!({ "language": "jinja", "rows": 20 }),
3176 &types,
3177 )
3178 .unwrap();
3179 let value = FieldValue::Text("{{ page.title }}".to_string());
3180 let markup = render_field(
3181 &CodeEditorField,
3182 &NoOverrides,
3183 &scope(),
3184 &cx("markup", &value, &opts),
3185 );
3186 let html = markup.as_str();
3187 assert!(html.contains(r#"data-lat-widget="codeeditor""#), "{html}");
3188 assert!(html.contains(r#"data-language="jinja""#), "{html}");
3189 assert!(html.contains(r#"rows="20""#), "{html}");
3190 assert!(html.contains("<textarea"), "{html}");
3191 assert_eq!(
3192 CodeEditorField.assets(&opts),
3193 crate::CODE_EDITOR_ASSETS.to_vec()
3194 );
3195 assert!(CodeEditorField
3197 .resolve_options(&serde_json::json!({ "language": "cobol" }), &types)
3198 .is_err());
3199 }
3200
3201 #[test]
3202 fn rich_editor_renders_its_toolbar_and_cleans_what_it_stores() {
3203 let types = FieldRegistry::default();
3204 let opts = RichEditorField
3205 .resolve_options(
3206 &serde_json::json!({ "toolbar": ["bold", "|", "link"] }),
3207 &types,
3208 )
3209 .unwrap();
3210 let value = FieldValue::Text("<p>Hi</p>".to_string());
3211 let markup = render_field(
3212 &RichEditorField,
3213 &NoOverrides,
3214 &scope(),
3215 &cx("body", &value, &opts),
3216 );
3217 let html = markup.as_str();
3218 assert!(html.contains(r#"data-lat-widget="richeditor""#), "{html}");
3219 assert!(html.contains(r#"data-rich-cmd="bold""#), "{html}");
3220 assert!(html.contains(r#"data-rich-cmd="link""#), "{html}");
3221 assert!(!html.contains(r#"data-rich-cmd="image""#), "{html}");
3222 assert!(html.contains("icons.svg#lat-bold"), "{html}");
3223 assert!(html.contains("<textarea"), "{html}");
3225 assert!(!html.contains("<p>Hi</p>"), "{html}");
3226 assert!(RichEditorField
3227 .resolve_options(&serde_json::json!({ "toolbar": ["blink"] }), &types)
3228 .is_err());
3229 }
3230
3231 #[test]
3232 fn markdown_renders_its_tabs_and_offers_no_preview_when_told() {
3233 let types = FieldRegistry::default();
3234 let value = FieldValue::Text("# Hi".to_string());
3235 let opts = MarkdownField
3236 .resolve_options(&serde_json::Value::Null, &types)
3237 .unwrap();
3238 let html = render_field(
3239 &MarkdownField,
3240 &NoOverrides,
3241 &scope(),
3242 &cx("body", &value, &opts),
3243 );
3244 let html = html.as_str();
3245 assert!(html.contains(r#"data-lat-widget="markdown""#), "{html}");
3246 assert!(
3247 html.contains(r#"data-preview="/admin/markdown/preview""#),
3248 "{html}"
3249 );
3250 assert!(html.contains(r#"data-md-tab="preview""#), "{html}");
3251 assert!(html.contains("icons.svg#lat-heading"), "{html}");
3252 assert_eq!(MarkdownField.assets(&opts), crate::MARKDOWN_ASSETS.to_vec());
3253 let opts = MarkdownField
3254 .resolve_options(&serde_json::json!({ "preview": false }), &types)
3255 .unwrap();
3256 let html = render_field(
3257 &MarkdownField,
3258 &NoOverrides,
3259 &scope(),
3260 &cx("body", &value, &opts),
3261 );
3262 assert!(!html.as_str().contains("data-md-tab"), "{}", html.as_str());
3263 }
3264
3265 #[test]
3266 fn no_overrides_uses_the_compiled_default() {
3267 let opts = ResolvedOptions::none();
3269 let value = FieldValue::Text("x".to_string());
3270 let ft = text_field();
3271 let vm = ft.view_model(&cx("t", &value, &opts));
3272 let direct = ft.render_default(&vm);
3273 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
3274 assert_eq!(direct.as_str(), routed.as_str());
3275 }
3276
3277 #[test]
3278 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
3279 let field = text_field();
3280 let opts = field
3281 .resolve_options(
3282 &serde_json::json!({ "input": "email" }),
3283 &builtin_registry(),
3284 )
3285 .unwrap();
3286 assert!(matches!(
3288 field.intrinsic_rules(&opts).as_slice(),
3289 [Rule::Email]
3290 ));
3291 let value = FieldValue::Text("a@b.test".to_string());
3292 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
3293 assert!(markup.as_str().contains(r#"type="email""#));
3294 }
3295
3296 #[test]
3297 fn text_default_input_is_plain_text_with_no_extra_rule() {
3298 let field = text_field();
3299 let opts = field
3300 .resolve_options(&serde_json::Value::Null, &builtin_registry())
3301 .unwrap();
3302 assert!(field.intrinsic_rules(&opts).is_empty());
3303 let value = FieldValue::Text("hi".to_string());
3304 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
3305 assert!(markup.as_str().contains(r#"type="text""#));
3306 }
3307
3308 #[test]
3309 fn text_number_input_sets_the_type_and_numeric_rule() {
3310 let field = text_field();
3311 let opts = field
3312 .resolve_options(
3313 &serde_json::json!({ "input": "number" }),
3314 &builtin_registry(),
3315 )
3316 .unwrap();
3317 assert!(matches!(
3318 field.intrinsic_rules(&opts).as_slice(),
3319 [Rule::Numeric]
3320 ));
3321 let value = FieldValue::Text("42".to_string());
3322 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3323 assert!(markup.as_str().contains(r#"type="number""#));
3324 }
3325
3326 #[test]
3327 fn text_tel_input_sets_the_type_with_no_rule() {
3328 let field = text_field();
3329 let opts = field
3330 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
3331 .unwrap();
3332 assert!(field.intrinsic_rules(&opts).is_empty());
3333 let value = FieldValue::Text(String::new());
3334 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
3335 assert!(markup.as_str().contains(r#"type="tel""#));
3336 }
3337
3338 #[test]
3339 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
3340 let field = text_field();
3341 let opts = field
3342 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
3343 .unwrap();
3344 assert!(matches!(
3345 field.intrinsic_rules(&opts).as_slice(),
3346 [Rule::Url]
3347 ));
3348 let value = FieldValue::Text("https://example.com".to_string());
3349 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3350 let html = markup.as_str();
3351 assert!(html.contains(r#"type="url""#), "{html}");
3352 assert!(html.contains("lat-input-group"), "{html}");
3353 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
3354 }
3355
3356 #[test]
3357 fn text_url_input_copy_false_omits_the_button() {
3358 let field = text_field();
3359 let opts = field
3360 .resolve_options(
3361 &serde_json::json!({ "input": "url", "copy": false }),
3362 &builtin_registry(),
3363 )
3364 .unwrap();
3365 let value = FieldValue::Text("https://example.com".to_string());
3366 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
3367 let html = markup.as_str();
3368 assert!(html.contains(r#"type="url""#), "{html}");
3369 assert!(!html.contains("lat-input-group"), "{html}");
3371 assert!(!html.contains("data-lat-widget"), "{html}");
3372 }
3373
3374 #[test]
3375 fn text_number_input_emits_min_max_step_attributes() {
3376 let field = text_field();
3377 let opts = field
3378 .resolve_options(
3379 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
3380 &builtin_registry(),
3381 )
3382 .unwrap();
3383 let value = FieldValue::Text("5".to_string());
3386 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
3387 let html = markup.as_str();
3388 assert!(html.contains(r#"min="0""#), "{html}");
3389 assert!(html.contains(r#"max="10""#), "{html}");
3390 assert!(html.contains(r#"step="2""#), "{html}");
3391 }
3392
3393 #[test]
3394 fn text_input_reserved_attribute_name_aborts_resolve() {
3395 let mut inputs = builtin_input_registry();
3396 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
3397 let field = TextField::new(Arc::new(inputs));
3398 let Err(e) =
3399 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
3400 else {
3401 panic!("expected a reserved-attribute rejection");
3402 };
3403 assert!(e.0.contains("class"), "{e}");
3404 }
3405
3406 #[test]
3407 fn attribute_and_widget_name_rules() {
3408 assert!(is_name("min", false));
3409 assert!(is_name("data-x", false));
3410 assert!(!is_name("Min", false));
3411 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
3414 }
3415
3416 #[test]
3417 fn select_renders_options_with_the_current_value_selected() {
3418 let raw = serde_json::json!({
3419 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3420 });
3421 let opts = SelectField
3422 .resolve_options(&raw, &builtin_registry())
3423 .unwrap();
3424 let value = FieldValue::Text("closed".to_string());
3425 let markup = render_field(
3426 &SelectField,
3427 &NoOverrides,
3428 &scope(),
3429 &cx("status", &value, &opts),
3430 );
3431 let html = markup.as_str();
3432 assert!(html.contains(r#"<option value="open">Open</option>"#));
3433 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
3435 }
3436
3437 #[test]
3438 fn radio_renders_the_same_options_with_the_current_one_checked() {
3439 let raw = serde_json::json!({
3440 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
3441 });
3442 let opts = RadioField
3443 .resolve_options(&raw, &builtin_registry())
3444 .unwrap();
3445 let value = FieldValue::Text("closed".to_string());
3446 let markup = render_field(
3447 &RadioField,
3448 &NoOverrides,
3449 &scope(),
3450 &cx("status", &value, &opts),
3451 );
3452 let html = markup.as_str();
3453 assert!(html.contains(r#"value="open""#));
3454 assert!(
3455 html.contains(r#"value="closed" checked"#),
3456 "current is checked"
3457 );
3458 assert_eq!(html.matches(r#"name="status""#).count(), 2);
3461 assert!(html.contains("closed</label>"));
3463 }
3464
3465 fn scope<'a>() -> OverrideScope<'a> {
3466 OverrideScope {
3467 surface: Surface::Field,
3468 view_key: "text",
3469 resource: None,
3470 field: None,
3471 }
3472 }
3473
3474 fn ref_field() -> RefPickerField {
3475 let mut pickers = crate::picker::PickerRegistry::new();
3476 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
3477 pickers.insert(
3478 "acme.place".to_string(),
3479 crate::picker::PickerSourceReg::new("acme.place", source),
3480 );
3481 RefPickerField::new(Arc::new(pickers))
3482 }
3483
3484 #[test]
3485 fn reference_rejects_an_unknown_source() {
3486 let field = ref_field();
3487 let Err(e) = field.resolve_options(
3488 &serde_json::json!({ "source": "no.such" }),
3489 &builtin_registry(),
3490 ) else {
3491 panic!("expected an unknown-source rejection");
3492 };
3493 assert!(e.0.contains("no.such"), "{e}");
3494 }
3495
3496 #[test]
3497 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
3498 let field = ref_field();
3499 let opts = field
3500 .resolve_options(
3501 &serde_json::json!({ "source": "acme.place" }),
3502 &builtin_registry(),
3503 )
3504 .unwrap();
3505 let value = FieldValue::Text("42".to_string());
3506 let markup = render_field(
3507 &field,
3508 &NoOverrides,
3509 &scope(),
3510 &cx("place_id", &value, &opts),
3511 );
3512 let html = markup.as_str();
3513 assert!(html.contains(r#"type="hidden""#), "{html}");
3516 assert!(html.contains(r#"name="place_id""#), "{html}");
3517 assert!(html.contains(r#"value="42""#), "{html}");
3518 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
3520 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
3521 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
3522 }
3523
3524 #[test]
3525 fn reference_declares_its_widget_assets() {
3526 let field = ref_field();
3527 let opts = field
3528 .resolve_options(
3529 &serde_json::json!({ "source": "acme.place" }),
3530 &builtin_registry(),
3531 )
3532 .unwrap();
3533 assert_eq!(
3534 field.assets(&opts),
3535 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
3536 );
3537 }
3538}
3539
3540#[cfg(test)]
3541mod repeater_tests {
3542 use super::*;
3543
3544 fn options() -> ResolvedOptions {
3545 RepeaterField
3546 .resolve_options(
3547 &serde_json::json!({
3548 "fields": [
3549 { "name": "path", "label": "Path", "type": "text" },
3550 { "name": "allow", "label": "Allow", "type": "switch" },
3551 ]
3552 }),
3553 &builtin_registry(),
3554 )
3555 .unwrap()
3556 }
3557
3558 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3559 pairs
3560 .iter()
3561 .map(|(k, v)| (k.to_string(), v.to_string()))
3562 .collect()
3563 }
3564
3565 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
3566 let field = SubmittedField::new("rules", data);
3567 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
3568 Some(AttrValue::Json(value)) => value,
3569 other => panic!("expected a JSON array, got {other:?}"),
3570 }
3571 }
3572
3573 #[test]
3577 fn a_row_is_typed_by_its_sub_field_types() {
3578 let rows = stored(
3579 &submitted(&[
3580 ("rules[0][path]", "/private"),
3581 ("rules[1][path]", "/public"),
3582 ("rules[1][allow]", "on"),
3583 ]),
3584 &options(),
3585 );
3586
3587 assert_eq!(rows[0]["path"], "/private");
3588 assert_eq!(rows[0]["allow"], serde_json::json!(false));
3589 assert_eq!(rows[1]["allow"], serde_json::json!(true));
3590 }
3591
3592 #[test]
3595 fn rows_are_ordered_by_index_not_submission_order() {
3596 let rows = stored(
3597 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
3598 &options(),
3599 );
3600 assert_eq!(rows.as_array().unwrap().len(), 2);
3601 assert_eq!(rows[0]["path"], "/first");
3602 assert_eq!(rows[1]["path"], "/last");
3603 }
3604
3605 #[test]
3606 fn a_row_left_blank_is_dropped() {
3607 let rows = stored(
3608 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
3609 &options(),
3610 );
3611 assert_eq!(rows.as_array().unwrap().len(), 1);
3612 }
3613
3614 #[test]
3615 fn nothing_submitted_stores_an_empty_list() {
3616 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
3617 }
3618
3619 #[test]
3620 fn a_repeater_refuses_to_hold_a_repeater() {
3621 let err = RepeaterField
3622 .resolve_options(
3623 &serde_json::json!({
3624 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
3625 }),
3626 &builtin_registry(),
3627 )
3628 .err()
3629 .expect("a nested repeater is refused");
3630 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
3631 }
3632
3633 #[test]
3634 fn an_unregistered_sub_field_type_is_refused_at_boot() {
3635 let err = RepeaterField
3636 .resolve_options(
3637 &serde_json::json!({
3638 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
3639 }),
3640 &builtin_registry(),
3641 )
3642 .err()
3643 .expect("an unregistered type is refused");
3644 assert!(err.0.contains("acme.nope"), "{}", err.0);
3645 }
3646
3647 #[test]
3648 fn row_counts_are_bounded_when_asked() {
3649 let opts = RepeaterField
3650 .resolve_options(
3651 &serde_json::json!({
3652 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
3653 "min_items": 1,
3654 "max_items": 2
3655 }),
3656 &builtin_registry(),
3657 )
3658 .unwrap();
3659
3660 let empty = HashMap::new();
3661 let too_few = SubmittedField::new("rules", &empty);
3662 assert!(RepeaterField
3663 .to_attr(&too_few, &opts, Mode::Create)
3664 .is_err());
3665
3666 let three = submitted(&[
3667 ("rules[0][path]", "/a"),
3668 ("rules[1][path]", "/b"),
3669 ("rules[2][path]", "/c"),
3670 ]);
3671 let field = SubmittedField::new("rules", &three);
3672 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
3673 }
3674
3675 #[test]
3676 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
3677 let value = FieldValue::Json(serde_json::json!([
3678 { "path": "/private", "allow": false },
3679 ]));
3680 let cx = FieldCx {
3681 name: "rules",
3682 id: "rules",
3683 label: "Rules",
3684 value: &value,
3685 required: false,
3686 max_length: None,
3687 opts: &options(),
3688 base: "/admin",
3689 i18n: &Translator::new("en"),
3690 icons: test_icons(),
3691 };
3692 let html = RepeaterField
3693 .render_default(&RepeaterField.view_model(&cx))
3694 .into_string();
3695
3696 assert!(html.contains(r#"name="rules[0][path]""#));
3697 assert!(html.contains(r#"value="/private""#));
3698 assert!(html.contains("lat-repeater__blank"));
3700 assert!(html.contains(r#"name="rules[__index__][path]""#));
3701 }
3702
3703 fn list_options() -> ResolvedOptions {
3705 RepeaterField
3706 .resolve_options(
3707 &serde_json::json!({
3708 "fields": [
3709 { "name": "agent", "label": "Agent", "type": "text" },
3710 { "name": "disallow", "label": "Disallow", "type": "textarea" },
3711 ],
3712 "display": "list",
3713 "summary_field": "agent",
3714 }),
3715 &builtin_registry(),
3716 )
3717 .unwrap()
3718 }
3719
3720 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
3721 let cx = FieldCx {
3722 name: "rules",
3723 id: "rules",
3724 label: "Rules",
3725 value,
3726 required: false,
3727 max_length: None,
3728 opts,
3729 base: "/admin",
3730 i18n: &Translator::new("en"),
3731 icons: test_icons(),
3732 };
3733 RepeaterField
3734 .render_default(&RepeaterField.view_model(&cx))
3735 .into_string()
3736 }
3737
3738 #[test]
3739 fn a_list_repeater_collapses_its_rows_and_names_them() {
3740 let value = FieldValue::Json(serde_json::json!([
3741 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
3742 ]));
3743 let html = render_with(&list_options(), &value);
3744 assert!(html.contains("lat-repeater--list"), "list mode is declared");
3745 assert!(html.contains("<details"), "a row collapses");
3746 assert!(
3747 html.contains("Googlebot"),
3748 "the naming field titles the row"
3749 );
3750 assert!(html.contains(r#"name="rules[0][disallow]""#));
3752 }
3753
3754 #[test]
3755 fn a_row_with_no_name_yet_reads_as_untitled() {
3756 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
3757 let html = render_with(&list_options(), &value);
3758 assert!(html.contains("lat-repeater__untitled"));
3759 }
3760
3761 #[test]
3762 fn the_default_layout_stays_inline() {
3763 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
3765 let html = render_with(&options(), &value);
3766 assert!(!html.contains("lat-repeater--list"));
3767 assert!(!html.contains("<details"));
3768 }
3769
3770 #[test]
3771 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
3772 assert_eq!(
3774 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
3775 "/private"
3776 );
3777 assert_eq!(summary_text(&serde_json::json!("")), "");
3778 }
3779
3780 #[test]
3787 fn a_contributed_field_type_names_itself_and_converts() {
3788 struct Money;
3789 impl FieldType for Money {
3790 fn view_key(&self) -> &'static str {
3791 "acme.money"
3792 }
3793 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
3794 scalar_vm("acme.money", cx)
3795 }
3796 fn render_default(&self, _vm: &FieldVm) -> Markup {
3797 Markup::empty()
3798 }
3799 fn to_attr(
3800 &self,
3801 field: &SubmittedField<'_>,
3802 _opts: &ResolvedOptions,
3803 _mode: Mode,
3804 ) -> Result<Option<AttrValue>, String> {
3805 let raw = field.value().unwrap_or("0");
3807 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
3808 Ok(Some(AttrValue::Int(minor as i64)))
3809 }
3810 }
3811
3812 let reg = FieldTypeReg::new(Arc::new(Money));
3813 assert_eq!(reg.name(), "acme.money");
3816
3817 let mut registry = builtin_registry();
3818 let name = reg.name().to_string();
3819 assert!(
3820 registry.insert(name.clone(), reg.field_type).is_none(),
3821 "a dotted key does not collide with a built-in"
3822 );
3823
3824 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
3825 let submitted = SubmittedField::new("price", &data);
3826 let converted = registry[&name]
3827 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
3828 .unwrap();
3829 assert!(
3830 matches!(converted, Some(AttrValue::Int(1250))),
3831 "{converted:?}"
3832 );
3833 }
3834
3835 #[test]
3836 fn a_sub_field_label_is_localized_like_any_other() {
3837 use laterite_core::i18n::CatalogStore;
3838 use std::sync::Arc;
3839
3840 let po = concat!(
3841 "msgid \"\"\n",
3842 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
3843 "\n",
3844 "msgid \"Path\"\n",
3845 "msgstr \"Path-kn\"\n",
3846 );
3847 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
3848 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
3849
3850 let value = FieldValue::Text(String::new());
3851 let cx = FieldCx {
3852 name: "rules",
3853 id: "rules",
3854 label: "Rules",
3855 value: &value,
3856 required: false,
3857 max_length: None,
3858 opts: &options(),
3859 base: "/admin",
3860 i18n: &i18n,
3861 icons: test_icons(),
3862 };
3863 let html = RepeaterField
3864 .render_default(&RepeaterField.view_model(&cx))
3865 .into_string();
3866
3867 assert!(
3868 html.contains("Path-kn"),
3869 "sub-label was not localized: {html}"
3870 );
3871 assert!(
3872 !html.contains(">Path<"),
3873 "the source string still leaked: {html}"
3874 );
3875 }
3876}
3877
3878#[cfg(test)]
3879mod save_contract_tests {
3880 use super::*;
3881
3882 fn one(raw: Option<&str>) -> HashMap<String, String> {
3884 raw.into_iter()
3885 .map(|v| ("f".to_string(), v.to_string()))
3886 .collect()
3887 }
3888
3889 fn none() -> ResolvedOptions {
3890 ResolvedOptions::none()
3891 }
3892
3893 #[test]
3894 fn a_switch_stores_a_bool_and_absence_means_off() {
3895 let f = SwitchField;
3896 assert_eq!(
3899 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
3900 .unwrap(),
3901 Some(AttrValue::Bool(false))
3902 );
3903 assert_eq!(
3904 f.to_attr(
3905 &SubmittedField::new("f", &one(Some("on"))),
3906 &none(),
3907 Mode::Create
3908 )
3909 .unwrap(),
3910 Some(AttrValue::Bool(true))
3911 );
3912 }
3913
3914 #[test]
3915 fn a_switch_reads_back_whatever_the_backend_gave() {
3916 let f = SwitchField;
3917 for on in ["1", "true", "t", "on"] {
3919 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
3920 }
3921 for off in ["0", "false", "f", ""] {
3922 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
3923 }
3924 assert!(f.to_control(None, &none()).is_empty());
3925 }
3926
3927 #[test]
3928 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
3929 let f = DateField;
3930 assert_eq!(
3932 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
3933 "2026-09-09"
3934 );
3935 assert_eq!(
3936 f.to_control(Some("2026-09-09 12:30:45"), &none()),
3937 "2026-09-09"
3938 );
3939 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
3940 assert_eq!(f.to_control(None, &none()), "");
3941 }
3942
3943 #[test]
3944 fn a_cleared_date_stores_null_not_an_empty_string() {
3945 let f = DateField;
3946 assert_eq!(
3947 f.to_attr(
3948 &SubmittedField::new("f", &one(Some(""))),
3949 &none(),
3950 Mode::Update
3951 )
3952 .unwrap(),
3953 Some(AttrValue::Null)
3954 );
3955 assert_eq!(
3956 f.to_attr(
3957 &SubmittedField::new("f", &one(Some("2026-09-09"))),
3958 &none(),
3959 Mode::Create
3960 )
3961 .unwrap(),
3962 Some(AttrValue::Text("2026-09-09".into()))
3963 );
3964 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
3965 }
3966
3967 #[test]
3968 fn a_password_hashes_and_never_echoes() {
3969 let f = PasswordField;
3970 let stored = f
3971 .to_attr(
3972 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
3973 &none(),
3974 Mode::Create,
3975 )
3976 .unwrap()
3977 .unwrap();
3978 let hash = stored.as_str().unwrap();
3979 assert!(
3980 hash.starts_with("$argon2"),
3981 "stored as a hash, not plaintext"
3982 );
3983 assert_ne!(hash, "hunter2hunter2");
3984 assert_eq!(f.to_control(Some(hash), &none()), "");
3986 }
3987
3988 #[test]
3989 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
3990 let f = PasswordField;
3991 assert_eq!(
3993 f.to_attr(
3994 &SubmittedField::new("f", &one(Some(""))),
3995 &none(),
3996 Mode::Update
3997 )
3998 .unwrap(),
3999 None
4000 );
4001 assert_eq!(
4002 f.to_attr(
4003 &SubmittedField::new("f", &one(Some(" "))),
4004 &none(),
4005 Mode::Update
4006 )
4007 .unwrap(),
4008 None
4009 );
4010 assert_eq!(
4011 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
4012 .unwrap(),
4013 None
4014 );
4015 }
4016
4017 #[test]
4018 fn rich_text_is_cleaned_on_save_and_blank_when_empty() {
4019 let f = RichEditorField;
4020 let dirty = one(Some(
4021 r#"<p onclick="x()">Hi</p><script>1</script><img src="/a.png" data-media="4">"#,
4022 ));
4023 assert_eq!(
4024 f.to_attr(&SubmittedField::new("f", &dirty), &none(), Mode::Create)
4025 .unwrap(),
4026 Some(AttrValue::Text(
4027 r#"<p>Hi</p><img src="/a.png" data-media="4">"#.into()
4028 ))
4029 );
4030 assert_eq!(
4031 f.linked_media(Some(r#"<img data-media="4">"#), &none()),
4032 Some(vec![4])
4033 );
4034 let blank = one(Some("<p></p>"));
4036 assert_eq!(
4037 f.submitted(&SubmittedField::new("f", &blank), &none()),
4038 Some(String::new())
4039 );
4040 assert_eq!(
4041 f.to_attr(&SubmittedField::new("f", &blank), &none(), Mode::Create)
4042 .unwrap(),
4043 Some(AttrValue::Text(String::new()))
4044 );
4045 }
4046
4047 #[test]
4048 fn a_plain_field_stores_and_shows_the_text_unchanged() {
4049 let f = TextareaField;
4050 assert_eq!(
4051 f.to_attr(
4052 &SubmittedField::new("f", &one(Some("hello"))),
4053 &none(),
4054 Mode::Create
4055 )
4056 .unwrap(),
4057 Some(AttrValue::Text("hello".into()))
4058 );
4059 assert_eq!(
4061 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
4062 .unwrap(),
4063 None
4064 );
4065 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
4066 }
4067}