1use std::any::Any;
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use askama::Template;
16use laterite_core::validation::{Mode, Rule};
17use laterite_core::{AttrValue, Translator};
18use serde::{Deserialize, Serialize};
19
20use crate::html::Markup;
21use crate::picker::PickerRegistry;
22
23#[derive(Debug, Clone, Serialize)]
26#[serde(untagged)]
27pub enum FieldValue {
28 Null,
29 Text(String),
30 Json(serde_json::Value),
31}
32
33impl FieldValue {
34 pub fn as_text(&self) -> &str {
36 match self {
37 FieldValue::Text(s) => s,
38 _ => "",
39 }
40 }
41}
42
43#[derive(Default)]
47pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
48
49impl ResolvedOptions {
50 pub fn none() -> Self {
52 Self(None)
53 }
54 pub fn new<T: Any + Send + Sync>(value: T) -> Self {
56 Self(Some(Arc::new(value)))
57 }
58 pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
60 self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Chrome {
68 Wrapped,
69 Bare,
70}
71
72#[non_exhaustive]
74pub struct FieldCx<'a> {
75 pub name: &'a str,
77 pub id: &'a str,
79 pub label: &'a str,
80 pub value: &'a FieldValue,
81 pub required: bool,
83 pub opts: &'a ResolvedOptions,
84 pub base: &'a str,
87 pub i18n: &'a Translator,
95}
96
97#[derive(Debug, Clone, Serialize)]
100pub struct FieldVm {
101 pub view_key: String,
102 pub name: String,
103 pub id: String,
104 pub label: String,
105 pub required: bool,
106 pub value: FieldValue,
107 pub data: serde_json::Value,
108}
109
110pub struct SubmittedField<'a> {
118 name: &'a str,
119 data: &'a HashMap<String, String>,
120}
121
122impl<'a> SubmittedField<'a> {
123 pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
124 Self { name, data }
125 }
126
127 pub fn name(&self) -> &str {
130 self.name
131 }
132
133 pub fn value(&self) -> Option<&str> {
138 self.data.get(self.name).map(String::as_str)
139 }
140
141 pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
146 let prefix = format!("{}[", self.name);
147 self.data.iter().filter_map(move |(key, value)| {
148 key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
149 })
150 }
151
152 pub fn all(&self) -> &'a HashMap<String, String> {
155 self.data
156 }
157}
158
159pub trait FieldType: Send + Sync + 'static {
160 fn view_key(&self) -> &'static str;
163
164 fn resolve_options(
171 &self,
172 _raw: &serde_json::Value,
173 _types: &FieldRegistry,
174 ) -> Result<ResolvedOptions, OptionsError> {
175 Ok(ResolvedOptions::none())
176 }
177 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
183 None
184 }
185
186 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
189 Vec::new()
190 }
191
192 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
194
195 fn render_default(&self, vm: &FieldVm) -> Markup;
198
199 fn to_attr(
213 &self,
214 field: &SubmittedField<'_>,
215 opts: &ResolvedOptions,
216 mode: Mode,
217 ) -> Result<Option<AttrValue>, String> {
218 let _ = (opts, mode);
219 Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
220 }
221
222 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
231 let _ = opts;
232 stored.unwrap_or_default().to_string()
233 }
234
235 fn chrome(&self) -> Chrome {
237 Chrome::Wrapped
238 }
239
240 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
245 Vec::new()
246 }
247}
248
249#[derive(Debug, thiserror::Error)]
251#[error("invalid field options: {0}")]
252pub struct OptionsError(pub String);
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum Surface {
257 Field,
258 Column,
259}
260
261pub struct OverrideScope<'a> {
264 pub surface: Surface,
265 pub view_key: &'a str,
266 pub resource: Option<&'a str>,
267 pub field: Option<&'a str>,
268}
269
270#[derive(Debug, thiserror::Error)]
272#[error("override render failed: {0}")]
273pub struct OverrideError(pub String);
274
275pub trait OverrideResolver: Send + Sync {
279 fn render_override(
282 &self,
283 scope: &OverrideScope<'_>,
284 vm: &serde_json::Value,
285 ) -> Option<Result<String, OverrideError>>;
286}
287
288pub struct NoOverrides;
291
292impl OverrideResolver for NoOverrides {
293 fn render_override(
294 &self,
295 _scope: &OverrideScope<'_>,
296 _vm: &serde_json::Value,
297 ) -> Option<Result<String, OverrideError>> {
298 None
299 }
300}
301
302pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
304
305pub struct FieldTypeReg {
312 pub field_type: Arc<dyn FieldType>,
313}
314
315impl FieldTypeReg {
316 pub fn new(field_type: Arc<dyn FieldType>) -> Self {
317 Self { field_type }
318 }
319
320 pub fn name(&self) -> &'static str {
322 self.field_type.view_key()
323 }
324}
325
326pub(crate) fn render_field(
329 ft: &dyn FieldType,
330 resolver: &dyn OverrideResolver,
331 scope: &OverrideScope<'_>,
332 cx: &FieldCx<'_>,
333) -> Markup {
334 let vm = ft.view_model(cx);
335 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
336 Some(Ok(html)) => Markup::from_override(html),
337 Some(Err(_)) | None => ft.render_default(&vm),
339 }
340}
341
342pub(crate) struct RepeaterOptions {
345 rows: Vec<RepeaterSub>,
346 min_items: usize,
347 max_items: Option<usize>,
348 display: RepeaterDisplay,
349 summary_field: Option<String>,
351}
352
353#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
360#[serde(rename_all = "snake_case")]
361pub enum RepeaterDisplay {
362 #[default]
365 Inline,
366 List,
369}
370
371pub(crate) struct RepeaterSub {
373 field: crate::form::FormField,
374 field_type: Arc<dyn FieldType>,
375 opts: ResolvedOptions,
376}
377
378#[derive(Deserialize)]
380struct RepeaterOptionsRaw {
381 #[serde(deserialize_with = "named_or_listed")]
383 fields: Vec<crate::form::FormField>,
384 #[serde(default)]
385 min_items: usize,
386 #[serde(default)]
387 max_items: Option<usize>,
388 #[serde(default)]
389 display: RepeaterDisplay,
390 #[serde(default)]
391 summary_field: Option<String>,
392}
393
394pub(crate) fn check_option_keys(
396 raw: &serde_json::Value,
397 accepted: &[String],
398) -> Result<(), OptionsError> {
399 let serde_json::Value::Object(entries) = raw else {
400 return Ok(());
401 };
402 for key in entries.keys() {
403 if !accepted.iter().any(|k| k == key) {
404 return Err(OptionsError(if accepted.is_empty() {
405 format!("unknown key `{key}`: this type takes no keys of its own")
406 } else {
407 format!(
408 "unknown key `{key}`, expected one of: {}",
409 accepted.join(", ")
410 )
411 }));
412 }
413 }
414 Ok(())
415}
416
417pub(crate) fn check_field_keys(
420 field: &crate::form::FormField,
421 field_type: &dyn FieldType,
422) -> Result<(), OptionsError> {
423 match field_type.option_keys(&field.options) {
424 Some(keys) => check_option_keys(&field.options, &keys),
425 None => Ok(()),
426 }
427}
428
429fn keys(names: &[&str]) -> Option<Vec<String>> {
430 Some(names.iter().map(|k| k.to_string()).collect())
431}
432
433fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
436where
437 D: serde::Deserializer<'de>,
438{
439 use crate::keyed::Keyed;
440 #[derive(Deserialize)]
441 #[serde(untagged)]
442 enum Shape {
443 Listed(Vec<crate::form::FormField>),
444 Named(serde_json::Map<String, serde_json::Value>),
445 }
446 match Shape::deserialize(de)? {
447 Shape::Listed(fields) => Ok(fields),
448 Shape::Named(entries) => entries
449 .into_iter()
450 .map(|(name, entry)| {
451 let mut field: crate::form::FormField =
452 serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
453 field.set_key(name);
454 Ok(field)
455 })
456 .collect(),
457 }
458}
459
460pub(crate) struct RepeaterField;
469
470impl RepeaterField {
471 const BLANK: &'static str = "__index__";
473}
474
475impl FieldType for RepeaterField {
476 fn view_key(&self) -> &'static str {
477 "repeater"
478 }
479
480 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
481 keys(&[
482 "fields",
483 "min_items",
484 "max_items",
485 "display",
486 "summary_field",
487 ])
488 }
489
490 fn resolve_options(
491 &self,
492 raw: &serde_json::Value,
493 types: &FieldRegistry,
494 ) -> Result<ResolvedOptions, OptionsError> {
495 let parsed: RepeaterOptionsRaw =
496 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
497 let mut rows = Vec::new();
498 for field in parsed.fields {
499 if field.field_type == "repeater" {
500 return Err(OptionsError(
501 "a repeater cannot hold a repeater".to_string(),
502 ));
503 }
504 let field_type = types
505 .get(&field.field_type)
506 .cloned()
507 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
508 check_field_keys(&field, field_type.as_ref())
509 .map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
510 let opts = field_type.resolve_options(&field.options, types)?;
511 rows.push(RepeaterSub {
512 field,
513 field_type,
514 opts,
515 });
516 }
517 Ok(ResolvedOptions::new(RepeaterOptions {
518 rows,
519 min_items: parsed.min_items,
520 max_items: parsed.max_items,
521 display: parsed.display,
522 summary_field: parsed.summary_field,
523 }))
524 }
525
526 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
527 let opts = cx.opts.get::<RepeaterOptions>();
528 let stored = match cx.value {
529 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
530 _ => Vec::new(),
531 };
532 let summary_index = opts
535 .and_then(|o| {
536 o.summary_field
537 .as_ref()
538 .and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
539 })
540 .unwrap_or(0);
541 let summaries = stored
542 .iter()
543 .map(|row| {
544 opts.and_then(|o| o.rows.get(summary_index))
545 .and_then(|sub| row.get(&sub.field.name))
546 .map(summary_text)
547 .unwrap_or_default()
548 })
549 .collect();
550 let data = RepeaterData {
551 rows: opts
552 .map(|o| {
553 stored
554 .iter()
555 .enumerate()
556 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
557 .collect()
558 })
559 .unwrap_or_default(),
560 blank: opts
561 .map(|o| render_row(o, cx, Self::BLANK, None))
562 .unwrap_or_default(),
563 list: opts.is_some_and(|o| o.display == RepeaterDisplay::List),
564 summary_index,
565 summaries,
566 };
567
568 FieldVm {
569 view_key: self.view_key().to_string(),
570 name: cx.name.to_string(),
571 id: cx.id.to_string(),
572 label: cx.label.to_string(),
573 required: cx.required,
574 value: cx.value.clone(),
575 data: serde_json::to_value(data).unwrap_or_default(),
576 }
577 }
578
579 fn render_default(&self, vm: &FieldVm) -> Markup {
580 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
581 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
582 }
583
584 fn to_attr(
585 &self,
586 field: &SubmittedField<'_>,
587 opts: &ResolvedOptions,
588 mode: Mode,
589 ) -> Result<Option<AttrValue>, String> {
590 let Some(options) = opts.get::<RepeaterOptions>() else {
591 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
592 };
593
594 let mut indices: Vec<usize> = field
597 .nested()
598 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
599 .collect();
600 indices.sort_unstable();
601 indices.dedup();
602
603 let mut rows = Vec::new();
604 for index in indices {
605 let mut object = serde_json::Map::new();
606 for sub in &options.rows {
607 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
608 let scoped = SubmittedField::new(&key, field.all());
609 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
610 object.insert(sub.field.name.clone(), attr_to_json(value));
611 }
612 }
613 let row = serde_json::Value::Object(object);
614 if !is_blank_row(&row) {
616 rows.push(row);
617 }
618 }
619
620 if rows.len() < options.min_items {
621 return Err(format!("at least {} required", options.min_items));
622 }
623 if let Some(max) = options.max_items {
624 if rows.len() > max {
625 return Err(format!("at most {max} allowed"));
626 }
627 }
628 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
629 }
630
631 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
632 stored.unwrap_or("[]").to_string()
633 }
634}
635
636fn summary_text(value: &serde_json::Value) -> String {
640 let raw = match value {
641 serde_json::Value::String(s) => s.clone(),
642 serde_json::Value::Null => String::new(),
643 other => other.to_string(),
644 };
645 raw.lines().next().unwrap_or_default().trim().to_string()
646}
647
648fn render_row(
649 options: &RepeaterOptions,
650 cx: &FieldCx<'_>,
651 index: &str,
652 row: Option<&serde_json::Value>,
653) -> Vec<RepeaterCell> {
654 options
655 .rows
656 .iter()
657 .map(|sub| {
658 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
659 let stored = row.and_then(|r| r.get(&sub.field.name));
660 let value = match stored {
661 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
662 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
663 Some(other) => FieldValue::Text(
664 sub.field_type
665 .to_control(Some(&other.to_string()), &sub.opts),
666 ),
667 };
668 let sub_cx = FieldCx {
669 name: &name,
670 id: &name,
671 label: cx.label,
672 value: &value,
673 required: false,
674 opts: &sub.opts,
675 base: cx.base,
676 i18n: cx.i18n,
677 };
678 let vm = sub.field_type.view_model(&sub_cx);
679 RepeaterCell {
680 label: cx.i18n.t(&sub.field.label),
681 control: sub.field_type.render_default(&vm).into_string(),
682 help: sub
683 .field
684 .help
685 .as_ref()
686 .map(|h| cx.i18n.t(h))
687 .unwrap_or_default(),
688 }
689 })
690 .collect()
691}
692
693fn attr_to_json(value: AttrValue) -> serde_json::Value {
695 match value {
696 AttrValue::Null => serde_json::Value::Null,
697 AttrValue::Bool(b) => serde_json::Value::Bool(b),
698 AttrValue::Int(i) => serde_json::Value::from(i),
699 AttrValue::Float(f) => serde_json::Value::from(f),
700 AttrValue::Json(j) => j,
701 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
702 }
703}
704
705fn is_blank_row(row: &serde_json::Value) -> bool {
707 row.as_object().is_none_or(|object| {
708 object.values().all(|v| match v {
709 serde_json::Value::String(s) => s.trim().is_empty(),
710 serde_json::Value::Bool(b) => !b,
711 serde_json::Value::Array(items) => items.is_empty(),
712 serde_json::Value::Null => true,
713 _ => false,
714 })
715 })
716}
717
718#[derive(Serialize, Deserialize)]
720struct RepeaterCell {
721 label: String,
722 control: String,
723 help: String,
727}
728
729#[derive(Serialize, Deserialize, Default)]
732struct RepeaterData {
733 rows: Vec<Vec<RepeaterCell>>,
734 blank: Vec<RepeaterCell>,
735 list: bool,
737 summary_index: usize,
739 summaries: Vec<String>,
742}
743
744#[derive(Template)]
745#[template(path = "fields/repeater.html")]
746struct RepeaterTmpl {
747 data: RepeaterData,
748}
749
750pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
753 let inputs = Arc::new(builtin_input_registry());
754 vec![
755 Arc::new(TextField::new(inputs)),
756 Arc::new(TextareaField),
757 Arc::new(RepeaterField),
758 Arc::new(SelectField),
759 Arc::new(SwitchField),
760 Arc::new(DateField),
761 Arc::new(PasswordField),
762 Arc::new(RadioField),
763 ]
764}
765
766pub(crate) fn builtin_registry() -> FieldRegistry {
768 builtin_field_types()
769 .into_iter()
770 .map(|ft| (ft.view_key().to_string(), ft))
771 .collect()
772}
773
774#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
776#[serde(rename_all = "snake_case")]
777pub enum Placement {
778 Leading,
779 Trailing,
780}
781
782#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct Adornment {
787 pub placement: Placement,
788 pub button: bool,
790 pub label: String,
792 pub widget: Option<String>,
795}
796
797pub trait InputType: Send + Sync + 'static {
803 fn key(&self) -> &'static str;
805 fn html_type(&self) -> &'static str {
807 "text"
808 }
809 fn resolve_options(
812 &self,
813 _raw: &serde_json::Value,
814 _types: &FieldRegistry,
815 ) -> Result<ResolvedOptions, OptionsError> {
816 Ok(ResolvedOptions::none())
817 }
818 fn option_keys(&self) -> Vec<&'static str> {
821 Vec::new()
822 }
823 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
825 Vec::new()
826 }
827 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
830 BTreeMap::new()
831 }
832 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
834 Vec::new()
835 }
836 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
839 Vec::new()
840 }
841}
842
843pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
845
846struct TextInput;
847impl InputType for TextInput {
848 fn key(&self) -> &'static str {
849 "text"
850 }
851}
852
853struct EmailInput;
854impl InputType for EmailInput {
855 fn key(&self) -> &'static str {
856 "email"
857 }
858 fn html_type(&self) -> &'static str {
859 "email"
860 }
861 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
862 vec![Rule::Email]
863 }
864}
865
866struct TelInput;
867impl InputType for TelInput {
868 fn key(&self) -> &'static str {
869 "tel"
870 }
871 fn html_type(&self) -> &'static str {
872 "tel"
873 }
874}
875
876#[derive(Default, Deserialize)]
878struct NumberOptions {
879 #[serde(default)]
880 min: Option<f64>,
881 #[serde(default)]
882 max: Option<f64>,
883 #[serde(default)]
884 step: Option<f64>,
885}
886
887struct NumberInput;
888impl InputType for NumberInput {
889 fn key(&self) -> &'static str {
890 "number"
891 }
892 fn html_type(&self) -> &'static str {
893 "number"
894 }
895 fn option_keys(&self) -> Vec<&'static str> {
896 vec!["min", "max", "step"]
897 }
898 fn resolve_options(
899 &self,
900 raw: &serde_json::Value,
901 _types: &FieldRegistry,
902 ) -> Result<ResolvedOptions, OptionsError> {
903 let opts: NumberOptions = if raw.is_null() {
904 NumberOptions::default()
905 } else {
906 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
907 };
908 Ok(ResolvedOptions::new(opts))
909 }
910 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
911 vec![Rule::Numeric]
912 }
913 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
914 let mut attrs = BTreeMap::new();
915 if let Some(o) = opts.get::<NumberOptions>() {
916 if let Some(min) = o.min {
917 attrs.insert("min".to_string(), min.to_string());
918 }
919 if let Some(max) = o.max {
920 attrs.insert("max".to_string(), max.to_string());
921 }
922 if let Some(step) = o.step {
923 attrs.insert("step".to_string(), step.to_string());
924 }
925 }
926 attrs
927 }
928}
929
930#[derive(Deserialize)]
932struct UrlOptions {
933 #[serde(default = "default_true")]
934 copy: bool,
935}
936
937impl Default for UrlOptions {
938 fn default() -> Self {
939 Self { copy: true }
940 }
941}
942
943fn default_true() -> bool {
944 true
945}
946
947struct UrlInput;
948impl InputType for UrlInput {
949 fn key(&self) -> &'static str {
950 "url"
951 }
952 fn html_type(&self) -> &'static str {
953 "url"
954 }
955 fn option_keys(&self) -> Vec<&'static str> {
956 vec!["copy"]
957 }
958 fn resolve_options(
959 &self,
960 raw: &serde_json::Value,
961 _types: &FieldRegistry,
962 ) -> Result<ResolvedOptions, OptionsError> {
963 let opts: UrlOptions = if raw.is_null() {
964 UrlOptions::default()
965 } else {
966 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
967 };
968 Ok(ResolvedOptions::new(opts))
969 }
970 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
971 vec![Rule::Url]
972 }
973 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
974 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
975 vec![Adornment {
976 placement: Placement::Trailing,
977 button: true,
978 label: "Copy".to_string(),
979 widget: Some("copy".to_string()),
980 }]
981 } else {
982 Vec::new()
983 }
984 }
985}
986
987pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
988 vec![
989 Arc::new(TextInput),
990 Arc::new(EmailInput),
991 Arc::new(TelInput),
992 Arc::new(NumberInput),
993 Arc::new(UrlInput),
994 ]
995}
996
997pub(crate) fn builtin_input_registry() -> InputRegistry {
999 builtin_input_types()
1000 .into_iter()
1001 .map(|i| (i.key().to_string(), i))
1002 .collect()
1003}
1004
1005const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
1008
1009fn validate_contributions(
1014 attrs: &BTreeMap<String, String>,
1015 adornments: &[Adornment],
1016) -> Result<(), OptionsError> {
1017 for name in attrs.keys() {
1018 if !is_name(name, false) {
1019 return Err(OptionsError(format!("invalid attribute name `{name}`")));
1020 }
1021 if RESERVED_ATTRS.contains(&name.as_str()) {
1022 return Err(OptionsError(format!("attribute `{name}` is reserved")));
1023 }
1024 }
1025 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
1026 if !is_name(widget, true) {
1027 return Err(OptionsError(format!("invalid widget name `{widget}`")));
1028 }
1029 }
1030 Ok(())
1031}
1032
1033pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
1035 let mut chars = s.chars();
1036 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
1037 && chars.all(|c| {
1038 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
1039 })
1040}
1041
1042#[derive(Deserialize)]
1044struct TextOptions {
1045 #[serde(default = "default_input")]
1046 input: String,
1047 #[serde(default)]
1049 placeholder: Option<String>,
1050}
1051
1052fn default_input() -> String {
1053 "text".to_string()
1054}
1055
1056struct TextResolved {
1059 html_type: &'static str,
1060 attrs: BTreeMap<String, String>,
1061 adornments: Vec<Adornment>,
1062 rules: Vec<Rule>,
1063 assets: Vec<&'static str>,
1064}
1065
1066#[derive(Default, Serialize, Deserialize)]
1069struct TextData {
1070 input_type: String,
1071 #[serde(default)]
1072 attrs: BTreeMap<String, String>,
1073 #[serde(default)]
1074 adornments: Vec<Adornment>,
1075}
1076
1077#[derive(Template)]
1078#[template(path = "fields/text.html")]
1079struct TextTmpl<'a> {
1080 name: &'a str,
1081 id: &'a str,
1082 value: &'a str,
1083 required: bool,
1084 input_type: &'a str,
1085 attrs: &'a BTreeMap<String, String>,
1086 leading: &'a [Adornment],
1087 trailing: &'a [Adornment],
1088}
1089
1090pub(crate) struct TextField {
1094 inputs: Arc<InputRegistry>,
1095}
1096
1097impl TextField {
1098 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
1099 Self { inputs }
1100 }
1101}
1102
1103impl FieldType for TextField {
1104 fn view_key(&self) -> &'static str {
1105 "text"
1106 }
1107 fn option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
1108 let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
1109 let input = self.inputs.get(input)?;
1112 let mut all = vec!["input".to_string(), "placeholder".to_string()];
1113 all.extend(input.option_keys().into_iter().map(str::to_string));
1114 Some(all)
1115 }
1116 fn resolve_options(
1117 &self,
1118 raw: &serde_json::Value,
1119 _types: &FieldRegistry,
1120 ) -> Result<ResolvedOptions, OptionsError> {
1121 let opts: TextOptions = if raw.is_null() {
1122 TextOptions {
1123 input: default_input(),
1124 placeholder: None,
1125 }
1126 } else {
1127 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1128 };
1129 let input = self
1130 .inputs
1131 .get(&opts.input)
1132 .cloned()
1133 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
1134 let input_opts = input.resolve_options(raw, _types)?;
1137 let mut attrs = input.attributes(&input_opts);
1138 let adornments = input.adornments(&input_opts);
1139 validate_contributions(&attrs, &adornments)?;
1140 if let Some(placeholder) = opts.placeholder {
1141 attrs.insert("placeholder".to_string(), placeholder);
1142 }
1143 Ok(ResolvedOptions::new(TextResolved {
1144 html_type: input.html_type(),
1145 rules: input.rules(&input_opts),
1146 assets: input.assets(&input_opts),
1147 attrs,
1148 adornments,
1149 }))
1150 }
1151 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
1152 opts.get::<TextResolved>()
1153 .map(|r| r.rules.clone())
1154 .unwrap_or_default()
1155 }
1156 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
1157 opts.get::<TextResolved>()
1158 .map(|r| r.assets.clone())
1159 .unwrap_or_default()
1160 }
1161 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1162 let data = match cx.opts.get::<TextResolved>() {
1163 Some(r) => TextData {
1164 input_type: r.html_type.to_string(),
1165 attrs: r.attrs.clone(),
1166 adornments: r.adornments.clone(),
1167 },
1168 None => TextData {
1169 input_type: "text".to_string(),
1170 ..TextData::default()
1171 },
1172 };
1173 FieldVm {
1174 view_key: "text".to_string(),
1175 name: cx.name.to_string(),
1176 id: cx.id.to_string(),
1177 label: cx.label.to_string(),
1178 required: cx.required,
1179 value: cx.value.clone(),
1180 data: serde_json::to_value(data).unwrap_or_default(),
1181 }
1182 }
1183 fn render_default(&self, vm: &FieldVm) -> Markup {
1184 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1185 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
1186 .adornments
1187 .into_iter()
1188 .partition(|a| matches!(a.placement, Placement::Leading));
1189 Markup::from_template(&TextTmpl {
1190 name: &vm.name,
1191 id: &vm.id,
1192 value: vm.value.as_text(),
1193 required: vm.required,
1194 input_type: &data.input_type,
1195 attrs: &data.attrs,
1196 leading: &leading,
1197 trailing: &trailing,
1198 })
1199 .unwrap_or_default()
1200 }
1201}
1202
1203#[derive(Template)]
1204#[template(path = "fields/textarea.html")]
1205struct TextareaTmpl<'a> {
1206 name: &'a str,
1207 id: &'a str,
1208 value: &'a str,
1209 required: bool,
1210 rows: Option<u16>,
1211 placeholder: Option<&'a str>,
1212}
1213
1214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1216struct TextareaOptions {
1217 #[serde(default)]
1218 rows: Option<u16>,
1219 #[serde(default)]
1220 placeholder: Option<String>,
1221}
1222
1223pub(crate) struct TextareaField;
1225
1226impl FieldType for TextareaField {
1227 fn view_key(&self) -> &'static str {
1228 "textarea"
1229 }
1230 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1231 keys(&["rows", "placeholder"])
1232 }
1233 fn resolve_options(
1234 &self,
1235 raw: &serde_json::Value,
1236 _types: &FieldRegistry,
1237 ) -> Result<ResolvedOptions, OptionsError> {
1238 let opts: TextareaOptions = if raw.is_null() {
1239 TextareaOptions::default()
1240 } else {
1241 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
1242 };
1243 Ok(ResolvedOptions::new(opts))
1244 }
1245 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1246 let mut vm = scalar_vm("textarea", cx);
1247 if let Some(opts) = cx.opts.get::<TextareaOptions>() {
1248 vm.data = serde_json::to_value(opts).unwrap_or_default();
1249 }
1250 vm
1251 }
1252 fn render_default(&self, vm: &FieldVm) -> Markup {
1253 let opts: TextareaOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1254 Markup::from_template(&TextareaTmpl {
1255 name: &vm.name,
1256 id: &vm.id,
1257 value: vm.value.as_text(),
1258 required: vm.required,
1259 rows: opts.rows,
1260 placeholder: opts.placeholder.as_deref(),
1261 })
1262 .unwrap_or_default()
1263 }
1264}
1265
1266#[derive(Debug, Default, Deserialize)]
1268struct SelectOptions {
1269 #[serde(default)]
1270 options: Vec<SelectOption>,
1271}
1272
1273#[derive(Debug, Deserialize)]
1274struct SelectOption {
1275 value: String,
1276 #[serde(default)]
1278 label: Option<String>,
1279}
1280
1281#[derive(Debug, Serialize, Deserialize)]
1284struct OptionView {
1285 value: String,
1286 label: String,
1287 selected: bool,
1288}
1289
1290#[derive(Template)]
1291#[template(path = "fields/select.html")]
1292struct SelectTmpl<'a> {
1293 name: &'a str,
1294 id: &'a str,
1295 required: bool,
1296 options: &'a [OptionView],
1297}
1298
1299pub(crate) struct SelectField;
1301
1302impl FieldType for SelectField {
1303 fn view_key(&self) -> &'static str {
1304 "select"
1305 }
1306 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1307 keys(&["options"])
1308 }
1309 fn resolve_options(
1310 &self,
1311 raw: &serde_json::Value,
1312 _types: &FieldRegistry,
1313 ) -> Result<ResolvedOptions, OptionsError> {
1314 let opts: SelectOptions =
1315 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1316 Ok(ResolvedOptions::new(opts))
1317 }
1318 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1319 let current = cx.value.as_text();
1320 let views: Vec<OptionView> = cx
1321 .opts
1322 .get::<SelectOptions>()
1323 .map(|o| {
1324 o.options
1325 .iter()
1326 .map(|so| OptionView {
1327 value: so.value.clone(),
1328 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1329 selected: so.value == current,
1330 })
1331 .collect()
1332 })
1333 .unwrap_or_default();
1334 FieldVm {
1335 view_key: "select".to_string(),
1336 name: cx.name.to_string(),
1337 id: cx.id.to_string(),
1338 label: cx.label.to_string(),
1339 required: cx.required,
1340 value: cx.value.clone(),
1341 data: serde_json::to_value(&views).unwrap_or_default(),
1342 }
1343 }
1344 fn render_default(&self, vm: &FieldVm) -> Markup {
1345 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1346 Markup::from_template(&SelectTmpl {
1347 name: &vm.name,
1348 id: &vm.id,
1349 required: vm.required,
1350 options: &options,
1351 })
1352 .unwrap_or_default()
1353 }
1354}
1355
1356#[derive(Template)]
1357#[template(path = "fields/switch.html")]
1358struct SwitchTmpl<'a> {
1359 name: &'a str,
1360 id: &'a str,
1361 on: bool,
1362}
1363
1364pub(crate) struct SwitchField;
1370
1371fn stored_is_on(stored: Option<&str>) -> bool {
1374 matches!(
1375 stored.map(str::trim).unwrap_or_default(),
1376 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1377 )
1378}
1379
1380impl FieldType for SwitchField {
1381 fn view_key(&self) -> &'static str {
1382 "switch"
1383 }
1384 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1385 keys(&[])
1386 }
1387
1388 fn to_attr(
1389 &self,
1390 field: &SubmittedField<'_>,
1391 _opts: &ResolvedOptions,
1392 _mode: Mode,
1393 ) -> Result<Option<AttrValue>, String> {
1394 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1397 }
1398
1399 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1400 if stored_is_on(stored) {
1401 "1".to_string()
1402 } else {
1403 String::new()
1404 }
1405 }
1406
1407 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1408 scalar_vm("switch", cx)
1409 }
1410
1411 fn render_default(&self, vm: &FieldVm) -> Markup {
1412 Markup::from_template(&SwitchTmpl {
1413 name: &vm.name,
1414 id: &vm.id,
1415 on: stored_is_on(Some(vm.value.as_text())),
1416 })
1417 .unwrap_or_default()
1418 }
1419}
1420
1421#[derive(Template)]
1422#[template(path = "fields/date.html")]
1423struct DateTmpl<'a> {
1424 name: &'a str,
1425 id: &'a str,
1426 value: &'a str,
1427 required: bool,
1428}
1429
1430pub(crate) struct DateField;
1436
1437impl FieldType for DateField {
1438 fn view_key(&self) -> &'static str {
1439 "date"
1440 }
1441 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1442 keys(&[])
1443 }
1444
1445 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1446 vec![Rule::Date]
1447 }
1448
1449 fn to_attr(
1450 &self,
1451 field: &SubmittedField<'_>,
1452 _opts: &ResolvedOptions,
1453 _mode: Mode,
1454 ) -> Result<Option<AttrValue>, String> {
1455 Ok(field.value().map(|v| match v.trim() {
1456 "" => AttrValue::Null,
1457 date => AttrValue::Text(date.to_string()),
1458 }))
1459 }
1460
1461 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1462 let stored = stored.unwrap_or_default().trim();
1463 stored
1466 .split(['T', ' '])
1467 .next()
1468 .unwrap_or_default()
1469 .to_string()
1470 }
1471
1472 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1473 scalar_vm("date", cx)
1474 }
1475
1476 fn render_default(&self, vm: &FieldVm) -> Markup {
1477 Markup::from_template(&DateTmpl {
1478 name: &vm.name,
1479 id: &vm.id,
1480 value: vm.value.as_text(),
1481 required: vm.required,
1482 })
1483 .unwrap_or_default()
1484 }
1485}
1486
1487#[derive(Template)]
1488#[template(path = "fields/password.html")]
1489struct PasswordTmpl<'a> {
1490 name: &'a str,
1491 id: &'a str,
1492 required: bool,
1493}
1494
1495pub(crate) struct PasswordField;
1503
1504impl FieldType for PasswordField {
1505 fn view_key(&self) -> &'static str {
1506 "password"
1507 }
1508 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1509 keys(&[])
1510 }
1511
1512 fn to_attr(
1513 &self,
1514 field: &SubmittedField<'_>,
1515 _opts: &ResolvedOptions,
1516 _mode: Mode,
1517 ) -> Result<Option<AttrValue>, String> {
1518 match field.value().map(str::trim).unwrap_or_default() {
1519 "" => Ok(None),
1521 plain => laterite_auth::password::hash_password(plain)
1522 .map(|hash| Some(AttrValue::Text(hash)))
1523 .map_err(|e| e.to_string()),
1524 }
1525 }
1526
1527 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1530 String::new()
1531 }
1532
1533 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1534 scalar_vm("password", cx)
1535 }
1536
1537 fn render_default(&self, vm: &FieldVm) -> Markup {
1538 Markup::from_template(&PasswordTmpl {
1539 name: &vm.name,
1540 id: &vm.id,
1541 required: vm.required,
1542 })
1543 .unwrap_or_default()
1544 }
1545}
1546
1547#[derive(Template)]
1548#[template(path = "fields/radio.html")]
1549struct RadioTmpl<'a> {
1550 name: &'a str,
1551 options: &'a [OptionView],
1552 required: bool,
1553}
1554
1555pub(crate) struct RadioField;
1560
1561impl FieldType for RadioField {
1562 fn view_key(&self) -> &'static str {
1563 "radio"
1564 }
1565 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1566 keys(&["options"])
1567 }
1568
1569 fn resolve_options(
1570 &self,
1571 raw: &serde_json::Value,
1572 _types: &FieldRegistry,
1573 ) -> Result<ResolvedOptions, OptionsError> {
1574 let opts: SelectOptions =
1575 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1576 Ok(ResolvedOptions::new(opts))
1577 }
1578
1579 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1580 let current = cx.value.as_text();
1581 let views: Vec<OptionView> = cx
1582 .opts
1583 .get::<SelectOptions>()
1584 .map(|o| {
1585 o.options
1586 .iter()
1587 .map(|so| OptionView {
1588 value: so.value.clone(),
1589 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1590 selected: so.value == current,
1591 })
1592 .collect()
1593 })
1594 .unwrap_or_default();
1595 FieldVm {
1596 view_key: "radio".to_string(),
1597 name: cx.name.to_string(),
1598 id: cx.id.to_string(),
1599 label: cx.label.to_string(),
1600 required: cx.required,
1601 value: cx.value.clone(),
1602 data: serde_json::to_value(&views).unwrap_or_default(),
1603 }
1604 }
1605
1606 fn render_default(&self, vm: &FieldVm) -> Markup {
1607 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1608 Markup::from_template(&RadioTmpl {
1609 name: &vm.name,
1610 options: &options,
1611 required: vm.required,
1612 })
1613 .unwrap_or_default()
1614 }
1615}
1616
1617fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1619 FieldVm {
1620 view_key: view_key.to_string(),
1621 name: cx.name.to_string(),
1622 id: cx.id.to_string(),
1623 label: cx.label.to_string(),
1624 required: cx.required,
1625 value: cx.value.clone(),
1626 data: serde_json::Value::Null,
1627 }
1628}
1629
1630#[derive(Deserialize)]
1632struct RefOptions {
1633 source: String,
1634}
1635
1636struct RefResolved {
1638 source: String,
1639}
1640
1641#[derive(Default, Serialize, Deserialize)]
1644struct RefData {
1645 source: String,
1646 search_url: String,
1647 resolve_url: String,
1648 value: String,
1649 placeholder: String,
1650}
1651
1652#[derive(Template)]
1653#[template(path = "fields/ref_picker.html")]
1654struct RefPickerTmpl<'a> {
1655 name: &'a str,
1656 id: &'a str,
1657 value: &'a str,
1658 search_url: &'a str,
1659 resolve_url: &'a str,
1660 placeholder: &'a str,
1661}
1662
1663pub(crate) struct RefPickerField {
1668 pickers: Arc<PickerRegistry>,
1669}
1670
1671impl RefPickerField {
1672 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1673 Self { pickers }
1674 }
1675}
1676
1677impl FieldType for RefPickerField {
1678 fn view_key(&self) -> &'static str {
1679 "reference"
1680 }
1681 fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
1682 keys(&["source"])
1683 }
1684 fn resolve_options(
1685 &self,
1686 raw: &serde_json::Value,
1687 _types: &FieldRegistry,
1688 ) -> Result<ResolvedOptions, OptionsError> {
1689 let opts: RefOptions =
1690 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1691 if !self.pickers.contains_key(&opts.source) {
1692 return Err(OptionsError(format!(
1693 "unknown picker source `{}`",
1694 opts.source
1695 )));
1696 }
1697 Ok(ResolvedOptions::new(RefResolved {
1698 source: opts.source,
1699 }))
1700 }
1701 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1702 let source = cx
1703 .opts
1704 .get::<RefResolved>()
1705 .map(|r| r.source.as_str())
1706 .unwrap_or_default();
1707 let data = RefData {
1708 search_url: format!("{}/pickers/{}/search", cx.base, source),
1709 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1710 source: source.to_string(),
1711 value: cx.value.as_text().to_string(),
1712 placeholder: "Search…".to_string(),
1713 };
1714 FieldVm {
1715 view_key: "reference".to_string(),
1716 name: cx.name.to_string(),
1717 id: cx.id.to_string(),
1718 label: cx.label.to_string(),
1719 required: cx.required,
1720 value: cx.value.clone(),
1721 data: serde_json::to_value(data).unwrap_or_default(),
1722 }
1723 }
1724 fn render_default(&self, vm: &FieldVm) -> Markup {
1725 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1726 Markup::from_template(&RefPickerTmpl {
1727 name: &vm.name,
1728 id: &vm.id,
1729 value: vm.value.as_text(),
1730 search_url: &data.search_url,
1731 resolve_url: &data.resolve_url,
1732 placeholder: &data.placeholder,
1733 })
1734 .unwrap_or_default()
1735 }
1736 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1737 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1738 }
1739}
1740
1741#[cfg(test)]
1742mod tests {
1743 use super::*;
1744
1745 fn untranslated() -> Translator {
1748 Translator::new("en")
1749 }
1750
1751 fn cx_with<'a>(
1752 name: &'a str,
1753 value: &'a FieldValue,
1754 opts: &'a ResolvedOptions,
1755 i18n: &'a Translator,
1756 ) -> FieldCx<'a> {
1757 FieldCx {
1758 name,
1759 id: name,
1760 label: "Label",
1761 value,
1762 required: true,
1763 opts,
1764 base: "/admin",
1765 i18n,
1766 }
1767 }
1768
1769 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1770 cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
1773 }
1774
1775 fn text_field() -> TextField {
1776 TextField::new(Arc::new(builtin_input_registry()))
1777 }
1778
1779 struct BadAttrInput;
1781 impl InputType for BadAttrInput {
1782 fn key(&self) -> &'static str {
1783 "bad"
1784 }
1785 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1786 BTreeMap::from([("class".to_string(), "x".to_string())])
1787 }
1788 }
1789
1790 #[test]
1791 fn text_renders_its_value_escaped() {
1792 let opts = ResolvedOptions::none();
1793 let value = FieldValue::Text("a<b>&\"c".to_string());
1794 let markup = render_field(
1795 &text_field(),
1796 &NoOverrides,
1797 &scope(),
1798 &cx("title", &value, &opts),
1799 );
1800 let html = markup.as_str();
1801 assert!(html.contains(r#"name="title""#));
1802 assert!(html.contains("required"));
1803 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
1807 assert!(
1808 html.contains("<") || html.contains("<"),
1809 "angle escaped: {html}"
1810 );
1811 assert!(
1812 html.contains("&") || html.contains("&"),
1813 "ampersand escaped: {html}"
1814 );
1815 }
1816
1817 #[test]
1818 fn textarea_uses_its_own_control() {
1819 let opts = ResolvedOptions::none();
1820 let value = FieldValue::Text("body".to_string());
1821 let markup = render_field(
1822 &TextareaField,
1823 &NoOverrides,
1824 &scope(),
1825 &cx("body", &value, &opts),
1826 );
1827 assert!(markup.as_str().contains("<textarea"));
1828 }
1829
1830 #[test]
1831 fn no_overrides_uses_the_compiled_default() {
1832 let opts = ResolvedOptions::none();
1834 let value = FieldValue::Text("x".to_string());
1835 let ft = text_field();
1836 let vm = ft.view_model(&cx("t", &value, &opts));
1837 let direct = ft.render_default(&vm);
1838 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
1839 assert_eq!(direct.as_str(), routed.as_str());
1840 }
1841
1842 #[test]
1843 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
1844 let field = text_field();
1845 let opts = field
1846 .resolve_options(
1847 &serde_json::json!({ "input": "email" }),
1848 &builtin_registry(),
1849 )
1850 .unwrap();
1851 assert!(matches!(
1853 field.intrinsic_rules(&opts).as_slice(),
1854 [Rule::Email]
1855 ));
1856 let value = FieldValue::Text("a@b.test".to_string());
1857 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
1858 assert!(markup.as_str().contains(r#"type="email""#));
1859 }
1860
1861 #[test]
1862 fn text_default_input_is_plain_text_with_no_extra_rule() {
1863 let field = text_field();
1864 let opts = field
1865 .resolve_options(&serde_json::Value::Null, &builtin_registry())
1866 .unwrap();
1867 assert!(field.intrinsic_rules(&opts).is_empty());
1868 let value = FieldValue::Text("hi".to_string());
1869 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
1870 assert!(markup.as_str().contains(r#"type="text""#));
1871 }
1872
1873 #[test]
1874 fn text_number_input_sets_the_type_and_numeric_rule() {
1875 let field = text_field();
1876 let opts = field
1877 .resolve_options(
1878 &serde_json::json!({ "input": "number" }),
1879 &builtin_registry(),
1880 )
1881 .unwrap();
1882 assert!(matches!(
1883 field.intrinsic_rules(&opts).as_slice(),
1884 [Rule::Numeric]
1885 ));
1886 let value = FieldValue::Text("42".to_string());
1887 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1888 assert!(markup.as_str().contains(r#"type="number""#));
1889 }
1890
1891 #[test]
1892 fn text_tel_input_sets_the_type_with_no_rule() {
1893 let field = text_field();
1894 let opts = field
1895 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
1896 .unwrap();
1897 assert!(field.intrinsic_rules(&opts).is_empty());
1898 let value = FieldValue::Text(String::new());
1899 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
1900 assert!(markup.as_str().contains(r#"type="tel""#));
1901 }
1902
1903 #[test]
1904 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
1905 let field = text_field();
1906 let opts = field
1907 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
1908 .unwrap();
1909 assert!(matches!(
1910 field.intrinsic_rules(&opts).as_slice(),
1911 [Rule::Url]
1912 ));
1913 let value = FieldValue::Text("https://example.com".to_string());
1914 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1915 let html = markup.as_str();
1916 assert!(html.contains(r#"type="url""#), "{html}");
1917 assert!(html.contains("lat-input-group"), "{html}");
1918 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
1919 }
1920
1921 #[test]
1922 fn text_url_input_copy_false_omits_the_button() {
1923 let field = text_field();
1924 let opts = field
1925 .resolve_options(
1926 &serde_json::json!({ "input": "url", "copy": false }),
1927 &builtin_registry(),
1928 )
1929 .unwrap();
1930 let value = FieldValue::Text("https://example.com".to_string());
1931 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1932 let html = markup.as_str();
1933 assert!(html.contains(r#"type="url""#), "{html}");
1934 assert!(!html.contains("lat-input-group"), "{html}");
1936 assert!(!html.contains("data-lat-widget"), "{html}");
1937 }
1938
1939 #[test]
1940 fn text_number_input_emits_min_max_step_attributes() {
1941 let field = text_field();
1942 let opts = field
1943 .resolve_options(
1944 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
1945 &builtin_registry(),
1946 )
1947 .unwrap();
1948 let value = FieldValue::Text("5".to_string());
1951 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1952 let html = markup.as_str();
1953 assert!(html.contains(r#"min="0""#), "{html}");
1954 assert!(html.contains(r#"max="10""#), "{html}");
1955 assert!(html.contains(r#"step="2""#), "{html}");
1956 }
1957
1958 #[test]
1959 fn text_input_reserved_attribute_name_aborts_resolve() {
1960 let mut inputs = builtin_input_registry();
1961 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
1962 let field = TextField::new(Arc::new(inputs));
1963 let Err(e) =
1964 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
1965 else {
1966 panic!("expected a reserved-attribute rejection");
1967 };
1968 assert!(e.0.contains("class"), "{e}");
1969 }
1970
1971 #[test]
1972 fn attribute_and_widget_name_rules() {
1973 assert!(is_name("min", false));
1974 assert!(is_name("data-x", false));
1975 assert!(!is_name("Min", false));
1976 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
1979 }
1980
1981 #[test]
1982 fn select_renders_options_with_the_current_value_selected() {
1983 let raw = serde_json::json!({
1984 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1985 });
1986 let opts = SelectField
1987 .resolve_options(&raw, &builtin_registry())
1988 .unwrap();
1989 let value = FieldValue::Text("closed".to_string());
1990 let markup = render_field(
1991 &SelectField,
1992 &NoOverrides,
1993 &scope(),
1994 &cx("status", &value, &opts),
1995 );
1996 let html = markup.as_str();
1997 assert!(html.contains(r#"<option value="open">Open</option>"#));
1998 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
2000 }
2001
2002 #[test]
2003 fn radio_renders_the_same_options_with_the_current_one_checked() {
2004 let raw = serde_json::json!({
2005 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
2006 });
2007 let opts = RadioField
2008 .resolve_options(&raw, &builtin_registry())
2009 .unwrap();
2010 let value = FieldValue::Text("closed".to_string());
2011 let markup = render_field(
2012 &RadioField,
2013 &NoOverrides,
2014 &scope(),
2015 &cx("status", &value, &opts),
2016 );
2017 let html = markup.as_str();
2018 assert!(html.contains(r#"value="open""#));
2019 assert!(
2020 html.contains(r#"value="closed" checked"#),
2021 "current is checked"
2022 );
2023 assert_eq!(html.matches(r#"name="status""#).count(), 2);
2026 assert!(html.contains("closed</label>"));
2028 }
2029
2030 fn scope<'a>() -> OverrideScope<'a> {
2031 OverrideScope {
2032 surface: Surface::Field,
2033 view_key: "text",
2034 resource: None,
2035 field: None,
2036 }
2037 }
2038
2039 fn ref_field() -> RefPickerField {
2040 let mut pickers = crate::picker::PickerRegistry::new();
2041 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
2042 pickers.insert(
2043 "acme.place".to_string(),
2044 crate::picker::PickerSourceReg::new("acme.place", source),
2045 );
2046 RefPickerField::new(Arc::new(pickers))
2047 }
2048
2049 #[test]
2050 fn reference_rejects_an_unknown_source() {
2051 let field = ref_field();
2052 let Err(e) = field.resolve_options(
2053 &serde_json::json!({ "source": "no.such" }),
2054 &builtin_registry(),
2055 ) else {
2056 panic!("expected an unknown-source rejection");
2057 };
2058 assert!(e.0.contains("no.such"), "{e}");
2059 }
2060
2061 #[test]
2062 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
2063 let field = ref_field();
2064 let opts = field
2065 .resolve_options(
2066 &serde_json::json!({ "source": "acme.place" }),
2067 &builtin_registry(),
2068 )
2069 .unwrap();
2070 let value = FieldValue::Text("42".to_string());
2071 let markup = render_field(
2072 &field,
2073 &NoOverrides,
2074 &scope(),
2075 &cx("place_id", &value, &opts),
2076 );
2077 let html = markup.as_str();
2078 assert!(html.contains(r#"type="hidden""#), "{html}");
2081 assert!(html.contains(r#"name="place_id""#), "{html}");
2082 assert!(html.contains(r#"value="42""#), "{html}");
2083 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
2085 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
2086 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
2087 }
2088
2089 #[test]
2090 fn reference_declares_its_widget_assets() {
2091 let field = ref_field();
2092 let opts = field
2093 .resolve_options(
2094 &serde_json::json!({ "source": "acme.place" }),
2095 &builtin_registry(),
2096 )
2097 .unwrap();
2098 assert_eq!(
2099 field.assets(&opts),
2100 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
2101 );
2102 }
2103}
2104
2105#[cfg(test)]
2106mod repeater_tests {
2107 use super::*;
2108
2109 fn options() -> ResolvedOptions {
2110 RepeaterField
2111 .resolve_options(
2112 &serde_json::json!({
2113 "fields": [
2114 { "name": "path", "label": "Path", "type": "text" },
2115 { "name": "allow", "label": "Allow", "type": "switch" },
2116 ]
2117 }),
2118 &builtin_registry(),
2119 )
2120 .unwrap()
2121 }
2122
2123 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2124 pairs
2125 .iter()
2126 .map(|(k, v)| (k.to_string(), v.to_string()))
2127 .collect()
2128 }
2129
2130 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
2131 let field = SubmittedField::new("rules", data);
2132 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
2133 Some(AttrValue::Json(value)) => value,
2134 other => panic!("expected a JSON array, got {other:?}"),
2135 }
2136 }
2137
2138 #[test]
2142 fn a_row_is_typed_by_its_sub_field_types() {
2143 let rows = stored(
2144 &submitted(&[
2145 ("rules[0][path]", "/private"),
2146 ("rules[1][path]", "/public"),
2147 ("rules[1][allow]", "on"),
2148 ]),
2149 &options(),
2150 );
2151
2152 assert_eq!(rows[0]["path"], "/private");
2153 assert_eq!(rows[0]["allow"], serde_json::json!(false));
2154 assert_eq!(rows[1]["allow"], serde_json::json!(true));
2155 }
2156
2157 #[test]
2160 fn rows_are_ordered_by_index_not_submission_order() {
2161 let rows = stored(
2162 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
2163 &options(),
2164 );
2165 assert_eq!(rows.as_array().unwrap().len(), 2);
2166 assert_eq!(rows[0]["path"], "/first");
2167 assert_eq!(rows[1]["path"], "/last");
2168 }
2169
2170 #[test]
2171 fn a_row_left_blank_is_dropped() {
2172 let rows = stored(
2173 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
2174 &options(),
2175 );
2176 assert_eq!(rows.as_array().unwrap().len(), 1);
2177 }
2178
2179 #[test]
2180 fn nothing_submitted_stores_an_empty_list() {
2181 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
2182 }
2183
2184 #[test]
2185 fn a_repeater_refuses_to_hold_a_repeater() {
2186 let err = RepeaterField
2187 .resolve_options(
2188 &serde_json::json!({
2189 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
2190 }),
2191 &builtin_registry(),
2192 )
2193 .err()
2194 .expect("a nested repeater is refused");
2195 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
2196 }
2197
2198 #[test]
2199 fn an_unregistered_sub_field_type_is_refused_at_boot() {
2200 let err = RepeaterField
2201 .resolve_options(
2202 &serde_json::json!({
2203 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
2204 }),
2205 &builtin_registry(),
2206 )
2207 .err()
2208 .expect("an unregistered type is refused");
2209 assert!(err.0.contains("acme.nope"), "{}", err.0);
2210 }
2211
2212 #[test]
2213 fn row_counts_are_bounded_when_asked() {
2214 let opts = RepeaterField
2215 .resolve_options(
2216 &serde_json::json!({
2217 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
2218 "min_items": 1,
2219 "max_items": 2
2220 }),
2221 &builtin_registry(),
2222 )
2223 .unwrap();
2224
2225 let empty = HashMap::new();
2226 let too_few = SubmittedField::new("rules", &empty);
2227 assert!(RepeaterField
2228 .to_attr(&too_few, &opts, Mode::Create)
2229 .is_err());
2230
2231 let three = submitted(&[
2232 ("rules[0][path]", "/a"),
2233 ("rules[1][path]", "/b"),
2234 ("rules[2][path]", "/c"),
2235 ]);
2236 let field = SubmittedField::new("rules", &three);
2237 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
2238 }
2239
2240 #[test]
2241 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
2242 let value = FieldValue::Json(serde_json::json!([
2243 { "path": "/private", "allow": false },
2244 ]));
2245 let cx = FieldCx {
2246 name: "rules",
2247 id: "rules",
2248 label: "Rules",
2249 value: &value,
2250 required: false,
2251 opts: &options(),
2252 base: "/admin",
2253 i18n: &Translator::new("en"),
2254 };
2255 let html = RepeaterField
2256 .render_default(&RepeaterField.view_model(&cx))
2257 .into_string();
2258
2259 assert!(html.contains(r#"name="rules[0][path]""#));
2260 assert!(html.contains(r#"value="/private""#));
2261 assert!(html.contains("lat-repeater__blank"));
2263 assert!(html.contains(r#"name="rules[__index__][path]""#));
2264 }
2265
2266 fn list_options() -> ResolvedOptions {
2268 RepeaterField
2269 .resolve_options(
2270 &serde_json::json!({
2271 "fields": [
2272 { "name": "agent", "label": "Agent", "type": "text" },
2273 { "name": "disallow", "label": "Disallow", "type": "textarea" },
2274 ],
2275 "display": "list",
2276 "summary_field": "agent",
2277 }),
2278 &builtin_registry(),
2279 )
2280 .unwrap()
2281 }
2282
2283 fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
2284 let cx = FieldCx {
2285 name: "rules",
2286 id: "rules",
2287 label: "Rules",
2288 value,
2289 required: false,
2290 opts,
2291 base: "/admin",
2292 i18n: &Translator::new("en"),
2293 };
2294 RepeaterField
2295 .render_default(&RepeaterField.view_model(&cx))
2296 .into_string()
2297 }
2298
2299 #[test]
2300 fn a_list_repeater_collapses_its_rows_and_names_them() {
2301 let value = FieldValue::Json(serde_json::json!([
2302 { "agent": "Googlebot", "disallow": "/private\n/tmp" },
2303 ]));
2304 let html = render_with(&list_options(), &value);
2305 assert!(html.contains("lat-repeater--list"), "list mode is declared");
2306 assert!(html.contains("<details"), "a row collapses");
2307 assert!(
2308 html.contains("Googlebot"),
2309 "the naming field titles the row"
2310 );
2311 assert!(html.contains(r#"name="rules[0][disallow]""#));
2313 }
2314
2315 #[test]
2316 fn a_row_with_no_name_yet_reads_as_untitled() {
2317 let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
2318 let html = render_with(&list_options(), &value);
2319 assert!(html.contains("lat-repeater__untitled"));
2320 }
2321
2322 #[test]
2323 fn the_default_layout_stays_inline() {
2324 let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
2326 let html = render_with(&options(), &value);
2327 assert!(!html.contains("lat-repeater--list"));
2328 assert!(!html.contains("<details"));
2329 }
2330
2331 #[test]
2332 fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
2333 assert_eq!(
2335 summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
2336 "/private"
2337 );
2338 assert_eq!(summary_text(&serde_json::json!("")), "");
2339 }
2340
2341 #[test]
2348 fn a_contributed_field_type_names_itself_and_converts() {
2349 struct Money;
2350 impl FieldType for Money {
2351 fn view_key(&self) -> &'static str {
2352 "acme.money"
2353 }
2354 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
2355 scalar_vm("acme.money", cx)
2356 }
2357 fn render_default(&self, _vm: &FieldVm) -> Markup {
2358 Markup::empty()
2359 }
2360 fn to_attr(
2361 &self,
2362 field: &SubmittedField<'_>,
2363 _opts: &ResolvedOptions,
2364 _mode: Mode,
2365 ) -> Result<Option<AttrValue>, String> {
2366 let raw = field.value().unwrap_or("0");
2368 let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
2369 Ok(Some(AttrValue::Int(minor as i64)))
2370 }
2371 }
2372
2373 let reg = FieldTypeReg::new(Arc::new(Money));
2374 assert_eq!(reg.name(), "acme.money");
2377
2378 let mut registry = builtin_registry();
2379 let name = reg.name().to_string();
2380 assert!(
2381 registry.insert(name.clone(), reg.field_type).is_none(),
2382 "a dotted key does not collide with a built-in"
2383 );
2384
2385 let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
2386 let submitted = SubmittedField::new("price", &data);
2387 let converted = registry[&name]
2388 .to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
2389 .unwrap();
2390 assert!(
2391 matches!(converted, Some(AttrValue::Int(1250))),
2392 "{converted:?}"
2393 );
2394 }
2395
2396 #[test]
2397 fn a_sub_field_label_is_localized_like_any_other() {
2398 use laterite_core::i18n::CatalogStore;
2399 use std::sync::Arc;
2400
2401 let po = concat!(
2402 "msgid \"\"\n",
2403 "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
2404 "\n",
2405 "msgid \"Path\"\n",
2406 "msgstr \"Path-kn\"\n",
2407 );
2408 let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
2409 let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
2410
2411 let value = FieldValue::Text(String::new());
2412 let cx = FieldCx {
2413 name: "rules",
2414 id: "rules",
2415 label: "Rules",
2416 value: &value,
2417 required: false,
2418 opts: &options(),
2419 base: "/admin",
2420 i18n: &i18n,
2421 };
2422 let html = RepeaterField
2423 .render_default(&RepeaterField.view_model(&cx))
2424 .into_string();
2425
2426 assert!(
2427 html.contains("Path-kn"),
2428 "sub-label was not localized: {html}"
2429 );
2430 assert!(
2431 !html.contains(">Path<"),
2432 "the source string still leaked: {html}"
2433 );
2434 }
2435}
2436
2437#[cfg(test)]
2438mod save_contract_tests {
2439 use super::*;
2440
2441 fn one(raw: Option<&str>) -> HashMap<String, String> {
2443 raw.into_iter()
2444 .map(|v| ("f".to_string(), v.to_string()))
2445 .collect()
2446 }
2447
2448 fn none() -> ResolvedOptions {
2449 ResolvedOptions::none()
2450 }
2451
2452 #[test]
2453 fn a_switch_stores_a_bool_and_absence_means_off() {
2454 let f = SwitchField;
2455 assert_eq!(
2458 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2459 .unwrap(),
2460 Some(AttrValue::Bool(false))
2461 );
2462 assert_eq!(
2463 f.to_attr(
2464 &SubmittedField::new("f", &one(Some("on"))),
2465 &none(),
2466 Mode::Create
2467 )
2468 .unwrap(),
2469 Some(AttrValue::Bool(true))
2470 );
2471 }
2472
2473 #[test]
2474 fn a_switch_reads_back_whatever_the_backend_gave() {
2475 let f = SwitchField;
2476 for on in ["1", "true", "t", "on"] {
2478 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2479 }
2480 for off in ["0", "false", "f", ""] {
2481 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2482 }
2483 assert!(f.to_control(None, &none()).is_empty());
2484 }
2485
2486 #[test]
2487 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2488 let f = DateField;
2489 assert_eq!(
2491 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2492 "2026-09-09"
2493 );
2494 assert_eq!(
2495 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2496 "2026-09-09"
2497 );
2498 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2499 assert_eq!(f.to_control(None, &none()), "");
2500 }
2501
2502 #[test]
2503 fn a_cleared_date_stores_null_not_an_empty_string() {
2504 let f = DateField;
2505 assert_eq!(
2506 f.to_attr(
2507 &SubmittedField::new("f", &one(Some(""))),
2508 &none(),
2509 Mode::Update
2510 )
2511 .unwrap(),
2512 Some(AttrValue::Null)
2513 );
2514 assert_eq!(
2515 f.to_attr(
2516 &SubmittedField::new("f", &one(Some("2026-09-09"))),
2517 &none(),
2518 Mode::Create
2519 )
2520 .unwrap(),
2521 Some(AttrValue::Text("2026-09-09".into()))
2522 );
2523 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2524 }
2525
2526 #[test]
2527 fn a_password_hashes_and_never_echoes() {
2528 let f = PasswordField;
2529 let stored = f
2530 .to_attr(
2531 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2532 &none(),
2533 Mode::Create,
2534 )
2535 .unwrap()
2536 .unwrap();
2537 let hash = stored.as_str().unwrap();
2538 assert!(
2539 hash.starts_with("$argon2"),
2540 "stored as a hash, not plaintext"
2541 );
2542 assert_ne!(hash, "hunter2hunter2");
2543 assert_eq!(f.to_control(Some(hash), &none()), "");
2545 }
2546
2547 #[test]
2548 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2549 let f = PasswordField;
2550 assert_eq!(
2552 f.to_attr(
2553 &SubmittedField::new("f", &one(Some(""))),
2554 &none(),
2555 Mode::Update
2556 )
2557 .unwrap(),
2558 None
2559 );
2560 assert_eq!(
2561 f.to_attr(
2562 &SubmittedField::new("f", &one(Some(" "))),
2563 &none(),
2564 Mode::Update
2565 )
2566 .unwrap(),
2567 None
2568 );
2569 assert_eq!(
2570 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2571 .unwrap(),
2572 None
2573 );
2574 }
2575
2576 #[test]
2577 fn a_plain_field_stores_and_shows_the_text_unchanged() {
2578 let f = TextareaField;
2579 assert_eq!(
2580 f.to_attr(
2581 &SubmittedField::new("f", &one(Some("hello"))),
2582 &none(),
2583 Mode::Create
2584 )
2585 .unwrap(),
2586 Some(AttrValue::Text("hello".into()))
2587 );
2588 assert_eq!(
2590 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2591 .unwrap(),
2592 None
2593 );
2594 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
2595 }
2596}