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 trigger: Option<Trigger>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum PresetShape {
81 Exact,
83 #[default]
85 Slug,
86 Url,
88 File,
90}
91
92impl PresetShape {
93 pub fn as_str(self) -> &'static str {
95 match self {
96 PresetShape::Exact => "exact",
97 PresetShape::Slug => "slug",
98 PresetShape::Url => "url",
99 PresetShape::File => "file",
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub struct Preset {
110 pub field: String,
111 pub shape: PresetShape,
112}
113
114impl Preset {
115 pub fn new(field: impl Into<String>, shape: PresetShape) -> Self {
116 Self {
117 field: field.into(),
118 shape,
119 }
120 }
121}
122
123impl<'de> Deserialize<'de> for Preset {
124 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
125 #[derive(Deserialize)]
126 #[serde(deny_unknown_fields)]
127 struct Full {
128 field: String,
129 #[serde(rename = "type", default)]
130 shape: PresetShape,
131 }
132 #[derive(Deserialize)]
133 #[serde(untagged)]
134 enum Raw {
135 Field(String),
136 Full(Full),
137 }
138 Ok(match Raw::deserialize(de)? {
139 Raw::Field(field) => Preset::new(field, PresetShape::Slug),
140 Raw::Full(full) => Preset::new(full.field, full.shape),
141 })
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct Trigger {
155 pub action: String,
158 pub field: String,
160 pub condition: String,
163}
164
165impl Trigger {
166 pub fn new(
167 action: impl Into<String>,
168 field: impl Into<String>,
169 condition: impl Into<String>,
170 ) -> Self {
171 Self {
172 action: action.into(),
173 field: field.into(),
174 condition: condition.into(),
175 }
176 }
177
178 pub fn check(&self) -> Result<(), String> {
180 for action in self.action.split('|') {
181 let known = matches!(action, "show" | "hide" | "enable" | "disable" | "empty")
182 || (action.starts_with("fill[") && action.ends_with(']'));
183 if !known {
184 return Err(format!(
185 "unknown trigger action `{action}`: show, hide, enable, disable, empty or fill[value]"
186 ));
187 }
188 }
189 let known = matches!(self.condition.as_str(), "checked" | "unchecked")
190 || (self.condition.starts_with("value[") && self.condition.ends_with(']'));
191 if !known {
192 return Err(format!(
193 "unknown trigger condition `{}`: checked, unchecked or value[..]",
194 self.condition
195 ));
196 }
197 Ok(())
198 }
199
200 pub fn watched(&self) -> &str {
202 self.field.trim_end_matches("[]")
203 }
204}
205
206pub(crate) fn behaviour_attrs(field: &FormField) -> String {
209 fn escaped(text: &str) -> String {
210 text.replace('&', "&")
211 .replace('<', "<")
212 .replace('>', ">")
213 .replace('"', """)
214 }
215 let mut attrs = String::new();
216 if let Some(preset) = &field.preset {
217 attrs.push_str(&format!(
218 r#" data-lat-preset="{}" data-lat-preset-type="{}""#,
219 escaped(&preset.field),
220 preset.shape.as_str()
221 ));
222 }
223 if let Some(trigger) = &field.trigger {
224 attrs.push_str(&format!(
225 r#" data-lat-trigger-action="{}" data-lat-trigger-field="{}" data-lat-trigger-condition="{}""#,
226 escaped(&trigger.action),
227 escaped(&trigger.field),
228 escaped(&trigger.condition)
229 ));
230 }
231 attrs
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub struct Span(u8);
240
241impl Span {
242 pub const FULL: Span = Span(12);
244
245 pub fn columns(columns: u8) -> Self {
247 Span(columns.clamp(1, 12))
248 }
249
250 pub fn width(self) -> u8 {
252 self.0
253 }
254
255 pub fn parse(text: &str) -> Result<Self, String> {
257 let columns = match text.trim() {
258 "full" | "12/12" => 12,
259 "1/2" | "half" | "left" | "right" | "auto" => 6,
260 "1/3" | "third" => 4,
261 "2/3" => 8,
262 "1/4" | "quarter" => 3,
263 "3/4" => 9,
264 other => match other.parse::<u8>() {
265 Ok(n) if (1..=12).contains(&n) => n,
266 _ => {
267 return Err(format!(
268 "unknown span `{other}`, expected full, 1/2, 1/3, 2/3, 1/4, 3/4 or 1 to 12"
269 ))
270 }
271 },
272 };
273 Ok(Span(columns))
274 }
275
276 fn class(self) -> String {
278 if self.0 == 12 {
279 String::new()
280 } else {
281 format!(" lat-field--{}", self.0)
282 }
283 }
284}
285
286impl Default for Span {
287 fn default() -> Self {
288 Span::FULL
289 }
290}
291
292impl Serialize for Span {
293 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
294 match self.0 {
295 12 => serializer.serialize_str("full"),
296 6 => serializer.serialize_str("1/2"),
297 4 => serializer.serialize_str("1/3"),
298 8 => serializer.serialize_str("2/3"),
299 3 => serializer.serialize_str("1/4"),
300 9 => serializer.serialize_str("3/4"),
301 n => serializer.serialize_u8(n),
302 }
303 }
304}
305
306impl<'de> Deserialize<'de> for Span {
307 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
308 #[derive(Deserialize)]
309 #[serde(untagged)]
310 enum Raw {
311 Columns(u8),
312 Text(String),
313 }
314 match Raw::deserialize(deserializer)? {
315 Raw::Columns(n) if (1..=12).contains(&n) => Ok(Span(n)),
316 Raw::Columns(n) => Err(serde::de::Error::custom(format!(
317 "unknown span `{n}`, expected 1 to 12"
318 ))),
319 Raw::Text(text) => Span::parse(&text).map_err(serde::de::Error::custom),
320 }
321 }
322}
323
324pub(crate) fn layout_classes(span: Span, break_row: bool) -> String {
326 let mut classes = span.class();
327 if break_row {
328 classes.push_str(" lat-field--break");
329 }
330 classes
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
335#[serde(rename_all = "lowercase")]
336pub enum EnterPolicy {
337 #[default]
339 Submit,
340 Off,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum FormFocus {
348 #[default]
351 Auto,
352 Off,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
358#[serde(rename_all = "lowercase")]
359pub enum FieldEnter {
360 #[default]
362 Submit,
363 Off,
365 Next,
367}
368
369impl FieldEnter {
370 fn attribute(self) -> &'static str {
373 match self {
374 FieldEnter::Submit => "",
375 FieldEnter::Off => "off",
376 FieldEnter::Next => "next",
377 }
378 }
379}
380
381impl Default for FormConfig {
382 fn default() -> Self {
385 Self {
386 entity: String::new(),
387 title: Text::new(""),
388 base_path: String::new(),
389 id_field: "id".to_string(),
390 fields: Vec::new(),
391 enter: EnterPolicy::default(),
392 confirm_leave: true,
393 focus: FormFocus::default(),
394 persist: None,
395 timestamps: false,
396 }
397 }
398}
399
400impl Default for FormField {
401 fn default() -> Self {
403 Self::of("", Text::new(""), "text")
404 }
405}
406
407impl<'de> Deserialize<'de> for FormField {
408 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
409 struct Entry;
410 impl<'de> serde::de::Visitor<'de> for Entry {
411 type Value = FormField;
412
413 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
414 f.write_str("a field: its own keys and its type's")
415 }
416
417 fn visit_map<A: serde::de::MapAccess<'de>>(
418 self,
419 map: A,
420 ) -> Result<FormField, A::Error> {
421 let mut field = FormField::default();
422 let theirs = crate::keyed::split_entry(map, |key, map| {
423 match key {
424 "name" => field.name = map.next_value()?,
425 "label" => field.label = map.next_value()?,
426 "type" => field.field_type = map.next_value()?,
427 "rules" => field.rules = map.next_value()?,
428 "translatable" => field.translatable = map.next_value()?,
429 "help" => field.help = map.next_value()?,
430 "enter" => field.enter = map.next_value()?,
431 "span" => field.span = map.next_value()?,
432 "break" => field.break_row = map.next_value()?,
433 "preset" => field.preset = map.next_value()?,
434 "trigger" => field.trigger = map.next_value()?,
435 _ => return Ok(false),
436 }
437 Ok(true)
438 })?;
439 field.options = theirs;
440 if let Some(trigger) = &field.trigger {
441 trigger.check().map_err(serde::de::Error::custom)?;
442 }
443 Ok(field)
444 }
445
446 fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
448 Ok(FormField::default())
449 }
450 }
451 de.deserialize_map(Entry)
452 }
453}
454
455impl Serialize for FormField {
456 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
457 use serde::ser::SerializeMap;
458 let mut map = ser.serialize_map(None)?;
459 map.serialize_entry("name", &self.name)?;
460 map.serialize_entry("label", &self.label)?;
461 map.serialize_entry("type", &self.field_type)?;
462 map.serialize_entry("rules", &self.rules)?;
463 map.serialize_entry("translatable", &self.translatable)?;
464 map.serialize_entry("help", &self.help)?;
465 map.serialize_entry("enter", &self.enter)?;
466 map.serialize_entry("span", &self.span)?;
467 map.serialize_entry("break", &self.break_row)?;
468 if let Some(preset) = &self.preset {
469 map.serialize_entry("preset", preset)?;
470 }
471 if let Some(trigger) = &self.trigger {
472 map.serialize_entry("trigger", trigger)?;
473 }
474 crate::keyed::serialize_theirs(&mut map, &self.options)?;
475 map.end()
476 }
477}
478
479impl crate::keyed::Keyed for FormField {
480 fn key(&self) -> &str {
481 &self.name
482 }
483 fn set_key(&mut self, key: String) {
484 self.name = key;
485 }
486}
487
488impl FormField {
489 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
491 Self {
492 name: name.to_string(),
493 label: label.into(),
494 field_type: field_type.to_string(),
495 options: serde_json::Value::Null,
496 rules: Vec::new(),
497 translatable: false,
498 help: None,
499 enter: FieldEnter::default(),
500 span: Span::default(),
501 break_row: false,
502 preset: None,
503 trigger: None,
504 }
505 }
506
507 pub fn preset(mut self, field: &str, shape: PresetShape) -> Self {
510 self.preset = Some(Preset::new(field, shape));
511 self
512 }
513
514 pub fn trigger(mut self, trigger: Trigger) -> Self {
516 self.trigger = Some(trigger);
517 self
518 }
519
520 pub fn text(name: &str, label: impl Into<Text>) -> Self {
521 Self::of(name, label, "text")
522 }
523
524 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
525 Self::of(name, label, "textarea")
526 }
527
528 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
532 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
533 }
534
535 pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
537 Self::of(name, label, "select").options(Self::choices(options))
538 }
539
540 pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
542 Self::of(name, label, "radio").options(Self::choices(options))
543 }
544
545 pub fn switch(name: &str, label: impl Into<Text>) -> Self {
547 Self::of(name, label, "switch")
548 }
549
550 pub fn date(name: &str, label: impl Into<Text>) -> Self {
552 Self::of(name, label, "date")
553 }
554
555 pub fn password(name: &str, label: impl Into<Text>) -> Self {
558 Self::of(name, label, "password")
559 }
560
561 pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
563 Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
564 }
565
566 pub fn repeater_list(
575 name: &str,
576 label: impl Into<Text>,
577 fields: Vec<FormField>,
578 summary_field: Option<&str>,
579 ) -> Self {
580 Self::of(name, label, "repeater").options(serde_json::json!({
581 "fields": fields,
582 "display": "list",
583 "summary_field": summary_field,
584 }))
585 }
586
587 fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
589 let options: Vec<serde_json::Value> = options
590 .into_iter()
591 .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
592 .collect();
593 serde_json::json!({ "options": options })
594 }
595
596 pub fn options(mut self, options: serde_json::Value) -> Self {
598 self.options = options;
599 self
600 }
601
602 pub fn required(mut self) -> Self {
604 self.rules.push(Rule::Required);
605 self
606 }
607
608 pub fn required_on(mut self, mode: Mode) -> Self {
611 self.rules.push(Rule::RequiredOn(mode));
612 self
613 }
614
615 pub fn unique(mut self) -> Self {
618 self.rules.push(Rule::Unique);
619 self
620 }
621
622 pub fn max_length(mut self, n: usize) -> Self {
624 self.rules.push(Rule::MaxLength(n));
625 self
626 }
627
628 pub fn min_length(mut self, n: usize) -> Self {
630 self.rules.push(Rule::MinLength(n));
631 self
632 }
633
634 pub fn email(mut self) -> Self {
636 self.rules.push(Rule::Email);
637 self
638 }
639
640 pub fn translatable(mut self) -> Self {
642 self.translatable = true;
643 self
644 }
645
646 pub fn help(mut self, text: impl Into<Text>) -> Self {
648 self.help = Some(text.into());
649 self
650 }
651
652 pub fn span(mut self, span: Span) -> Self {
654 self.span = span;
655 self
656 }
657
658 pub fn break_row(mut self) -> Self {
660 self.break_row = true;
661 self
662 }
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(deny_unknown_fields, default)]
672#[non_exhaustive]
673pub struct FormConfig {
674 pub entity: String,
675 pub title: Text,
677 pub base_path: String,
678 pub id_field: String,
679 #[serde(
680 deserialize_with = "crate::keyed::deserialize",
681 serialize_with = "crate::keyed::serialize"
682 )]
683 pub fields: Vec<FormField>,
684 #[serde(default)]
686 pub enter: EnterPolicy,
687 pub confirm_leave: bool,
689 pub focus: FormFocus,
691 pub persist: Option<String>,
694 #[serde(default)]
697 pub timestamps: bool,
698}
699
700impl FormConfig {
701 pub fn new(
706 entity: impl Into<String>,
707 title: impl Into<Text>,
708 base_path: impl Into<String>,
709 id_field: impl Into<String>,
710 fields: Vec<FormField>,
711 ) -> Self {
712 Self {
713 enter: Default::default(),
714 confirm_leave: true,
715 focus: FormFocus::default(),
716 entity: entity.into(),
717 title: title.into(),
718 base_path: base_path.into(),
719 id_field: id_field.into(),
720 fields,
721 persist: None,
722 timestamps: false,
723 }
724 }
725
726 pub fn persist(mut self, name: impl Into<String>) -> Self {
729 self.persist = Some(name.into());
730 self
731 }
732
733 pub fn confirm_leave(mut self, ask: bool) -> Self {
735 self.confirm_leave = ask;
736 self
737 }
738
739 pub fn focus(mut self, focus: FormFocus) -> Self {
741 self.focus = focus;
742 self
743 }
744
745 pub fn timestamps(mut self) -> Self {
748 self.timestamps = true;
749 self
750 }
751
752 fn idents_valid(&self) -> bool {
753 valid_ident(&self.entity)
754 && valid_ident(&self.id_field)
755 && self.fields.iter().all(|f| valid_ident(&f.name))
756 }
757}
758
759pub(crate) struct PreparedForm {
764 config: FormConfig,
765 fields: Vec<PreparedField>,
767 persister: Arc<dyn Persister>,
769 listeners: Vec<Arc<dyn ModelListener>>,
771}
772
773struct PreparedField {
776 field_type: Arc<dyn crate::field::FieldType>,
778 opts: ResolvedOptions,
779 rules: Vec<Rule>,
780}
781
782impl PreparedForm {
783 pub(crate) fn prepare(
787 config: FormConfig,
788 field_types: &crate::field::FieldRegistry,
789 persisters: &PersisterRegistry,
790 listeners: &[ModelListenerReg],
791 ) -> Result<Self, String> {
792 let mut fields = Vec::with_capacity(config.fields.len());
793 for f in &config.fields {
794 let names = || config.fields.iter().map(|other| other.name.as_str());
795 if let Some(preset) = &f.preset {
796 if !names().any(|n| n == preset.field) {
797 return Err(format!(
798 "field `{}` presets from `{}`, which is not in this form",
799 f.name, preset.field
800 ));
801 }
802 }
803 if let Some(trigger) = &f.trigger {
804 trigger
805 .check()
806 .map_err(|e| format!("field `{}`: {e}", f.name))?;
807 if !names().any(|n| n == trigger.watched()) {
808 return Err(format!(
809 "field `{}` watches `{}`, which is not in this form",
810 f.name,
811 trigger.watched()
812 ));
813 }
814 }
815 let ft = field_types.get(&f.field_type).ok_or_else(|| {
816 format!(
817 "field `{}` uses unregistered type `{}`",
818 f.name, f.field_type
819 )
820 })?;
821 crate::field::check_field_keys(f, ft.as_ref())
822 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
823 let opts = ft
824 .resolve_options(&f.options, field_types)
825 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
826 let mut rules = ft.intrinsic_rules(&opts);
827 rules.extend(f.rules.clone());
828 fields.push(PreparedField {
829 field_type: ft.clone(),
830 opts,
831 rules,
832 });
833 }
834 let persister: Arc<dyn Persister> = match &config.persist {
835 Some(key) => persisters
836 .get(key)
837 .cloned()
838 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
839 None => Arc::new(DefaultPersister::from_config(&config)),
840 };
841 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
844 if config.timestamps {
845 resolved.push(Arc::new(laterite_core::Timestamps));
846 }
847 resolved.extend(
848 listeners
849 .iter()
850 .filter(|reg| reg.matches(&config.entity))
851 .map(|reg| reg.listener.clone()),
852 );
853 let listeners = resolved;
854 Ok(Self {
855 config,
856 fields,
857 persister,
858 listeners,
859 })
860 }
861}
862
863fn declared_record(
870 form: &PreparedForm,
871 data: &HashMap<String, String>,
872 mode: Mode,
873) -> Result<Record, String> {
874 let mut rec = Record::new(&form.config.entity);
875 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
876 let submitted = crate::field::SubmittedField::new(&field.name, data);
877 if let Some(value) = prepared
878 .field_type
879 .to_attr(&submitted, &prepared.opts, mode)?
880 {
881 rec.set(field.name.clone(), value);
882 }
883 }
884 Ok(rec)
885}
886
887fn gathered(form: &PreparedForm, mut data: HashMap<String, String>) -> HashMap<String, String> {
894 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
895 if data.contains_key(&field.name) {
896 continue;
897 }
898 let value = prepared.field_type.submitted(
899 &crate::field::SubmittedField::new(&field.name, &data),
900 &prepared.opts,
901 );
902 if let Some(value) = value {
903 data.insert(field.name.clone(), value);
904 }
905 }
906 data
907}
908
909fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
911 form.config
912 .fields
913 .iter()
914 .zip(&form.fields)
915 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
918 .collect()
919}
920
921pub(crate) async fn write(
926 db: &Db,
927 form: &PreparedForm,
928 data: &HashMap<String, String>,
929 id: Option<&str>,
930 actor: &Actor,
931) -> Result<Record, SaveError> {
932 let (mut rec, op) = prepare(db, form, data, id).await?;
933 persist::save(
934 persist::SaveRequest {
935 db,
936 listeners: &form.listeners,
937 persister: form.persister.as_ref(),
938 actor,
939 op,
940 id,
941 },
942 &mut rec,
943 )
944 .await?;
945 Ok(rec)
946}
947
948async fn prepare(
952 db: &Db,
953 form: &PreparedForm,
954 data: &HashMap<String, String>,
955 id: Option<&str>,
956) -> Result<(Record, Op), SaveError> {
957 if !form.config.idents_valid() {
958 return Err(SaveError::Failed(format!(
959 "form `{}` names an invalid entity or field identifier",
960 form.config.entity
961 )));
962 }
963 let (mode, op) = match id {
964 None => (Mode::Create, Op::Create),
965 Some(_) => (Mode::Update, Op::Update),
966 };
967 let bag = validate(
968 db,
969 &form.config.entity,
970 &form.config.id_field,
971 &merged_field_rules(form),
972 data,
973 mode,
974 id,
975 )
976 .await
977 .map_err(|e| SaveError::Failed(e.to_string()))?;
978 if !bag.is_empty() {
979 return Err(SaveError::Invalid(bag));
980 }
981 let mut rec = declared_record(form, data, mode).map_err(|e| {
982 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
983 SaveError::Failed(e)
984 })?;
985 if let Some(n) = id.and_then(|id| id.parse::<i64>().ok()) {
986 rec.set_id(n);
987 }
988 Ok((rec, op))
989}
990
991pub(crate) type Forms = Arc<HashMap<String, Arc<PreparedForm>>>;
994
995#[derive(Clone)]
1003pub struct FormHandle {
1004 db: Db,
1005 form: Arc<PreparedForm>,
1006}
1007
1008impl FormHandle {
1009 pub(crate) fn new(db: Db, form: Arc<PreparedForm>) -> Self {
1010 Self { db, form }
1011 }
1012
1013 pub fn entity(&self) -> &str {
1015 &self.form.config.entity
1016 }
1017
1018 pub fn id_field(&self) -> &str {
1020 &self.form.config.id_field
1021 }
1022
1023 pub fn fields(&self) -> &[FormField] {
1025 &self.form.config.fields
1026 }
1027
1028 pub async fn create(
1034 &self,
1035 data: HashMap<String, String>,
1036 actor: &Actor,
1037 ) -> Result<i64, SaveError> {
1038 let data = gathered(&self.form, data);
1039 let rec = write(&self.db, &self.form, &data, None, actor).await?;
1040 rec.id()
1041 .ok_or_else(|| SaveError::Failed("the persister returned no id".to_string()))
1042 }
1043
1044 pub async fn load(&self, id: i64) -> Result<Option<HashMap<String, String>>, SaveError> {
1049 load_values(&self.db, &self.form, &id.to_string())
1050 .await
1051 .map_err(|e| SaveError::Failed(e.to_string()))
1052 }
1053
1054 pub async fn check(
1059 &self,
1060 id: Option<i64>,
1061 data: HashMap<String, String>,
1062 ) -> Result<(), SaveError> {
1063 let data = gathered(&self.form, data);
1064 let id = id.map(|id| id.to_string());
1065 prepare(&self.db, &self.form, &data, id.as_deref())
1066 .await
1067 .map(|_| ())
1068 }
1069
1070 pub async fn update(
1073 &self,
1074 id: i64,
1075 data: HashMap<String, String>,
1076 actor: &Actor,
1077 ) -> Result<(), SaveError> {
1078 let data = gathered(&self.form, data);
1079 write(&self.db, &self.form, &data, Some(&id.to_string()), actor)
1080 .await
1081 .map(|_| ())
1082 }
1083}
1084
1085pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
1087 rules
1088 .iter()
1089 .filter_map(|r| match r {
1090 Rule::MaxLength(n) => Some(*n),
1091 _ => None,
1092 })
1093 .min()
1094}
1095
1096fn has_required(rules: &[Rule]) -> bool {
1098 rules
1099 .iter()
1100 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
1101}
1102
1103pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
1105 render(build(
1106 state,
1107 form,
1108 &format!("{}/new", form.config.base_path),
1109 None,
1110 &HashMap::new(),
1111 &ErrorBag::default(),
1112 &shell,
1113 ))
1114}
1115
1116pub(crate) async fn create(
1119 state: &AdminState,
1120 form: &PreparedForm,
1121 data: HashMap<String, String>,
1122 shell: crate::Shell,
1123 user: &laterite_auth::AuthenticatedUser,
1124 session: &crate::session::SessionHandle,
1125 headers: &axum::http::HeaderMap,
1126) -> Response {
1127 if !form.config.idents_valid() {
1128 return render_error();
1129 }
1130 let htmx = is_htmx(headers);
1131 let action = format!("{}/new", form.config.base_path);
1132 let data = gathered(form, data);
1133 let actor = Actor::from(user);
1134 match write(&state.db, form, &data, None, &actor).await {
1135 Ok(_) => {
1138 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1139 saved_response(htmx, &form.config.base_path)
1140 }
1141 Err(SaveError::Invalid(bag)) => {
1144 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1145 }
1146 Err(SaveError::Failed(msg)) => {
1147 tracing::error!(error = %msg, "admin create failed");
1148 render(build(
1149 state,
1150 form,
1151 &action,
1152 Some(t!("Could not save. Check the values and try again.")),
1153 &data,
1154 &ErrorBag::default(),
1155 &shell,
1156 ))
1157 }
1158 }
1159}
1160
1161pub(crate) async fn edit_form(
1163 state: &AdminState,
1164 form: &PreparedForm,
1165 id: String,
1166 shell: crate::Shell,
1167) -> Response {
1168 if !form.config.idents_valid() {
1169 return render_error();
1170 }
1171 let values = match load_values(&state.db, form, &id).await {
1172 Ok(Some(values)) => values,
1173 Ok(None) => return not_found(),
1174 Err(_) => return render_error(),
1175 };
1176
1177 render(build(
1178 state,
1179 form,
1180 &format!("{}/{}/edit", form.config.base_path, id),
1181 None,
1182 &values,
1183 &ErrorBag::default(),
1184 &shell,
1185 ))
1186}
1187
1188pub(crate) async fn load_values(
1192 db: &Db,
1193 form: &PreparedForm,
1194 id: &str,
1195) -> Result<Option<HashMap<String, String>>, sqlx::Error> {
1196 let (sql, values) = {
1200 let cast = text_cast(db.backend);
1201 let mut select = Query::select();
1202 for field in &form.config.fields {
1203 select.expr_as(
1204 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1205 Alias::new(&field.name),
1206 );
1207 }
1208 select.from(Alias::new(&form.config.entity)).and_where(
1209 Expr::col(Alias::new(&form.config.id_field))
1210 .cast_as(Alias::new(cast))
1211 .eq(id.to_string()),
1212 );
1213 to_sql(db.backend, select)
1214 };
1215 let Some(row) = bind_values(sqlx::query(&sql), values)
1216 .fetch_optional(&db.pool)
1217 .await?
1218 else {
1219 return Ok(None);
1220 };
1221 Ok(Some(
1222 form.config
1223 .fields
1224 .iter()
1225 .zip(&form.fields)
1226 .map(|(f, prepared)| {
1227 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1228 let value = prepared
1229 .field_type
1230 .to_control(stored.as_deref(), &prepared.opts);
1231 (f.name.clone(), value)
1232 })
1233 .collect(),
1234 ))
1235}
1236
1237#[allow(clippy::too_many_arguments)]
1242pub(crate) async fn update(
1243 state: &AdminState,
1244 form: &PreparedForm,
1245 id: String,
1246 data: HashMap<String, String>,
1247 shell: crate::Shell,
1248 user: &laterite_auth::AuthenticatedUser,
1249 session: &crate::session::SessionHandle,
1250 headers: &axum::http::HeaderMap,
1251) -> Response {
1252 if !form.config.idents_valid() {
1253 return render_error();
1254 }
1255 let htmx = is_htmx(headers);
1256 let action = format!("{}/{}/edit", form.config.base_path, id);
1257 let data = gathered(form, data);
1258 let actor = Actor::from(user);
1259 match write(&state.db, form, &data, Some(&id), &actor).await {
1260 Ok(_) => {
1261 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1262 saved_response(htmx, &form.config.base_path)
1263 }
1264 Err(SaveError::Invalid(bag)) => {
1267 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1268 }
1269 Err(SaveError::Failed(msg)) => {
1270 tracing::error!(error = %msg, "admin update failed");
1271 render(build(
1272 state,
1273 form,
1274 &action,
1275 Some(t!("Could not save. Check the values and try again.")),
1276 &data,
1277 &ErrorBag::default(),
1278 &shell,
1279 ))
1280 }
1281 }
1282}
1283
1284#[allow(clippy::too_many_arguments)]
1288fn invalid_response(
1289 htmx: bool,
1290 state: &AdminState,
1291 form: &PreparedForm,
1292 action: &str,
1293 error: Option<Text>,
1294 values: &HashMap<String, String>,
1295 bag: &ErrorBag,
1296 shell: &crate::Shell,
1297) -> Response {
1298 let page = build(state, form, action, error, values, bag, shell);
1299 let body = if htmx {
1300 render(FormFragment {
1301 shell: page.shell,
1302 action: page.action,
1303 cancel_path: page.cancel_path,
1304 error: page.error,
1305 fields: page.fields,
1306 enter_off: page.enter_off,
1307 refused: page.refused,
1308 leave_off: page.leave_off,
1309 focus: page.focus,
1310 })
1311 } else {
1312 render(page)
1313 };
1314 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1315}
1316
1317pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1321 if htmx {
1322 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1323 } else {
1324 Redirect::to(to).into_response()
1325 }
1326}
1327
1328pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1330 headers.contains_key("hx-request")
1331}
1332
1333fn build(
1334 state: &AdminState,
1335 form: &PreparedForm,
1336 action: &str,
1337 error: Option<Text>,
1338 values: &HashMap<String, String>,
1339 bag: &ErrorBag,
1340 shell: &crate::Shell,
1341) -> FormTemplate {
1342 let fields = form
1343 .config
1344 .fields
1345 .iter()
1346 .zip(&form.fields)
1347 .map(|(f, pf)| {
1348 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1349 let required = has_required(&pf.rules);
1350 let label = shell.tt(&f.label);
1353 let cx = FieldCx {
1354 name: &f.name,
1355 id: &f.name,
1356 label: &label,
1357 value: &value,
1358 required,
1359 max_length: max_length(&pf.rules),
1360 opts: &pf.opts,
1361 base: &shell.base,
1362 i18n: shell.i18n(),
1363 };
1364 let control = match state.field_types.get(&f.field_type) {
1367 Some(ft) => {
1368 let scope = OverrideScope {
1369 surface: Surface::Field,
1370 view_key: &f.field_type,
1371 resource: Some(&form.config.base_path),
1372 field: Some(&f.name),
1373 };
1374 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1375 }
1376 None => String::new(),
1377 };
1378 FieldView {
1379 id: f.name.clone(),
1380 label,
1381 control,
1382 required,
1383 enter: f.enter.attribute(),
1384 layout: layout_classes(f.span, f.break_row),
1385 attrs: behaviour_attrs(f),
1386 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1388 }
1389 })
1390 .collect();
1391 let keys: Vec<&str> = form
1395 .config
1396 .fields
1397 .iter()
1398 .zip(&form.fields)
1399 .filter_map(|(f, pf)| {
1400 state
1401 .field_types
1402 .get(&f.field_type)
1403 .map(|ft| ft.assets(&pf.opts))
1404 })
1405 .flatten()
1406 .collect();
1407 let shell_title = shell.tt(&form.config.title);
1409 let refused = error.is_some() || !bag.is_empty();
1410 let error = error.map(|m| shell.tt(&m));
1411 let mut shell = shell.clone();
1412 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1413 FormTemplate {
1414 shell,
1415 title: shell_title,
1416 action: action.to_string(),
1417 cancel_path: form.config.base_path.clone(),
1418 error,
1419 fields,
1420 enter_off: form.config.enter == EnterPolicy::Off,
1421 refused,
1422 leave_off: !form.config.confirm_leave,
1423 focus: match form.config.focus {
1424 FormFocus::Off => "off",
1425 FormFocus::Auto if action.ends_with("/new") => "first",
1428 FormFocus::Auto => "",
1429 },
1430 }
1431}
1432
1433struct FieldView {
1434 id: String,
1436 label: String,
1437 control: String,
1440 required: bool,
1441 errors: Vec<String>,
1442 enter: &'static str,
1444 layout: String,
1446 attrs: String,
1448}
1449
1450#[derive(Template)]
1453#[template(path = "_form_fields.html")]
1454struct FormFragment {
1455 shell: crate::Shell,
1456 action: String,
1457 cancel_path: String,
1458 error: Option<String>,
1459 fields: Vec<FieldView>,
1460 enter_off: bool,
1462 refused: bool,
1464 leave_off: bool,
1466 focus: &'static str,
1468}
1469
1470#[derive(Template)]
1471#[template(path = "form.html")]
1472struct FormTemplate {
1473 shell: crate::Shell,
1474 title: String,
1475 action: String,
1476 cancel_path: String,
1477 error: Option<String>,
1478 fields: Vec<FieldView>,
1479 enter_off: bool,
1481 refused: bool,
1483 leave_off: bool,
1485 focus: &'static str,
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491 use super::*;
1492 use axum::http::StatusCode;
1493 use laterite_core::strata::{
1494 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1495 };
1496 use laterite_core::testing::{connect_test, TestGuard};
1497 use laterite_core::Db;
1498 use laterite_core::SaveCx;
1499
1500 struct CreateSamples;
1503 #[async_trait(?Send)]
1504 impl Migration for CreateSamples {
1505 fn name(&self) -> &str {
1506 "0001_create_samples"
1507 }
1508 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1509 s.exec(
1510 Table::create()
1511 .table(Alias::new("samples"))
1512 .if_not_exists()
1513 .col(
1514 ColumnDef::new(Alias::new("id"))
1515 .big_integer()
1516 .not_null()
1517 .auto_increment()
1518 .primary_key(),
1519 )
1520 .col(ColumnDef::new(Alias::new("code")).text().not_null())
1521 .col(ColumnDef::new(Alias::new("name")).text().not_null())
1522 .col(ColumnDef::new(Alias::new("created_at")).text())
1525 .col(ColumnDef::new(Alias::new("updated_at")).text())
1526 .to_owned(),
1527 )
1528 .await
1529 }
1530 }
1531
1532 fn config() -> PreparedForm {
1533 let config = FormConfig {
1534 enter: Default::default(),
1535 confirm_leave: true,
1536 focus: Default::default(),
1537 entity: "samples".to_string(),
1538 title: "Sample".into(),
1539 base_path: "/admin/samples".to_string(),
1540 id_field: "id".to_string(),
1541 fields: vec![
1542 FormField::text("code", "Code").required().unique(),
1543 FormField::text("name", "Name").required(),
1544 ],
1545 persist: None,
1546 timestamps: false,
1547 };
1548 PreparedForm::prepare(
1549 config,
1550 &crate::field::builtin_registry(),
1551 &PersisterRegistry::new(),
1552 &[],
1553 )
1554 .unwrap()
1555 }
1556
1557 #[tokio::test]
1560 async fn a_delete_removes_the_row_through_the_pipeline() {
1561 use crate::persist::{delete, DefaultPersister, DeleteRequest};
1562
1563 let (db, _guard) = test_db().await;
1564 let st = state(db.clone());
1565 let form = config();
1566
1567 let mut data = HashMap::new();
1568 data.insert("code".to_string(), "c1".to_string());
1569 data.insert("name".to_string(), "Chair".to_string());
1570 create(
1571 &st,
1572 &form,
1573 data,
1574 crate::Shell::test(),
1575 &crate::audit::test_actor(),
1576 &crate::session::SessionHandle::from_blob(None),
1577 &axum::http::HeaderMap::new(),
1578 )
1579 .await;
1580 assert_eq!(
1581 fetch_text(&db, "name", "c1").await.as_deref(),
1582 Some("Chair")
1583 );
1584
1585 let id = row_id(&db, "c1").await;
1586 let persister = DefaultPersister::from_config(&form.config);
1587 let rec = delete(DeleteRequest {
1588 db: &db,
1589 listeners: &[],
1590 persister: &persister,
1591 actor: &laterite_core::Actor::system("test"),
1592 entity: "samples",
1593 id: &id,
1594 })
1595 .await
1596 .expect("deleted");
1597
1598 assert_eq!(fetch_text(&db, "name", "c1").await, None);
1600 assert_eq!(rec.text("code"), Some("c1"));
1601 }
1602
1603 async fn row_id(db: &Db, code: &str) -> String {
1605 let stmt = Query::select()
1606 .expr_as(
1607 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1608 Alias::new("v"),
1609 )
1610 .from(Alias::new("samples"))
1611 .and_where(Expr::col(Alias::new("code")).eq(code))
1612 .to_owned();
1613 let (sql, values) = laterite_core::query::build(db.backend, stmt);
1614 let row = bind_values(sqlx::query(&sql), values)
1615 .fetch_one(&db.pool)
1616 .await
1617 .unwrap();
1618 row.get_text("v").unwrap()
1619 }
1620
1621 pub(super) fn state(db: Db) -> AdminState {
1622 AdminState::new(
1623 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1624 db,
1625 )
1626 }
1627
1628 pub(super) async fn test_db() -> (Db, TestGuard) {
1631 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1632 connect_test(&[samples]).await
1633 }
1634
1635 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1638 let stmt = Query::select()
1639 .expr_as(
1640 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1641 Alias::new("v"),
1642 )
1643 .from(Alias::new("samples"))
1644 .and_where(Expr::col(Alias::new("code")).eq(code))
1645 .to_owned();
1646 let (sql, values) = to_sql(db.backend, stmt);
1647 let row = bind_values(sqlx::query(&sql), values)
1648 .fetch_optional(&db.pool)
1649 .await
1650 .unwrap()?;
1651 row.get_text_opt("v").ok().flatten()
1652 }
1653
1654 async fn body_of(resp: Response) -> String {
1655 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1656 .await
1657 .unwrap();
1658 String::from_utf8(bytes.to_vec()).unwrap()
1659 }
1660
1661 async fn count(db: &Db) -> i64 {
1662 sqlx::query_scalar("select count(*) from samples")
1663 .fetch_one(&db.pool)
1664 .await
1665 .unwrap()
1666 }
1667
1668 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1669 pairs
1670 .iter()
1671 .map(|(k, v)| (k.to_string(), v.to_string()))
1672 .collect()
1673 }
1674
1675 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1677 let mut h = axum::http::HeaderMap::new();
1678 h.insert("hx-request", "true".parse().unwrap());
1679 h
1680 }
1681
1682 #[tokio::test]
1683 async fn create_then_fetch() {
1684 let (db, _guard) = test_db().await;
1685 let cfg = config();
1686 let st = state(db.clone());
1687
1688 let resp = create(
1689 &st,
1690 &cfg,
1691 data(&[("code", "editor"), ("name", "Content Editor")]),
1692 crate::Shell::test(),
1693 &crate::audit::test_actor(),
1694 &crate::session::SessionHandle::from_blob(None),
1695 &axum::http::HeaderMap::new(),
1696 )
1697 .await;
1698 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1699
1700 assert_eq!(
1701 fetch_text(&db, "name", "editor").await.as_deref(),
1702 Some("Content Editor")
1703 );
1704 }
1705
1706 #[tokio::test]
1707 async fn update_changes_the_row() {
1708 let (db, _guard) = test_db().await;
1709 let cfg = config();
1710 let st = state(db.clone());
1711 create(
1712 &st,
1713 &cfg,
1714 data(&[("code", "editor"), ("name", "Editor")]),
1715 crate::Shell::test(),
1716 &crate::audit::test_actor(),
1717 &crate::session::SessionHandle::from_blob(None),
1718 &axum::http::HeaderMap::new(),
1719 )
1720 .await;
1721
1722 let id = fetch_text(&db, "id", "editor")
1723 .await
1724 .expect("row should exist after create");
1725
1726 let resp = update(
1728 &st,
1729 &cfg,
1730 id,
1731 data(&[("code", "editor"), ("name", "Senior Editor")]),
1732 crate::Shell::test(),
1733 &crate::audit::test_actor(),
1734 &crate::session::SessionHandle::from_blob(None),
1735 &axum::http::HeaderMap::new(),
1736 )
1737 .await;
1738 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1739
1740 assert_eq!(
1741 fetch_text(&db, "name", "editor").await.as_deref(),
1742 Some("Senior Editor")
1743 );
1744 }
1745
1746 #[tokio::test]
1747 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1748 let (db, _guard) = test_db().await;
1749 let cfg = config();
1750 let st = state(db.clone());
1751
1752 let resp = create(
1753 &st,
1754 &cfg,
1755 data(&[("code", ""), ("name", "No Code")]),
1756 crate::Shell::test(),
1757 &crate::audit::test_actor(),
1758 &crate::session::SessionHandle::from_blob(None),
1759 &axum::http::HeaderMap::new(),
1760 )
1761 .await;
1762 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1764 assert_eq!(count(&db).await, 0);
1765 assert!(body_of(resp).await.contains("Code is required."));
1767 }
1768
1769 #[tokio::test]
1770 async fn create_rejects_a_duplicate_unique_value() {
1771 let (db, _guard) = test_db().await;
1772 let cfg = config();
1773 let st = state(db.clone());
1774 create(
1775 &st,
1776 &cfg,
1777 data(&[("code", "editor"), ("name", "Editor")]),
1778 crate::Shell::test(),
1779 &crate::audit::test_actor(),
1780 &crate::session::SessionHandle::from_blob(None),
1781 &axum::http::HeaderMap::new(),
1782 )
1783 .await;
1784
1785 let resp = create(
1787 &st,
1788 &cfg,
1789 data(&[("code", "editor"), ("name", "Other")]),
1790 crate::Shell::test(),
1791 &crate::audit::test_actor(),
1792 &crate::session::SessionHandle::from_blob(None),
1793 &axum::http::HeaderMap::new(),
1794 )
1795 .await;
1796 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1797 assert_eq!(count(&db).await, 1);
1798 assert!(body_of(resp).await.contains("already taken"));
1799 }
1800
1801 #[tokio::test]
1802 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1803 let (db, _guard) = test_db().await;
1804 let st = state(db.clone());
1805 let cfg = PreparedForm::prepare(
1808 FormConfig {
1809 enter: Default::default(),
1810 confirm_leave: true,
1811 focus: Default::default(),
1812 entity: "samples".to_string(),
1813 title: "Sample".into(),
1814 base_path: "/admin/samples".to_string(),
1815 id_field: "id".to_string(),
1816 fields: vec![
1817 FormField::text("code", "Code").required(),
1818 FormField::of("name", "Email", "text")
1819 .options(serde_json::json!({ "input": "email" }))
1820 .required(),
1821 ],
1822 persist: None,
1823 timestamps: false,
1824 },
1825 &st.field_types,
1826 &PersisterRegistry::new(),
1827 &[],
1828 )
1829 .unwrap();
1830
1831 let resp = create(
1832 &st,
1833 &cfg,
1834 data(&[("code", "c1"), ("name", "nope")]),
1835 crate::Shell::test(),
1836 &crate::audit::test_actor(),
1837 &crate::session::SessionHandle::from_blob(None),
1838 &axum::http::HeaderMap::new(),
1839 )
1840 .await;
1841 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1842 assert_eq!(count(&db).await, 0);
1843
1844 let resp = create(
1845 &st,
1846 &cfg,
1847 data(&[("code", "c1"), ("name", "a@b.test")]),
1848 crate::Shell::test(),
1849 &crate::audit::test_actor(),
1850 &crate::session::SessionHandle::from_blob(None),
1851 &axum::http::HeaderMap::new(),
1852 )
1853 .await;
1854 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1855 assert_eq!(count(&db).await, 1);
1856 }
1857
1858 #[test]
1859 fn prepare_rejects_an_unregistered_field_type() {
1860 let config = FormConfig {
1861 enter: Default::default(),
1862 confirm_leave: true,
1863 focus: Default::default(),
1864 entity: "samples".to_string(),
1865 title: "Sample".into(),
1866 base_path: "/admin/samples".to_string(),
1867 id_field: "id".to_string(),
1868 fields: vec![FormField::of("place", "Place", "no.such.type")],
1869 persist: None,
1870 timestamps: false,
1871 };
1872 let Err(err) = PreparedForm::prepare(
1873 config,
1874 &crate::field::builtin_registry(),
1875 &PersisterRegistry::new(),
1876 &[],
1877 ) else {
1878 panic!("expected prepare to reject an unregistered type");
1879 };
1880 assert!(err.contains("no.such.type"), "{err}");
1881 }
1882
1883 #[test]
1884 fn prepare_rejects_a_malformed_option() {
1885 let config = FormConfig {
1888 enter: Default::default(),
1889 confirm_leave: true,
1890 focus: Default::default(),
1891 entity: "samples".to_string(),
1892 title: "Sample".into(),
1893 base_path: "/admin/samples".to_string(),
1894 id_field: "id".to_string(),
1895 fields: vec![
1896 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1897 ],
1898 persist: None,
1899 timestamps: false,
1900 };
1901 let Err(err) = PreparedForm::prepare(
1902 config,
1903 &crate::field::builtin_registry(),
1904 &PersisterRegistry::new(),
1905 &[],
1906 ) else {
1907 panic!("expected prepare to reject a malformed option");
1908 };
1909 assert!(err.contains("`email`"), "{err}");
1910 }
1911
1912 struct RollbackPersister;
1915 #[laterite_core::strata::async_trait]
1916 impl Persister for RollbackPersister {
1917 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1918 let backend = cx.backend();
1919 let insert = Query::insert()
1920 .into_table(Alias::new("samples"))
1921 .columns([Alias::new("code"), Alias::new("name")])
1922 .values_panic(["rolled".into(), "back".into()])
1923 .to_owned();
1924 let (sql, values) = to_sql(backend, insert);
1925 bind_values(sqlx::query(&sql), values)
1926 .execute(cx.conn())
1927 .await
1928 .map_err(|e| SaveError::Failed(e.to_string()))?;
1929 Err(SaveError::Failed("deliberate".to_string()))
1930 }
1931 async fn update(
1932 &self,
1933 _cx: &mut SaveCx<'_>,
1934 _id: &str,
1935 _rec: &Record,
1936 ) -> Result<(), SaveError> {
1937 Ok(())
1938 }
1939 }
1940
1941 struct RejectingPersister;
1943 #[laterite_core::strata::async_trait]
1944 impl Persister for RejectingPersister {
1945 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1946 let mut bag = ErrorBag::default();
1947 bag.add("code", t!("Code is not allowed here."));
1948 Err(SaveError::Invalid(bag))
1949 }
1950 async fn update(
1951 &self,
1952 _cx: &mut SaveCx<'_>,
1953 _id: &str,
1954 _rec: &Record,
1955 ) -> Result<(), SaveError> {
1956 Ok(())
1957 }
1958 }
1959
1960 fn config_with(persister: &str) -> FormConfig {
1961 FormConfig {
1962 enter: Default::default(),
1963 confirm_leave: true,
1964 focus: Default::default(),
1965 entity: "samples".to_string(),
1966 title: "Sample".into(),
1967 base_path: "/admin/samples".to_string(),
1968 id_field: "id".to_string(),
1969 fields: vec![
1970 FormField::text("code", "Code").required(),
1971 FormField::text("name", "Name").required(),
1972 ],
1973 persist: Some(persister.to_string()),
1974 timestamps: false,
1975 }
1976 }
1977
1978 #[test]
1979 fn prepare_rejects_an_unregistered_persister() {
1980 let Err(err) = PreparedForm::prepare(
1981 config_with("no.such.persister"),
1982 &crate::field::builtin_registry(),
1983 &PersisterRegistry::new(),
1984 &[],
1985 ) else {
1986 panic!("expected prepare to reject an unregistered persister");
1987 };
1988 assert!(err.contains("no.such.persister"), "{err}");
1989 }
1990
1991 #[tokio::test]
1992 async fn a_persister_transaction_rolls_back_on_error() {
1993 let (db, _guard) = test_db().await;
1994 let st = state(db.clone());
1995 let mut persisters = PersisterRegistry::new();
1996 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1997 let form = PreparedForm::prepare(
1998 config_with("test.rollback"),
1999 &st.field_types,
2000 &persisters,
2001 &[],
2002 )
2003 .unwrap();
2004
2005 let resp = create(
2006 &st,
2007 &form,
2008 data(&[("code", "rolled"), ("name", "back")]),
2009 crate::Shell::test(),
2010 &crate::audit::test_actor(),
2011 &crate::session::SessionHandle::from_blob(None),
2012 &axum::http::HeaderMap::new(),
2013 )
2014 .await;
2015 assert_eq!(resp.status(), StatusCode::OK);
2018 assert_eq!(count(&db).await, 0);
2019 }
2020
2021 #[tokio::test]
2022 async fn a_persister_invalid_error_re_renders_422() {
2023 let (db, _guard) = test_db().await;
2024 let st = state(db.clone());
2025 let mut persisters = PersisterRegistry::new();
2026 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
2027 let form = PreparedForm::prepare(
2028 config_with("test.reject"),
2029 &st.field_types,
2030 &persisters,
2031 &[],
2032 )
2033 .unwrap();
2034
2035 let resp = create(
2036 &st,
2037 &form,
2038 data(&[("code", "editor"), ("name", "Editor")]),
2039 crate::Shell::test(),
2040 &crate::audit::test_actor(),
2041 &crate::session::SessionHandle::from_blob(None),
2042 &axum::http::HeaderMap::new(),
2043 )
2044 .await;
2045 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2046 assert!(body_of(resp).await.contains("Code is not allowed here."));
2047 }
2048}
2049
2050#[cfg(test)]
2051mod timestamp_tests {
2052 use super::tests::*;
2053 use super::*;
2054 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
2055 use laterite_core::Actor;
2056
2057 fn timestamped(on: bool) -> FormConfig {
2058 FormConfig {
2059 enter: Default::default(),
2060 confirm_leave: true,
2061 focus: Default::default(),
2062 entity: "samples".to_string(),
2063 title: "Sample".into(),
2064 base_path: "/admin/samples".to_string(),
2065 id_field: "id".to_string(),
2066 fields: vec![
2067 FormField::text("code", "Code").required(),
2068 FormField::text("name", "Name").required(),
2069 ],
2070 persist: None,
2071 timestamps: on,
2072 }
2073 }
2074
2075 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
2076 let form = PreparedForm::prepare(
2077 timestamped(on),
2078 &crate::field::builtin_registry(),
2079 &PersisterRegistry::new(),
2080 &[],
2081 )
2082 .unwrap();
2083 let mut data = HashMap::new();
2084 data.insert("code".to_string(), "c1".to_string());
2085 data.insert("name".to_string(), "Chair".to_string());
2086 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
2087 let actor = Actor::system("test");
2088 persist::save(
2089 persist::SaveRequest {
2090 db,
2091 listeners: &form.listeners,
2092 persister: form.persister.as_ref(),
2093 actor: &actor,
2094 op: Op::Create,
2095 id: None,
2096 },
2097 &mut rec,
2098 )
2099 .await
2100 .expect("the insert should succeed");
2101 rec
2102 }
2103
2104 #[tokio::test]
2105 async fn opting_in_stamps_the_row() {
2106 let (db, _guard) = test_db().await;
2107 let rec = create_through_pipeline(&db, true).await;
2108 assert!(rec.datetime(CREATED_AT).is_some());
2109 assert!(rec.datetime(UPDATED_AT).is_some());
2110 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2112 }
2113
2114 #[tokio::test]
2115 async fn leaving_it_off_writes_no_timestamps() {
2116 let (db, _guard) = test_db().await;
2117 let rec = create_through_pipeline(&db, false).await;
2118 assert!(rec.get(CREATED_AT).is_none());
2119 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2120 }
2121}
2122
2123#[cfg(test)]
2124mod htmx_tests {
2125 use super::tests::*;
2126 use super::*;
2127 use axum::http::StatusCode;
2128
2129 fn invalid() -> HashMap<String, String> {
2131 let mut d = HashMap::new();
2132 d.insert("code".to_string(), String::new());
2133 d.insert("name".to_string(), "No code".to_string());
2134 d
2135 }
2136
2137 fn valid() -> HashMap<String, String> {
2138 let mut d = HashMap::new();
2139 d.insert("code".to_string(), "c1".to_string());
2140 d.insert("name".to_string(), "Chair".to_string());
2141 d
2142 }
2143
2144 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2145 let (db, _guard) = test_db().await;
2146 let st = state(db);
2147 let form = PreparedForm::prepare(
2148 FormConfig {
2149 enter: Default::default(),
2150 confirm_leave: true,
2151 focus: Default::default(),
2152 entity: "samples".to_string(),
2153 title: "Sample".into(),
2154 base_path: "/admin/samples".to_string(),
2155 id_field: "id".to_string(),
2156 fields: vec![
2157 FormField::text("code", "Code").required(),
2158 FormField::text("name", "Name").required(),
2159 ],
2160 persist: None,
2161 timestamps: false,
2162 },
2163 &st.field_types,
2164 &PersisterRegistry::new(),
2165 &[],
2166 )
2167 .unwrap();
2168 create(
2169 &st,
2170 &form,
2171 data,
2172 crate::Shell::test(),
2173 &crate::audit::test_actor(),
2174 &crate::session::SessionHandle::from_blob(None),
2175 &headers,
2176 )
2177 .await
2178 }
2179
2180 async fn body_of(resp: Response) -> String {
2181 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2182 .await
2183 .unwrap();
2184 String::from_utf8(bytes.to_vec()).unwrap()
2185 }
2186
2187 #[tokio::test]
2188 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2189 let resp = post(invalid(), htmx_headers()).await;
2190 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2191 let html = body_of(resp).await;
2192 assert!(html.contains("<form"), "the form comes back");
2193 assert!(html.contains("lat-field__error"), "with its errors");
2194 assert!(!html.contains("<body"), "no page chrome");
2197 }
2198
2199 #[tokio::test]
2200 async fn a_plain_submit_that_fails_returns_the_whole_page() {
2201 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2202 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2203 let html = body_of(resp).await;
2204 assert!(html.contains("<body"), "the admin still works without htmx");
2205 assert!(html.contains("lat-field__error"));
2206 }
2207
2208 #[tokio::test]
2209 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2210 let resp = post(valid(), htmx_headers()).await;
2211 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2213 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2214 }
2215
2216 #[tokio::test]
2217 async fn a_plain_submit_that_succeeds_still_redirects() {
2218 let resp = post(valid(), axum::http::HeaderMap::new()).await;
2219 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2220 }
2221}