1use std::collections::HashMap;
22use std::sync::Arc;
23
24use askama::Template;
25use axum::http::StatusCode;
26use axum::response::{IntoResponse, Redirect, Response};
27use laterite_core::query::{bind_values, build as to_sql, text_cast};
28use laterite_core::validation::{validate, FieldRules, Mode, Rule};
29use laterite_core::{
30 t, Actor, AnyRowExt, Db, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
31};
32use sea_query::{Alias, Expr, Query};
33use serde::{Deserialize, Serialize};
34
35use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
36use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
37use crate::sql::valid_ident;
38use crate::{not_found, render, render_error, AdminState};
39
40#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct FormField {
47 pub name: String,
48 pub label: Text,
50 pub field_type: String,
53 pub options: serde_json::Value,
56 pub rules: Vec<Rule>,
58 pub translatable: bool,
62 pub help: Option<Text>,
64 pub enter: FieldEnter,
67 pub span: Span,
69 pub break_row: bool,
71 pub preset: Option<Preset>,
73 pub hidden: bool,
76 pub trigger: Option<Trigger>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum PresetShape {
84 Exact,
86 #[default]
88 Slug,
89 Url,
91 File,
93}
94
95impl PresetShape {
96 pub fn as_str(self) -> &'static str {
98 match self {
99 PresetShape::Exact => "exact",
100 PresetShape::Slug => "slug",
101 PresetShape::Url => "url",
102 PresetShape::File => "file",
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112pub struct Preset {
113 pub field: String,
114 pub shape: PresetShape,
115}
116
117impl Preset {
118 pub fn new(field: impl Into<String>, shape: PresetShape) -> Self {
119 Self {
120 field: field.into(),
121 shape,
122 }
123 }
124}
125
126impl<'de> Deserialize<'de> for Preset {
127 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
128 #[derive(Deserialize)]
129 #[serde(deny_unknown_fields)]
130 struct Full {
131 field: String,
132 #[serde(rename = "type", default)]
133 shape: PresetShape,
134 }
135 #[derive(Deserialize)]
136 #[serde(untagged)]
137 enum Raw {
138 Field(String),
139 Full(Full),
140 }
141 Ok(match Raw::deserialize(de)? {
142 Raw::Field(field) => Preset::new(field, PresetShape::Slug),
143 Raw::Full(full) => Preset::new(full.field, full.shape),
144 })
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct Trigger {
158 pub action: String,
161 pub field: String,
163 pub condition: String,
166}
167
168impl Trigger {
169 pub fn new(
170 action: impl Into<String>,
171 field: impl Into<String>,
172 condition: impl Into<String>,
173 ) -> Self {
174 Self {
175 action: action.into(),
176 field: field.into(),
177 condition: condition.into(),
178 }
179 }
180
181 pub fn check(&self) -> Result<(), String> {
183 for action in self.action.split('|') {
184 let known = matches!(action, "show" | "hide" | "enable" | "disable" | "empty")
185 || (action.starts_with("fill[") && action.ends_with(']'));
186 if !known {
187 return Err(format!(
188 "unknown trigger action `{action}`: show, hide, enable, disable, empty or fill[value]"
189 ));
190 }
191 }
192 let known = matches!(self.condition.as_str(), "checked" | "unchecked")
193 || (self.condition.starts_with("value[") && self.condition.ends_with(']'));
194 if !known {
195 return Err(format!(
196 "unknown trigger condition `{}`: checked, unchecked or value[..]",
197 self.condition
198 ));
199 }
200 Ok(())
201 }
202
203 pub fn watched(&self) -> &str {
205 self.field.trim_end_matches("[]")
206 }
207}
208
209pub(crate) fn behaviour_attrs(field: &FormField) -> String {
212 fn escaped(text: &str) -> String {
213 text.replace('&', "&")
214 .replace('<', "<")
215 .replace('>', ">")
216 .replace('"', """)
217 }
218 let mut attrs = String::new();
219 if let Some(preset) = &field.preset {
220 attrs.push_str(&format!(
221 r#" data-lat-preset="{}" data-lat-preset-type="{}""#,
222 escaped(&preset.field),
223 preset.shape.as_str()
224 ));
225 }
226 if let Some(trigger) = &field.trigger {
227 attrs.push_str(&format!(
228 r#" data-lat-trigger-action="{}" data-lat-trigger-field="{}" data-lat-trigger-condition="{}""#,
229 escaped(&trigger.action),
230 escaped(&trigger.field),
231 escaped(&trigger.condition)
232 ));
233 }
234 attrs
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct Span(u8);
243
244impl Span {
245 pub const FULL: Span = Span(12);
247
248 pub fn columns(columns: u8) -> Self {
250 Span(columns.clamp(1, 12))
251 }
252
253 pub fn width(self) -> u8 {
255 self.0
256 }
257
258 pub fn parse(text: &str) -> Result<Self, String> {
260 let columns = match text.trim() {
261 "full" | "12/12" => 12,
262 "1/2" | "half" | "left" | "right" | "auto" => 6,
263 "1/3" | "third" => 4,
264 "2/3" => 8,
265 "1/4" | "quarter" => 3,
266 "3/4" => 9,
267 other => match other.parse::<u8>() {
268 Ok(n) if (1..=12).contains(&n) => n,
269 _ => {
270 return Err(format!(
271 "unknown span `{other}`, expected full, 1/2, 1/3, 2/3, 1/4, 3/4 or 1 to 12"
272 ))
273 }
274 },
275 };
276 Ok(Span(columns))
277 }
278
279 fn class(self) -> String {
281 if self.0 == 12 {
282 String::new()
283 } else {
284 format!(" lat-field--{}", self.0)
285 }
286 }
287}
288
289impl Default for Span {
290 fn default() -> Self {
291 Span::FULL
292 }
293}
294
295impl Serialize for Span {
296 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
297 match self.0 {
298 12 => serializer.serialize_str("full"),
299 6 => serializer.serialize_str("1/2"),
300 4 => serializer.serialize_str("1/3"),
301 8 => serializer.serialize_str("2/3"),
302 3 => serializer.serialize_str("1/4"),
303 9 => serializer.serialize_str("3/4"),
304 n => serializer.serialize_u8(n),
305 }
306 }
307}
308
309impl<'de> Deserialize<'de> for Span {
310 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
311 #[derive(Deserialize)]
312 #[serde(untagged)]
313 enum Raw {
314 Columns(u8),
315 Text(String),
316 }
317 match Raw::deserialize(deserializer)? {
318 Raw::Columns(n) if (1..=12).contains(&n) => Ok(Span(n)),
319 Raw::Columns(n) => Err(serde::de::Error::custom(format!(
320 "unknown span `{n}`, expected 1 to 12"
321 ))),
322 Raw::Text(text) => Span::parse(&text).map_err(serde::de::Error::custom),
323 }
324 }
325}
326
327pub(crate) fn layout_classes(span: Span, break_row: bool) -> String {
329 let mut classes = span.class();
330 if break_row {
331 classes.push_str(" lat-field--break");
332 }
333 classes
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
338#[serde(rename_all = "lowercase")]
339pub enum EnterPolicy {
340 #[default]
342 Submit,
343 Off,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
349#[serde(rename_all = "lowercase")]
350pub enum FormFocus {
351 #[default]
354 Auto,
355 Off,
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
361#[serde(rename_all = "lowercase")]
362pub enum FieldEnter {
363 #[default]
365 Submit,
366 Off,
368 Next,
370}
371
372impl FieldEnter {
373 fn attribute(self) -> &'static str {
376 match self {
377 FieldEnter::Submit => "",
378 FieldEnter::Off => "off",
379 FieldEnter::Next => "next",
380 }
381 }
382}
383
384impl Default for FormConfig {
385 fn default() -> Self {
388 Self {
389 entity: String::new(),
390 title: Text::new(""),
391 base_path: String::new(),
392 id_field: "id".to_string(),
393 fields: Vec::new(),
394 enter: EnterPolicy::default(),
395 confirm_leave: true,
396 focus: FormFocus::default(),
397 persist: None,
398 timestamps: false,
399 done_path: None,
400 }
401 }
402}
403
404impl Default for FormField {
405 fn default() -> Self {
407 Self::of("", Text::new(""), "text")
408 }
409}
410
411impl<'de> Deserialize<'de> for FormField {
412 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
413 struct Entry;
414 impl<'de> serde::de::Visitor<'de> for Entry {
415 type Value = FormField;
416
417 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
418 f.write_str("a field: its own keys and its type's")
419 }
420
421 fn visit_map<A: serde::de::MapAccess<'de>>(
422 self,
423 map: A,
424 ) -> Result<FormField, A::Error> {
425 let mut field = FormField::default();
426 let theirs = crate::keyed::split_entry(map, |key, map| {
427 match key {
428 "name" => field.name = map.next_value()?,
429 "label" => field.label = map.next_value()?,
430 "type" => field.field_type = map.next_value()?,
431 "rules" => field.rules = map.next_value()?,
432 "translatable" => field.translatable = map.next_value()?,
433 "help" => field.help = map.next_value()?,
434 "enter" => field.enter = map.next_value()?,
435 "span" => field.span = map.next_value()?,
436 "break" => field.break_row = map.next_value()?,
437 "preset" => field.preset = map.next_value()?,
438 "trigger" => field.trigger = map.next_value()?,
439 "hidden" => field.hidden = map.next_value()?,
440 _ => return Ok(false),
441 }
442 Ok(true)
443 })?;
444 field.options = theirs;
445 if let Some(trigger) = &field.trigger {
446 trigger.check().map_err(serde::de::Error::custom)?;
447 }
448 Ok(field)
449 }
450
451 fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
453 Ok(FormField::default())
454 }
455 }
456 de.deserialize_map(Entry)
457 }
458}
459
460impl Serialize for FormField {
461 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
462 use serde::ser::SerializeMap;
463 let mut map = ser.serialize_map(None)?;
464 map.serialize_entry("name", &self.name)?;
465 map.serialize_entry("label", &self.label)?;
466 map.serialize_entry("type", &self.field_type)?;
467 map.serialize_entry("rules", &self.rules)?;
468 map.serialize_entry("translatable", &self.translatable)?;
469 map.serialize_entry("help", &self.help)?;
470 map.serialize_entry("enter", &self.enter)?;
471 map.serialize_entry("span", &self.span)?;
472 map.serialize_entry("break", &self.break_row)?;
473 if let Some(preset) = &self.preset {
474 map.serialize_entry("preset", preset)?;
475 }
476 if self.hidden {
477 map.serialize_entry("hidden", &true)?;
478 }
479 if let Some(trigger) = &self.trigger {
480 map.serialize_entry("trigger", trigger)?;
481 }
482 crate::keyed::serialize_theirs(&mut map, &self.options)?;
483 map.end()
484 }
485}
486
487impl crate::keyed::Keyed for FormField {
488 fn key(&self) -> &str {
489 &self.name
490 }
491 fn set_key(&mut self, key: String) {
492 self.name = key;
493 }
494}
495
496impl FormField {
497 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
499 Self {
500 name: name.to_string(),
501 label: label.into(),
502 field_type: field_type.to_string(),
503 options: serde_json::Value::Null,
504 rules: Vec::new(),
505 translatable: false,
506 help: None,
507 enter: FieldEnter::default(),
508 span: Span::default(),
509 break_row: false,
510 preset: None,
511 trigger: None,
512 hidden: false,
513 }
514 }
515
516 pub fn preset(mut self, field: &str, shape: PresetShape) -> Self {
519 self.preset = Some(Preset::new(field, shape));
520 self
521 }
522
523 pub fn trigger(mut self, trigger: Trigger) -> Self {
525 self.trigger = Some(trigger);
526 self
527 }
528
529 pub fn text(name: &str, label: impl Into<Text>) -> Self {
530 Self::of(name, label, "text")
531 }
532
533 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
534 Self::of(name, label, "textarea")
535 }
536
537 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
541 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
542 }
543
544 pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
546 Self::of(name, label, "select").options(Self::choices(options))
547 }
548
549 pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
551 Self::of(name, label, "radio").options(Self::choices(options))
552 }
553
554 pub fn switch(name: &str, label: impl Into<Text>) -> Self {
556 Self::of(name, label, "switch")
557 }
558
559 pub fn date(name: &str, label: impl Into<Text>) -> Self {
561 Self::of(name, label, "date")
562 }
563
564 pub fn password(name: &str, label: impl Into<Text>) -> Self {
567 Self::of(name, label, "password")
568 }
569
570 pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
572 Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
573 }
574
575 pub fn repeater_list(
584 name: &str,
585 label: impl Into<Text>,
586 fields: Vec<FormField>,
587 summary_field: Option<&str>,
588 ) -> Self {
589 Self::of(name, label, "repeater").options(serde_json::json!({
590 "fields": fields,
591 "display": "list",
592 "summary_field": summary_field,
593 }))
594 }
595
596 fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
598 let options: Vec<serde_json::Value> = options
599 .into_iter()
600 .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
601 .collect();
602 serde_json::json!({ "options": options })
603 }
604
605 pub fn options(mut self, options: serde_json::Value) -> Self {
607 self.options = options;
608 self
609 }
610
611 pub fn required(mut self) -> Self {
613 self.rules.push(Rule::Required);
614 self
615 }
616
617 pub fn required_on(mut self, mode: Mode) -> Self {
620 self.rules.push(Rule::RequiredOn(mode));
621 self
622 }
623
624 pub fn unique(mut self) -> Self {
627 self.rules.push(Rule::Unique);
628 self
629 }
630
631 pub fn max_length(mut self, n: usize) -> Self {
633 self.rules.push(Rule::MaxLength(n));
634 self
635 }
636
637 pub fn min_length(mut self, n: usize) -> Self {
639 self.rules.push(Rule::MinLength(n));
640 self
641 }
642
643 pub fn email(mut self) -> Self {
645 self.rules.push(Rule::Email);
646 self
647 }
648
649 pub fn translatable(mut self) -> Self {
651 self.translatable = true;
652 self
653 }
654
655 pub fn help(mut self, text: impl Into<Text>) -> Self {
657 self.help = Some(text.into());
658 self
659 }
660
661 pub fn span(mut self, span: Span) -> Self {
663 self.span = span;
664 self
665 }
666
667 pub fn break_row(mut self) -> Self {
669 self.break_row = true;
670 self
671 }
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
680#[serde(deny_unknown_fields, default)]
681#[non_exhaustive]
682pub struct FormConfig {
683 pub entity: String,
684 pub title: Text,
686 pub base_path: String,
687 pub id_field: String,
688 #[serde(
689 deserialize_with = "crate::keyed::deserialize",
690 serialize_with = "crate::keyed::serialize"
691 )]
692 pub fields: Vec<FormField>,
693 #[serde(default)]
695 pub enter: EnterPolicy,
696 pub confirm_leave: bool,
698 pub focus: FormFocus,
700 pub persist: Option<String>,
703 #[serde(default)]
706 pub timestamps: bool,
707 #[serde(skip)]
711 pub(crate) done_path: Option<String>,
712}
713
714impl FormConfig {
715 pub fn new(
720 entity: impl Into<String>,
721 title: impl Into<Text>,
722 base_path: impl Into<String>,
723 id_field: impl Into<String>,
724 fields: Vec<FormField>,
725 ) -> Self {
726 Self {
727 enter: Default::default(),
728 confirm_leave: true,
729 focus: FormFocus::default(),
730 entity: entity.into(),
731 title: title.into(),
732 base_path: base_path.into(),
733 id_field: id_field.into(),
734 fields,
735 persist: None,
736 timestamps: false,
737 done_path: None,
738 }
739 }
740
741 pub fn persist(mut self, name: impl Into<String>) -> Self {
744 self.persist = Some(name.into());
745 self
746 }
747
748 pub fn confirm_leave(mut self, ask: bool) -> Self {
750 self.confirm_leave = ask;
751 self
752 }
753
754 pub fn focus(mut self, focus: FormFocus) -> Self {
756 self.focus = focus;
757 self
758 }
759
760 pub fn timestamps(mut self) -> Self {
763 self.timestamps = true;
764 self
765 }
766
767 fn idents_valid(&self) -> bool {
768 valid_ident(&self.entity)
769 && valid_ident(&self.id_field)
770 && self.fields.iter().all(|f| valid_ident(&f.name))
771 }
772}
773
774#[derive(Clone)]
779pub(crate) struct PreparedForm {
780 pub(crate) config: FormConfig,
781 fields: Vec<PreparedField>,
783 persister: Arc<dyn Persister>,
785 listeners: Vec<Arc<dyn ModelListener>>,
787 pub(crate) stay: bool,
790}
791
792#[derive(Clone)]
795struct PreparedField {
796 field_type: Arc<dyn crate::field::FieldType>,
798 opts: ResolvedOptions,
799 rules: Vec<Rule>,
800}
801
802impl PreparedForm {
803 pub(crate) fn persister(&self) -> Arc<dyn Persister> {
805 self.persister.clone()
806 }
807
808 pub(crate) fn has_field(&self, field: &str) -> bool {
810 self.config.fields.iter().any(|f| f.name == field)
811 }
812
813 pub(crate) fn field_assets(&self, field: &str) -> Vec<&'static str> {
815 self.config
816 .fields
817 .iter()
818 .zip(&self.fields)
819 .find(|(f, _)| f.name == field)
820 .map(|(_, pf)| pf.field_type.assets(&pf.opts))
821 .unwrap_or_default()
822 }
823
824 pub(crate) fn control(
828 &self,
829 state: &AdminState,
830 field: &str,
831 dom_id: &str,
832 value: &str,
833 shell: &crate::Shell,
834 ) -> Option<String> {
835 let (f, pf) = self
836 .config
837 .fields
838 .iter()
839 .zip(&self.fields)
840 .find(|(f, _)| f.name == field)?;
841 let value = FieldValue::Text(value.to_string());
842 let label = shell.tt(&f.label);
843 let cx = FieldCx {
844 name: &f.name,
845 id: dom_id,
846 label: &label,
847 value: &value,
848 required: has_required(&pf.rules),
849 max_length: max_length(&pf.rules),
850 opts: &pf.opts,
851 base: &shell.base,
852 i18n: shell.i18n(),
853 icons: crate::icons::Icons::new(&shell.icon_set, &shell.sprite_url),
854 };
855 let ft = state.field_types.get(&f.field_type)?;
856 let scope = OverrideScope {
857 surface: Surface::Field,
858 view_key: &f.field_type,
859 resource: Some(&self.config.base_path),
860 field: Some(&f.name),
861 };
862 Some(render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string())
863 }
864
865 pub(crate) fn prepare(
869 config: FormConfig,
870 field_types: &crate::field::FieldRegistry,
871 persisters: &PersisterRegistry,
872 listeners: &[ModelListenerReg],
873 ) -> Result<Self, String> {
874 let mut fields = Vec::with_capacity(config.fields.len());
875 for f in &config.fields {
876 let names = || config.fields.iter().map(|other| other.name.as_str());
877 if let Some(preset) = &f.preset {
878 if !names().any(|n| n == preset.field) {
879 return Err(format!(
880 "field `{}` presets from `{}`, which is not in this form",
881 f.name, preset.field
882 ));
883 }
884 }
885 if let Some(trigger) = &f.trigger {
886 trigger
887 .check()
888 .map_err(|e| format!("field `{}`: {e}", f.name))?;
889 if !names().any(|n| n == trigger.watched()) {
890 return Err(format!(
891 "field `{}` watches `{}`, which is not in this form",
892 f.name,
893 trigger.watched()
894 ));
895 }
896 }
897 let ft = field_types.get(&f.field_type).ok_or_else(|| {
898 format!(
899 "field `{}` uses unregistered type `{}`",
900 f.name, f.field_type
901 )
902 })?;
903 crate::field::check_field_keys(f, ft.as_ref())
904 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
905 let opts = ft
906 .resolve_options(&f.options, field_types)
907 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
908 let mut rules = ft.intrinsic_rules(&opts);
909 rules.extend(f.rules.clone());
910 fields.push(PreparedField {
911 field_type: ft.clone(),
912 opts,
913 rules,
914 });
915 }
916 let persister: Arc<dyn Persister> = match &config.persist {
917 Some(key) => persisters
918 .get(key)
919 .cloned()
920 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
921 None => Arc::new(DefaultPersister::from_config(&config)),
922 };
923 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
926 if config.timestamps {
927 resolved.push(Arc::new(laterite_core::Timestamps));
928 }
929 resolved.extend(
930 listeners
931 .iter()
932 .filter(|reg| reg.matches(&config.entity))
933 .map(|reg| reg.listener.clone()),
934 );
935 let listeners = resolved;
936 Ok(Self {
937 config,
938 fields,
939 persister,
940 listeners,
941 stay: false,
942 })
943 }
944}
945
946fn declared_record(
953 form: &PreparedForm,
954 data: &HashMap<String, String>,
955 mode: Mode,
956) -> Result<Record, String> {
957 let mut rec = Record::new(&form.config.entity);
958 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
959 let submitted = crate::field::SubmittedField::new(&field.name, data);
960 if let Some(value) = prepared
961 .field_type
962 .to_attr(&submitted, &prepared.opts, mode)?
963 {
964 rec.set(field.name.clone(), value);
965 }
966 }
967 Ok(rec)
968}
969
970pub(crate) fn gathered(
977 form: &PreparedForm,
978 mut data: HashMap<String, String>,
979) -> HashMap<String, String> {
980 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
981 if data.contains_key(&field.name) {
982 continue;
983 }
984 let value = prepared.field_type.submitted(
985 &crate::field::SubmittedField::new(&field.name, &data),
986 &prepared.opts,
987 );
988 if let Some(value) = value {
989 data.insert(field.name.clone(), value);
990 }
991 }
992 data
993}
994
995fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
997 form.config
998 .fields
999 .iter()
1000 .zip(&form.fields)
1001 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
1004 .collect()
1005}
1006
1007pub(crate) async fn write(
1012 db: &Db,
1013 cache: Option<&laterite_core::Cache>,
1014 form: &PreparedForm,
1015 data: &HashMap<String, String>,
1016 id: Option<&str>,
1017 actor: &Actor,
1018) -> Result<Record, SaveError> {
1019 let (mut rec, op) = prepare(db, form, data, id).await?;
1020 persist::save(
1021 persist::SaveRequest {
1022 db,
1023 cache,
1024 listeners: &form.listeners,
1025 persister: form.persister.as_ref(),
1026 actor,
1027 op,
1028 id,
1029 },
1030 &mut rec,
1031 )
1032 .await?;
1033 link_media(
1034 db,
1035 form,
1036 data,
1037 rec.id()
1038 .map(|i| i.to_string())
1039 .or_else(|| id.map(str::to_string)),
1040 )
1041 .await;
1042 Ok(rec)
1043}
1044
1045async fn link_media(
1049 db: &Db,
1050 form: &PreparedForm,
1051 data: &HashMap<String, String>,
1052 owner_id: Option<String>,
1053) {
1054 let Some(owner_id) = owner_id else {
1055 return;
1056 };
1057 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
1058 let submitted = data.get(&field.name).map(String::as_str);
1059 let Some(ids) = prepared.field_type.linked_media(submitted, &prepared.opts) else {
1060 continue;
1061 };
1062 let owner = laterite_media::Owner {
1063 owner_type: &form.config.entity,
1064 owner_id: &owner_id,
1065 field: &field.name,
1066 };
1067 if let Err(e) = laterite_media::link::set(db, owner, &ids).await {
1068 tracing::warn!(error = %e, entity = %form.config.entity, field = %field.name, "media links could not be written");
1069 }
1070 }
1071}
1072
1073async fn prepare(
1077 db: &Db,
1078 form: &PreparedForm,
1079 data: &HashMap<String, String>,
1080 id: Option<&str>,
1081) -> Result<(Record, Op), SaveError> {
1082 if !form.config.idents_valid() {
1083 return Err(SaveError::Failed(format!(
1084 "form `{}` names an invalid entity or field identifier",
1085 form.config.entity
1086 )));
1087 }
1088 let (mode, op) = match id {
1089 None => (Mode::Create, Op::Create),
1090 Some(_) => (Mode::Update, Op::Update),
1091 };
1092 let bag = validate(
1093 db,
1094 &form.config.entity,
1095 &form.config.id_field,
1096 &merged_field_rules(form),
1097 data,
1098 mode,
1099 id,
1100 )
1101 .await
1102 .map_err(|e| SaveError::Failed(e.to_string()))?;
1103 if !bag.is_empty() {
1104 return Err(SaveError::Invalid(bag));
1105 }
1106 let mut rec = declared_record(form, data, mode).map_err(|e| {
1107 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
1108 SaveError::Failed(e)
1109 })?;
1110 if let Some(n) = id.and_then(|id| id.parse::<i64>().ok()) {
1111 rec.set_id(n);
1112 }
1113 Ok((rec, op))
1114}
1115
1116pub(crate) type Forms = Arc<HashMap<String, Arc<PreparedForm>>>;
1119
1120#[derive(Clone)]
1128pub struct FormHandle {
1129 db: Db,
1130 cache: laterite_core::Cache,
1131 form: Arc<PreparedForm>,
1132}
1133
1134impl FormHandle {
1135 pub(crate) fn new(db: Db, cache: laterite_core::Cache, form: Arc<PreparedForm>) -> Self {
1136 Self { db, cache, form }
1137 }
1138
1139 pub fn entity(&self) -> &str {
1141 &self.form.config.entity
1142 }
1143
1144 pub fn id_field(&self) -> &str {
1146 &self.form.config.id_field
1147 }
1148
1149 pub fn fields(&self) -> &[FormField] {
1151 &self.form.config.fields
1152 }
1153
1154 pub async fn create(
1160 &self,
1161 data: HashMap<String, String>,
1162 actor: &Actor,
1163 ) -> Result<i64, SaveError> {
1164 let data = gathered(&self.form, data);
1165 let rec = write(&self.db, Some(&self.cache), &self.form, &data, None, actor).await?;
1166 rec.id()
1167 .ok_or_else(|| SaveError::Failed("the persister returned no id".to_string()))
1168 }
1169
1170 pub async fn load(&self, id: i64) -> Result<Option<HashMap<String, String>>, SaveError> {
1175 load_values(&self.db, &self.form, &id.to_string())
1176 .await
1177 .map_err(|e| SaveError::Failed(e.to_string()))
1178 }
1179
1180 pub async fn check(
1185 &self,
1186 id: Option<i64>,
1187 data: HashMap<String, String>,
1188 ) -> Result<(), SaveError> {
1189 let data = gathered(&self.form, data);
1190 let id = id.map(|id| id.to_string());
1191 prepare(&self.db, &self.form, &data, id.as_deref())
1192 .await
1193 .map(|_| ())
1194 }
1195
1196 pub async fn update(
1199 &self,
1200 id: i64,
1201 data: HashMap<String, String>,
1202 actor: &Actor,
1203 ) -> Result<(), SaveError> {
1204 let data = gathered(&self.form, data);
1205 write(
1206 &self.db,
1207 Some(&self.cache),
1208 &self.form,
1209 &data,
1210 Some(&id.to_string()),
1211 actor,
1212 )
1213 .await
1214 .map(|_| ())
1215 }
1216}
1217
1218pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
1220 rules
1221 .iter()
1222 .filter_map(|r| match r {
1223 Rule::MaxLength(n) => Some(*n),
1224 _ => None,
1225 })
1226 .min()
1227}
1228
1229fn has_required(rules: &[Rule]) -> bool {
1231 rules
1232 .iter()
1233 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
1234}
1235
1236pub(crate) fn new_form(
1238 state: &AdminState,
1239 form: &PreparedForm,
1240 shell: crate::Shell,
1241 pending: Vec<String>,
1242) -> Response {
1243 let mut page = build(
1244 state,
1245 form,
1246 &format!("{}/new", form.config.base_path),
1247 None,
1248 &HashMap::new(),
1249 &ErrorBag::default(),
1250 &shell,
1251 );
1252 page.pending = pending;
1253 render(page)
1254}
1255
1256fn after_save(form: &PreparedForm, data: &HashMap<String, String>, id: Option<i64>) -> String {
1259 let stay = data.get("then").map(String::as_str) == Some("stay");
1260 match (stay, id) {
1261 (true, Some(id)) => format!("{}/{}/edit", form.config.base_path, id),
1262 _ => form
1263 .config
1264 .done_path
1265 .clone()
1266 .unwrap_or_else(|| form.config.base_path.clone()),
1267 }
1268}
1269
1270pub(crate) async fn create(
1273 state: &AdminState,
1274 form: &PreparedForm,
1275 data: HashMap<String, String>,
1276 shell: crate::Shell,
1277 user: &laterite_auth::AuthenticatedUser,
1278 session: &crate::session::SessionHandle,
1279 headers: &axum::http::HeaderMap,
1280) -> Response {
1281 if !form.config.idents_valid() {
1282 return render_error();
1283 }
1284 let htmx = is_htmx(headers);
1285 let action = format!("{}/new", form.config.base_path);
1286 let mut data = gathered(form, data);
1287 let then = data.remove("then");
1288 let actor = Actor::from(user);
1289 match write(&state.db, Some(&state.cache), form, &data, None, &actor).await {
1290 Ok(rec) => {
1293 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1294 let mut choice = HashMap::new();
1295 if let Some(then) = then {
1296 choice.insert("then".to_string(), then);
1297 }
1298 saved_response(htmx, &after_save(form, &choice, rec.id()))
1299 }
1300 Err(SaveError::Invalid(bag)) => {
1303 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1304 }
1305 Err(SaveError::Failed(msg)) => {
1306 tracing::error!(error = %msg, "admin create failed");
1307 render(build(
1308 state,
1309 form,
1310 &action,
1311 Some(t!("Could not save. Check the values and try again.")),
1312 &data,
1313 &ErrorBag::default(),
1314 &shell,
1315 ))
1316 }
1317 }
1318}
1319
1320pub(crate) async fn edit_form(
1322 state: &AdminState,
1323 form: &PreparedForm,
1324 id: String,
1325 shell: crate::Shell,
1326 sections: Vec<RelationSection>,
1327) -> Response {
1328 if !form.config.idents_valid() {
1329 return render_error();
1330 }
1331 let values = match load_values(&state.db, form, &id).await {
1332 Ok(Some(values)) => values,
1333 Ok(None) => return not_found(),
1334 Err(_) => return render_error(),
1335 };
1336
1337 let mut page = build(
1338 state,
1339 form,
1340 &format!("{}/{}/edit", form.config.base_path, id),
1341 None,
1342 &values,
1343 &ErrorBag::default(),
1344 &shell,
1345 );
1346 page.sections = sections;
1347 render(page)
1348}
1349
1350pub(crate) async fn reveal(
1357 state: &AdminState,
1358 form: &PreparedForm,
1359 id: &str,
1360 field: &str,
1361 user: &laterite_auth::AuthenticatedUser,
1362) -> Response {
1363 if !form
1364 .config
1365 .fields
1366 .iter()
1367 .any(|f| f.name == field && f.field_type == "sensitive")
1368 {
1369 return crate::AdminError::NotFound.into_response();
1370 }
1371 let (sql, values) = {
1373 let cast = text_cast(state.db.backend);
1374 let mut select = Query::select();
1375 select
1376 .expr_as(
1377 Expr::col(Alias::new(field)).cast_as(Alias::new(cast)),
1378 Alias::new("sealed"),
1379 )
1380 .from(Alias::new(&form.config.entity))
1381 .and_where(
1382 Expr::col(Alias::new(&form.config.id_field))
1383 .cast_as(Alias::new(cast))
1384 .eq(id.to_string()),
1385 );
1386 to_sql(state.db.backend, select)
1387 };
1388 let sealed = match bind_values(sqlx::query(&sql), values)
1389 .fetch_optional(&state.db.pool)
1390 .await
1391 {
1392 Ok(Some(row)) => row
1393 .get_text_opt("sealed")
1394 .unwrap_or_default()
1395 .unwrap_or_default(),
1396 Ok(None) => return crate::AdminError::NotFound.into_response(),
1397 Err(e) => return crate::AdminError::Internal(anyhow::anyhow!(e)).into_response(),
1398 };
1399 let Some(plain) = crate::field::SensitiveField::open(&state.crypt, field, &sealed) else {
1400 return crate::AdminError::Internal(anyhow::anyhow!(
1401 "the value does not open under the application key"
1402 ))
1403 .into_response();
1404 };
1405 crate::audit::record(
1406 state,
1407 user,
1408 &format!("{}.reveal", form.config.entity),
1409 Some(&form.config.entity),
1410 Some(id),
1411 None,
1412 Some(field),
1413 )
1414 .await;
1415 (
1416 StatusCode::OK,
1417 [
1418 (
1419 axum::http::header::CONTENT_TYPE,
1420 "text/plain; charset=utf-8",
1421 ),
1422 (axum::http::header::CACHE_CONTROL, "private, no-store"),
1423 ],
1424 plain,
1425 )
1426 .into_response()
1427}
1428
1429pub(crate) async fn load_values(
1430 db: &Db,
1431 form: &PreparedForm,
1432 id: &str,
1433) -> Result<Option<HashMap<String, String>>, sqlx::Error> {
1434 let (sql, values) = {
1438 let cast = text_cast(db.backend);
1439 let mut select = Query::select();
1440 for field in &form.config.fields {
1441 select.expr_as(
1442 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1443 Alias::new(&field.name),
1444 );
1445 }
1446 select.from(Alias::new(&form.config.entity)).and_where(
1447 Expr::col(Alias::new(&form.config.id_field))
1448 .cast_as(Alias::new(cast))
1449 .eq(id.to_string()),
1450 );
1451 to_sql(db.backend, select)
1452 };
1453 let Some(row) = bind_values(sqlx::query(&sql), values)
1454 .fetch_optional(&db.pool)
1455 .await?
1456 else {
1457 return Ok(None);
1458 };
1459 Ok(Some(
1460 form.config
1461 .fields
1462 .iter()
1463 .zip(&form.fields)
1464 .map(|(f, prepared)| {
1465 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1466 let value = prepared
1467 .field_type
1468 .to_control(stored.as_deref(), &prepared.opts);
1469 (f.name.clone(), value)
1470 })
1471 .collect(),
1472 ))
1473}
1474
1475#[allow(clippy::too_many_arguments)]
1480pub(crate) async fn update(
1481 state: &AdminState,
1482 form: &PreparedForm,
1483 id: String,
1484 data: HashMap<String, String>,
1485 shell: crate::Shell,
1486 user: &laterite_auth::AuthenticatedUser,
1487 session: &crate::session::SessionHandle,
1488 headers: &axum::http::HeaderMap,
1489) -> Response {
1490 if !form.config.idents_valid() {
1491 return render_error();
1492 }
1493 let htmx = is_htmx(headers);
1494 let action = format!("{}/{}/edit", form.config.base_path, id);
1495 let mut data = gathered(form, data);
1496 let then = data.remove("then");
1497 let actor = Actor::from(user);
1498 match write(
1499 &state.db,
1500 Some(&state.cache),
1501 form,
1502 &data,
1503 Some(&id),
1504 &actor,
1505 )
1506 .await
1507 {
1508 Ok(_) => {
1509 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1510 let mut choice = HashMap::new();
1511 if let Some(then) = then {
1512 choice.insert("then".to_string(), then);
1513 }
1514 saved_response(htmx, &after_save(form, &choice, id.parse().ok()))
1515 }
1516 Err(SaveError::Invalid(bag)) => {
1519 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1520 }
1521 Err(SaveError::Failed(msg)) => {
1522 tracing::error!(error = %msg, "admin update failed");
1523 render(build(
1524 state,
1525 form,
1526 &action,
1527 Some(t!("Could not save. Check the values and try again.")),
1528 &data,
1529 &ErrorBag::default(),
1530 &shell,
1531 ))
1532 }
1533 }
1534}
1535
1536#[allow(clippy::too_many_arguments)]
1540pub(crate) fn page(
1543 state: &AdminState,
1544 form: &PreparedForm,
1545 action: &str,
1546 values: &HashMap<String, String>,
1547 shell: &crate::Shell,
1548) -> Response {
1549 render(build(
1550 state,
1551 form,
1552 action,
1553 None,
1554 values,
1555 &ErrorBag::default(),
1556 shell,
1557 ))
1558}
1559
1560#[allow(clippy::too_many_arguments)]
1563pub(crate) fn refused(
1564 htmx: bool,
1565 state: &AdminState,
1566 form: &PreparedForm,
1567 action: &str,
1568 error: Option<Text>,
1569 values: &HashMap<String, String>,
1570 bag: &ErrorBag,
1571 shell: &crate::Shell,
1572) -> Response {
1573 invalid_response(htmx, state, form, action, error, values, bag, shell)
1574}
1575
1576#[allow(clippy::too_many_arguments)]
1577fn invalid_response(
1578 htmx: bool,
1579 state: &AdminState,
1580 form: &PreparedForm,
1581 action: &str,
1582 error: Option<Text>,
1583 values: &HashMap<String, String>,
1584 bag: &ErrorBag,
1585 shell: &crate::Shell,
1586) -> Response {
1587 let page = build(state, form, action, error, values, bag, shell);
1588 let body = if htmx {
1589 render(FormFragment {
1590 shell: page.shell,
1591 action: page.action,
1592 cancel_path: page.cancel_path,
1593 error: page.error,
1594 fields: page.fields,
1595 stay: page.stay,
1596 enter_off: page.enter_off,
1597 refused: page.refused,
1598 leave_off: page.leave_off,
1599 focus: page.focus,
1600 })
1601 } else {
1602 render(page)
1603 };
1604 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1605}
1606
1607pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1611 if htmx {
1612 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1613 } else {
1614 Redirect::to(to).into_response()
1615 }
1616}
1617
1618pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1620 headers.contains_key("hx-request")
1621}
1622
1623fn build(
1624 state: &AdminState,
1625 form: &PreparedForm,
1626 action: &str,
1627 error: Option<Text>,
1628 values: &HashMap<String, String>,
1629 bag: &ErrorBag,
1630 shell: &crate::Shell,
1631) -> FormTemplate {
1632 let fields = form
1633 .config
1634 .fields
1635 .iter()
1636 .zip(&form.fields)
1637 .map(|(f, pf)| {
1638 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1639 let required = has_required(&pf.rules);
1640 let label = shell.tt(&f.label);
1643 let cx = FieldCx {
1644 name: &f.name,
1645 id: &f.name,
1646 label: &label,
1647 value: &value,
1648 required,
1649 max_length: max_length(&pf.rules),
1650 opts: &pf.opts,
1651 base: &shell.base,
1652 i18n: shell.i18n(),
1653 icons: crate::icons::Icons::new(&shell.icon_set, &shell.sprite_url),
1654 };
1655 let control = if f.hidden {
1658 format!(
1659 r#"<input type="hidden" name="{}" value="{}">"#,
1660 escape_attr(&f.name),
1661 escape_attr(values.get(&f.name).map(String::as_str).unwrap_or_default())
1662 )
1663 } else {
1664 match state.field_types.get(&f.field_type) {
1665 Some(ft) => {
1666 let scope = OverrideScope {
1667 surface: Surface::Field,
1668 view_key: &f.field_type,
1669 resource: Some(&form.config.base_path),
1670 field: Some(&f.name),
1671 };
1672 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx)
1673 .into_string()
1674 }
1675 None => String::new(),
1676 }
1677 };
1678 FieldView {
1679 id: f.name.clone(),
1680 label,
1681 control,
1682 required,
1683 enter: f.enter.attribute(),
1684 layout: layout_classes(f.span, f.break_row),
1685 attrs: behaviour_attrs(f),
1686 hidden: f.hidden,
1687 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1689 }
1690 })
1691 .collect();
1692 let keys: Vec<&str> = form
1696 .config
1697 .fields
1698 .iter()
1699 .zip(&form.fields)
1700 .filter_map(|(f, pf)| {
1701 state
1702 .field_types
1703 .get(&f.field_type)
1704 .map(|ft| ft.assets(&pf.opts))
1705 })
1706 .flatten()
1707 .collect();
1708 let shell_title = shell.tt(&form.config.title);
1710 let refused = error.is_some() || !bag.is_empty();
1711 let error = error.map(|m| shell.tt(&m));
1712 let mut shell = shell.clone();
1713 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1714 FormTemplate {
1715 shell,
1716 title: shell_title,
1717 action: action.to_string(),
1718 cancel_path: form
1719 .config
1720 .done_path
1721 .clone()
1722 .unwrap_or_else(|| form.config.base_path.clone()),
1723 error,
1724 fields,
1725 stay: form.stay,
1726 sections: Vec::new(),
1727 pending: Vec::new(),
1728 enter_off: form.config.enter == EnterPolicy::Off,
1729 refused,
1730 leave_off: !form.config.confirm_leave,
1731 focus: match form.config.focus {
1732 FormFocus::Off => "off",
1733 FormFocus::Auto if action.ends_with("/new") => "first",
1736 FormFocus::Auto => "",
1737 },
1738 }
1739}
1740
1741struct FieldView {
1742 id: String,
1744 label: String,
1745 control: String,
1748 required: bool,
1749 errors: Vec<String>,
1750 enter: &'static str,
1752 layout: String,
1754 attrs: String,
1756 hidden: bool,
1758}
1759
1760pub(crate) struct RelationSection {
1762 pub toolbar: crate::html::Markup,
1764 pub html: crate::html::Markup,
1765}
1766
1767pub(crate) fn escape_attr(s: &str) -> String {
1768 s.replace('&', "&")
1769 .replace('"', """)
1770 .replace('<', "<")
1771 .replace('>', ">")
1772}
1773
1774#[derive(Template)]
1777#[template(path = "_form_fields.html")]
1778struct FormFragment {
1779 shell: crate::Shell,
1780 action: String,
1781 cancel_path: String,
1782 error: Option<String>,
1783 fields: Vec<FieldView>,
1784 stay: bool,
1785 enter_off: bool,
1787 refused: bool,
1789 leave_off: bool,
1791 focus: &'static str,
1793}
1794
1795#[derive(Template)]
1796#[template(path = "form.html")]
1797struct FormTemplate {
1798 shell: crate::Shell,
1799 title: String,
1800 action: String,
1801 cancel_path: String,
1802 error: Option<String>,
1803 fields: Vec<FieldView>,
1804 stay: bool,
1806 sections: Vec<RelationSection>,
1808 pending: Vec<String>,
1810 enter_off: bool,
1812 refused: bool,
1814 leave_off: bool,
1816 focus: &'static str,
1818}
1819
1820pub struct ValueFieldView {
1823 pub label: String,
1824 pub id: String,
1826 pub help: Option<String>,
1827 pub control: String,
1829 pub layout: String,
1831 pub attrs: String,
1833}
1834
1835#[derive(Template)]
1836#[template(path = "_value_fields.html")]
1837struct ValueFieldsTemplate {
1838 fields: Vec<ValueFieldView>,
1839}
1840
1841pub struct ValueForm {
1847 fields: Vec<FormField>,
1848 types: Arc<crate::field::FieldRegistry>,
1849 overrides: Arc<dyn crate::field::OverrideResolver>,
1850 scope: String,
1852}
1853
1854impl ValueForm {
1855 pub(crate) fn new(
1858 fields: &[FormField],
1859 types: Arc<crate::field::FieldRegistry>,
1860 overrides: Arc<dyn crate::field::OverrideResolver>,
1861 scope: &str,
1862 ) -> Result<Self, Text> {
1863 for field in fields {
1864 let Some(ft) = types.get(&field.field_type) else {
1865 return Err(Text::dynamic(format!(
1866 "field {:?} uses unregistered type {:?}",
1867 field.name, field.field_type
1868 )));
1869 };
1870 if ft.resolve_options(&field.options, &types).is_err() {
1871 return Err(Text::dynamic(format!(
1872 "field {:?} has malformed options",
1873 field.name
1874 )));
1875 }
1876 }
1877 Ok(Self {
1878 fields: fields.to_vec(),
1879 types,
1880 overrides,
1881 scope: scope.to_string(),
1882 })
1883 }
1884
1885 pub fn fields(&self) -> &[FormField] {
1887 &self.fields
1888 }
1889
1890 pub fn views(&self, stored: &serde_json::Value, shell: &crate::Shell) -> Vec<ValueFieldView> {
1892 value_views(
1893 &self.fields,
1894 &self.types,
1895 self.overrides.as_ref(),
1896 stored,
1897 shell,
1898 &self.scope,
1899 )
1900 }
1901
1902 pub fn render(&self, stored: &serde_json::Value, shell: &crate::Shell) -> crate::html::Markup {
1905 let template = ValueFieldsTemplate {
1906 fields: self.views(stored, shell),
1907 };
1908 crate::html::Markup::from_template(&template).unwrap_or_default()
1909 }
1910
1911 pub fn collect(&self, data: &HashMap<String, String>) -> Result<serde_json::Value, Text> {
1914 value_collect(&self.fields, &self.types, data)
1915 }
1916}
1917
1918pub(crate) fn value_views(
1921 fields: &[FormField],
1922 types: &crate::field::FieldRegistry,
1923 overrides: &dyn crate::field::OverrideResolver,
1924 stored: &serde_json::Value,
1925 shell: &crate::Shell,
1926 scope: &str,
1927) -> Vec<ValueFieldView> {
1928 fields
1929 .iter()
1930 .map(|f| {
1931 let label = shell.tt(&f.label);
1932 let control = match types.get(&f.field_type) {
1933 Some(ft) => {
1934 let opts = ft
1935 .resolve_options(&f.options, types)
1936 .unwrap_or_else(|_| ResolvedOptions::none());
1937 let value = match stored.get(&f.name) {
1938 Some(serde_json::Value::Array(rows)) => {
1939 FieldValue::Json(serde_json::Value::Array(rows.clone()))
1940 }
1941 Some(serde_json::Value::String(text)) => {
1942 FieldValue::Text(ft.to_control(Some(text), &opts))
1943 }
1944 Some(serde_json::Value::Null) | None => {
1945 FieldValue::Text(ft.to_control(None, &opts))
1946 }
1947 Some(other) => {
1948 FieldValue::Text(ft.to_control(Some(&other.to_string()), &opts))
1949 }
1950 };
1951 let cx = FieldCx {
1952 name: &f.name,
1953 id: &f.name,
1954 label: &label,
1955 value: &value,
1956 required: false,
1957 max_length: max_length(&f.rules),
1958 opts: &opts,
1959 base: &shell.base,
1960 i18n: shell.i18n(),
1961 icons: crate::icons::Icons::new(&shell.icon_set, &shell.sprite_url),
1962 };
1963 let scope = OverrideScope {
1964 surface: Surface::Field,
1965 view_key: &f.field_type,
1966 resource: Some(scope),
1967 field: Some(&f.name),
1968 };
1969 render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
1970 }
1971 None => String::new(),
1972 };
1973 ValueFieldView {
1974 label,
1975 id: f.name.clone(),
1976 help: f.help.as_ref().map(|h| shell.tt(h)),
1977 control,
1978 layout: layout_classes(f.span, f.break_row),
1979 attrs: behaviour_attrs(f),
1980 }
1981 })
1982 .collect()
1983}
1984
1985pub(crate) fn value_collect(
1987 fields: &[FormField],
1988 types: &crate::field::FieldRegistry,
1989 data: &HashMap<String, String>,
1990) -> Result<serde_json::Value, Text> {
1991 let mut object = serde_json::Map::new();
1992 for field in fields {
1993 let Some(field_type) = types.get(&field.field_type) else {
1994 return Err(t!("This screen uses a field type that is not registered."));
1995 };
1996 let opts = field_type
1997 .resolve_options(&field.options, types)
1998 .map_err(|_| t!("This screen has a malformed field."))?;
1999 let submitted = crate::field::SubmittedField::new(&field.name, data);
2000 let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
2001 Ok(Some(value)) => attr_to_json(value),
2002 Ok(None) => serde_json::Value::String(String::new()),
2003 Err(_) => return Err(t!("Check the values and try again.")),
2004 };
2005 object.insert(field.name.clone(), value);
2006 }
2007 Ok(serde_json::Value::Object(object))
2008}
2009
2010pub(crate) fn attr_to_json(value: laterite_core::AttrValue) -> serde_json::Value {
2012 use laterite_core::AttrValue;
2013 match value {
2014 AttrValue::Null => serde_json::Value::String(String::new()),
2015 AttrValue::Bool(b) => serde_json::Value::Bool(b),
2016 AttrValue::Int(i) => serde_json::Value::from(i),
2017 AttrValue::Float(f) => serde_json::Value::from(f),
2018 AttrValue::Json(j) => j,
2019 other => serde_json::Value::String(other.to_text().unwrap_or_default()),
2020 }
2021}
2022
2023#[cfg(test)]
2024mod tests {
2025 use super::*;
2026 use axum::http::StatusCode;
2027 use laterite_core::strata::{
2028 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
2029 };
2030 use laterite_core::testing::{connect_test, TestGuard};
2031 use laterite_core::Db;
2032 use laterite_core::SaveCx;
2033
2034 struct CreateSamples;
2037 #[async_trait(?Send)]
2038 impl Migration for CreateSamples {
2039 fn name(&self) -> &str {
2040 "0001_create_samples"
2041 }
2042 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
2043 s.exec(
2044 Table::create()
2045 .table(Alias::new("samples"))
2046 .if_not_exists()
2047 .col(
2048 ColumnDef::new(Alias::new("id"))
2049 .big_integer()
2050 .not_null()
2051 .auto_increment()
2052 .primary_key(),
2053 )
2054 .col(ColumnDef::new(Alias::new("code")).text().not_null())
2055 .col(ColumnDef::new(Alias::new("name")).text().not_null())
2056 .col(ColumnDef::new(Alias::new("created_at")).text())
2059 .col(ColumnDef::new(Alias::new("updated_at")).text())
2060 .to_owned(),
2061 )
2062 .await
2063 }
2064 }
2065
2066 fn config() -> PreparedForm {
2067 let config = FormConfig {
2068 enter: Default::default(),
2069 confirm_leave: true,
2070 focus: Default::default(),
2071 entity: "samples".to_string(),
2072 title: "Sample".into(),
2073 base_path: "/admin/samples".to_string(),
2074 id_field: "id".to_string(),
2075 fields: vec![
2076 FormField::text("code", "Code").required().unique(),
2077 FormField::text("name", "Name").required(),
2078 ],
2079 persist: None,
2080 timestamps: false,
2081 done_path: None,
2082 };
2083 PreparedForm::prepare(
2084 config,
2085 &crate::field::builtin_registry(),
2086 &PersisterRegistry::new(),
2087 &[],
2088 )
2089 .unwrap()
2090 }
2091
2092 #[tokio::test]
2095 async fn a_delete_removes_the_row_through_the_pipeline() {
2096 use crate::persist::{delete, DefaultPersister, DeleteRequest};
2097
2098 let (db, _guard) = test_db().await;
2099 let st = state(db.clone());
2100 let form = config();
2101
2102 let mut data = HashMap::new();
2103 data.insert("code".to_string(), "c1".to_string());
2104 data.insert("name".to_string(), "Chair".to_string());
2105 create(
2106 &st,
2107 &form,
2108 data,
2109 crate::Shell::test(),
2110 &crate::audit::test_actor(),
2111 &crate::session::SessionHandle::from_blob(None),
2112 &axum::http::HeaderMap::new(),
2113 )
2114 .await;
2115 assert_eq!(
2116 fetch_text(&db, "name", "c1").await.as_deref(),
2117 Some("Chair")
2118 );
2119
2120 let id = row_id(&db, "c1").await;
2121 let persister = DefaultPersister::from_config(&form.config);
2122 let rec = delete(DeleteRequest {
2123 db: &db,
2124 cache: None,
2125 listeners: &[],
2126 persister: &persister,
2127 actor: &laterite_core::Actor::system("test"),
2128 entity: "samples",
2129 id: &id,
2130 })
2131 .await
2132 .expect("deleted");
2133
2134 assert_eq!(fetch_text(&db, "name", "c1").await, None);
2136 assert_eq!(rec.text("code"), Some("c1"));
2137 }
2138
2139 async fn row_id(db: &Db, code: &str) -> String {
2141 let stmt = Query::select()
2142 .expr_as(
2143 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
2144 Alias::new("v"),
2145 )
2146 .from(Alias::new("samples"))
2147 .and_where(Expr::col(Alias::new("code")).eq(code))
2148 .to_owned();
2149 let (sql, values) = laterite_core::query::build(db.backend, stmt);
2150 let row = bind_values(sqlx::query(&sql), values)
2151 .fetch_one(&db.pool)
2152 .await
2153 .unwrap();
2154 row.get_text("v").unwrap()
2155 }
2156
2157 pub(super) fn state(db: Db) -> AdminState {
2158 AdminState::new(
2159 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2160 db,
2161 )
2162 }
2163
2164 pub(super) async fn test_db() -> (Db, TestGuard) {
2167 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
2168 connect_test(&[samples]).await
2169 }
2170
2171 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
2174 let stmt = Query::select()
2175 .expr_as(
2176 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
2177 Alias::new("v"),
2178 )
2179 .from(Alias::new("samples"))
2180 .and_where(Expr::col(Alias::new("code")).eq(code))
2181 .to_owned();
2182 let (sql, values) = to_sql(db.backend, stmt);
2183 let row = bind_values(sqlx::query(&sql), values)
2184 .fetch_optional(&db.pool)
2185 .await
2186 .unwrap()?;
2187 row.get_text_opt("v").ok().flatten()
2188 }
2189
2190 async fn body_of(resp: Response) -> String {
2191 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2192 .await
2193 .unwrap();
2194 String::from_utf8(bytes.to_vec()).unwrap()
2195 }
2196
2197 async fn count(db: &Db) -> i64 {
2198 sqlx::query_scalar("select count(*) from samples")
2199 .fetch_one(&db.pool)
2200 .await
2201 .unwrap()
2202 }
2203
2204 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2205 pairs
2206 .iter()
2207 .map(|(k, v)| (k.to_string(), v.to_string()))
2208 .collect()
2209 }
2210
2211 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
2213 let mut h = axum::http::HeaderMap::new();
2214 h.insert("hx-request", "true".parse().unwrap());
2215 h
2216 }
2217
2218 #[tokio::test]
2219 async fn create_then_fetch() {
2220 let (db, _guard) = test_db().await;
2221 let cfg = config();
2222 let st = state(db.clone());
2223
2224 let resp = create(
2225 &st,
2226 &cfg,
2227 data(&[("code", "editor"), ("name", "Content Editor")]),
2228 crate::Shell::test(),
2229 &crate::audit::test_actor(),
2230 &crate::session::SessionHandle::from_blob(None),
2231 &axum::http::HeaderMap::new(),
2232 )
2233 .await;
2234 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2235
2236 assert_eq!(
2237 fetch_text(&db, "name", "editor").await.as_deref(),
2238 Some("Content Editor")
2239 );
2240 }
2241
2242 #[tokio::test]
2243 async fn update_changes_the_row() {
2244 let (db, _guard) = test_db().await;
2245 let cfg = config();
2246 let st = state(db.clone());
2247 create(
2248 &st,
2249 &cfg,
2250 data(&[("code", "editor"), ("name", "Editor")]),
2251 crate::Shell::test(),
2252 &crate::audit::test_actor(),
2253 &crate::session::SessionHandle::from_blob(None),
2254 &axum::http::HeaderMap::new(),
2255 )
2256 .await;
2257
2258 let id = fetch_text(&db, "id", "editor")
2259 .await
2260 .expect("row should exist after create");
2261
2262 let resp = update(
2264 &st,
2265 &cfg,
2266 id,
2267 data(&[("code", "editor"), ("name", "Senior Editor")]),
2268 crate::Shell::test(),
2269 &crate::audit::test_actor(),
2270 &crate::session::SessionHandle::from_blob(None),
2271 &axum::http::HeaderMap::new(),
2272 )
2273 .await;
2274 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2275
2276 assert_eq!(
2277 fetch_text(&db, "name", "editor").await.as_deref(),
2278 Some("Senior Editor")
2279 );
2280 }
2281
2282 #[tokio::test]
2283 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
2284 let (db, _guard) = test_db().await;
2285 let cfg = config();
2286 let st = state(db.clone());
2287
2288 let resp = create(
2289 &st,
2290 &cfg,
2291 data(&[("code", ""), ("name", "No Code")]),
2292 crate::Shell::test(),
2293 &crate::audit::test_actor(),
2294 &crate::session::SessionHandle::from_blob(None),
2295 &axum::http::HeaderMap::new(),
2296 )
2297 .await;
2298 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2300 assert_eq!(count(&db).await, 0);
2301 assert!(body_of(resp).await.contains("Code is required."));
2303 }
2304
2305 #[tokio::test]
2306 async fn create_rejects_a_duplicate_unique_value() {
2307 let (db, _guard) = test_db().await;
2308 let cfg = config();
2309 let st = state(db.clone());
2310 create(
2311 &st,
2312 &cfg,
2313 data(&[("code", "editor"), ("name", "Editor")]),
2314 crate::Shell::test(),
2315 &crate::audit::test_actor(),
2316 &crate::session::SessionHandle::from_blob(None),
2317 &axum::http::HeaderMap::new(),
2318 )
2319 .await;
2320
2321 let resp = create(
2323 &st,
2324 &cfg,
2325 data(&[("code", "editor"), ("name", "Other")]),
2326 crate::Shell::test(),
2327 &crate::audit::test_actor(),
2328 &crate::session::SessionHandle::from_blob(None),
2329 &axum::http::HeaderMap::new(),
2330 )
2331 .await;
2332 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2333 assert_eq!(count(&db).await, 1);
2334 assert!(body_of(resp).await.contains("already taken"));
2335 }
2336
2337 #[tokio::test]
2338 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
2339 let (db, _guard) = test_db().await;
2340 let st = state(db.clone());
2341 let cfg = PreparedForm::prepare(
2344 FormConfig {
2345 enter: Default::default(),
2346 confirm_leave: true,
2347 focus: Default::default(),
2348 entity: "samples".to_string(),
2349 title: "Sample".into(),
2350 base_path: "/admin/samples".to_string(),
2351 id_field: "id".to_string(),
2352 fields: vec![
2353 FormField::text("code", "Code").required(),
2354 FormField::of("name", "Email", "text")
2355 .options(serde_json::json!({ "input": "email" }))
2356 .required(),
2357 ],
2358 persist: None,
2359 timestamps: false,
2360 done_path: None,
2361 },
2362 &st.field_types,
2363 &PersisterRegistry::new(),
2364 &[],
2365 )
2366 .unwrap();
2367
2368 let resp = create(
2369 &st,
2370 &cfg,
2371 data(&[("code", "c1"), ("name", "nope")]),
2372 crate::Shell::test(),
2373 &crate::audit::test_actor(),
2374 &crate::session::SessionHandle::from_blob(None),
2375 &axum::http::HeaderMap::new(),
2376 )
2377 .await;
2378 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2379 assert_eq!(count(&db).await, 0);
2380
2381 let resp = create(
2382 &st,
2383 &cfg,
2384 data(&[("code", "c1"), ("name", "a@b.test")]),
2385 crate::Shell::test(),
2386 &crate::audit::test_actor(),
2387 &crate::session::SessionHandle::from_blob(None),
2388 &axum::http::HeaderMap::new(),
2389 )
2390 .await;
2391 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2392 assert_eq!(count(&db).await, 1);
2393 }
2394
2395 #[test]
2396 fn prepare_rejects_an_unregistered_field_type() {
2397 let config = FormConfig {
2398 enter: Default::default(),
2399 confirm_leave: true,
2400 focus: Default::default(),
2401 entity: "samples".to_string(),
2402 title: "Sample".into(),
2403 base_path: "/admin/samples".to_string(),
2404 id_field: "id".to_string(),
2405 fields: vec![FormField::of("place", "Place", "no.such.type")],
2406 persist: None,
2407 timestamps: false,
2408 done_path: None,
2409 };
2410 let Err(err) = PreparedForm::prepare(
2411 config,
2412 &crate::field::builtin_registry(),
2413 &PersisterRegistry::new(),
2414 &[],
2415 ) else {
2416 panic!("expected prepare to reject an unregistered type");
2417 };
2418 assert!(err.contains("no.such.type"), "{err}");
2419 }
2420
2421 #[test]
2422 fn prepare_rejects_a_malformed_option() {
2423 let config = FormConfig {
2426 enter: Default::default(),
2427 confirm_leave: true,
2428 focus: Default::default(),
2429 entity: "samples".to_string(),
2430 title: "Sample".into(),
2431 base_path: "/admin/samples".to_string(),
2432 id_field: "id".to_string(),
2433 fields: vec![
2434 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
2435 ],
2436 persist: None,
2437 timestamps: false,
2438 done_path: None,
2439 };
2440 let Err(err) = PreparedForm::prepare(
2441 config,
2442 &crate::field::builtin_registry(),
2443 &PersisterRegistry::new(),
2444 &[],
2445 ) else {
2446 panic!("expected prepare to reject a malformed option");
2447 };
2448 assert!(err.contains("`email`"), "{err}");
2449 }
2450
2451 struct RollbackPersister;
2454 #[laterite_core::strata::async_trait]
2455 impl Persister for RollbackPersister {
2456 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
2457 let backend = cx.backend();
2458 let insert = Query::insert()
2459 .into_table(Alias::new("samples"))
2460 .columns([Alias::new("code"), Alias::new("name")])
2461 .values_panic(["rolled".into(), "back".into()])
2462 .to_owned();
2463 let (sql, values) = to_sql(backend, insert);
2464 bind_values(sqlx::query(&sql), values)
2465 .execute(cx.conn())
2466 .await
2467 .map_err(|e| SaveError::Failed(e.to_string()))?;
2468 Err(SaveError::Failed("deliberate".to_string()))
2469 }
2470 async fn update(
2471 &self,
2472 _cx: &mut SaveCx<'_>,
2473 _id: &str,
2474 _rec: &Record,
2475 ) -> Result<(), SaveError> {
2476 Ok(())
2477 }
2478 }
2479
2480 struct RejectingPersister;
2482 #[laterite_core::strata::async_trait]
2483 impl Persister for RejectingPersister {
2484 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
2485 let mut bag = ErrorBag::default();
2486 bag.add("code", t!("Code is not allowed here."));
2487 Err(SaveError::Invalid(bag))
2488 }
2489 async fn update(
2490 &self,
2491 _cx: &mut SaveCx<'_>,
2492 _id: &str,
2493 _rec: &Record,
2494 ) -> Result<(), SaveError> {
2495 Ok(())
2496 }
2497 }
2498
2499 fn config_with(persister: &str) -> FormConfig {
2500 FormConfig {
2501 enter: Default::default(),
2502 confirm_leave: true,
2503 focus: Default::default(),
2504 entity: "samples".to_string(),
2505 title: "Sample".into(),
2506 base_path: "/admin/samples".to_string(),
2507 id_field: "id".to_string(),
2508 fields: vec![
2509 FormField::text("code", "Code").required(),
2510 FormField::text("name", "Name").required(),
2511 ],
2512 persist: Some(persister.to_string()),
2513 timestamps: false,
2514 done_path: None,
2515 }
2516 }
2517
2518 #[test]
2519 fn prepare_rejects_an_unregistered_persister() {
2520 let Err(err) = PreparedForm::prepare(
2521 config_with("no.such.persister"),
2522 &crate::field::builtin_registry(),
2523 &PersisterRegistry::new(),
2524 &[],
2525 ) else {
2526 panic!("expected prepare to reject an unregistered persister");
2527 };
2528 assert!(err.contains("no.such.persister"), "{err}");
2529 }
2530
2531 #[tokio::test]
2532 async fn a_persister_transaction_rolls_back_on_error() {
2533 let (db, _guard) = test_db().await;
2534 let st = state(db.clone());
2535 let mut persisters = PersisterRegistry::new();
2536 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
2537 let form = PreparedForm::prepare(
2538 config_with("test.rollback"),
2539 &st.field_types,
2540 &persisters,
2541 &[],
2542 )
2543 .unwrap();
2544
2545 let resp = create(
2546 &st,
2547 &form,
2548 data(&[("code", "rolled"), ("name", "back")]),
2549 crate::Shell::test(),
2550 &crate::audit::test_actor(),
2551 &crate::session::SessionHandle::from_blob(None),
2552 &axum::http::HeaderMap::new(),
2553 )
2554 .await;
2555 assert_eq!(resp.status(), StatusCode::OK);
2558 assert_eq!(count(&db).await, 0);
2559 }
2560
2561 #[tokio::test]
2562 async fn a_persister_invalid_error_re_renders_422() {
2563 let (db, _guard) = test_db().await;
2564 let st = state(db.clone());
2565 let mut persisters = PersisterRegistry::new();
2566 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
2567 let form = PreparedForm::prepare(
2568 config_with("test.reject"),
2569 &st.field_types,
2570 &persisters,
2571 &[],
2572 )
2573 .unwrap();
2574
2575 let resp = create(
2576 &st,
2577 &form,
2578 data(&[("code", "editor"), ("name", "Editor")]),
2579 crate::Shell::test(),
2580 &crate::audit::test_actor(),
2581 &crate::session::SessionHandle::from_blob(None),
2582 &axum::http::HeaderMap::new(),
2583 )
2584 .await;
2585 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2586 assert!(body_of(resp).await.contains("Code is not allowed here."));
2587 }
2588}
2589
2590#[cfg(test)]
2591mod timestamp_tests {
2592 use super::tests::*;
2593 use super::*;
2594 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
2595 use laterite_core::Actor;
2596
2597 fn timestamped(on: bool) -> FormConfig {
2598 FormConfig {
2599 enter: Default::default(),
2600 confirm_leave: true,
2601 focus: Default::default(),
2602 entity: "samples".to_string(),
2603 title: "Sample".into(),
2604 base_path: "/admin/samples".to_string(),
2605 id_field: "id".to_string(),
2606 fields: vec![
2607 FormField::text("code", "Code").required(),
2608 FormField::text("name", "Name").required(),
2609 ],
2610 persist: None,
2611 timestamps: on,
2612 done_path: None,
2613 }
2614 }
2615
2616 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
2617 let form = PreparedForm::prepare(
2618 timestamped(on),
2619 &crate::field::builtin_registry(),
2620 &PersisterRegistry::new(),
2621 &[],
2622 )
2623 .unwrap();
2624 let mut data = HashMap::new();
2625 data.insert("code".to_string(), "c1".to_string());
2626 data.insert("name".to_string(), "Chair".to_string());
2627 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
2628 let actor = Actor::system("test");
2629 persist::save(
2630 persist::SaveRequest {
2631 db,
2632 cache: None,
2633 listeners: &form.listeners,
2634 persister: form.persister.as_ref(),
2635 actor: &actor,
2636 op: Op::Create,
2637 id: None,
2638 },
2639 &mut rec,
2640 )
2641 .await
2642 .expect("the insert should succeed");
2643 rec
2644 }
2645
2646 #[tokio::test]
2647 async fn opting_in_stamps_the_row() {
2648 let (db, _guard) = test_db().await;
2649 let rec = create_through_pipeline(&db, true).await;
2650 assert!(rec.datetime(CREATED_AT).is_some());
2651 assert!(rec.datetime(UPDATED_AT).is_some());
2652 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2654 }
2655
2656 #[tokio::test]
2657 async fn leaving_it_off_writes_no_timestamps() {
2658 let (db, _guard) = test_db().await;
2659 let rec = create_through_pipeline(&db, false).await;
2660 assert!(rec.get(CREATED_AT).is_none());
2661 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2662 }
2663}
2664
2665#[cfg(test)]
2666mod htmx_tests {
2667 use super::tests::*;
2668 use super::*;
2669 use axum::http::StatusCode;
2670
2671 fn invalid() -> HashMap<String, String> {
2673 let mut d = HashMap::new();
2674 d.insert("code".to_string(), String::new());
2675 d.insert("name".to_string(), "No code".to_string());
2676 d
2677 }
2678
2679 fn valid() -> HashMap<String, String> {
2680 let mut d = HashMap::new();
2681 d.insert("code".to_string(), "c1".to_string());
2682 d.insert("name".to_string(), "Chair".to_string());
2683 d
2684 }
2685
2686 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2687 let (db, _guard) = test_db().await;
2688 let st = state(db);
2689 let form = PreparedForm::prepare(
2690 FormConfig {
2691 enter: Default::default(),
2692 confirm_leave: true,
2693 focus: Default::default(),
2694 entity: "samples".to_string(),
2695 title: "Sample".into(),
2696 base_path: "/admin/samples".to_string(),
2697 id_field: "id".to_string(),
2698 fields: vec![
2699 FormField::text("code", "Code").required(),
2700 FormField::text("name", "Name").required(),
2701 ],
2702 persist: None,
2703 timestamps: false,
2704 done_path: None,
2705 },
2706 &st.field_types,
2707 &PersisterRegistry::new(),
2708 &[],
2709 )
2710 .unwrap();
2711 create(
2712 &st,
2713 &form,
2714 data,
2715 crate::Shell::test(),
2716 &crate::audit::test_actor(),
2717 &crate::session::SessionHandle::from_blob(None),
2718 &headers,
2719 )
2720 .await
2721 }
2722
2723 async fn body_of(resp: Response) -> String {
2724 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2725 .await
2726 .unwrap();
2727 String::from_utf8(bytes.to_vec()).unwrap()
2728 }
2729
2730 #[tokio::test]
2731 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2732 let resp = post(invalid(), htmx_headers()).await;
2733 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2734 let html = body_of(resp).await;
2735 assert!(html.contains("<form"), "the form comes back");
2736 assert!(html.contains("lat-field__error"), "with its errors");
2737 assert!(!html.contains("<body"), "no page chrome");
2740 }
2741
2742 #[tokio::test]
2743 async fn a_plain_submit_that_fails_returns_the_whole_page() {
2744 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2745 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2746 let html = body_of(resp).await;
2747 assert!(html.contains("<body"), "the admin still works without htmx");
2748 assert!(html.contains("lat-field__error"));
2749 }
2750
2751 #[tokio::test]
2752 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2753 let resp = post(valid(), htmx_headers()).await;
2754 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2756 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2757 }
2758
2759 #[tokio::test]
2760 async fn a_plain_submit_that_succeeds_still_redirects() {
2761 let resp = post(valid(), axum::http::HeaderMap::new()).await;
2762 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2763 }
2764}