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 trait FieldType: Send + Sync + 'static {
104 fn view_key(&self) -> &'static str;
107
108 fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
111 Ok(ResolvedOptions::none())
112 }
113
114 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
117 Vec::new()
118 }
119
120 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
122
123 fn render_default(&self, vm: &FieldVm) -> Markup;
126
127 fn to_attr(
141 &self,
142 raw: Option<&str>,
143 opts: &ResolvedOptions,
144 mode: Mode,
145 ) -> Result<Option<AttrValue>, String> {
146 let _ = (opts, mode);
147 Ok(raw.map(|v| AttrValue::Text(v.to_string())))
148 }
149
150 fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
159 let _ = opts;
160 stored.unwrap_or_default().to_string()
161 }
162
163 fn chrome(&self) -> Chrome {
165 Chrome::Wrapped
166 }
167
168 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
173 Vec::new()
174 }
175}
176
177#[derive(Debug, thiserror::Error)]
179#[error("invalid field options: {0}")]
180pub struct OptionsError(pub String);
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Surface {
185 Field,
186 Column,
187}
188
189pub struct OverrideScope<'a> {
192 pub surface: Surface,
193 pub view_key: &'a str,
194 pub resource: Option<&'a str>,
195 pub field: Option<&'a str>,
196}
197
198#[derive(Debug, thiserror::Error)]
200#[error("override render failed: {0}")]
201pub struct OverrideError(pub String);
202
203pub trait OverrideResolver: Send + Sync {
207 fn render_override(
210 &self,
211 scope: &OverrideScope<'_>,
212 vm: &serde_json::Value,
213 ) -> Option<Result<String, OverrideError>>;
214}
215
216pub struct NoOverrides;
219
220impl OverrideResolver for NoOverrides {
221 fn render_override(
222 &self,
223 _scope: &OverrideScope<'_>,
224 _vm: &serde_json::Value,
225 ) -> Option<Result<String, OverrideError>> {
226 None
227 }
228}
229
230pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
232
233pub(crate) fn render_field(
236 ft: &dyn FieldType,
237 resolver: &dyn OverrideResolver,
238 scope: &OverrideScope<'_>,
239 cx: &FieldCx<'_>,
240) -> Markup {
241 let vm = ft.view_model(cx);
242 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
243 Some(Ok(html)) => Markup::from_override(html),
244 Some(Err(_)) | None => ft.render_default(&vm),
246 }
247}
248
249pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
252 let inputs = Arc::new(builtin_input_registry());
253 vec![
254 Arc::new(TextField::new(inputs)),
255 Arc::new(TextareaField),
256 Arc::new(SelectField),
257 Arc::new(SwitchField),
258 Arc::new(DateField),
259 Arc::new(PasswordField),
260 Arc::new(RadioField),
261 ]
262}
263
264pub(crate) fn builtin_registry() -> FieldRegistry {
266 builtin_field_types()
267 .into_iter()
268 .map(|ft| (ft.view_key().to_string(), ft))
269 .collect()
270}
271
272#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
274#[serde(rename_all = "snake_case")]
275pub enum Placement {
276 Leading,
277 Trailing,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Adornment {
285 pub placement: Placement,
286 pub button: bool,
288 pub label: String,
290 pub widget: Option<String>,
293}
294
295pub trait InputType: Send + Sync + 'static {
301 fn key(&self) -> &'static str;
303 fn html_type(&self) -> &'static str {
305 "text"
306 }
307 fn resolve_options(&self, _raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
310 Ok(ResolvedOptions::none())
311 }
312 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
314 Vec::new()
315 }
316 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
319 BTreeMap::new()
320 }
321 fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
323 Vec::new()
324 }
325 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
328 Vec::new()
329 }
330}
331
332pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
334
335struct TextInput;
336impl InputType for TextInput {
337 fn key(&self) -> &'static str {
338 "text"
339 }
340}
341
342struct EmailInput;
343impl InputType for EmailInput {
344 fn key(&self) -> &'static str {
345 "email"
346 }
347 fn html_type(&self) -> &'static str {
348 "email"
349 }
350 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
351 vec![Rule::Email]
352 }
353}
354
355struct TelInput;
356impl InputType for TelInput {
357 fn key(&self) -> &'static str {
358 "tel"
359 }
360 fn html_type(&self) -> &'static str {
361 "tel"
362 }
363}
364
365#[derive(Default, Deserialize)]
367struct NumberOptions {
368 #[serde(default)]
369 min: Option<f64>,
370 #[serde(default)]
371 max: Option<f64>,
372 #[serde(default)]
373 step: Option<f64>,
374}
375
376struct NumberInput;
377impl InputType for NumberInput {
378 fn key(&self) -> &'static str {
379 "number"
380 }
381 fn html_type(&self) -> &'static str {
382 "number"
383 }
384 fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
385 let opts: NumberOptions = if raw.is_null() {
386 NumberOptions::default()
387 } else {
388 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
389 };
390 Ok(ResolvedOptions::new(opts))
391 }
392 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
393 vec![Rule::Numeric]
394 }
395 fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
396 let mut attrs = BTreeMap::new();
397 if let Some(o) = opts.get::<NumberOptions>() {
398 if let Some(min) = o.min {
399 attrs.insert("min".to_string(), min.to_string());
400 }
401 if let Some(max) = o.max {
402 attrs.insert("max".to_string(), max.to_string());
403 }
404 if let Some(step) = o.step {
405 attrs.insert("step".to_string(), step.to_string());
406 }
407 }
408 attrs
409 }
410}
411
412#[derive(Deserialize)]
414struct UrlOptions {
415 #[serde(default = "default_true")]
416 copy: bool,
417}
418
419impl Default for UrlOptions {
420 fn default() -> Self {
421 Self { copy: true }
422 }
423}
424
425fn default_true() -> bool {
426 true
427}
428
429struct UrlInput;
430impl InputType for UrlInput {
431 fn key(&self) -> &'static str {
432 "url"
433 }
434 fn html_type(&self) -> &'static str {
435 "url"
436 }
437 fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
438 let opts: UrlOptions = if raw.is_null() {
439 UrlOptions::default()
440 } else {
441 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
442 };
443 Ok(ResolvedOptions::new(opts))
444 }
445 fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
446 vec![Rule::Url]
447 }
448 fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
449 if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
450 vec![Adornment {
451 placement: Placement::Trailing,
452 button: true,
453 label: "Copy".to_string(),
454 widget: Some("copy".to_string()),
455 }]
456 } else {
457 Vec::new()
458 }
459 }
460}
461
462pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
463 vec![
464 Arc::new(TextInput),
465 Arc::new(EmailInput),
466 Arc::new(TelInput),
467 Arc::new(NumberInput),
468 Arc::new(UrlInput),
469 ]
470}
471
472pub(crate) fn builtin_input_registry() -> InputRegistry {
474 builtin_input_types()
475 .into_iter()
476 .map(|i| (i.key().to_string(), i))
477 .collect()
478}
479
480const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
483
484fn validate_contributions(
489 attrs: &BTreeMap<String, String>,
490 adornments: &[Adornment],
491) -> Result<(), OptionsError> {
492 for name in attrs.keys() {
493 if !is_name(name, false) {
494 return Err(OptionsError(format!("invalid attribute name `{name}`")));
495 }
496 if RESERVED_ATTRS.contains(&name.as_str()) {
497 return Err(OptionsError(format!("attribute `{name}` is reserved")));
498 }
499 }
500 for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
501 if !is_name(widget, true) {
502 return Err(OptionsError(format!("invalid widget name `{widget}`")));
503 }
504 }
505 Ok(())
506}
507
508pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
510 let mut chars = s.chars();
511 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
512 && chars.all(|c| {
513 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
514 })
515}
516
517#[derive(Deserialize)]
519struct TextOptions {
520 #[serde(default = "default_input")]
521 input: String,
522}
523
524fn default_input() -> String {
525 "text".to_string()
526}
527
528struct TextResolved {
531 html_type: &'static str,
532 attrs: BTreeMap<String, String>,
533 adornments: Vec<Adornment>,
534 rules: Vec<Rule>,
535 assets: Vec<&'static str>,
536}
537
538#[derive(Default, Serialize, Deserialize)]
541struct TextData {
542 input_type: String,
543 #[serde(default)]
544 attrs: BTreeMap<String, String>,
545 #[serde(default)]
546 adornments: Vec<Adornment>,
547}
548
549#[derive(Template)]
550#[template(path = "fields/text.html")]
551struct TextTmpl<'a> {
552 name: &'a str,
553 id: &'a str,
554 value: &'a str,
555 required: bool,
556 input_type: &'a str,
557 attrs: &'a BTreeMap<String, String>,
558 leading: &'a [Adornment],
559 trailing: &'a [Adornment],
560}
561
562pub(crate) struct TextField {
566 inputs: Arc<InputRegistry>,
567}
568
569impl TextField {
570 pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
571 Self { inputs }
572 }
573}
574
575impl FieldType for TextField {
576 fn view_key(&self) -> &'static str {
577 "text"
578 }
579 fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
580 let opts: TextOptions = if raw.is_null() {
581 TextOptions {
582 input: default_input(),
583 }
584 } else {
585 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
586 };
587 let input = self
588 .inputs
589 .get(&opts.input)
590 .cloned()
591 .ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
592 let input_opts = input.resolve_options(raw)?;
595 let attrs = input.attributes(&input_opts);
596 let adornments = input.adornments(&input_opts);
597 validate_contributions(&attrs, &adornments)?;
598 Ok(ResolvedOptions::new(TextResolved {
599 html_type: input.html_type(),
600 rules: input.rules(&input_opts),
601 assets: input.assets(&input_opts),
602 attrs,
603 adornments,
604 }))
605 }
606 fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
607 opts.get::<TextResolved>()
608 .map(|r| r.rules.clone())
609 .unwrap_or_default()
610 }
611 fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
612 opts.get::<TextResolved>()
613 .map(|r| r.assets.clone())
614 .unwrap_or_default()
615 }
616 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
617 let data = match cx.opts.get::<TextResolved>() {
618 Some(r) => TextData {
619 input_type: r.html_type.to_string(),
620 attrs: r.attrs.clone(),
621 adornments: r.adornments.clone(),
622 },
623 None => TextData {
624 input_type: "text".to_string(),
625 ..TextData::default()
626 },
627 };
628 FieldVm {
629 view_key: "text".to_string(),
630 name: cx.name.to_string(),
631 id: cx.id.to_string(),
632 label: cx.label.to_string(),
633 required: cx.required,
634 value: cx.value.clone(),
635 data: serde_json::to_value(data).unwrap_or_default(),
636 }
637 }
638 fn render_default(&self, vm: &FieldVm) -> Markup {
639 let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
640 let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
641 .adornments
642 .into_iter()
643 .partition(|a| matches!(a.placement, Placement::Leading));
644 Markup::from_template(&TextTmpl {
645 name: &vm.name,
646 id: &vm.id,
647 value: vm.value.as_text(),
648 required: vm.required,
649 input_type: &data.input_type,
650 attrs: &data.attrs,
651 leading: &leading,
652 trailing: &trailing,
653 })
654 .unwrap_or_default()
655 }
656}
657
658#[derive(Template)]
659#[template(path = "fields/textarea.html")]
660struct TextareaTmpl<'a> {
661 name: &'a str,
662 id: &'a str,
663 value: &'a str,
664 required: bool,
665}
666
667pub(crate) struct TextareaField;
669
670impl FieldType for TextareaField {
671 fn view_key(&self) -> &'static str {
672 "textarea"
673 }
674 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
675 scalar_vm("textarea", cx)
676 }
677 fn render_default(&self, vm: &FieldVm) -> Markup {
678 Markup::from_template(&TextareaTmpl {
679 name: &vm.name,
680 id: &vm.id,
681 value: vm.value.as_text(),
682 required: vm.required,
683 })
684 .unwrap_or_default()
685 }
686}
687
688#[derive(Debug, Default, Deserialize)]
690struct SelectOptions {
691 #[serde(default)]
692 options: Vec<SelectOption>,
693}
694
695#[derive(Debug, Deserialize)]
696struct SelectOption {
697 value: String,
698 #[serde(default)]
700 label: Option<String>,
701}
702
703#[derive(Debug, Serialize, Deserialize)]
706struct OptionView {
707 value: String,
708 label: String,
709 selected: bool,
710}
711
712#[derive(Template)]
713#[template(path = "fields/select.html")]
714struct SelectTmpl<'a> {
715 name: &'a str,
716 id: &'a str,
717 required: bool,
718 options: &'a [OptionView],
719}
720
721pub(crate) struct SelectField;
723
724impl FieldType for SelectField {
725 fn view_key(&self) -> &'static str {
726 "select"
727 }
728 fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
729 let opts: SelectOptions =
730 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
731 Ok(ResolvedOptions::new(opts))
732 }
733 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
734 let current = cx.value.as_text();
735 let views: Vec<OptionView> = cx
736 .opts
737 .get::<SelectOptions>()
738 .map(|o| {
739 o.options
740 .iter()
741 .map(|so| OptionView {
742 value: so.value.clone(),
743 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
744 selected: so.value == current,
745 })
746 .collect()
747 })
748 .unwrap_or_default();
749 FieldVm {
750 view_key: "select".to_string(),
751 name: cx.name.to_string(),
752 id: cx.id.to_string(),
753 label: cx.label.to_string(),
754 required: cx.required,
755 value: cx.value.clone(),
756 data: serde_json::to_value(&views).unwrap_or_default(),
757 }
758 }
759 fn render_default(&self, vm: &FieldVm) -> Markup {
760 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
761 Markup::from_template(&SelectTmpl {
762 name: &vm.name,
763 id: &vm.id,
764 required: vm.required,
765 options: &options,
766 })
767 .unwrap_or_default()
768 }
769}
770
771#[derive(Template)]
772#[template(path = "fields/switch.html")]
773struct SwitchTmpl<'a> {
774 name: &'a str,
775 id: &'a str,
776 on: bool,
777}
778
779pub(crate) struct SwitchField;
785
786fn stored_is_on(stored: Option<&str>) -> bool {
789 matches!(
790 stored.map(str::trim).unwrap_or_default(),
791 "1" | "true" | "TRUE" | "t" | "yes" | "on"
792 )
793}
794
795impl FieldType for SwitchField {
796 fn view_key(&self) -> &'static str {
797 "switch"
798 }
799
800 fn to_attr(
801 &self,
802 raw: Option<&str>,
803 _opts: &ResolvedOptions,
804 _mode: Mode,
805 ) -> Result<Option<AttrValue>, String> {
806 Ok(Some(AttrValue::Bool(stored_is_on(raw))))
809 }
810
811 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
812 if stored_is_on(stored) {
813 "1".to_string()
814 } else {
815 String::new()
816 }
817 }
818
819 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
820 scalar_vm("switch", cx)
821 }
822
823 fn render_default(&self, vm: &FieldVm) -> Markup {
824 Markup::from_template(&SwitchTmpl {
825 name: &vm.name,
826 id: &vm.id,
827 on: stored_is_on(Some(vm.value.as_text())),
828 })
829 .unwrap_or_default()
830 }
831}
832
833#[derive(Template)]
834#[template(path = "fields/date.html")]
835struct DateTmpl<'a> {
836 name: &'a str,
837 id: &'a str,
838 value: &'a str,
839 required: bool,
840}
841
842pub(crate) struct DateField;
848
849impl FieldType for DateField {
850 fn view_key(&self) -> &'static str {
851 "date"
852 }
853
854 fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
855 vec![Rule::Date]
856 }
857
858 fn to_attr(
859 &self,
860 raw: Option<&str>,
861 _opts: &ResolvedOptions,
862 _mode: Mode,
863 ) -> Result<Option<AttrValue>, String> {
864 Ok(raw.map(|v| match v.trim() {
865 "" => AttrValue::Null,
866 date => AttrValue::Text(date.to_string()),
867 }))
868 }
869
870 fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
871 let stored = stored.unwrap_or_default().trim();
872 stored
875 .split(['T', ' '])
876 .next()
877 .unwrap_or_default()
878 .to_string()
879 }
880
881 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
882 scalar_vm("date", cx)
883 }
884
885 fn render_default(&self, vm: &FieldVm) -> Markup {
886 Markup::from_template(&DateTmpl {
887 name: &vm.name,
888 id: &vm.id,
889 value: vm.value.as_text(),
890 required: vm.required,
891 })
892 .unwrap_or_default()
893 }
894}
895
896#[derive(Template)]
897#[template(path = "fields/password.html")]
898struct PasswordTmpl<'a> {
899 name: &'a str,
900 id: &'a str,
901 required: bool,
902}
903
904pub(crate) struct PasswordField;
912
913impl FieldType for PasswordField {
914 fn view_key(&self) -> &'static str {
915 "password"
916 }
917
918 fn to_attr(
919 &self,
920 raw: Option<&str>,
921 _opts: &ResolvedOptions,
922 _mode: Mode,
923 ) -> Result<Option<AttrValue>, String> {
924 match raw.map(str::trim).unwrap_or_default() {
925 "" => Ok(None),
927 plain => laterite_auth::password::hash_password(plain)
928 .map(|hash| Some(AttrValue::Text(hash)))
929 .map_err(|e| e.to_string()),
930 }
931 }
932
933 fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
936 String::new()
937 }
938
939 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
940 scalar_vm("password", cx)
941 }
942
943 fn render_default(&self, vm: &FieldVm) -> Markup {
944 Markup::from_template(&PasswordTmpl {
945 name: &vm.name,
946 id: &vm.id,
947 required: vm.required,
948 })
949 .unwrap_or_default()
950 }
951}
952
953#[derive(Template)]
954#[template(path = "fields/radio.html")]
955struct RadioTmpl<'a> {
956 name: &'a str,
957 options: &'a [OptionView],
958 required: bool,
959}
960
961pub(crate) struct RadioField;
966
967impl FieldType for RadioField {
968 fn view_key(&self) -> &'static str {
969 "radio"
970 }
971
972 fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
973 let opts: SelectOptions =
974 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
975 Ok(ResolvedOptions::new(opts))
976 }
977
978 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
979 let current = cx.value.as_text();
980 let views: Vec<OptionView> = cx
981 .opts
982 .get::<SelectOptions>()
983 .map(|o| {
984 o.options
985 .iter()
986 .map(|so| OptionView {
987 value: so.value.clone(),
988 label: so.label.clone().unwrap_or_else(|| so.value.clone()),
989 selected: so.value == current,
990 })
991 .collect()
992 })
993 .unwrap_or_default();
994 FieldVm {
995 view_key: "radio".to_string(),
996 name: cx.name.to_string(),
997 id: cx.id.to_string(),
998 label: cx.label.to_string(),
999 required: cx.required,
1000 value: cx.value.clone(),
1001 data: serde_json::to_value(&views).unwrap_or_default(),
1002 }
1003 }
1004
1005 fn render_default(&self, vm: &FieldVm) -> Markup {
1006 let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1007 Markup::from_template(&RadioTmpl {
1008 name: &vm.name,
1009 options: &options,
1010 required: vm.required,
1011 })
1012 .unwrap_or_default()
1013 }
1014}
1015
1016fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
1018 FieldVm {
1019 view_key: view_key.to_string(),
1020 name: cx.name.to_string(),
1021 id: cx.id.to_string(),
1022 label: cx.label.to_string(),
1023 required: cx.required,
1024 value: cx.value.clone(),
1025 data: serde_json::Value::Null,
1026 }
1027}
1028
1029#[derive(Deserialize)]
1031struct RefOptions {
1032 source: String,
1033}
1034
1035struct RefResolved {
1037 source: String,
1038}
1039
1040#[derive(Default, Serialize, Deserialize)]
1043struct RefData {
1044 source: String,
1045 search_url: String,
1046 resolve_url: String,
1047 value: String,
1048 placeholder: String,
1049}
1050
1051#[derive(Template)]
1052#[template(path = "fields/ref_picker.html")]
1053struct RefPickerTmpl<'a> {
1054 name: &'a str,
1055 id: &'a str,
1056 value: &'a str,
1057 search_url: &'a str,
1058 resolve_url: &'a str,
1059 placeholder: &'a str,
1060}
1061
1062pub(crate) struct RefPickerField {
1067 pickers: Arc<PickerRegistry>,
1068}
1069
1070impl RefPickerField {
1071 pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
1072 Self { pickers }
1073 }
1074}
1075
1076impl FieldType for RefPickerField {
1077 fn view_key(&self) -> &'static str {
1078 "reference"
1079 }
1080 fn resolve_options(&self, raw: &serde_json::Value) -> Result<ResolvedOptions, OptionsError> {
1081 let opts: RefOptions =
1082 serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
1083 if !self.pickers.contains_key(&opts.source) {
1084 return Err(OptionsError(format!(
1085 "unknown picker source `{}`",
1086 opts.source
1087 )));
1088 }
1089 Ok(ResolvedOptions::new(RefResolved {
1090 source: opts.source,
1091 }))
1092 }
1093 fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
1094 let source = cx
1095 .opts
1096 .get::<RefResolved>()
1097 .map(|r| r.source.as_str())
1098 .unwrap_or_default();
1099 let data = RefData {
1100 search_url: format!("{}/pickers/{}/search", cx.base, source),
1101 resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
1102 source: source.to_string(),
1103 value: cx.value.as_text().to_string(),
1104 placeholder: "Search…".to_string(),
1105 };
1106 FieldVm {
1107 view_key: "reference".to_string(),
1108 name: cx.name.to_string(),
1109 id: cx.id.to_string(),
1110 label: cx.label.to_string(),
1111 required: cx.required,
1112 value: cx.value.clone(),
1113 data: serde_json::to_value(data).unwrap_or_default(),
1114 }
1115 }
1116 fn render_default(&self, vm: &FieldVm) -> Markup {
1117 let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
1118 Markup::from_template(&RefPickerTmpl {
1119 name: &vm.name,
1120 id: &vm.id,
1121 value: vm.value.as_text(),
1122 search_url: &data.search_url,
1123 resolve_url: &data.resolve_url,
1124 placeholder: &data.placeholder,
1125 })
1126 .unwrap_or_default()
1127 }
1128 fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
1129 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1130 }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use super::*;
1136
1137 fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
1138 FieldCx {
1139 name,
1140 id: name,
1141 label: "Label",
1142 value,
1143 required: true,
1144 opts,
1145 base: "/admin",
1146 }
1147 }
1148
1149 fn text_field() -> TextField {
1150 TextField::new(Arc::new(builtin_input_registry()))
1151 }
1152
1153 struct BadAttrInput;
1155 impl InputType for BadAttrInput {
1156 fn key(&self) -> &'static str {
1157 "bad"
1158 }
1159 fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
1160 BTreeMap::from([("class".to_string(), "x".to_string())])
1161 }
1162 }
1163
1164 #[test]
1165 fn text_renders_its_value_escaped() {
1166 let opts = ResolvedOptions::none();
1167 let value = FieldValue::Text("a<b>&\"c".to_string());
1168 let markup = render_field(
1169 &text_field(),
1170 &NoOverrides,
1171 &scope(),
1172 &cx("title", &value, &opts),
1173 );
1174 let html = markup.as_str();
1175 assert!(html.contains(r#"name="title""#));
1176 assert!(html.contains("required"));
1177 assert!(!html.contains("<b>"), "no raw tag injected: {html}");
1181 assert!(
1182 html.contains("<") || html.contains("<"),
1183 "angle escaped: {html}"
1184 );
1185 assert!(
1186 html.contains("&") || html.contains("&"),
1187 "ampersand escaped: {html}"
1188 );
1189 }
1190
1191 #[test]
1192 fn textarea_uses_its_own_control() {
1193 let opts = ResolvedOptions::none();
1194 let value = FieldValue::Text("body".to_string());
1195 let markup = render_field(
1196 &TextareaField,
1197 &NoOverrides,
1198 &scope(),
1199 &cx("body", &value, &opts),
1200 );
1201 assert!(markup.as_str().contains("<textarea"));
1202 }
1203
1204 #[test]
1205 fn no_overrides_uses_the_compiled_default() {
1206 let opts = ResolvedOptions::none();
1208 let value = FieldValue::Text("x".to_string());
1209 let ft = text_field();
1210 let vm = ft.view_model(&cx("t", &value, &opts));
1211 let direct = ft.render_default(&vm);
1212 let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
1213 assert_eq!(direct.as_str(), routed.as_str());
1214 }
1215
1216 #[test]
1217 fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
1218 let field = text_field();
1219 let opts = field
1220 .resolve_options(&serde_json::json!({ "input": "email" }))
1221 .unwrap();
1222 assert!(matches!(
1224 field.intrinsic_rules(&opts).as_slice(),
1225 [Rule::Email]
1226 ));
1227 let value = FieldValue::Text("a@b.test".to_string());
1228 let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
1229 assert!(markup.as_str().contains(r#"type="email""#));
1230 }
1231
1232 #[test]
1233 fn text_default_input_is_plain_text_with_no_extra_rule() {
1234 let field = text_field();
1235 let opts = field.resolve_options(&serde_json::Value::Null).unwrap();
1236 assert!(field.intrinsic_rules(&opts).is_empty());
1237 let value = FieldValue::Text("hi".to_string());
1238 let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
1239 assert!(markup.as_str().contains(r#"type="text""#));
1240 }
1241
1242 #[test]
1243 fn text_number_input_sets_the_type_and_numeric_rule() {
1244 let field = text_field();
1245 let opts = field
1246 .resolve_options(&serde_json::json!({ "input": "number" }))
1247 .unwrap();
1248 assert!(matches!(
1249 field.intrinsic_rules(&opts).as_slice(),
1250 [Rule::Numeric]
1251 ));
1252 let value = FieldValue::Text("42".to_string());
1253 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1254 assert!(markup.as_str().contains(r#"type="number""#));
1255 }
1256
1257 #[test]
1258 fn text_tel_input_sets_the_type_with_no_rule() {
1259 let field = text_field();
1260 let opts = field
1261 .resolve_options(&serde_json::json!({ "input": "tel" }))
1262 .unwrap();
1263 assert!(field.intrinsic_rules(&opts).is_empty());
1264 let value = FieldValue::Text(String::new());
1265 let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
1266 assert!(markup.as_str().contains(r#"type="tel""#));
1267 }
1268
1269 #[test]
1270 fn text_url_input_sets_the_type_url_rule_and_copy_button() {
1271 let field = text_field();
1272 let opts = field
1273 .resolve_options(&serde_json::json!({ "input": "url" }))
1274 .unwrap();
1275 assert!(matches!(
1276 field.intrinsic_rules(&opts).as_slice(),
1277 [Rule::Url]
1278 ));
1279 let value = FieldValue::Text("https://example.com".to_string());
1280 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1281 let html = markup.as_str();
1282 assert!(html.contains(r#"type="url""#), "{html}");
1283 assert!(html.contains("lat-input-group"), "{html}");
1284 assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
1285 }
1286
1287 #[test]
1288 fn text_url_input_copy_false_omits_the_button() {
1289 let field = text_field();
1290 let opts = field
1291 .resolve_options(&serde_json::json!({ "input": "url", "copy": false }))
1292 .unwrap();
1293 let value = FieldValue::Text("https://example.com".to_string());
1294 let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
1295 let html = markup.as_str();
1296 assert!(html.contains(r#"type="url""#), "{html}");
1297 assert!(!html.contains("lat-input-group"), "{html}");
1299 assert!(!html.contains("data-lat-widget"), "{html}");
1300 }
1301
1302 #[test]
1303 fn text_number_input_emits_min_max_step_attributes() {
1304 let field = text_field();
1305 let opts = field
1306 .resolve_options(
1307 &serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
1308 )
1309 .unwrap();
1310 let value = FieldValue::Text("5".to_string());
1313 let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
1314 let html = markup.as_str();
1315 assert!(html.contains(r#"min="0""#), "{html}");
1316 assert!(html.contains(r#"max="10""#), "{html}");
1317 assert!(html.contains(r#"step="2""#), "{html}");
1318 }
1319
1320 #[test]
1321 fn text_input_reserved_attribute_name_aborts_resolve() {
1322 let mut inputs = builtin_input_registry();
1323 inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
1324 let field = TextField::new(Arc::new(inputs));
1325 let Err(e) = field.resolve_options(&serde_json::json!({ "input": "bad" })) else {
1326 panic!("expected a reserved-attribute rejection");
1327 };
1328 assert!(e.0.contains("class"), "{e}");
1329 }
1330
1331 #[test]
1332 fn attribute_and_widget_name_rules() {
1333 assert!(is_name("min", false));
1334 assert!(is_name("data-x", false));
1335 assert!(!is_name("Min", false));
1336 assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
1339 }
1340
1341 #[test]
1342 fn select_renders_options_with_the_current_value_selected() {
1343 let raw = serde_json::json!({
1344 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1345 });
1346 let opts = SelectField.resolve_options(&raw).unwrap();
1347 let value = FieldValue::Text("closed".to_string());
1348 let markup = render_field(
1349 &SelectField,
1350 &NoOverrides,
1351 &scope(),
1352 &cx("status", &value, &opts),
1353 );
1354 let html = markup.as_str();
1355 assert!(html.contains(r#"<option value="open">Open</option>"#));
1356 assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
1358 }
1359
1360 #[test]
1361 fn radio_renders_the_same_options_with_the_current_one_checked() {
1362 let raw = serde_json::json!({
1363 "options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
1364 });
1365 let opts = RadioField.resolve_options(&raw).unwrap();
1366 let value = FieldValue::Text("closed".to_string());
1367 let markup = render_field(
1368 &RadioField,
1369 &NoOverrides,
1370 &scope(),
1371 &cx("status", &value, &opts),
1372 );
1373 let html = markup.as_str();
1374 assert!(html.contains(r#"value="open""#));
1375 assert!(
1376 html.contains(r#"value="closed" checked"#),
1377 "current is checked"
1378 );
1379 assert_eq!(html.matches(r#"name="status""#).count(), 2);
1382 assert!(html.contains("closed</label>"));
1384 }
1385
1386 fn scope<'a>() -> OverrideScope<'a> {
1387 OverrideScope {
1388 surface: Surface::Field,
1389 view_key: "text",
1390 resource: None,
1391 field: None,
1392 }
1393 }
1394
1395 fn ref_field() -> RefPickerField {
1396 let mut pickers = crate::picker::PickerRegistry::new();
1397 let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
1398 pickers.insert(
1399 "acme.place".to_string(),
1400 crate::picker::PickerSourceReg::new("acme.place", source),
1401 );
1402 RefPickerField::new(Arc::new(pickers))
1403 }
1404
1405 #[test]
1406 fn reference_rejects_an_unknown_source() {
1407 let field = ref_field();
1408 let Err(e) = field.resolve_options(&serde_json::json!({ "source": "no.such" })) else {
1409 panic!("expected an unknown-source rejection");
1410 };
1411 assert!(e.0.contains("no.such"), "{e}");
1412 }
1413
1414 #[test]
1415 fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
1416 let field = ref_field();
1417 let opts = field
1418 .resolve_options(&serde_json::json!({ "source": "acme.place" }))
1419 .unwrap();
1420 let value = FieldValue::Text("42".to_string());
1421 let markup = render_field(
1422 &field,
1423 &NoOverrides,
1424 &scope(),
1425 &cx("place_id", &value, &opts),
1426 );
1427 let html = markup.as_str();
1428 assert!(html.contains(r#"type="hidden""#), "{html}");
1431 assert!(html.contains(r#"name="place_id""#), "{html}");
1432 assert!(html.contains(r#"value="42""#), "{html}");
1433 assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
1435 assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
1436 assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
1437 }
1438
1439 #[test]
1440 fn reference_declares_its_widget_assets() {
1441 let field = ref_field();
1442 let opts = field
1443 .resolve_options(&serde_json::json!({ "source": "acme.place" }))
1444 .unwrap();
1445 assert_eq!(
1446 field.assets(&opts),
1447 vec!["fields/ref-picker.js", "fields/ref-picker.css"]
1448 );
1449 }
1450}
1451
1452#[cfg(test)]
1453mod save_contract_tests {
1454 use super::*;
1455
1456 fn none() -> ResolvedOptions {
1457 ResolvedOptions::none()
1458 }
1459
1460 #[test]
1461 fn a_switch_stores_a_bool_and_absence_means_off() {
1462 let f = SwitchField;
1463 assert_eq!(
1466 f.to_attr(None, &none(), Mode::Update).unwrap(),
1467 Some(AttrValue::Bool(false))
1468 );
1469 assert_eq!(
1470 f.to_attr(Some("on"), &none(), Mode::Create).unwrap(),
1471 Some(AttrValue::Bool(true))
1472 );
1473 }
1474
1475 #[test]
1476 fn a_switch_reads_back_whatever_the_backend_gave() {
1477 let f = SwitchField;
1478 for on in ["1", "true", "t", "on"] {
1480 assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
1481 }
1482 for off in ["0", "false", "f", ""] {
1483 assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
1484 }
1485 assert!(f.to_control(None, &none()).is_empty());
1486 }
1487
1488 #[test]
1489 fn a_date_shows_only_the_date_part_of_a_stored_instant() {
1490 let f = DateField;
1491 assert_eq!(
1493 f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
1494 "2026-09-09"
1495 );
1496 assert_eq!(
1497 f.to_control(Some("2026-09-09 12:30:45"), &none()),
1498 "2026-09-09"
1499 );
1500 assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
1501 assert_eq!(f.to_control(None, &none()), "");
1502 }
1503
1504 #[test]
1505 fn a_cleared_date_stores_null_not_an_empty_string() {
1506 let f = DateField;
1507 assert_eq!(
1508 f.to_attr(Some(""), &none(), Mode::Update).unwrap(),
1509 Some(AttrValue::Null)
1510 );
1511 assert_eq!(
1512 f.to_attr(Some("2026-09-09"), &none(), Mode::Create)
1513 .unwrap(),
1514 Some(AttrValue::Text("2026-09-09".into()))
1515 );
1516 assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
1517 }
1518
1519 #[test]
1520 fn a_password_hashes_and_never_echoes() {
1521 let f = PasswordField;
1522 let stored = f
1523 .to_attr(Some("hunter2hunter2"), &none(), Mode::Create)
1524 .unwrap()
1525 .unwrap();
1526 let hash = stored.as_str().unwrap();
1527 assert!(
1528 hash.starts_with("$argon2"),
1529 "stored as a hash, not plaintext"
1530 );
1531 assert_ne!(hash, "hunter2hunter2");
1532 assert_eq!(f.to_control(Some(hash), &none()), "");
1534 }
1535
1536 #[test]
1537 fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
1538 let f = PasswordField;
1539 assert_eq!(f.to_attr(Some(""), &none(), Mode::Update).unwrap(), None);
1541 assert_eq!(f.to_attr(Some(" "), &none(), Mode::Update).unwrap(), None);
1542 assert_eq!(f.to_attr(None, &none(), Mode::Update).unwrap(), None);
1543 }
1544
1545 #[test]
1546 fn a_plain_field_stores_and_shows_the_text_unchanged() {
1547 let f = TextareaField;
1548 assert_eq!(
1549 f.to_attr(Some("hello"), &none(), Mode::Create).unwrap(),
1550 Some(AttrValue::Text("hello".into()))
1551 );
1552 assert_eq!(f.to_attr(None, &none(), Mode::Update).unwrap(), None);
1554 assert_eq!(f.to_control(Some("hello"), &none()), "hello");
1555 }
1556}