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 };
854 let ft = state.field_types.get(&f.field_type)?;
855 let scope = OverrideScope {
856 surface: Surface::Field,
857 view_key: &f.field_type,
858 resource: Some(&self.config.base_path),
859 field: Some(&f.name),
860 };
861 Some(render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string())
862 }
863
864 pub(crate) fn prepare(
868 config: FormConfig,
869 field_types: &crate::field::FieldRegistry,
870 persisters: &PersisterRegistry,
871 listeners: &[ModelListenerReg],
872 ) -> Result<Self, String> {
873 let mut fields = Vec::with_capacity(config.fields.len());
874 for f in &config.fields {
875 let names = || config.fields.iter().map(|other| other.name.as_str());
876 if let Some(preset) = &f.preset {
877 if !names().any(|n| n == preset.field) {
878 return Err(format!(
879 "field `{}` presets from `{}`, which is not in this form",
880 f.name, preset.field
881 ));
882 }
883 }
884 if let Some(trigger) = &f.trigger {
885 trigger
886 .check()
887 .map_err(|e| format!("field `{}`: {e}", f.name))?;
888 if !names().any(|n| n == trigger.watched()) {
889 return Err(format!(
890 "field `{}` watches `{}`, which is not in this form",
891 f.name,
892 trigger.watched()
893 ));
894 }
895 }
896 let ft = field_types.get(&f.field_type).ok_or_else(|| {
897 format!(
898 "field `{}` uses unregistered type `{}`",
899 f.name, f.field_type
900 )
901 })?;
902 crate::field::check_field_keys(f, ft.as_ref())
903 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
904 let opts = ft
905 .resolve_options(&f.options, field_types)
906 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
907 let mut rules = ft.intrinsic_rules(&opts);
908 rules.extend(f.rules.clone());
909 fields.push(PreparedField {
910 field_type: ft.clone(),
911 opts,
912 rules,
913 });
914 }
915 let persister: Arc<dyn Persister> = match &config.persist {
916 Some(key) => persisters
917 .get(key)
918 .cloned()
919 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
920 None => Arc::new(DefaultPersister::from_config(&config)),
921 };
922 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
925 if config.timestamps {
926 resolved.push(Arc::new(laterite_core::Timestamps));
927 }
928 resolved.extend(
929 listeners
930 .iter()
931 .filter(|reg| reg.matches(&config.entity))
932 .map(|reg| reg.listener.clone()),
933 );
934 let listeners = resolved;
935 Ok(Self {
936 config,
937 fields,
938 persister,
939 listeners,
940 stay: false,
941 })
942 }
943}
944
945fn declared_record(
952 form: &PreparedForm,
953 data: &HashMap<String, String>,
954 mode: Mode,
955) -> Result<Record, String> {
956 let mut rec = Record::new(&form.config.entity);
957 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
958 let submitted = crate::field::SubmittedField::new(&field.name, data);
959 if let Some(value) = prepared
960 .field_type
961 .to_attr(&submitted, &prepared.opts, mode)?
962 {
963 rec.set(field.name.clone(), value);
964 }
965 }
966 Ok(rec)
967}
968
969pub(crate) fn gathered(
976 form: &PreparedForm,
977 mut data: HashMap<String, String>,
978) -> HashMap<String, String> {
979 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
980 if data.contains_key(&field.name) {
981 continue;
982 }
983 let value = prepared.field_type.submitted(
984 &crate::field::SubmittedField::new(&field.name, &data),
985 &prepared.opts,
986 );
987 if let Some(value) = value {
988 data.insert(field.name.clone(), value);
989 }
990 }
991 data
992}
993
994fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
996 form.config
997 .fields
998 .iter()
999 .zip(&form.fields)
1000 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
1003 .collect()
1004}
1005
1006pub(crate) async fn write(
1011 db: &Db,
1012 form: &PreparedForm,
1013 data: &HashMap<String, String>,
1014 id: Option<&str>,
1015 actor: &Actor,
1016) -> Result<Record, SaveError> {
1017 let (mut rec, op) = prepare(db, form, data, id).await?;
1018 persist::save(
1019 persist::SaveRequest {
1020 db,
1021 listeners: &form.listeners,
1022 persister: form.persister.as_ref(),
1023 actor,
1024 op,
1025 id,
1026 },
1027 &mut rec,
1028 )
1029 .await?;
1030 Ok(rec)
1031}
1032
1033async fn prepare(
1037 db: &Db,
1038 form: &PreparedForm,
1039 data: &HashMap<String, String>,
1040 id: Option<&str>,
1041) -> Result<(Record, Op), SaveError> {
1042 if !form.config.idents_valid() {
1043 return Err(SaveError::Failed(format!(
1044 "form `{}` names an invalid entity or field identifier",
1045 form.config.entity
1046 )));
1047 }
1048 let (mode, op) = match id {
1049 None => (Mode::Create, Op::Create),
1050 Some(_) => (Mode::Update, Op::Update),
1051 };
1052 let bag = validate(
1053 db,
1054 &form.config.entity,
1055 &form.config.id_field,
1056 &merged_field_rules(form),
1057 data,
1058 mode,
1059 id,
1060 )
1061 .await
1062 .map_err(|e| SaveError::Failed(e.to_string()))?;
1063 if !bag.is_empty() {
1064 return Err(SaveError::Invalid(bag));
1065 }
1066 let mut rec = declared_record(form, data, mode).map_err(|e| {
1067 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
1068 SaveError::Failed(e)
1069 })?;
1070 if let Some(n) = id.and_then(|id| id.parse::<i64>().ok()) {
1071 rec.set_id(n);
1072 }
1073 Ok((rec, op))
1074}
1075
1076pub(crate) type Forms = Arc<HashMap<String, Arc<PreparedForm>>>;
1079
1080#[derive(Clone)]
1088pub struct FormHandle {
1089 db: Db,
1090 form: Arc<PreparedForm>,
1091}
1092
1093impl FormHandle {
1094 pub(crate) fn new(db: Db, form: Arc<PreparedForm>) -> Self {
1095 Self { db, form }
1096 }
1097
1098 pub fn entity(&self) -> &str {
1100 &self.form.config.entity
1101 }
1102
1103 pub fn id_field(&self) -> &str {
1105 &self.form.config.id_field
1106 }
1107
1108 pub fn fields(&self) -> &[FormField] {
1110 &self.form.config.fields
1111 }
1112
1113 pub async fn create(
1119 &self,
1120 data: HashMap<String, String>,
1121 actor: &Actor,
1122 ) -> Result<i64, SaveError> {
1123 let data = gathered(&self.form, data);
1124 let rec = write(&self.db, &self.form, &data, None, actor).await?;
1125 rec.id()
1126 .ok_or_else(|| SaveError::Failed("the persister returned no id".to_string()))
1127 }
1128
1129 pub async fn load(&self, id: i64) -> Result<Option<HashMap<String, String>>, SaveError> {
1134 load_values(&self.db, &self.form, &id.to_string())
1135 .await
1136 .map_err(|e| SaveError::Failed(e.to_string()))
1137 }
1138
1139 pub async fn check(
1144 &self,
1145 id: Option<i64>,
1146 data: HashMap<String, String>,
1147 ) -> Result<(), SaveError> {
1148 let data = gathered(&self.form, data);
1149 let id = id.map(|id| id.to_string());
1150 prepare(&self.db, &self.form, &data, id.as_deref())
1151 .await
1152 .map(|_| ())
1153 }
1154
1155 pub async fn update(
1158 &self,
1159 id: i64,
1160 data: HashMap<String, String>,
1161 actor: &Actor,
1162 ) -> Result<(), SaveError> {
1163 let data = gathered(&self.form, data);
1164 write(&self.db, &self.form, &data, Some(&id.to_string()), actor)
1165 .await
1166 .map(|_| ())
1167 }
1168}
1169
1170pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
1172 rules
1173 .iter()
1174 .filter_map(|r| match r {
1175 Rule::MaxLength(n) => Some(*n),
1176 _ => None,
1177 })
1178 .min()
1179}
1180
1181fn has_required(rules: &[Rule]) -> bool {
1183 rules
1184 .iter()
1185 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
1186}
1187
1188pub(crate) fn new_form(
1190 state: &AdminState,
1191 form: &PreparedForm,
1192 shell: crate::Shell,
1193 pending: Vec<String>,
1194) -> Response {
1195 let mut page = build(
1196 state,
1197 form,
1198 &format!("{}/new", form.config.base_path),
1199 None,
1200 &HashMap::new(),
1201 &ErrorBag::default(),
1202 &shell,
1203 );
1204 page.pending = pending;
1205 render(page)
1206}
1207
1208fn after_save(form: &PreparedForm, data: &HashMap<String, String>, id: Option<i64>) -> String {
1211 let stay = data.get("then").map(String::as_str) == Some("stay");
1212 match (stay, id) {
1213 (true, Some(id)) => format!("{}/{}/edit", form.config.base_path, id),
1214 _ => form
1215 .config
1216 .done_path
1217 .clone()
1218 .unwrap_or_else(|| form.config.base_path.clone()),
1219 }
1220}
1221
1222pub(crate) async fn create(
1225 state: &AdminState,
1226 form: &PreparedForm,
1227 data: HashMap<String, String>,
1228 shell: crate::Shell,
1229 user: &laterite_auth::AuthenticatedUser,
1230 session: &crate::session::SessionHandle,
1231 headers: &axum::http::HeaderMap,
1232) -> Response {
1233 if !form.config.idents_valid() {
1234 return render_error();
1235 }
1236 let htmx = is_htmx(headers);
1237 let action = format!("{}/new", form.config.base_path);
1238 let mut data = gathered(form, data);
1239 let then = data.remove("then");
1240 let actor = Actor::from(user);
1241 match write(&state.db, form, &data, None, &actor).await {
1242 Ok(rec) => {
1245 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1246 let mut choice = HashMap::new();
1247 if let Some(then) = then {
1248 choice.insert("then".to_string(), then);
1249 }
1250 saved_response(htmx, &after_save(form, &choice, rec.id()))
1251 }
1252 Err(SaveError::Invalid(bag)) => {
1255 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1256 }
1257 Err(SaveError::Failed(msg)) => {
1258 tracing::error!(error = %msg, "admin create failed");
1259 render(build(
1260 state,
1261 form,
1262 &action,
1263 Some(t!("Could not save. Check the values and try again.")),
1264 &data,
1265 &ErrorBag::default(),
1266 &shell,
1267 ))
1268 }
1269 }
1270}
1271
1272pub(crate) async fn edit_form(
1274 state: &AdminState,
1275 form: &PreparedForm,
1276 id: String,
1277 shell: crate::Shell,
1278 sections: Vec<RelationSection>,
1279) -> Response {
1280 if !form.config.idents_valid() {
1281 return render_error();
1282 }
1283 let values = match load_values(&state.db, form, &id).await {
1284 Ok(Some(values)) => values,
1285 Ok(None) => return not_found(),
1286 Err(_) => return render_error(),
1287 };
1288
1289 let mut page = build(
1290 state,
1291 form,
1292 &format!("{}/{}/edit", form.config.base_path, id),
1293 None,
1294 &values,
1295 &ErrorBag::default(),
1296 &shell,
1297 );
1298 page.sections = sections;
1299 render(page)
1300}
1301
1302pub(crate) async fn load_values(
1306 db: &Db,
1307 form: &PreparedForm,
1308 id: &str,
1309) -> Result<Option<HashMap<String, String>>, sqlx::Error> {
1310 let (sql, values) = {
1314 let cast = text_cast(db.backend);
1315 let mut select = Query::select();
1316 for field in &form.config.fields {
1317 select.expr_as(
1318 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1319 Alias::new(&field.name),
1320 );
1321 }
1322 select.from(Alias::new(&form.config.entity)).and_where(
1323 Expr::col(Alias::new(&form.config.id_field))
1324 .cast_as(Alias::new(cast))
1325 .eq(id.to_string()),
1326 );
1327 to_sql(db.backend, select)
1328 };
1329 let Some(row) = bind_values(sqlx::query(&sql), values)
1330 .fetch_optional(&db.pool)
1331 .await?
1332 else {
1333 return Ok(None);
1334 };
1335 Ok(Some(
1336 form.config
1337 .fields
1338 .iter()
1339 .zip(&form.fields)
1340 .map(|(f, prepared)| {
1341 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1342 let value = prepared
1343 .field_type
1344 .to_control(stored.as_deref(), &prepared.opts);
1345 (f.name.clone(), value)
1346 })
1347 .collect(),
1348 ))
1349}
1350
1351#[allow(clippy::too_many_arguments)]
1356pub(crate) async fn update(
1357 state: &AdminState,
1358 form: &PreparedForm,
1359 id: String,
1360 data: HashMap<String, String>,
1361 shell: crate::Shell,
1362 user: &laterite_auth::AuthenticatedUser,
1363 session: &crate::session::SessionHandle,
1364 headers: &axum::http::HeaderMap,
1365) -> Response {
1366 if !form.config.idents_valid() {
1367 return render_error();
1368 }
1369 let htmx = is_htmx(headers);
1370 let action = format!("{}/{}/edit", form.config.base_path, id);
1371 let mut data = gathered(form, data);
1372 let then = data.remove("then");
1373 let actor = Actor::from(user);
1374 match write(&state.db, form, &data, Some(&id), &actor).await {
1375 Ok(_) => {
1376 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1377 let mut choice = HashMap::new();
1378 if let Some(then) = then {
1379 choice.insert("then".to_string(), then);
1380 }
1381 saved_response(htmx, &after_save(form, &choice, id.parse().ok()))
1382 }
1383 Err(SaveError::Invalid(bag)) => {
1386 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1387 }
1388 Err(SaveError::Failed(msg)) => {
1389 tracing::error!(error = %msg, "admin update failed");
1390 render(build(
1391 state,
1392 form,
1393 &action,
1394 Some(t!("Could not save. Check the values and try again.")),
1395 &data,
1396 &ErrorBag::default(),
1397 &shell,
1398 ))
1399 }
1400 }
1401}
1402
1403#[allow(clippy::too_many_arguments)]
1407pub(crate) fn page(
1410 state: &AdminState,
1411 form: &PreparedForm,
1412 action: &str,
1413 values: &HashMap<String, String>,
1414 shell: &crate::Shell,
1415) -> Response {
1416 render(build(
1417 state,
1418 form,
1419 action,
1420 None,
1421 values,
1422 &ErrorBag::default(),
1423 shell,
1424 ))
1425}
1426
1427#[allow(clippy::too_many_arguments)]
1430pub(crate) fn refused(
1431 htmx: bool,
1432 state: &AdminState,
1433 form: &PreparedForm,
1434 action: &str,
1435 error: Option<Text>,
1436 values: &HashMap<String, String>,
1437 bag: &ErrorBag,
1438 shell: &crate::Shell,
1439) -> Response {
1440 invalid_response(htmx, state, form, action, error, values, bag, shell)
1441}
1442
1443#[allow(clippy::too_many_arguments)]
1444fn invalid_response(
1445 htmx: bool,
1446 state: &AdminState,
1447 form: &PreparedForm,
1448 action: &str,
1449 error: Option<Text>,
1450 values: &HashMap<String, String>,
1451 bag: &ErrorBag,
1452 shell: &crate::Shell,
1453) -> Response {
1454 let page = build(state, form, action, error, values, bag, shell);
1455 let body = if htmx {
1456 render(FormFragment {
1457 shell: page.shell,
1458 action: page.action,
1459 cancel_path: page.cancel_path,
1460 error: page.error,
1461 fields: page.fields,
1462 stay: page.stay,
1463 enter_off: page.enter_off,
1464 refused: page.refused,
1465 leave_off: page.leave_off,
1466 focus: page.focus,
1467 })
1468 } else {
1469 render(page)
1470 };
1471 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1472}
1473
1474pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1478 if htmx {
1479 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1480 } else {
1481 Redirect::to(to).into_response()
1482 }
1483}
1484
1485pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1487 headers.contains_key("hx-request")
1488}
1489
1490fn build(
1491 state: &AdminState,
1492 form: &PreparedForm,
1493 action: &str,
1494 error: Option<Text>,
1495 values: &HashMap<String, String>,
1496 bag: &ErrorBag,
1497 shell: &crate::Shell,
1498) -> FormTemplate {
1499 let fields = form
1500 .config
1501 .fields
1502 .iter()
1503 .zip(&form.fields)
1504 .map(|(f, pf)| {
1505 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1506 let required = has_required(&pf.rules);
1507 let label = shell.tt(&f.label);
1510 let cx = FieldCx {
1511 name: &f.name,
1512 id: &f.name,
1513 label: &label,
1514 value: &value,
1515 required,
1516 max_length: max_length(&pf.rules),
1517 opts: &pf.opts,
1518 base: &shell.base,
1519 i18n: shell.i18n(),
1520 };
1521 let control = if f.hidden {
1524 format!(
1525 r#"<input type="hidden" name="{}" value="{}">"#,
1526 escape_attr(&f.name),
1527 escape_attr(values.get(&f.name).map(String::as_str).unwrap_or_default())
1528 )
1529 } else {
1530 match state.field_types.get(&f.field_type) {
1531 Some(ft) => {
1532 let scope = OverrideScope {
1533 surface: Surface::Field,
1534 view_key: &f.field_type,
1535 resource: Some(&form.config.base_path),
1536 field: Some(&f.name),
1537 };
1538 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx)
1539 .into_string()
1540 }
1541 None => String::new(),
1542 }
1543 };
1544 FieldView {
1545 id: f.name.clone(),
1546 label,
1547 control,
1548 required,
1549 enter: f.enter.attribute(),
1550 layout: layout_classes(f.span, f.break_row),
1551 attrs: behaviour_attrs(f),
1552 hidden: f.hidden,
1553 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1555 }
1556 })
1557 .collect();
1558 let keys: Vec<&str> = form
1562 .config
1563 .fields
1564 .iter()
1565 .zip(&form.fields)
1566 .filter_map(|(f, pf)| {
1567 state
1568 .field_types
1569 .get(&f.field_type)
1570 .map(|ft| ft.assets(&pf.opts))
1571 })
1572 .flatten()
1573 .collect();
1574 let shell_title = shell.tt(&form.config.title);
1576 let refused = error.is_some() || !bag.is_empty();
1577 let error = error.map(|m| shell.tt(&m));
1578 let mut shell = shell.clone();
1579 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1580 FormTemplate {
1581 shell,
1582 title: shell_title,
1583 action: action.to_string(),
1584 cancel_path: form
1585 .config
1586 .done_path
1587 .clone()
1588 .unwrap_or_else(|| form.config.base_path.clone()),
1589 error,
1590 fields,
1591 stay: form.stay,
1592 sections: Vec::new(),
1593 pending: Vec::new(),
1594 enter_off: form.config.enter == EnterPolicy::Off,
1595 refused,
1596 leave_off: !form.config.confirm_leave,
1597 focus: match form.config.focus {
1598 FormFocus::Off => "off",
1599 FormFocus::Auto if action.ends_with("/new") => "first",
1602 FormFocus::Auto => "",
1603 },
1604 }
1605}
1606
1607struct FieldView {
1608 id: String,
1610 label: String,
1611 control: String,
1614 required: bool,
1615 errors: Vec<String>,
1616 enter: &'static str,
1618 layout: String,
1620 attrs: String,
1622 hidden: bool,
1624}
1625
1626pub(crate) struct RelationSection {
1628 pub title: String,
1629 pub new_href: Option<String>,
1630 pub new_label: String,
1631 pub html: crate::html::Markup,
1632}
1633
1634pub(crate) fn escape_attr(s: &str) -> String {
1635 s.replace('&', "&")
1636 .replace('"', """)
1637 .replace('<', "<")
1638 .replace('>', ">")
1639}
1640
1641#[derive(Template)]
1644#[template(path = "_form_fields.html")]
1645struct FormFragment {
1646 shell: crate::Shell,
1647 action: String,
1648 cancel_path: String,
1649 error: Option<String>,
1650 fields: Vec<FieldView>,
1651 stay: bool,
1652 enter_off: bool,
1654 refused: bool,
1656 leave_off: bool,
1658 focus: &'static str,
1660}
1661
1662#[derive(Template)]
1663#[template(path = "form.html")]
1664struct FormTemplate {
1665 shell: crate::Shell,
1666 title: String,
1667 action: String,
1668 cancel_path: String,
1669 error: Option<String>,
1670 fields: Vec<FieldView>,
1671 stay: bool,
1673 sections: Vec<RelationSection>,
1675 pending: Vec<String>,
1677 enter_off: bool,
1679 refused: bool,
1681 leave_off: bool,
1683 focus: &'static str,
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689 use super::*;
1690 use axum::http::StatusCode;
1691 use laterite_core::strata::{
1692 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1693 };
1694 use laterite_core::testing::{connect_test, TestGuard};
1695 use laterite_core::Db;
1696 use laterite_core::SaveCx;
1697
1698 struct CreateSamples;
1701 #[async_trait(?Send)]
1702 impl Migration for CreateSamples {
1703 fn name(&self) -> &str {
1704 "0001_create_samples"
1705 }
1706 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1707 s.exec(
1708 Table::create()
1709 .table(Alias::new("samples"))
1710 .if_not_exists()
1711 .col(
1712 ColumnDef::new(Alias::new("id"))
1713 .big_integer()
1714 .not_null()
1715 .auto_increment()
1716 .primary_key(),
1717 )
1718 .col(ColumnDef::new(Alias::new("code")).text().not_null())
1719 .col(ColumnDef::new(Alias::new("name")).text().not_null())
1720 .col(ColumnDef::new(Alias::new("created_at")).text())
1723 .col(ColumnDef::new(Alias::new("updated_at")).text())
1724 .to_owned(),
1725 )
1726 .await
1727 }
1728 }
1729
1730 fn config() -> PreparedForm {
1731 let config = FormConfig {
1732 enter: Default::default(),
1733 confirm_leave: true,
1734 focus: Default::default(),
1735 entity: "samples".to_string(),
1736 title: "Sample".into(),
1737 base_path: "/admin/samples".to_string(),
1738 id_field: "id".to_string(),
1739 fields: vec![
1740 FormField::text("code", "Code").required().unique(),
1741 FormField::text("name", "Name").required(),
1742 ],
1743 persist: None,
1744 timestamps: false,
1745 done_path: None,
1746 };
1747 PreparedForm::prepare(
1748 config,
1749 &crate::field::builtin_registry(),
1750 &PersisterRegistry::new(),
1751 &[],
1752 )
1753 .unwrap()
1754 }
1755
1756 #[tokio::test]
1759 async fn a_delete_removes_the_row_through_the_pipeline() {
1760 use crate::persist::{delete, DefaultPersister, DeleteRequest};
1761
1762 let (db, _guard) = test_db().await;
1763 let st = state(db.clone());
1764 let form = config();
1765
1766 let mut data = HashMap::new();
1767 data.insert("code".to_string(), "c1".to_string());
1768 data.insert("name".to_string(), "Chair".to_string());
1769 create(
1770 &st,
1771 &form,
1772 data,
1773 crate::Shell::test(),
1774 &crate::audit::test_actor(),
1775 &crate::session::SessionHandle::from_blob(None),
1776 &axum::http::HeaderMap::new(),
1777 )
1778 .await;
1779 assert_eq!(
1780 fetch_text(&db, "name", "c1").await.as_deref(),
1781 Some("Chair")
1782 );
1783
1784 let id = row_id(&db, "c1").await;
1785 let persister = DefaultPersister::from_config(&form.config);
1786 let rec = delete(DeleteRequest {
1787 db: &db,
1788 listeners: &[],
1789 persister: &persister,
1790 actor: &laterite_core::Actor::system("test"),
1791 entity: "samples",
1792 id: &id,
1793 })
1794 .await
1795 .expect("deleted");
1796
1797 assert_eq!(fetch_text(&db, "name", "c1").await, None);
1799 assert_eq!(rec.text("code"), Some("c1"));
1800 }
1801
1802 async fn row_id(db: &Db, code: &str) -> String {
1804 let stmt = Query::select()
1805 .expr_as(
1806 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1807 Alias::new("v"),
1808 )
1809 .from(Alias::new("samples"))
1810 .and_where(Expr::col(Alias::new("code")).eq(code))
1811 .to_owned();
1812 let (sql, values) = laterite_core::query::build(db.backend, stmt);
1813 let row = bind_values(sqlx::query(&sql), values)
1814 .fetch_one(&db.pool)
1815 .await
1816 .unwrap();
1817 row.get_text("v").unwrap()
1818 }
1819
1820 pub(super) fn state(db: Db) -> AdminState {
1821 AdminState::new(
1822 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1823 db,
1824 )
1825 }
1826
1827 pub(super) async fn test_db() -> (Db, TestGuard) {
1830 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1831 connect_test(&[samples]).await
1832 }
1833
1834 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1837 let stmt = Query::select()
1838 .expr_as(
1839 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1840 Alias::new("v"),
1841 )
1842 .from(Alias::new("samples"))
1843 .and_where(Expr::col(Alias::new("code")).eq(code))
1844 .to_owned();
1845 let (sql, values) = to_sql(db.backend, stmt);
1846 let row = bind_values(sqlx::query(&sql), values)
1847 .fetch_optional(&db.pool)
1848 .await
1849 .unwrap()?;
1850 row.get_text_opt("v").ok().flatten()
1851 }
1852
1853 async fn body_of(resp: Response) -> String {
1854 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1855 .await
1856 .unwrap();
1857 String::from_utf8(bytes.to_vec()).unwrap()
1858 }
1859
1860 async fn count(db: &Db) -> i64 {
1861 sqlx::query_scalar("select count(*) from samples")
1862 .fetch_one(&db.pool)
1863 .await
1864 .unwrap()
1865 }
1866
1867 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1868 pairs
1869 .iter()
1870 .map(|(k, v)| (k.to_string(), v.to_string()))
1871 .collect()
1872 }
1873
1874 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1876 let mut h = axum::http::HeaderMap::new();
1877 h.insert("hx-request", "true".parse().unwrap());
1878 h
1879 }
1880
1881 #[tokio::test]
1882 async fn create_then_fetch() {
1883 let (db, _guard) = test_db().await;
1884 let cfg = config();
1885 let st = state(db.clone());
1886
1887 let resp = create(
1888 &st,
1889 &cfg,
1890 data(&[("code", "editor"), ("name", "Content Editor")]),
1891 crate::Shell::test(),
1892 &crate::audit::test_actor(),
1893 &crate::session::SessionHandle::from_blob(None),
1894 &axum::http::HeaderMap::new(),
1895 )
1896 .await;
1897 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1898
1899 assert_eq!(
1900 fetch_text(&db, "name", "editor").await.as_deref(),
1901 Some("Content Editor")
1902 );
1903 }
1904
1905 #[tokio::test]
1906 async fn update_changes_the_row() {
1907 let (db, _guard) = test_db().await;
1908 let cfg = config();
1909 let st = state(db.clone());
1910 create(
1911 &st,
1912 &cfg,
1913 data(&[("code", "editor"), ("name", "Editor")]),
1914 crate::Shell::test(),
1915 &crate::audit::test_actor(),
1916 &crate::session::SessionHandle::from_blob(None),
1917 &axum::http::HeaderMap::new(),
1918 )
1919 .await;
1920
1921 let id = fetch_text(&db, "id", "editor")
1922 .await
1923 .expect("row should exist after create");
1924
1925 let resp = update(
1927 &st,
1928 &cfg,
1929 id,
1930 data(&[("code", "editor"), ("name", "Senior Editor")]),
1931 crate::Shell::test(),
1932 &crate::audit::test_actor(),
1933 &crate::session::SessionHandle::from_blob(None),
1934 &axum::http::HeaderMap::new(),
1935 )
1936 .await;
1937 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1938
1939 assert_eq!(
1940 fetch_text(&db, "name", "editor").await.as_deref(),
1941 Some("Senior Editor")
1942 );
1943 }
1944
1945 #[tokio::test]
1946 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1947 let (db, _guard) = test_db().await;
1948 let cfg = config();
1949 let st = state(db.clone());
1950
1951 let resp = create(
1952 &st,
1953 &cfg,
1954 data(&[("code", ""), ("name", "No Code")]),
1955 crate::Shell::test(),
1956 &crate::audit::test_actor(),
1957 &crate::session::SessionHandle::from_blob(None),
1958 &axum::http::HeaderMap::new(),
1959 )
1960 .await;
1961 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1963 assert_eq!(count(&db).await, 0);
1964 assert!(body_of(resp).await.contains("Code is required."));
1966 }
1967
1968 #[tokio::test]
1969 async fn create_rejects_a_duplicate_unique_value() {
1970 let (db, _guard) = test_db().await;
1971 let cfg = config();
1972 let st = state(db.clone());
1973 create(
1974 &st,
1975 &cfg,
1976 data(&[("code", "editor"), ("name", "Editor")]),
1977 crate::Shell::test(),
1978 &crate::audit::test_actor(),
1979 &crate::session::SessionHandle::from_blob(None),
1980 &axum::http::HeaderMap::new(),
1981 )
1982 .await;
1983
1984 let resp = create(
1986 &st,
1987 &cfg,
1988 data(&[("code", "editor"), ("name", "Other")]),
1989 crate::Shell::test(),
1990 &crate::audit::test_actor(),
1991 &crate::session::SessionHandle::from_blob(None),
1992 &axum::http::HeaderMap::new(),
1993 )
1994 .await;
1995 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1996 assert_eq!(count(&db).await, 1);
1997 assert!(body_of(resp).await.contains("already taken"));
1998 }
1999
2000 #[tokio::test]
2001 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
2002 let (db, _guard) = test_db().await;
2003 let st = state(db.clone());
2004 let cfg = PreparedForm::prepare(
2007 FormConfig {
2008 enter: Default::default(),
2009 confirm_leave: true,
2010 focus: Default::default(),
2011 entity: "samples".to_string(),
2012 title: "Sample".into(),
2013 base_path: "/admin/samples".to_string(),
2014 id_field: "id".to_string(),
2015 fields: vec![
2016 FormField::text("code", "Code").required(),
2017 FormField::of("name", "Email", "text")
2018 .options(serde_json::json!({ "input": "email" }))
2019 .required(),
2020 ],
2021 persist: None,
2022 timestamps: false,
2023 done_path: None,
2024 },
2025 &st.field_types,
2026 &PersisterRegistry::new(),
2027 &[],
2028 )
2029 .unwrap();
2030
2031 let resp = create(
2032 &st,
2033 &cfg,
2034 data(&[("code", "c1"), ("name", "nope")]),
2035 crate::Shell::test(),
2036 &crate::audit::test_actor(),
2037 &crate::session::SessionHandle::from_blob(None),
2038 &axum::http::HeaderMap::new(),
2039 )
2040 .await;
2041 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2042 assert_eq!(count(&db).await, 0);
2043
2044 let resp = create(
2045 &st,
2046 &cfg,
2047 data(&[("code", "c1"), ("name", "a@b.test")]),
2048 crate::Shell::test(),
2049 &crate::audit::test_actor(),
2050 &crate::session::SessionHandle::from_blob(None),
2051 &axum::http::HeaderMap::new(),
2052 )
2053 .await;
2054 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2055 assert_eq!(count(&db).await, 1);
2056 }
2057
2058 #[test]
2059 fn prepare_rejects_an_unregistered_field_type() {
2060 let config = FormConfig {
2061 enter: Default::default(),
2062 confirm_leave: true,
2063 focus: Default::default(),
2064 entity: "samples".to_string(),
2065 title: "Sample".into(),
2066 base_path: "/admin/samples".to_string(),
2067 id_field: "id".to_string(),
2068 fields: vec![FormField::of("place", "Place", "no.such.type")],
2069 persist: None,
2070 timestamps: false,
2071 done_path: None,
2072 };
2073 let Err(err) = PreparedForm::prepare(
2074 config,
2075 &crate::field::builtin_registry(),
2076 &PersisterRegistry::new(),
2077 &[],
2078 ) else {
2079 panic!("expected prepare to reject an unregistered type");
2080 };
2081 assert!(err.contains("no.such.type"), "{err}");
2082 }
2083
2084 #[test]
2085 fn prepare_rejects_a_malformed_option() {
2086 let config = FormConfig {
2089 enter: Default::default(),
2090 confirm_leave: true,
2091 focus: Default::default(),
2092 entity: "samples".to_string(),
2093 title: "Sample".into(),
2094 base_path: "/admin/samples".to_string(),
2095 id_field: "id".to_string(),
2096 fields: vec![
2097 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
2098 ],
2099 persist: None,
2100 timestamps: false,
2101 done_path: None,
2102 };
2103 let Err(err) = PreparedForm::prepare(
2104 config,
2105 &crate::field::builtin_registry(),
2106 &PersisterRegistry::new(),
2107 &[],
2108 ) else {
2109 panic!("expected prepare to reject a malformed option");
2110 };
2111 assert!(err.contains("`email`"), "{err}");
2112 }
2113
2114 struct RollbackPersister;
2117 #[laterite_core::strata::async_trait]
2118 impl Persister for RollbackPersister {
2119 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
2120 let backend = cx.backend();
2121 let insert = Query::insert()
2122 .into_table(Alias::new("samples"))
2123 .columns([Alias::new("code"), Alias::new("name")])
2124 .values_panic(["rolled".into(), "back".into()])
2125 .to_owned();
2126 let (sql, values) = to_sql(backend, insert);
2127 bind_values(sqlx::query(&sql), values)
2128 .execute(cx.conn())
2129 .await
2130 .map_err(|e| SaveError::Failed(e.to_string()))?;
2131 Err(SaveError::Failed("deliberate".to_string()))
2132 }
2133 async fn update(
2134 &self,
2135 _cx: &mut SaveCx<'_>,
2136 _id: &str,
2137 _rec: &Record,
2138 ) -> Result<(), SaveError> {
2139 Ok(())
2140 }
2141 }
2142
2143 struct RejectingPersister;
2145 #[laterite_core::strata::async_trait]
2146 impl Persister for RejectingPersister {
2147 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
2148 let mut bag = ErrorBag::default();
2149 bag.add("code", t!("Code is not allowed here."));
2150 Err(SaveError::Invalid(bag))
2151 }
2152 async fn update(
2153 &self,
2154 _cx: &mut SaveCx<'_>,
2155 _id: &str,
2156 _rec: &Record,
2157 ) -> Result<(), SaveError> {
2158 Ok(())
2159 }
2160 }
2161
2162 fn config_with(persister: &str) -> FormConfig {
2163 FormConfig {
2164 enter: Default::default(),
2165 confirm_leave: true,
2166 focus: Default::default(),
2167 entity: "samples".to_string(),
2168 title: "Sample".into(),
2169 base_path: "/admin/samples".to_string(),
2170 id_field: "id".to_string(),
2171 fields: vec![
2172 FormField::text("code", "Code").required(),
2173 FormField::text("name", "Name").required(),
2174 ],
2175 persist: Some(persister.to_string()),
2176 timestamps: false,
2177 done_path: None,
2178 }
2179 }
2180
2181 #[test]
2182 fn prepare_rejects_an_unregistered_persister() {
2183 let Err(err) = PreparedForm::prepare(
2184 config_with("no.such.persister"),
2185 &crate::field::builtin_registry(),
2186 &PersisterRegistry::new(),
2187 &[],
2188 ) else {
2189 panic!("expected prepare to reject an unregistered persister");
2190 };
2191 assert!(err.contains("no.such.persister"), "{err}");
2192 }
2193
2194 #[tokio::test]
2195 async fn a_persister_transaction_rolls_back_on_error() {
2196 let (db, _guard) = test_db().await;
2197 let st = state(db.clone());
2198 let mut persisters = PersisterRegistry::new();
2199 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
2200 let form = PreparedForm::prepare(
2201 config_with("test.rollback"),
2202 &st.field_types,
2203 &persisters,
2204 &[],
2205 )
2206 .unwrap();
2207
2208 let resp = create(
2209 &st,
2210 &form,
2211 data(&[("code", "rolled"), ("name", "back")]),
2212 crate::Shell::test(),
2213 &crate::audit::test_actor(),
2214 &crate::session::SessionHandle::from_blob(None),
2215 &axum::http::HeaderMap::new(),
2216 )
2217 .await;
2218 assert_eq!(resp.status(), StatusCode::OK);
2221 assert_eq!(count(&db).await, 0);
2222 }
2223
2224 #[tokio::test]
2225 async fn a_persister_invalid_error_re_renders_422() {
2226 let (db, _guard) = test_db().await;
2227 let st = state(db.clone());
2228 let mut persisters = PersisterRegistry::new();
2229 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
2230 let form = PreparedForm::prepare(
2231 config_with("test.reject"),
2232 &st.field_types,
2233 &persisters,
2234 &[],
2235 )
2236 .unwrap();
2237
2238 let resp = create(
2239 &st,
2240 &form,
2241 data(&[("code", "editor"), ("name", "Editor")]),
2242 crate::Shell::test(),
2243 &crate::audit::test_actor(),
2244 &crate::session::SessionHandle::from_blob(None),
2245 &axum::http::HeaderMap::new(),
2246 )
2247 .await;
2248 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2249 assert!(body_of(resp).await.contains("Code is not allowed here."));
2250 }
2251}
2252
2253#[cfg(test)]
2254mod timestamp_tests {
2255 use super::tests::*;
2256 use super::*;
2257 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
2258 use laterite_core::Actor;
2259
2260 fn timestamped(on: bool) -> FormConfig {
2261 FormConfig {
2262 enter: Default::default(),
2263 confirm_leave: true,
2264 focus: Default::default(),
2265 entity: "samples".to_string(),
2266 title: "Sample".into(),
2267 base_path: "/admin/samples".to_string(),
2268 id_field: "id".to_string(),
2269 fields: vec![
2270 FormField::text("code", "Code").required(),
2271 FormField::text("name", "Name").required(),
2272 ],
2273 persist: None,
2274 timestamps: on,
2275 done_path: None,
2276 }
2277 }
2278
2279 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
2280 let form = PreparedForm::prepare(
2281 timestamped(on),
2282 &crate::field::builtin_registry(),
2283 &PersisterRegistry::new(),
2284 &[],
2285 )
2286 .unwrap();
2287 let mut data = HashMap::new();
2288 data.insert("code".to_string(), "c1".to_string());
2289 data.insert("name".to_string(), "Chair".to_string());
2290 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
2291 let actor = Actor::system("test");
2292 persist::save(
2293 persist::SaveRequest {
2294 db,
2295 listeners: &form.listeners,
2296 persister: form.persister.as_ref(),
2297 actor: &actor,
2298 op: Op::Create,
2299 id: None,
2300 },
2301 &mut rec,
2302 )
2303 .await
2304 .expect("the insert should succeed");
2305 rec
2306 }
2307
2308 #[tokio::test]
2309 async fn opting_in_stamps_the_row() {
2310 let (db, _guard) = test_db().await;
2311 let rec = create_through_pipeline(&db, true).await;
2312 assert!(rec.datetime(CREATED_AT).is_some());
2313 assert!(rec.datetime(UPDATED_AT).is_some());
2314 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2316 }
2317
2318 #[tokio::test]
2319 async fn leaving_it_off_writes_no_timestamps() {
2320 let (db, _guard) = test_db().await;
2321 let rec = create_through_pipeline(&db, false).await;
2322 assert!(rec.get(CREATED_AT).is_none());
2323 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2324 }
2325}
2326
2327#[cfg(test)]
2328mod htmx_tests {
2329 use super::tests::*;
2330 use super::*;
2331 use axum::http::StatusCode;
2332
2333 fn invalid() -> HashMap<String, String> {
2335 let mut d = HashMap::new();
2336 d.insert("code".to_string(), String::new());
2337 d.insert("name".to_string(), "No code".to_string());
2338 d
2339 }
2340
2341 fn valid() -> HashMap<String, String> {
2342 let mut d = HashMap::new();
2343 d.insert("code".to_string(), "c1".to_string());
2344 d.insert("name".to_string(), "Chair".to_string());
2345 d
2346 }
2347
2348 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2349 let (db, _guard) = test_db().await;
2350 let st = state(db);
2351 let form = PreparedForm::prepare(
2352 FormConfig {
2353 enter: Default::default(),
2354 confirm_leave: true,
2355 focus: Default::default(),
2356 entity: "samples".to_string(),
2357 title: "Sample".into(),
2358 base_path: "/admin/samples".to_string(),
2359 id_field: "id".to_string(),
2360 fields: vec![
2361 FormField::text("code", "Code").required(),
2362 FormField::text("name", "Name").required(),
2363 ],
2364 persist: None,
2365 timestamps: false,
2366 done_path: None,
2367 },
2368 &st.field_types,
2369 &PersisterRegistry::new(),
2370 &[],
2371 )
2372 .unwrap();
2373 create(
2374 &st,
2375 &form,
2376 data,
2377 crate::Shell::test(),
2378 &crate::audit::test_actor(),
2379 &crate::session::SessionHandle::from_blob(None),
2380 &headers,
2381 )
2382 .await
2383 }
2384
2385 async fn body_of(resp: Response) -> String {
2386 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2387 .await
2388 .unwrap();
2389 String::from_utf8(bytes.to_vec()).unwrap()
2390 }
2391
2392 #[tokio::test]
2393 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2394 let resp = post(invalid(), htmx_headers()).await;
2395 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2396 let html = body_of(resp).await;
2397 assert!(html.contains("<form"), "the form comes back");
2398 assert!(html.contains("lat-field__error"), "with its errors");
2399 assert!(!html.contains("<body"), "no page chrome");
2402 }
2403
2404 #[tokio::test]
2405 async fn a_plain_submit_that_fails_returns_the_whole_page() {
2406 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2407 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2408 let html = body_of(resp).await;
2409 assert!(html.contains("<body"), "the admin still works without htmx");
2410 assert!(html.contains("lat-field__error"));
2411 }
2412
2413 #[tokio::test]
2414 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2415 let resp = post(valid(), htmx_headers()).await;
2416 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2418 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2419 }
2420
2421 #[tokio::test]
2422 async fn a_plain_submit_that_succeeds_still_redirects() {
2423 let resp = post(valid(), axum::http::HeaderMap::new()).await;
2424 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2425 }
2426}