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;
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}
88
89#[derive(Debug, Clone, Serialize)]
92pub struct FieldVm {
93 pub view_key: String,
94 pub name: String,
95 pub id: String,
96 pub label: String,
97 pub required: bool,
98 pub value: FieldValue,
99 pub data: serde_json::Value,
100}
101
102pub struct SubmittedField<'a> {
110 name: &'a str,
111 data: &'a HashMap<String, String>,
112}
113
114impl<'a> SubmittedField<'a> {
115 pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
116 Self { name, data }
117 }
118
119 pub fn name(&self) -> &str {
122 self.name
123 }
124
125 pub fn value(&self) -> Option<&str> {
130 self.data.get(self.name).map(String::as_str)
131 }
132
133 pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
138 let prefix = format!("{}[", self.name);
139 self.data.iter().filter_map(move |(key, value)| {
140 key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
141 })
142 }
143
144 pub fn all(&self) -> &'a HashMap<String, String> {
147 self.data
148 }
149}
150
151pub trait FieldType: Send + Sync + 'static {
152 fn view_key(&self) -> &'static str;
155
156 fn resolve_options(
163 &self,
164 _raw: &serde_json::Value,
165 _types: &FieldRegistry,
166 ) -> Result<ResolvedOptions, OptionsError> {
167 Ok(ResolvedOptions::none())
168 }
169
170 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
173 Vec::new()
174 }
175
176 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
178
179 fn render_default(&self, vm: &FieldVm) -> Markup;
182
183 fn to_attr(
197 &self,
198 field: &SubmittedField<'_>,
199 opts: &ResolvedOptions,
200 mode: Mode,
201 ) -> Result<Option<AttrValue>, String> {
202 let _ = (opts, mode);
203 Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
204 }
205
206 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
215 let _ = opts;
216 stored.unwrap_or_default().to_string()
217 }
218
219 fn chrome(&self) -> Chrome {
221 Chrome::Wrapped
222 }
223
224 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
229 Vec::new()
230 }
231}
232
233#[derive(Debug, thiserror::Error)]
235#[error("invalid field options: {0}")]
236pub struct OptionsError(pub String);
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum Surface {
241 Field,
242 Column,
243}
244
245pub struct OverrideScope<'a> {
248 pub surface: Surface,
249 pub view_key: &'a str,
250 pub resource: Option<&'a str>,
251 pub field: Option<&'a str>,
252}
253
254#[derive(Debug, thiserror::Error)]
256#[error("override render failed: {0}")]
257pub struct OverrideError(pub String);
258
259pub trait OverrideResolver: Send + Sync {
263 fn render_override(
266 &self,
267 scope: &OverrideScope<'_>,
268 vm: &serde_json::Value,
269 ) -> Option<Result<String, OverrideError>>;
270}
271
272pub struct NoOverrides;
275
276impl OverrideResolver for NoOverrides {
277 fn render_override(
278 &self,
279 _scope: &OverrideScope<'_>,
280 _vm: &serde_json::Value,
281 ) -> Option<Result<String, OverrideError>> {
282 None
283 }
284}
285
286pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
288
289pub(crate) fn render_field(
292 ft: &dyn FieldType,
293 resolver: &dyn OverrideResolver,
294 scope: &OverrideScope<'_>,
295 cx: &FieldCx<'_>,
296) -> Markup {
297 let vm = ft.view_model(cx);
298 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
299 Some(Ok(html)) => Markup::from_override(html),
300 Some(Err(_)) | None => ft.render_default(&vm),
302 }
303}
304
305pub(crate) struct RepeaterOptions {
308 rows: Vec<RepeaterSub>,
309 min_items: usize,
310 max_items: Option<usize>,
311}
312
313pub(crate) struct RepeaterSub {
315 field: crate::form::FormField,
316 field_type: Arc<dyn FieldType>,
317 opts: ResolvedOptions,
318}
319
320#[derive(Deserialize)]
322struct RepeaterOptionsRaw {
323 fields: Vec<crate::form::FormField>,
324 #[serde(default)]
325 min_items: usize,
326 #[serde(default)]
327 max_items: Option<usize>,
328}
329
330pub(crate) struct RepeaterField;
339
340impl RepeaterField {
341 const BLANK: &'static str = "__index__";
343}
344
345impl FieldType for RepeaterField {
346 fn view_key(&self) -> &'static str {
347 "repeater"
348 }
349
350 fn resolve_options(
351 &self,
352 raw: &serde_json::Value,
353 types: &FieldRegistry,
354 ) -> Result<ResolvedOptions, OptionsError> {
355 let parsed: RepeaterOptionsRaw =
356 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
357 let mut rows = Vec::new();
358 for field in parsed.fields {
359 if field.field_type == "repeater" {
360 return Err(OptionsError(
361 "a repeater cannot hold a repeater".to_string(),
362 ));
363 }
364 let field_type = types
365 .get(&field.field_type)
366 .cloned()
367 .ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
368 let opts = field_type.resolve_options(&field.options, types)?;
369 rows.push(RepeaterSub {
370 field,
371 field_type,
372 opts,
373 });
374 }
375 Ok(ResolvedOptions::new(RepeaterOptions {
376 rows,
377 min_items: parsed.min_items,
378 max_items: parsed.max_items,
379 }))
380 }
381
382 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
383 let opts = cx.opts.get::<RepeaterOptions>();
384 let stored = match cx.value {
385 FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
386 _ => Vec::new(),
387 };
388 let data = RepeaterData {
389 rows: opts
390 .map(|o| {
391 stored
392 .iter()
393 .enumerate()
394 .map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
395 .collect()
396 })
397 .unwrap_or_default(),
398 blank: opts
399 .map(|o| render_row(o, cx, Self::BLANK, None))
400 .unwrap_or_default(),
401 };
402
403 FieldVm {
404 view_key: self.view_key().to_string(),
405 name: cx.name.to_string(),
406 id: cx.id.to_string(),
407 label: cx.label.to_string(),
408 required: cx.required,
409 value: cx.value.clone(),
410 data: serde_json::to_value(data).unwrap_or_default(),
411 }
412 }
413
414 fn render_default(&self, vm: &FieldVm) -> Markup {
415 let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
416 Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
417 }
418
419 fn to_attr(
420 &self,
421 field: &SubmittedField<'_>,
422 opts: &ResolvedOptions,
423 mode: Mode,
424 ) -> Result<Option<AttrValue>, String> {
425 let Some(options) = opts.get::<RepeaterOptions>() else {
426 return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
427 };
428
429 let mut indices: Vec<usize> = field
432 .nested()
433 .filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
434 .collect();
435 indices.sort_unstable();
436 indices.dedup();
437
438 let mut rows = Vec::new();
439 for index in indices {
440 let mut object = serde_json::Map::new();
441 for sub in &options.rows {
442 let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
443 let scoped = SubmittedField::new(&key, field.all());
444 if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
445 object.insert(sub.field.name.clone(), attr_to_json(value));
446 }
447 }
448 let row = serde_json::Value::Object(object);
449 if !is_blank_row(&row) {
451 rows.push(row);
452 }
453 }
454
455 if rows.len() < options.min_items {
456 return Err(format!("at least {} required", options.min_items));
457 }
458 if let Some(max) = options.max_items {
459 if rows.len() > max {
460 return Err(format!("at most {max} allowed"));
461 }
462 }
463 Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
464 }
465
466 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
467 stored.unwrap_or("[]").to_string()
468 }
469}
470
471fn render_row(
473 options: &RepeaterOptions,
474 cx: &FieldCx<'_>,
475 index: &str,
476 row: Option<&serde_json::Value>,
477) -> Vec<RepeaterCell> {
478 options
479 .rows
480 .iter()
481 .map(|sub| {
482 let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
483 let stored = row.and_then(|r| r.get(&sub.field.name));
484 let value = match stored {
485 Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
486 Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
487 Some(other) => FieldValue::Text(
488 sub.field_type
489 .to_control(Some(&other.to_string()), &sub.opts),
490 ),
491 };
492 let sub_cx = FieldCx {
493 name: &name,
494 id: &name,
495 label: cx.label,
496 value: &value,
497 required: false,
498 opts: &sub.opts,
499 base: cx.base,
500 };
501 let vm = sub.field_type.view_model(&sub_cx);
502 RepeaterCell {
503 label: sub.field.label.source().to_string(),
507 control: sub.field_type.render_default(&vm).into_string(),
508 }
509 })
510 .collect()
511}
512
513fn attr_to_json(value: AttrValue) -> serde_json::Value {
515 match value {
516 AttrValue::Null => serde_json::Value::Null,
517 AttrValue::Bool(b) => serde_json::Value::Bool(b),
518 AttrValue::Int(i) => serde_json::Value::from(i),
519 AttrValue::Float(f) => serde_json::Value::from(f),
520 AttrValue::Json(j) => j,
521 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
522 }
523}
524
525fn is_blank_row(row: &serde_json::Value) -> bool {
527 row.as_object().is_none_or(|object| {
528 object.values().all(|v| match v {
529 serde_json::Value::String(s) => s.trim().is_empty(),
530 serde_json::Value::Bool(b) => !b,
531 serde_json::Value::Array(items) => items.is_empty(),
532 serde_json::Value::Null => true,
533 _ => false,
534 })
535 })
536}
537
538#[derive(Serialize, Deserialize)]
540struct RepeaterCell {
541 label: String,
542 control: String,
543}
544
545#[derive(Serialize, Deserialize, Default)]
548struct RepeaterData {
549 rows: Vec<Vec<RepeaterCell>>,
550 blank: Vec<RepeaterCell>,
551}
552
553#[derive(Template)]
554#[template(path = "fields/repeater.html")]
555struct RepeaterTmpl {
556 data: RepeaterData,
557}
558
559pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
562 let inputs = Arc::new(builtin_input_registry());
563 vec![
564 Arc::new(TextField::new(inputs)),
565 Arc::new(TextareaField),
566 Arc::new(RepeaterField),
567 Arc::new(SelectField),
568 Arc::new(SwitchField),
569 Arc::new(DateField),
570 Arc::new(PasswordField),
571 Arc::new(RadioField),
572 ]
573}
574
575pub(crate) fn builtin_registry() -> FieldRegistry {
577 builtin_field_types()
578 .into_iter()
579 .map(|ft| (ft.view_key().to_string(), ft))
580 .collect()
581}
582
583#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
585#[serde(rename_all = "snake_case")]
586pub enum Placement {
587 Leading,
588 Trailing,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct Adornment {
596 pub placement: Placement,
597 pub button: bool,
599 pub label: String,
601 pub widget: Option<String>,
604}
605
606pub trait InputType: Send + Sync + 'static {
612 fn key(&self) -> &'static str;
614 fn html_type(&self) -> &'static str {
616 "text"
617 }
618 fn resolve_options(
621 &self,
622 _raw: &serde_json::Value,
623 _types: &FieldRegistry,
624 ) -> Result<ResolvedOptions, OptionsError> {
625 Ok(ResolvedOptions::none())
626 }
627 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
629 Vec::new()
630 }
631 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
634 BTreeMap::new()
635 }
636 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
638 Vec::new()
639 }
640 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
643 Vec::new()
644 }
645}
646
647pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
649
650struct TextInput;
651impl InputType for TextInput {
652 fn key(&self) -> &'static str {
653 "text"
654 }
655}
656
657struct EmailInput;
658impl InputType for EmailInput {
659 fn key(&self) -> &'static str {
660 "email"
661 }
662 fn html_type(&self) -> &'static str {
663 "email"
664 }
665 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
666 vec![Rule::Email]
667 }
668}
669
670struct TelInput;
671impl InputType for TelInput {
672 fn key(&self) -> &'static str {
673 "tel"
674 }
675 fn html_type(&self) -> &'static str {
676 "tel"
677 }
678}
679
680#[derive(Default, Deserialize)]
682struct NumberOptions {
683 #[serde(default)]
684 min: Option<f64>,
685 #[serde(default)]
686 max: Option<f64>,
687 #[serde(default)]
688 step: Option<f64>,
689}
690
691struct NumberInput;
692impl InputType for NumberInput {
693 fn key(&self) -> &'static str {
694 "number"
695 }
696 fn html_type(&self) -> &'static str {
697 "number"
698 }
699 fn resolve_options(
700 &self,
701 raw: &serde_json::Value,
702 _types: &FieldRegistry,
703 ) -> Result<ResolvedOptions, OptionsError> {
704 let opts: NumberOptions = if raw.is_null() {
705 NumberOptions::default()
706 } else {
707 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
708 };
709 Ok(ResolvedOptions::new(opts))
710 }
711 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
712 vec![Rule::Numeric]
713 }
714 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
715 let mut attrs = BTreeMap::new();
716 if let Some(o) = opts.get::<NumberOptions>() {
717 if let Some(min) = o.min {
718 attrs.insert("min".to_string(), min.to_string());
719 }
720 if let Some(max) = o.max {
721 attrs.insert("max".to_string(), max.to_string());
722 }
723 if let Some(step) = o.step {
724 attrs.insert("step".to_string(), step.to_string());
725 }
726 }
727 attrs
728 }
729}
730
731#[derive(Deserialize)]
733struct UrlOptions {
734 #[serde(default = "default_true")]
735 copy: bool,
736}
737
738impl Default for UrlOptions {
739 fn default() -> Self {
740 Self { copy: true }
741 }
742}
743
744fn default_true() -> bool {
745 true
746}
747
748struct UrlInput;
749impl InputType for UrlInput {
750 fn key(&self) -> &'static str {
751 "url"
752 }
753 fn html_type(&self) -> &'static str {
754 "url"
755 }
756 fn resolve_options(
757 &self,
758 raw: &serde_json::Value,
759 _types: &FieldRegistry,
760 ) -> Result<ResolvedOptions, OptionsError> {
761 let opts: UrlOptions = if raw.is_null() {
762 UrlOptions::default()
763 } else {
764 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
765 };
766 Ok(ResolvedOptions::new(opts))
767 }
768 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
769 vec![Rule::Url]
770 }
771 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
772 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
773 vec![Adornment {
774 placement: Placement::Trailing,
775 button: true,
776 label: "Copy".to_string(),
777 widget: Some("copy".to_string()),
778 }]
779 } else {
780 Vec::new()
781 }
782 }
783}
784
785pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
786 vec![
787 Arc::new(TextInput),
788 Arc::new(EmailInput),
789 Arc::new(TelInput),
790 Arc::new(NumberInput),
791 Arc::new(UrlInput),
792 ]
793}
794
795pub(crate) fn builtin_input_registry() -> InputRegistry {
797 builtin_input_types()
798 .into_iter()
799 .map(|i| (i.key().to_string(), i))
800 .collect()
801}
802
803const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
806
807fn validate_contributions(
812 attrs: &BTreeMap<String, String>,
813 adornments: &[Adornment],
814) -> Result<(), OptionsError> {
815 for name in attrs.keys() {
816 if !is_name(name, false) {
817 return Err(OptionsError(format!("invalid attribute name `{name}`")));
818 }
819 if RESERVED_ATTRS.contains(&name.as_str()) {
820 return Err(OptionsError(format!("attribute `{name}` is reserved")));
821 }
822 }
823 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
824 if !is_name(widget, true) {
825 return Err(OptionsError(format!("invalid widget name `{widget}`")));
826 }
827 }
828 Ok(())
829}
830
831pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
833 let mut chars = s.chars();
834 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
835 && chars.all(|c| {
836 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
837 })
838}
839
840#[derive(Deserialize)]
842struct TextOptions {
843 #[serde(default = "default_input")]
844 input: String,
845}
846
847fn default_input() -> String {
848 "text".to_string()
849}
850
851struct TextResolved {
854 html_type: &'static str,
855 attrs: BTreeMap<String, String>,
856 adornments: Vec<Adornment>,
857 rules: Vec<Rule>,
858 assets: Vec<&'static str>,
859}
860
861#[derive(Default, Serialize, Deserialize)]
864struct TextData {
865 input_type: String,
866 #[serde(default)]
867 attrs: BTreeMap<String, String>,
868 #[serde(default)]
869 adornments: Vec<Adornment>,
870}
871
872#[derive(Template)]
873#[template(path = "fields/text.html")]
874struct TextTmpl<'a> {
875 name: &'a str,
876 id: &'a str,
877 value: &'a str,
878 required: bool,
879 input_type: &'a str,
880 attrs: &'a BTreeMap<String, String>,
881 leading: &'a [Adornment],
882 trailing: &'a [Adornment],
883}
884
885pub(crate) struct TextField {
889 inputs: Arc<InputRegistry>,
890}
891
892impl TextField {
893 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
894 Self { inputs }
895 }
896}
897
898impl FieldType for TextField {
899 fn view_key(&self) -> &'static str {
900 "text"
901 }
902 fn resolve_options(
903 &self,
904 raw: &serde_json::Value,
905 _types: &FieldRegistry,
906 ) -> Result<ResolvedOptions, OptionsError> {
907 let opts: TextOptions = if raw.is_null() {
908 TextOptions {
909 input: default_input(),
910 }
911 } else {
912 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
913 };
914 let input = self
915 .inputs
916 .get(&opts.input)
917 .cloned()
918 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
919 let input_opts = input.resolve_options(raw, _types)?;
922 let attrs = input.attributes(&input_opts);
923 let adornments = input.adornments(&input_opts);
924 validate_contributions(&attrs, &adornments)?;
925 Ok(ResolvedOptions::new(TextResolved {
926 html_type: input.html_type(),
927 rules: input.rules(&input_opts),
928 assets: input.assets(&input_opts),
929 attrs,
930 adornments,
931 }))
932 }
933 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
934 opts.get::<TextResolved>()
935 .map(|r| r.rules.clone())
936 .unwrap_or_default()
937 }
938 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
939 opts.get::<TextResolved>()
940 .map(|r| r.assets.clone())
941 .unwrap_or_default()
942 }
943 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
944 let data = match cx.opts.get::<TextResolved>() {
945 Some(r) => TextData {
946 input_type: r.html_type.to_string(),
947 attrs: r.attrs.clone(),
948 adornments: r.adornments.clone(),
949 },
950 None => TextData {
951 input_type: "text".to_string(),
952 ..TextData::default()
953 },
954 };
955 FieldVm {
956 view_key: "text".to_string(),
957 name: cx.name.to_string(),
958 id: cx.id.to_string(),
959 label: cx.label.to_string(),
960 required: cx.required,
961 value: cx.value.clone(),
962 data: serde_json::to_value(data).unwrap_or_default(),
963 }
964 }
965 fn render_default(&self, vm: &FieldVm) -> Markup {
966 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
967 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
968 .adornments
969 .into_iter()
970 .partition(|a| matches!(a.placement, Placement::Leading));
971 Markup::from_template(&TextTmpl {
972 name: &vm.name,
973 id: &vm.id,
974 value: vm.value.as_text(),
975 required: vm.required,
976 input_type: &data.input_type,
977 attrs: &data.attrs,
978 leading: &leading,
979 trailing: &trailing,
980 })
981 .unwrap_or_default()
982 }
983}
984
985#[derive(Template)]
986#[template(path = "fields/textarea.html")]
987struct TextareaTmpl<'a> {
988 name: &'a str,
989 id: &'a str,
990 value: &'a str,
991 required: bool,
992}
993
994pub(crate) struct TextareaField;
996
997impl FieldType for TextareaField {
998 fn view_key(&self) -> &'static str {
999 "textarea"
1000 }
1001 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1002 scalar_vm("textarea", cx)
1003 }
1004 fn render_default(&self, vm: &FieldVm) -> Markup {
1005 Markup::from_template(&TextareaTmpl {
1006 name: &vm.name,
1007 id: &vm.id,
1008 value: vm.value.as_text(),
1009 required: vm.required,
1010 })
1011 .unwrap_or_default()
1012 }
1013}
1014
1015#[derive(Debug, Default, Deserialize)]
1017struct SelectOptions {
1018 #[serde(default)]
1019 options: Vec<SelectOption>,
1020}
1021
1022#[derive(Debug, Deserialize)]
1023struct SelectOption {
1024 value: String,
1025 #[serde(default)]
1027 label: Option<String>,
1028}
1029
1030#[derive(Debug, Serialize, Deserialize)]
1033struct OptionView {
1034 value: String,
1035 label: String,
1036 selected: bool,
1037}
1038
1039#[derive(Template)]
1040#[template(path = "fields/select.html")]
1041struct SelectTmpl<'a> {
1042 name: &'a str,
1043 id: &'a str,
1044 required: bool,
1045 options: &'a [OptionView],
1046}
1047
1048pub(crate) struct SelectField;
1050
1051impl FieldType for SelectField {
1052 fn view_key(&self) -> &'static str {
1053 "select"
1054 }
1055 fn resolve_options(
1056 &self,
1057 raw: &serde_json::Value,
1058 _types: &FieldRegistry,
1059 ) -> Result<ResolvedOptions, OptionsError> {
1060 let opts: SelectOptions =
1061 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1062 Ok(ResolvedOptions::new(opts))
1063 }
1064 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1065 let current = cx.value.as_text();
1066 let views: Vec<OptionView> = cx
1067 .opts
1068 .get::<SelectOptions>()
1069 .map(|o| {
1070 o.options
1071 .iter()
1072 .map(|so| OptionView {
1073 value: so.value.clone(),
1074 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1075 selected: so.value == current,
1076 })
1077 .collect()
1078 })
1079 .unwrap_or_default();
1080 FieldVm {
1081 view_key: "select".to_string(),
1082 name: cx.name.to_string(),
1083 id: cx.id.to_string(),
1084 label: cx.label.to_string(),
1085 required: cx.required,
1086 value: cx.value.clone(),
1087 data: serde_json::to_value(&views).unwrap_or_default(),
1088 }
1089 }
1090 fn render_default(&self, vm: &FieldVm) -> Markup {
1091 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1092 Markup::from_template(&SelectTmpl {
1093 name: &vm.name,
1094 id: &vm.id,
1095 required: vm.required,
1096 options: &options,
1097 })
1098 .unwrap_or_default()
1099 }
1100}
1101
1102#[derive(Template)]
1103#[template(path = "fields/switch.html")]
1104struct SwitchTmpl<'a> {
1105 name: &'a str,
1106 id: &'a str,
1107 on: bool,
1108}
1109
1110pub(crate) struct SwitchField;
1116
1117fn stored_is_on(stored: Option<&str>) -> bool {
1120 matches!(
1121 stored.map(str::trim).unwrap_or_default(),
1122 "1" | "true" | "TRUE" | "t" | "yes" | "on"
1123 )
1124}
1125
1126impl FieldType for SwitchField {
1127 fn view_key(&self) -> &'static str {
1128 "switch"
1129 }
1130
1131 fn to_attr(
1132 &self,
1133 field: &SubmittedField<'_>,
1134 _opts: &ResolvedOptions,
1135 _mode: Mode,
1136 ) -> Result<Option<AttrValue>, String> {
1137 Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
1140 }
1141
1142 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1143 if stored_is_on(stored) {
1144 "1".to_string()
1145 } else {
1146 String::new()
1147 }
1148 }
1149
1150 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1151 scalar_vm("switch", cx)
1152 }
1153
1154 fn render_default(&self, vm: &FieldVm) -> Markup {
1155 Markup::from_template(&SwitchTmpl {
1156 name: &vm.name,
1157 id: &vm.id,
1158 on: stored_is_on(Some(vm.value.as_text())),
1159 })
1160 .unwrap_or_default()
1161 }
1162}
1163
1164#[derive(Template)]
1165#[template(path = "fields/date.html")]
1166struct DateTmpl<'a> {
1167 name: &'a str,
1168 id: &'a str,
1169 value: &'a str,
1170 required: bool,
1171}
1172
1173pub(crate) struct DateField;
1179
1180impl FieldType for DateField {
1181 fn view_key(&self) -> &'static str {
1182 "date"
1183 }
1184
1185 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
1186 vec![Rule::Date]
1187 }
1188
1189 fn to_attr(
1190 &self,
1191 field: &SubmittedField<'_>,
1192 _opts: &ResolvedOptions,
1193 _mode: Mode,
1194 ) -> Result<Option<AttrValue>, String> {
1195 Ok(field.value().map(|v| match v.trim() {
1196 "" => AttrValue::Null,
1197 date => AttrValue::Text(date.to_string()),
1198 }))
1199 }
1200
1201 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1202 let stored = stored.unwrap_or_default().trim();
1203 stored
1206 .split(['T', ' '])
1207 .next()
1208 .unwrap_or_default()
1209 .to_string()
1210 }
1211
1212 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1213 scalar_vm("date", cx)
1214 }
1215
1216 fn render_default(&self, vm: &FieldVm) -> Markup {
1217 Markup::from_template(&DateTmpl {
1218 name: &vm.name,
1219 id: &vm.id,
1220 value: vm.value.as_text(),
1221 required: vm.required,
1222 })
1223 .unwrap_or_default()
1224 }
1225}
1226
1227#[derive(Template)]
1228#[template(path = "fields/password.html")]
1229struct PasswordTmpl<'a> {
1230 name: &'a str,
1231 id: &'a str,
1232 required: bool,
1233}
1234
1235pub(crate) struct PasswordField;
1243
1244impl FieldType for PasswordField {
1245 fn view_key(&self) -> &'static str {
1246 "password"
1247 }
1248
1249 fn to_attr(
1250 &self,
1251 field: &SubmittedField<'_>,
1252 _opts: &ResolvedOptions,
1253 _mode: Mode,
1254 ) -> Result<Option<AttrValue>, String> {
1255 match field.value().map(str::trim).unwrap_or_default() {
1256 "" => Ok(None),
1258 plain => laterite_auth::password::hash_password(plain)
1259 .map(|hash| Some(AttrValue::Text(hash)))
1260 .map_err(|e| e.to_string()),
1261 }
1262 }
1263
1264 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
1267 String::new()
1268 }
1269
1270 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1271 scalar_vm("password", cx)
1272 }
1273
1274 fn render_default(&self, vm: &FieldVm) -> Markup {
1275 Markup::from_template(&PasswordTmpl {
1276 name: &vm.name,
1277 id: &vm.id,
1278 required: vm.required,
1279 })
1280 .unwrap_or_default()
1281 }
1282}
1283
1284#[derive(Template)]
1285#[template(path = "fields/radio.html")]
1286struct RadioTmpl<'a> {
1287 name: &'a str,
1288 options: &'a [OptionView],
1289 required: bool,
1290}
1291
1292pub(crate) struct RadioField;
1297
1298impl FieldType for RadioField {
1299 fn view_key(&self) -> &'static str {
1300 "radio"
1301 }
1302
1303 fn resolve_options(
1304 &self,
1305 raw: &serde_json::Value,
1306 _types: &FieldRegistry,
1307 ) -> Result<ResolvedOptions, OptionsError> {
1308 let opts: SelectOptions =
1309 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1310 Ok(ResolvedOptions::new(opts))
1311 }
1312
1313 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1314 let current = cx.value.as_text();
1315 let views: Vec<OptionView> = cx
1316 .opts
1317 .get::<SelectOptions>()
1318 .map(|o| {
1319 o.options
1320 .iter()
1321 .map(|so| OptionView {
1322 value: so.value.clone(),
1323 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
1324 selected: so.value == current,
1325 })
1326 .collect()
1327 })
1328 .unwrap_or_default();
1329 FieldVm {
1330 view_key: "radio".to_string(),
1331 name: cx.name.to_string(),
1332 id: cx.id.to_string(),
1333 label: cx.label.to_string(),
1334 required: cx.required,
1335 value: cx.value.clone(),
1336 data: serde_json::to_value(&views).unwrap_or_default(),
1337 }
1338 }
1339
1340 fn render_default(&self, vm: &FieldVm) -> Markup {
1341 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1342 Markup::from_template(&RadioTmpl {
1343 name: &vm.name,
1344 options: &options,
1345 required: vm.required,
1346 })
1347 .unwrap_or_default()
1348 }
1349}
1350
1351fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1353 FieldVm {
1354 view_key: view_key.to_string(),
1355 name: cx.name.to_string(),
1356 id: cx.id.to_string(),
1357 label: cx.label.to_string(),
1358 required: cx.required,
1359 value: cx.value.clone(),
1360 data: serde_json::Value::Null,
1361 }
1362}
1363
1364#[derive(Deserialize)]
1366struct RefOptions {
1367 source: String,
1368}
1369
1370struct RefResolved {
1372 source: String,
1373}
1374
1375#[derive(Default, Serialize, Deserialize)]
1378struct RefData {
1379 source: String,
1380 search_url: String,
1381 resolve_url: String,
1382 value: String,
1383 placeholder: String,
1384}
1385
1386#[derive(Template)]
1387#[template(path = "fields/ref_picker.html")]
1388struct RefPickerTmpl<'a> {
1389 name: &'a str,
1390 id: &'a str,
1391 value: &'a str,
1392 search_url: &'a str,
1393 resolve_url: &'a str,
1394 placeholder: &'a str,
1395}
1396
1397pub(crate) struct RefPickerField {
1402 pickers: Arc<PickerRegistry>,
1403}
1404
1405impl RefPickerField {
1406 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1407 Self { pickers }
1408 }
1409}
1410
1411impl FieldType for RefPickerField {
1412 fn view_key(&self) -> &'static str {
1413 "reference"
1414 }
1415 fn resolve_options(
1416 &self,
1417 raw: &serde_json::Value,
1418 _types: &FieldRegistry,
1419 ) -> Result<ResolvedOptions, OptionsError> {
1420 let opts: RefOptions =
1421 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1422 if !self.pickers.contains_key(&opts.source) {
1423 return Err(OptionsError(format!(
1424 "unknown picker source `{}`",
1425 opts.source
1426 )));
1427 }
1428 Ok(ResolvedOptions::new(RefResolved {
1429 source: opts.source,
1430 }))
1431 }
1432 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1433 let source = cx
1434 .opts
1435 .get::<RefResolved>()
1436 .map(|r| r.source.as_str())
1437 .unwrap_or_default();
1438 let data = RefData {
1439 search_url: format!("{}/pickers/{}/search", cx.base, source),
1440 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1441 source: source.to_string(),
1442 value: cx.value.as_text().to_string(),
1443 placeholder: "Search…".to_string(),
1444 };
1445 FieldVm {
1446 view_key: "reference".to_string(),
1447 name: cx.name.to_string(),
1448 id: cx.id.to_string(),
1449 label: cx.label.to_string(),
1450 required: cx.required,
1451 value: cx.value.clone(),
1452 data: serde_json::to_value(data).unwrap_or_default(),
1453 }
1454 }
1455 fn render_default(&self, vm: &FieldVm) -> Markup {
1456 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1457 Markup::from_template(&RefPickerTmpl {
1458 name: &vm.name,
1459 id: &vm.id,
1460 value: vm.value.as_text(),
1461 search_url: &data.search_url,
1462 resolve_url: &data.resolve_url,
1463 placeholder: &data.placeholder,
1464 })
1465 .unwrap_or_default()
1466 }
1467 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1468 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1469 }
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474 use super::*;
1475
1476 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1477 FieldCx {
1478 name,
1479 id: name,
1480 label: "Label",
1481 value,
1482 required: true,
1483 opts,
1484 base: "/admin",
1485 }
1486 }
1487
1488 fn text_field() -> TextField {
1489 TextField::new(Arc::new(builtin_input_registry()))
1490 }
1491
1492 struct BadAttrInput;
1494 impl InputType for BadAttrInput {
1495 fn key(&self) -> &'static str {
1496 "bad"
1497 }
1498 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1499 BTreeMap::from([("class".to_string(), "x".to_string())])
1500 }
1501 }
1502
1503 #[test]
1504 fn text_renders_its_value_escaped() {
1505 let opts = ResolvedOptions::none();
1506 let value = FieldValue::Text("a<b>&\"c".to_string());
1507 let markup = render_field(
1508 &text_field(),
1509 &NoOverrides,
1510 &scope(),
1511 &cx("title", &value, &opts),
1512 );
1513 let html = markup.as_str();
1514 assert!(html.contains(r#"name="title""#));
1515 assert!(html.contains("required"));
1516 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
1520 assert!(
1521 html.contains("<") || html.contains("<"),
1522 "angle escaped: {html}"
1523 );
1524 assert!(
1525 html.contains("&") || html.contains("&"),
1526 "ampersand escaped: {html}"
1527 );
1528 }
1529
1530 #[test]
1531 fn textarea_uses_its_own_control() {
1532 let opts = ResolvedOptions::none();
1533 let value = FieldValue::Text("body".to_string());
1534 let markup = render_field(
1535 &TextareaField,
1536 &NoOverrides,
1537 &scope(),
1538 &cx("body", &value, &opts),
1539 );
1540 assert!(markup.as_str().contains("<textarea"));
1541 }
1542
1543 #[test]
1544 fn no_overrides_uses_the_compiled_default() {
1545 let opts = ResolvedOptions::none();
1547 let value = FieldValue::Text("x".to_string());
1548 let ft = text_field();
1549 let vm = ft.view_model(&cx("t", &value, &opts));
1550 let direct = ft.render_default(&vm);
1551 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
1552 assert_eq!(direct.as_str(), routed.as_str());
1553 }
1554
1555 #[test]
1556 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
1557 let field = text_field();
1558 let opts = field
1559 .resolve_options(
1560 &serde_json::json!({ "input": "email" }),
1561 &builtin_registry(),
1562 )
1563 .unwrap();
1564 assert!(matches!(
1566 field.intrinsic_rules(&opts).as_slice(),
1567 [Rule::Email]
1568 ));
1569 let value = FieldValue::Text("a@b.test".to_string());
1570 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
1571 assert!(markup.as_str().contains(r#"type="email""#));
1572 }
1573
1574 #[test]
1575 fn text_default_input_is_plain_text_with_no_extra_rule() {
1576 let field = text_field();
1577 let opts = field
1578 .resolve_options(&serde_json::Value::Null, &builtin_registry())
1579 .unwrap();
1580 assert!(field.intrinsic_rules(&opts).is_empty());
1581 let value = FieldValue::Text("hi".to_string());
1582 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
1583 assert!(markup.as_str().contains(r#"type="text""#));
1584 }
1585
1586 #[test]
1587 fn text_number_input_sets_the_type_and_numeric_rule() {
1588 let field = text_field();
1589 let opts = field
1590 .resolve_options(
1591 &serde_json::json!({ "input": "number" }),
1592 &builtin_registry(),
1593 )
1594 .unwrap();
1595 assert!(matches!(
1596 field.intrinsic_rules(&opts).as_slice(),
1597 [Rule::Numeric]
1598 ));
1599 let value = FieldValue::Text("42".to_string());
1600 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1601 assert!(markup.as_str().contains(r#"type="number""#));
1602 }
1603
1604 #[test]
1605 fn text_tel_input_sets_the_type_with_no_rule() {
1606 let field = text_field();
1607 let opts = field
1608 .resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
1609 .unwrap();
1610 assert!(field.intrinsic_rules(&opts).is_empty());
1611 let value = FieldValue::Text(String::new());
1612 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
1613 assert!(markup.as_str().contains(r#"type="tel""#));
1614 }
1615
1616 #[test]
1617 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
1618 let field = text_field();
1619 let opts = field
1620 .resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
1621 .unwrap();
1622 assert!(matches!(
1623 field.intrinsic_rules(&opts).as_slice(),
1624 [Rule::Url]
1625 ));
1626 let value = FieldValue::Text("https://example.com".to_string());
1627 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1628 let html = markup.as_str();
1629 assert!(html.contains(r#"type="url""#), "{html}");
1630 assert!(html.contains("lat-input-group"), "{html}");
1631 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
1632 }
1633
1634 #[test]
1635 fn text_url_input_copy_false_omits_the_button() {
1636 let field = text_field();
1637 let opts = field
1638 .resolve_options(
1639 &serde_json::json!({ "input": "url", "copy": false }),
1640 &builtin_registry(),
1641 )
1642 .unwrap();
1643 let value = FieldValue::Text("https://example.com".to_string());
1644 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1645 let html = markup.as_str();
1646 assert!(html.contains(r#"type="url""#), "{html}");
1647 assert!(!html.contains("lat-input-group"), "{html}");
1649 assert!(!html.contains("data-lat-widget"), "{html}");
1650 }
1651
1652 #[test]
1653 fn text_number_input_emits_min_max_step_attributes() {
1654 let field = text_field();
1655 let opts = field
1656 .resolve_options(
1657 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
1658 &builtin_registry(),
1659 )
1660 .unwrap();
1661 let value = FieldValue::Text("5".to_string());
1664 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1665 let html = markup.as_str();
1666 assert!(html.contains(r#"min="0""#), "{html}");
1667 assert!(html.contains(r#"max="10""#), "{html}");
1668 assert!(html.contains(r#"step="2""#), "{html}");
1669 }
1670
1671 #[test]
1672 fn text_input_reserved_attribute_name_aborts_resolve() {
1673 let mut inputs = builtin_input_registry();
1674 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
1675 let field = TextField::new(Arc::new(inputs));
1676 let Err(e) =
1677 field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
1678 else {
1679 panic!("expected a reserved-attribute rejection");
1680 };
1681 assert!(e.0.contains("class"), "{e}");
1682 }
1683
1684 #[test]
1685 fn attribute_and_widget_name_rules() {
1686 assert!(is_name("min", false));
1687 assert!(is_name("data-x", false));
1688 assert!(!is_name("Min", false));
1689 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
1692 }
1693
1694 #[test]
1695 fn select_renders_options_with_the_current_value_selected() {
1696 let raw = serde_json::json!({
1697 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1698 });
1699 let opts = SelectField
1700 .resolve_options(&raw, &builtin_registry())
1701 .unwrap();
1702 let value = FieldValue::Text("closed".to_string());
1703 let markup = render_field(
1704 &SelectField,
1705 &NoOverrides,
1706 &scope(),
1707 &cx("status", &value, &opts),
1708 );
1709 let html = markup.as_str();
1710 assert!(html.contains(r#"<option value="open">Open</option>"#));
1711 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
1713 }
1714
1715 #[test]
1716 fn radio_renders_the_same_options_with_the_current_one_checked() {
1717 let raw = serde_json::json!({
1718 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1719 });
1720 let opts = RadioField
1721 .resolve_options(&raw, &builtin_registry())
1722 .unwrap();
1723 let value = FieldValue::Text("closed".to_string());
1724 let markup = render_field(
1725 &RadioField,
1726 &NoOverrides,
1727 &scope(),
1728 &cx("status", &value, &opts),
1729 );
1730 let html = markup.as_str();
1731 assert!(html.contains(r#"value="open""#));
1732 assert!(
1733 html.contains(r#"value="closed" checked"#),
1734 "current is checked"
1735 );
1736 assert_eq!(html.matches(r#"name="status""#).count(), 2);
1739 assert!(html.contains("closed</label>"));
1741 }
1742
1743 fn scope<'a>() -> OverrideScope<'a> {
1744 OverrideScope {
1745 surface: Surface::Field,
1746 view_key: "text",
1747 resource: None,
1748 field: None,
1749 }
1750 }
1751
1752 fn ref_field() -> RefPickerField {
1753 let mut pickers = crate::picker::PickerRegistry::new();
1754 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
1755 pickers.insert(
1756 "acme.place".to_string(),
1757 crate::picker::PickerSourceReg::new("acme.place", source),
1758 );
1759 RefPickerField::new(Arc::new(pickers))
1760 }
1761
1762 #[test]
1763 fn reference_rejects_an_unknown_source() {
1764 let field = ref_field();
1765 let Err(e) = field.resolve_options(
1766 &serde_json::json!({ "source": "no.such" }),
1767 &builtin_registry(),
1768 ) else {
1769 panic!("expected an unknown-source rejection");
1770 };
1771 assert!(e.0.contains("no.such"), "{e}");
1772 }
1773
1774 #[test]
1775 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
1776 let field = ref_field();
1777 let opts = field
1778 .resolve_options(
1779 &serde_json::json!({ "source": "acme.place" }),
1780 &builtin_registry(),
1781 )
1782 .unwrap();
1783 let value = FieldValue::Text("42".to_string());
1784 let markup = render_field(
1785 &field,
1786 &NoOverrides,
1787 &scope(),
1788 &cx("place_id", &value, &opts),
1789 );
1790 let html = markup.as_str();
1791 assert!(html.contains(r#"type="hidden""#), "{html}");
1794 assert!(html.contains(r#"name="place_id""#), "{html}");
1795 assert!(html.contains(r#"value="42""#), "{html}");
1796 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
1798 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
1799 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
1800 }
1801
1802 #[test]
1803 fn reference_declares_its_widget_assets() {
1804 let field = ref_field();
1805 let opts = field
1806 .resolve_options(
1807 &serde_json::json!({ "source": "acme.place" }),
1808 &builtin_registry(),
1809 )
1810 .unwrap();
1811 assert_eq!(
1812 field.assets(&opts),
1813 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1814 );
1815 }
1816}
1817
1818#[cfg(test)]
1819mod repeater_tests {
1820 use super::*;
1821
1822 fn options() -> ResolvedOptions {
1823 RepeaterField
1824 .resolve_options(
1825 &serde_json::json!({
1826 "fields": [
1827 { "name": "path", "label": "Path", "type": "text" },
1828 { "name": "allow", "label": "Allow", "type": "switch" },
1829 ]
1830 }),
1831 &builtin_registry(),
1832 )
1833 .unwrap()
1834 }
1835
1836 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1837 pairs
1838 .iter()
1839 .map(|(k, v)| (k.to_string(), v.to_string()))
1840 .collect()
1841 }
1842
1843 fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
1844 let field = SubmittedField::new("rules", data);
1845 match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
1846 Some(AttrValue::Json(value)) => value,
1847 other => panic!("expected a JSON array, got {other:?}"),
1848 }
1849 }
1850
1851 #[test]
1855 fn a_row_is_typed_by_its_sub_field_types() {
1856 let rows = stored(
1857 &submitted(&[
1858 ("rules[0][path]", "/private"),
1859 ("rules[1][path]", "/public"),
1860 ("rules[1][allow]", "on"),
1861 ]),
1862 &options(),
1863 );
1864
1865 assert_eq!(rows[0]["path"], "/private");
1866 assert_eq!(rows[0]["allow"], serde_json::json!(false));
1867 assert_eq!(rows[1]["allow"], serde_json::json!(true));
1868 }
1869
1870 #[test]
1873 fn rows_are_ordered_by_index_not_submission_order() {
1874 let rows = stored(
1875 &submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
1876 &options(),
1877 );
1878 assert_eq!(rows.as_array().unwrap().len(), 2);
1879 assert_eq!(rows[0]["path"], "/first");
1880 assert_eq!(rows[1]["path"], "/last");
1881 }
1882
1883 #[test]
1884 fn a_row_left_blank_is_dropped() {
1885 let rows = stored(
1886 &submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
1887 &options(),
1888 );
1889 assert_eq!(rows.as_array().unwrap().len(), 1);
1890 }
1891
1892 #[test]
1893 fn nothing_submitted_stores_an_empty_list() {
1894 assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
1895 }
1896
1897 #[test]
1898 fn a_repeater_refuses_to_hold_a_repeater() {
1899 let err = RepeaterField
1900 .resolve_options(
1901 &serde_json::json!({
1902 "fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
1903 }),
1904 &builtin_registry(),
1905 )
1906 .err()
1907 .expect("a nested repeater is refused");
1908 assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
1909 }
1910
1911 #[test]
1912 fn an_unregistered_sub_field_type_is_refused_at_boot() {
1913 let err = RepeaterField
1914 .resolve_options(
1915 &serde_json::json!({
1916 "fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
1917 }),
1918 &builtin_registry(),
1919 )
1920 .err()
1921 .expect("an unregistered type is refused");
1922 assert!(err.0.contains("acme.nope"), "{}", err.0);
1923 }
1924
1925 #[test]
1926 fn row_counts_are_bounded_when_asked() {
1927 let opts = RepeaterField
1928 .resolve_options(
1929 &serde_json::json!({
1930 "fields": [{ "name": "path", "label": "Path", "type": "text" }],
1931 "min_items": 1,
1932 "max_items": 2
1933 }),
1934 &builtin_registry(),
1935 )
1936 .unwrap();
1937
1938 let empty = HashMap::new();
1939 let too_few = SubmittedField::new("rules", &empty);
1940 assert!(RepeaterField
1941 .to_attr(&too_few, &opts, Mode::Create)
1942 .is_err());
1943
1944 let three = submitted(&[
1945 ("rules[0][path]", "/a"),
1946 ("rules[1][path]", "/b"),
1947 ("rules[2][path]", "/c"),
1948 ]);
1949 let field = SubmittedField::new("rules", &three);
1950 assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
1951 }
1952
1953 #[test]
1954 fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
1955 let value = FieldValue::Json(serde_json::json!([
1956 { "path": "/private", "allow": false },
1957 ]));
1958 let cx = FieldCx {
1959 name: "rules",
1960 id: "rules",
1961 label: "Rules",
1962 value: &value,
1963 required: false,
1964 opts: &options(),
1965 base: "/admin",
1966 };
1967 let html = RepeaterField
1968 .render_default(&RepeaterField.view_model(&cx))
1969 .into_string();
1970
1971 assert!(html.contains(r#"name="rules[0][path]""#));
1972 assert!(html.contains(r#"value="/private""#));
1973 assert!(html.contains("lat-repeater__blank"));
1975 assert!(html.contains(r#"name="rules[__index__][path]""#));
1976 }
1977}
1978
1979#[cfg(test)]
1980mod save_contract_tests {
1981 use super::*;
1982
1983 fn one(raw: Option<&str>) -> HashMap<String, String> {
1985 raw.into_iter()
1986 .map(|v| ("f".to_string(), v.to_string()))
1987 .collect()
1988 }
1989
1990 fn none() -> ResolvedOptions {
1991 ResolvedOptions::none()
1992 }
1993
1994 #[test]
1995 fn a_switch_stores_a_bool_and_absence_means_off() {
1996 let f = SwitchField;
1997 assert_eq!(
2000 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2001 .unwrap(),
2002 Some(AttrValue::Bool(false))
2003 );
2004 assert_eq!(
2005 f.to_attr(
2006 &SubmittedField::new("f", &one(Some("on"))),
2007 &none(),
2008 Mode::Create
2009 )
2010 .unwrap(),
2011 Some(AttrValue::Bool(true))
2012 );
2013 }
2014
2015 #[test]
2016 fn a_switch_reads_back_whatever_the_backend_gave() {
2017 let f = SwitchField;
2018 for on in ["1", "true", "t", "on"] {
2020 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
2021 }
2022 for off in ["0", "false", "f", ""] {
2023 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
2024 }
2025 assert!(f.to_control(None, &none()).is_empty());
2026 }
2027
2028 #[test]
2029 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
2030 let f = DateField;
2031 assert_eq!(
2033 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
2034 "2026-09-09"
2035 );
2036 assert_eq!(
2037 f.to_control(Some("2026-09-09 12:30:45"), &none()),
2038 "2026-09-09"
2039 );
2040 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
2041 assert_eq!(f.to_control(None, &none()), "");
2042 }
2043
2044 #[test]
2045 fn a_cleared_date_stores_null_not_an_empty_string() {
2046 let f = DateField;
2047 assert_eq!(
2048 f.to_attr(
2049 &SubmittedField::new("f", &one(Some(""))),
2050 &none(),
2051 Mode::Update
2052 )
2053 .unwrap(),
2054 Some(AttrValue::Null)
2055 );
2056 assert_eq!(
2057 f.to_attr(
2058 &SubmittedField::new("f", &one(Some("2026-09-09"))),
2059 &none(),
2060 Mode::Create
2061 )
2062 .unwrap(),
2063 Some(AttrValue::Text("2026-09-09".into()))
2064 );
2065 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
2066 }
2067
2068 #[test]
2069 fn a_password_hashes_and_never_echoes() {
2070 let f = PasswordField;
2071 let stored = f
2072 .to_attr(
2073 &SubmittedField::new("f", &one(Some("hunter2hunter2"))),
2074 &none(),
2075 Mode::Create,
2076 )
2077 .unwrap()
2078 .unwrap();
2079 let hash = stored.as_str().unwrap();
2080 assert!(
2081 hash.starts_with("$argon2"),
2082 "stored as a hash, not plaintext"
2083 );
2084 assert_ne!(hash, "hunter2hunter2");
2085 assert_eq!(f.to_control(Some(hash), &none()), "");
2087 }
2088
2089 #[test]
2090 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
2091 let f = PasswordField;
2092 assert_eq!(
2094 f.to_attr(
2095 &SubmittedField::new("f", &one(Some(""))),
2096 &none(),
2097 Mode::Update
2098 )
2099 .unwrap(),
2100 None
2101 );
2102 assert_eq!(
2103 f.to_attr(
2104 &SubmittedField::new("f", &one(Some(" "))),
2105 &none(),
2106 Mode::Update
2107 )
2108 .unwrap(),
2109 None
2110 );
2111 assert_eq!(
2112 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2113 .unwrap(),
2114 None
2115 );
2116 }
2117
2118 #[test]
2119 fn a_plain_field_stores_and_shows_the_text_unchanged() {
2120 let f = TextareaField;
2121 assert_eq!(
2122 f.to_attr(
2123 &SubmittedField::new("f", &one(Some("hello"))),
2124 &none(),
2125 Mode::Create
2126 )
2127 .unwrap(),
2128 Some(AttrValue::Text("hello".into()))
2129 );
2130 assert_eq!(
2132 f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
2133 .unwrap(),
2134 None
2135 );
2136 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
2137 }
2138}