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, 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) fn max_length(rules: &[Rule]) -> Option<usize> {
923 rules
924 .iter()
925 .filter_map(|r| match r {
926 Rule::MaxLength(n) => Some(*n),
927 _ => None,
928 })
929 .min()
930}
931
932fn has_required(rules: &[Rule]) -> bool {
934 rules
935 .iter()
936 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
937}
938
939pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
941 render(build(
942 state,
943 form,
944 &format!("{}/new", form.config.base_path),
945 None,
946 &HashMap::new(),
947 &ErrorBag::default(),
948 &shell,
949 ))
950}
951
952pub(crate) async fn create(
955 state: &AdminState,
956 form: &PreparedForm,
957 data: HashMap<String, String>,
958 shell: crate::Shell,
959 user: &laterite_auth::AuthenticatedUser,
960 session: &crate::session::SessionHandle,
961 headers: &axum::http::HeaderMap,
962) -> Response {
963 if !form.config.idents_valid() {
964 return render_error();
965 }
966 let htmx = is_htmx(headers);
967 let action = format!("{}/new", form.config.base_path);
968 let data = gathered(form, data);
969
970 let bag = match validate(
971 &state.db,
972 &form.config.entity,
973 &form.config.id_field,
974 &merged_field_rules(form),
975 &data,
976 Mode::Create,
977 None,
978 )
979 .await
980 {
981 Ok(bag) => bag,
982 Err(_) => return render_error(),
983 };
984 if !bag.is_empty() {
985 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
988 }
989
990 let mut rec = match declared_record(form, &data, Mode::Create) {
991 Ok(rec) => rec,
992 Err(e) => {
993 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
994 return render_error();
995 }
996 };
997 let actor = Actor::from(user);
998 match persist::save(
999 persist::SaveRequest {
1000 db: &state.db,
1001 listeners: &form.listeners,
1002 persister: form.persister.as_ref(),
1003 actor: &actor,
1004 op: Op::Create,
1005 id: None,
1006 },
1007 &mut rec,
1008 )
1009 .await
1010 {
1011 Ok(()) => {
1014 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1015 saved_response(htmx, &form.config.base_path)
1016 }
1017 Err(SaveError::Invalid(bag)) => {
1019 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1020 }
1021 Err(SaveError::Failed(msg)) => {
1022 tracing::error!(error = %msg, "admin create failed");
1023 render(build(
1024 state,
1025 form,
1026 &action,
1027 Some(t!("Could not save. Check the values and try again.")),
1028 &data,
1029 &ErrorBag::default(),
1030 &shell,
1031 ))
1032 }
1033 }
1034}
1035
1036pub(crate) async fn edit_form(
1038 state: &AdminState,
1039 form: &PreparedForm,
1040 id: String,
1041 shell: crate::Shell,
1042) -> Response {
1043 if !form.config.idents_valid() {
1044 return render_error();
1045 }
1046 let (sql, values) = {
1050 let cast = text_cast(state.db.backend);
1051 let mut select = Query::select();
1052 for field in &form.config.fields {
1053 select.expr_as(
1054 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
1055 Alias::new(&field.name),
1056 );
1057 }
1058 select.from(Alias::new(&form.config.entity)).and_where(
1059 Expr::col(Alias::new(&form.config.id_field))
1060 .cast_as(Alias::new(cast))
1061 .eq(id.clone()),
1062 );
1063 to_sql(state.db.backend, select)
1064 };
1065 let row = match bind_values(sqlx::query(&sql), values)
1066 .fetch_optional(&state.db.pool)
1067 .await
1068 {
1069 Ok(row) => row,
1070 Err(_) => return render_error(),
1071 };
1072 let Some(row) = row else {
1073 return not_found();
1074 };
1075
1076 let values = form
1079 .config
1080 .fields
1081 .iter()
1082 .zip(&form.fields)
1083 .map(|(f, prepared)| {
1084 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
1085 let value = prepared
1086 .field_type
1087 .to_control(stored.as_deref(), &prepared.opts);
1088 (f.name.clone(), value)
1089 })
1090 .collect();
1091
1092 render(build(
1093 state,
1094 form,
1095 &format!("{}/{}/edit", form.config.base_path, id),
1096 None,
1097 &values,
1098 &ErrorBag::default(),
1099 &shell,
1100 ))
1101}
1102
1103#[allow(clippy::too_many_arguments)]
1108pub(crate) async fn update(
1109 state: &AdminState,
1110 form: &PreparedForm,
1111 id: String,
1112 data: HashMap<String, String>,
1113 shell: crate::Shell,
1114 user: &laterite_auth::AuthenticatedUser,
1115 session: &crate::session::SessionHandle,
1116 headers: &axum::http::HeaderMap,
1117) -> Response {
1118 if !form.config.idents_valid() {
1119 return render_error();
1120 }
1121 let htmx = is_htmx(headers);
1122 let action = format!("{}/{}/edit", form.config.base_path, id);
1123 let data = gathered(form, data);
1124
1125 let bag = match validate(
1126 &state.db,
1127 &form.config.entity,
1128 &form.config.id_field,
1129 &merged_field_rules(form),
1130 &data,
1131 Mode::Update,
1132 Some(&id),
1133 )
1134 .await
1135 {
1136 Ok(bag) => bag,
1137 Err(_) => return render_error(),
1138 };
1139 if !bag.is_empty() {
1140 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
1143 }
1144
1145 let mut rec = match declared_record(form, &data, Mode::Update) {
1146 Ok(rec) => rec,
1147 Err(e) => {
1148 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
1149 return render_error();
1150 }
1151 };
1152 if let Ok(n) = id.parse::<i64>() {
1153 rec.set_id(n);
1154 }
1155 let actor = Actor::from(user);
1156 match persist::save(
1157 persist::SaveRequest {
1158 db: &state.db,
1159 listeners: &form.listeners,
1160 persister: form.persister.as_ref(),
1161 actor: &actor,
1162 op: Op::Update,
1163 id: Some(&id),
1164 },
1165 &mut rec,
1166 )
1167 .await
1168 {
1169 Ok(()) => {
1170 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
1171 saved_response(htmx, &form.config.base_path)
1172 }
1173 Err(SaveError::Invalid(bag)) => {
1174 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
1175 }
1176 Err(SaveError::Failed(msg)) => {
1177 tracing::error!(error = %msg, "admin update failed");
1178 render(build(
1179 state,
1180 form,
1181 &action,
1182 Some(t!("Could not save. Check the values and try again.")),
1183 &data,
1184 &ErrorBag::default(),
1185 &shell,
1186 ))
1187 }
1188 }
1189}
1190
1191#[allow(clippy::too_many_arguments)]
1195fn invalid_response(
1196 htmx: bool,
1197 state: &AdminState,
1198 form: &PreparedForm,
1199 action: &str,
1200 error: Option<Text>,
1201 values: &HashMap<String, String>,
1202 bag: &ErrorBag,
1203 shell: &crate::Shell,
1204) -> Response {
1205 let page = build(state, form, action, error, values, bag, shell);
1206 let body = if htmx {
1207 render(FormFragment {
1208 shell: page.shell,
1209 action: page.action,
1210 cancel_path: page.cancel_path,
1211 error: page.error,
1212 fields: page.fields,
1213 enter_off: page.enter_off,
1214 refused: page.refused,
1215 leave_off: page.leave_off,
1216 focus: page.focus,
1217 })
1218 } else {
1219 render(page)
1220 };
1221 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1222}
1223
1224pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1228 if htmx {
1229 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1230 } else {
1231 Redirect::to(to).into_response()
1232 }
1233}
1234
1235pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1237 headers.contains_key("hx-request")
1238}
1239
1240fn build(
1241 state: &AdminState,
1242 form: &PreparedForm,
1243 action: &str,
1244 error: Option<Text>,
1245 values: &HashMap<String, String>,
1246 bag: &ErrorBag,
1247 shell: &crate::Shell,
1248) -> FormTemplate {
1249 let fields = form
1250 .config
1251 .fields
1252 .iter()
1253 .zip(&form.fields)
1254 .map(|(f, pf)| {
1255 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1256 let required = has_required(&pf.rules);
1257 let label = shell.tt(&f.label);
1260 let cx = FieldCx {
1261 name: &f.name,
1262 id: &f.name,
1263 label: &label,
1264 value: &value,
1265 required,
1266 max_length: max_length(&pf.rules),
1267 opts: &pf.opts,
1268 base: &shell.base,
1269 i18n: shell.i18n(),
1270 };
1271 let control = match state.field_types.get(&f.field_type) {
1274 Some(ft) => {
1275 let scope = OverrideScope {
1276 surface: Surface::Field,
1277 view_key: &f.field_type,
1278 resource: Some(&form.config.base_path),
1279 field: Some(&f.name),
1280 };
1281 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1282 }
1283 None => String::new(),
1284 };
1285 FieldView {
1286 id: f.name.clone(),
1287 label,
1288 control,
1289 required,
1290 enter: f.enter.attribute(),
1291 layout: layout_classes(f.span, f.break_row),
1292 attrs: behaviour_attrs(f),
1293 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1295 }
1296 })
1297 .collect();
1298 let keys: Vec<&str> = form
1302 .config
1303 .fields
1304 .iter()
1305 .zip(&form.fields)
1306 .filter_map(|(f, pf)| {
1307 state
1308 .field_types
1309 .get(&f.field_type)
1310 .map(|ft| ft.assets(&pf.opts))
1311 })
1312 .flatten()
1313 .collect();
1314 let shell_title = shell.tt(&form.config.title);
1316 let refused = error.is_some() || !bag.is_empty();
1317 let error = error.map(|m| shell.tt(&m));
1318 let mut shell = shell.clone();
1319 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1320 FormTemplate {
1321 shell,
1322 title: shell_title,
1323 action: action.to_string(),
1324 cancel_path: form.config.base_path.clone(),
1325 error,
1326 fields,
1327 enter_off: form.config.enter == EnterPolicy::Off,
1328 refused,
1329 leave_off: !form.config.confirm_leave,
1330 focus: match form.config.focus {
1331 FormFocus::Off => "off",
1332 FormFocus::Auto if action.ends_with("/new") => "first",
1335 FormFocus::Auto => "",
1336 },
1337 }
1338}
1339
1340struct FieldView {
1341 id: String,
1343 label: String,
1344 control: String,
1347 required: bool,
1348 errors: Vec<String>,
1349 enter: &'static str,
1351 layout: String,
1353 attrs: String,
1355}
1356
1357#[derive(Template)]
1360#[template(path = "_form_fields.html")]
1361struct FormFragment {
1362 shell: crate::Shell,
1363 action: String,
1364 cancel_path: String,
1365 error: Option<String>,
1366 fields: Vec<FieldView>,
1367 enter_off: bool,
1369 refused: bool,
1371 leave_off: bool,
1373 focus: &'static str,
1375}
1376
1377#[derive(Template)]
1378#[template(path = "form.html")]
1379struct FormTemplate {
1380 shell: crate::Shell,
1381 title: String,
1382 action: String,
1383 cancel_path: String,
1384 error: Option<String>,
1385 fields: Vec<FieldView>,
1386 enter_off: bool,
1388 refused: bool,
1390 leave_off: bool,
1392 focus: &'static str,
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::*;
1399 use axum::http::StatusCode;
1400 use laterite_core::strata::{
1401 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1402 };
1403 use laterite_core::testing::{connect_test, TestGuard};
1404 use laterite_core::Db;
1405 use laterite_core::SaveCx;
1406
1407 struct CreateSamples;
1410 #[async_trait(?Send)]
1411 impl Migration for CreateSamples {
1412 fn name(&self) -> &str {
1413 "0001_create_samples"
1414 }
1415 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1416 s.exec(
1417 Table::create()
1418 .table(Alias::new("samples"))
1419 .if_not_exists()
1420 .col(
1421 ColumnDef::new(Alias::new("id"))
1422 .big_integer()
1423 .not_null()
1424 .auto_increment()
1425 .primary_key(),
1426 )
1427 .col(ColumnDef::new(Alias::new("code")).text().not_null())
1428 .col(ColumnDef::new(Alias::new("name")).text().not_null())
1429 .col(ColumnDef::new(Alias::new("created_at")).text())
1432 .col(ColumnDef::new(Alias::new("updated_at")).text())
1433 .to_owned(),
1434 )
1435 .await
1436 }
1437 }
1438
1439 fn config() -> PreparedForm {
1440 let config = FormConfig {
1441 enter: Default::default(),
1442 confirm_leave: true,
1443 focus: Default::default(),
1444 entity: "samples".to_string(),
1445 title: "Sample".into(),
1446 base_path: "/admin/samples".to_string(),
1447 id_field: "id".to_string(),
1448 fields: vec![
1449 FormField::text("code", "Code").required().unique(),
1450 FormField::text("name", "Name").required(),
1451 ],
1452 persist: None,
1453 timestamps: false,
1454 };
1455 PreparedForm::prepare(
1456 config,
1457 &crate::field::builtin_registry(),
1458 &PersisterRegistry::new(),
1459 &[],
1460 )
1461 .unwrap()
1462 }
1463
1464 #[tokio::test]
1467 async fn a_delete_removes_the_row_through_the_pipeline() {
1468 use crate::persist::{delete, DefaultPersister, DeleteRequest};
1469
1470 let (db, _guard) = test_db().await;
1471 let st = state(db.clone());
1472 let form = config();
1473
1474 let mut data = HashMap::new();
1475 data.insert("code".to_string(), "c1".to_string());
1476 data.insert("name".to_string(), "Chair".to_string());
1477 create(
1478 &st,
1479 &form,
1480 data,
1481 crate::Shell::test(),
1482 &crate::audit::test_actor(),
1483 &crate::session::SessionHandle::from_blob(None),
1484 &axum::http::HeaderMap::new(),
1485 )
1486 .await;
1487 assert_eq!(
1488 fetch_text(&db, "name", "c1").await.as_deref(),
1489 Some("Chair")
1490 );
1491
1492 let id = row_id(&db, "c1").await;
1493 let persister = DefaultPersister::from_config(&form.config);
1494 let rec = delete(DeleteRequest {
1495 db: &db,
1496 listeners: &[],
1497 persister: &persister,
1498 actor: &laterite_core::Actor::system("test"),
1499 entity: "samples",
1500 id: &id,
1501 })
1502 .await
1503 .expect("deleted");
1504
1505 assert_eq!(fetch_text(&db, "name", "c1").await, None);
1507 assert_eq!(rec.text("code"), Some("c1"));
1508 }
1509
1510 async fn row_id(db: &Db, code: &str) -> String {
1512 let stmt = Query::select()
1513 .expr_as(
1514 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1515 Alias::new("v"),
1516 )
1517 .from(Alias::new("samples"))
1518 .and_where(Expr::col(Alias::new("code")).eq(code))
1519 .to_owned();
1520 let (sql, values) = laterite_core::query::build(db.backend, stmt);
1521 let row = bind_values(sqlx::query(&sql), values)
1522 .fetch_one(&db.pool)
1523 .await
1524 .unwrap();
1525 row.get_text("v").unwrap()
1526 }
1527
1528 pub(super) fn state(db: Db) -> AdminState {
1529 AdminState::new(
1530 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1531 db,
1532 )
1533 }
1534
1535 pub(super) async fn test_db() -> (Db, TestGuard) {
1538 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1539 connect_test(&[samples]).await
1540 }
1541
1542 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1545 let stmt = Query::select()
1546 .expr_as(
1547 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1548 Alias::new("v"),
1549 )
1550 .from(Alias::new("samples"))
1551 .and_where(Expr::col(Alias::new("code")).eq(code))
1552 .to_owned();
1553 let (sql, values) = to_sql(db.backend, stmt);
1554 let row = bind_values(sqlx::query(&sql), values)
1555 .fetch_optional(&db.pool)
1556 .await
1557 .unwrap()?;
1558 row.get_text_opt("v").ok().flatten()
1559 }
1560
1561 async fn body_of(resp: Response) -> String {
1562 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1563 .await
1564 .unwrap();
1565 String::from_utf8(bytes.to_vec()).unwrap()
1566 }
1567
1568 async fn count(db: &Db) -> i64 {
1569 sqlx::query_scalar("select count(*) from samples")
1570 .fetch_one(&db.pool)
1571 .await
1572 .unwrap()
1573 }
1574
1575 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1576 pairs
1577 .iter()
1578 .map(|(k, v)| (k.to_string(), v.to_string()))
1579 .collect()
1580 }
1581
1582 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1584 let mut h = axum::http::HeaderMap::new();
1585 h.insert("hx-request", "true".parse().unwrap());
1586 h
1587 }
1588
1589 #[tokio::test]
1590 async fn create_then_fetch() {
1591 let (db, _guard) = test_db().await;
1592 let cfg = config();
1593 let st = state(db.clone());
1594
1595 let resp = create(
1596 &st,
1597 &cfg,
1598 data(&[("code", "editor"), ("name", "Content Editor")]),
1599 crate::Shell::test(),
1600 &crate::audit::test_actor(),
1601 &crate::session::SessionHandle::from_blob(None),
1602 &axum::http::HeaderMap::new(),
1603 )
1604 .await;
1605 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1606
1607 assert_eq!(
1608 fetch_text(&db, "name", "editor").await.as_deref(),
1609 Some("Content Editor")
1610 );
1611 }
1612
1613 #[tokio::test]
1614 async fn update_changes_the_row() {
1615 let (db, _guard) = test_db().await;
1616 let cfg = config();
1617 let st = state(db.clone());
1618 create(
1619 &st,
1620 &cfg,
1621 data(&[("code", "editor"), ("name", "Editor")]),
1622 crate::Shell::test(),
1623 &crate::audit::test_actor(),
1624 &crate::session::SessionHandle::from_blob(None),
1625 &axum::http::HeaderMap::new(),
1626 )
1627 .await;
1628
1629 let id = fetch_text(&db, "id", "editor")
1630 .await
1631 .expect("row should exist after create");
1632
1633 let resp = update(
1635 &st,
1636 &cfg,
1637 id,
1638 data(&[("code", "editor"), ("name", "Senior Editor")]),
1639 crate::Shell::test(),
1640 &crate::audit::test_actor(),
1641 &crate::session::SessionHandle::from_blob(None),
1642 &axum::http::HeaderMap::new(),
1643 )
1644 .await;
1645 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1646
1647 assert_eq!(
1648 fetch_text(&db, "name", "editor").await.as_deref(),
1649 Some("Senior Editor")
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1655 let (db, _guard) = test_db().await;
1656 let cfg = config();
1657 let st = state(db.clone());
1658
1659 let resp = create(
1660 &st,
1661 &cfg,
1662 data(&[("code", ""), ("name", "No Code")]),
1663 crate::Shell::test(),
1664 &crate::audit::test_actor(),
1665 &crate::session::SessionHandle::from_blob(None),
1666 &axum::http::HeaderMap::new(),
1667 )
1668 .await;
1669 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1671 assert_eq!(count(&db).await, 0);
1672 assert!(body_of(resp).await.contains("Code is required."));
1674 }
1675
1676 #[tokio::test]
1677 async fn create_rejects_a_duplicate_unique_value() {
1678 let (db, _guard) = test_db().await;
1679 let cfg = config();
1680 let st = state(db.clone());
1681 create(
1682 &st,
1683 &cfg,
1684 data(&[("code", "editor"), ("name", "Editor")]),
1685 crate::Shell::test(),
1686 &crate::audit::test_actor(),
1687 &crate::session::SessionHandle::from_blob(None),
1688 &axum::http::HeaderMap::new(),
1689 )
1690 .await;
1691
1692 let resp = create(
1694 &st,
1695 &cfg,
1696 data(&[("code", "editor"), ("name", "Other")]),
1697 crate::Shell::test(),
1698 &crate::audit::test_actor(),
1699 &crate::session::SessionHandle::from_blob(None),
1700 &axum::http::HeaderMap::new(),
1701 )
1702 .await;
1703 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1704 assert_eq!(count(&db).await, 1);
1705 assert!(body_of(resp).await.contains("already taken"));
1706 }
1707
1708 #[tokio::test]
1709 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1710 let (db, _guard) = test_db().await;
1711 let st = state(db.clone());
1712 let cfg = PreparedForm::prepare(
1715 FormConfig {
1716 enter: Default::default(),
1717 confirm_leave: true,
1718 focus: Default::default(),
1719 entity: "samples".to_string(),
1720 title: "Sample".into(),
1721 base_path: "/admin/samples".to_string(),
1722 id_field: "id".to_string(),
1723 fields: vec![
1724 FormField::text("code", "Code").required(),
1725 FormField::of("name", "Email", "text")
1726 .options(serde_json::json!({ "input": "email" }))
1727 .required(),
1728 ],
1729 persist: None,
1730 timestamps: false,
1731 },
1732 &st.field_types,
1733 &PersisterRegistry::new(),
1734 &[],
1735 )
1736 .unwrap();
1737
1738 let resp = create(
1739 &st,
1740 &cfg,
1741 data(&[("code", "c1"), ("name", "nope")]),
1742 crate::Shell::test(),
1743 &crate::audit::test_actor(),
1744 &crate::session::SessionHandle::from_blob(None),
1745 &axum::http::HeaderMap::new(),
1746 )
1747 .await;
1748 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1749 assert_eq!(count(&db).await, 0);
1750
1751 let resp = create(
1752 &st,
1753 &cfg,
1754 data(&[("code", "c1"), ("name", "a@b.test")]),
1755 crate::Shell::test(),
1756 &crate::audit::test_actor(),
1757 &crate::session::SessionHandle::from_blob(None),
1758 &axum::http::HeaderMap::new(),
1759 )
1760 .await;
1761 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1762 assert_eq!(count(&db).await, 1);
1763 }
1764
1765 #[test]
1766 fn prepare_rejects_an_unregistered_field_type() {
1767 let config = FormConfig {
1768 enter: Default::default(),
1769 confirm_leave: true,
1770 focus: Default::default(),
1771 entity: "samples".to_string(),
1772 title: "Sample".into(),
1773 base_path: "/admin/samples".to_string(),
1774 id_field: "id".to_string(),
1775 fields: vec![FormField::of("place", "Place", "no.such.type")],
1776 persist: None,
1777 timestamps: false,
1778 };
1779 let Err(err) = PreparedForm::prepare(
1780 config,
1781 &crate::field::builtin_registry(),
1782 &PersisterRegistry::new(),
1783 &[],
1784 ) else {
1785 panic!("expected prepare to reject an unregistered type");
1786 };
1787 assert!(err.contains("no.such.type"), "{err}");
1788 }
1789
1790 #[test]
1791 fn prepare_rejects_a_malformed_option() {
1792 let config = FormConfig {
1795 enter: Default::default(),
1796 confirm_leave: true,
1797 focus: Default::default(),
1798 entity: "samples".to_string(),
1799 title: "Sample".into(),
1800 base_path: "/admin/samples".to_string(),
1801 id_field: "id".to_string(),
1802 fields: vec![
1803 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1804 ],
1805 persist: None,
1806 timestamps: false,
1807 };
1808 let Err(err) = PreparedForm::prepare(
1809 config,
1810 &crate::field::builtin_registry(),
1811 &PersisterRegistry::new(),
1812 &[],
1813 ) else {
1814 panic!("expected prepare to reject a malformed option");
1815 };
1816 assert!(err.contains("`email`"), "{err}");
1817 }
1818
1819 struct RollbackPersister;
1822 #[laterite_core::strata::async_trait]
1823 impl Persister for RollbackPersister {
1824 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1825 let backend = cx.backend();
1826 let insert = Query::insert()
1827 .into_table(Alias::new("samples"))
1828 .columns([Alias::new("code"), Alias::new("name")])
1829 .values_panic(["rolled".into(), "back".into()])
1830 .to_owned();
1831 let (sql, values) = to_sql(backend, insert);
1832 bind_values(sqlx::query(&sql), values)
1833 .execute(cx.conn())
1834 .await
1835 .map_err(|e| SaveError::Failed(e.to_string()))?;
1836 Err(SaveError::Failed("deliberate".to_string()))
1837 }
1838 async fn update(
1839 &self,
1840 _cx: &mut SaveCx<'_>,
1841 _id: &str,
1842 _rec: &Record,
1843 ) -> Result<(), SaveError> {
1844 Ok(())
1845 }
1846 }
1847
1848 struct RejectingPersister;
1850 #[laterite_core::strata::async_trait]
1851 impl Persister for RejectingPersister {
1852 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1853 let mut bag = ErrorBag::default();
1854 bag.add("code", t!("Code is not allowed here."));
1855 Err(SaveError::Invalid(bag))
1856 }
1857 async fn update(
1858 &self,
1859 _cx: &mut SaveCx<'_>,
1860 _id: &str,
1861 _rec: &Record,
1862 ) -> Result<(), SaveError> {
1863 Ok(())
1864 }
1865 }
1866
1867 fn config_with(persister: &str) -> FormConfig {
1868 FormConfig {
1869 enter: Default::default(),
1870 confirm_leave: true,
1871 focus: Default::default(),
1872 entity: "samples".to_string(),
1873 title: "Sample".into(),
1874 base_path: "/admin/samples".to_string(),
1875 id_field: "id".to_string(),
1876 fields: vec![
1877 FormField::text("code", "Code").required(),
1878 FormField::text("name", "Name").required(),
1879 ],
1880 persist: Some(persister.to_string()),
1881 timestamps: false,
1882 }
1883 }
1884
1885 #[test]
1886 fn prepare_rejects_an_unregistered_persister() {
1887 let Err(err) = PreparedForm::prepare(
1888 config_with("no.such.persister"),
1889 &crate::field::builtin_registry(),
1890 &PersisterRegistry::new(),
1891 &[],
1892 ) else {
1893 panic!("expected prepare to reject an unregistered persister");
1894 };
1895 assert!(err.contains("no.such.persister"), "{err}");
1896 }
1897
1898 #[tokio::test]
1899 async fn a_persister_transaction_rolls_back_on_error() {
1900 let (db, _guard) = test_db().await;
1901 let st = state(db.clone());
1902 let mut persisters = PersisterRegistry::new();
1903 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1904 let form = PreparedForm::prepare(
1905 config_with("test.rollback"),
1906 &st.field_types,
1907 &persisters,
1908 &[],
1909 )
1910 .unwrap();
1911
1912 let resp = create(
1913 &st,
1914 &form,
1915 data(&[("code", "rolled"), ("name", "back")]),
1916 crate::Shell::test(),
1917 &crate::audit::test_actor(),
1918 &crate::session::SessionHandle::from_blob(None),
1919 &axum::http::HeaderMap::new(),
1920 )
1921 .await;
1922 assert_eq!(resp.status(), StatusCode::OK);
1925 assert_eq!(count(&db).await, 0);
1926 }
1927
1928 #[tokio::test]
1929 async fn a_persister_invalid_error_re_renders_422() {
1930 let (db, _guard) = test_db().await;
1931 let st = state(db.clone());
1932 let mut persisters = PersisterRegistry::new();
1933 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1934 let form = PreparedForm::prepare(
1935 config_with("test.reject"),
1936 &st.field_types,
1937 &persisters,
1938 &[],
1939 )
1940 .unwrap();
1941
1942 let resp = create(
1943 &st,
1944 &form,
1945 data(&[("code", "editor"), ("name", "Editor")]),
1946 crate::Shell::test(),
1947 &crate::audit::test_actor(),
1948 &crate::session::SessionHandle::from_blob(None),
1949 &axum::http::HeaderMap::new(),
1950 )
1951 .await;
1952 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1953 assert!(body_of(resp).await.contains("Code is not allowed here."));
1954 }
1955}
1956
1957#[cfg(test)]
1958mod timestamp_tests {
1959 use super::tests::*;
1960 use super::*;
1961 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1962 use laterite_core::Actor;
1963
1964 fn timestamped(on: bool) -> FormConfig {
1965 FormConfig {
1966 enter: Default::default(),
1967 confirm_leave: true,
1968 focus: Default::default(),
1969 entity: "samples".to_string(),
1970 title: "Sample".into(),
1971 base_path: "/admin/samples".to_string(),
1972 id_field: "id".to_string(),
1973 fields: vec![
1974 FormField::text("code", "Code").required(),
1975 FormField::text("name", "Name").required(),
1976 ],
1977 persist: None,
1978 timestamps: on,
1979 }
1980 }
1981
1982 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1983 let form = PreparedForm::prepare(
1984 timestamped(on),
1985 &crate::field::builtin_registry(),
1986 &PersisterRegistry::new(),
1987 &[],
1988 )
1989 .unwrap();
1990 let mut data = HashMap::new();
1991 data.insert("code".to_string(), "c1".to_string());
1992 data.insert("name".to_string(), "Chair".to_string());
1993 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1994 let actor = Actor::system("test");
1995 persist::save(
1996 persist::SaveRequest {
1997 db,
1998 listeners: &form.listeners,
1999 persister: form.persister.as_ref(),
2000 actor: &actor,
2001 op: Op::Create,
2002 id: None,
2003 },
2004 &mut rec,
2005 )
2006 .await
2007 .expect("the insert should succeed");
2008 rec
2009 }
2010
2011 #[tokio::test]
2012 async fn opting_in_stamps_the_row() {
2013 let (db, _guard) = test_db().await;
2014 let rec = create_through_pipeline(&db, true).await;
2015 assert!(rec.datetime(CREATED_AT).is_some());
2016 assert!(rec.datetime(UPDATED_AT).is_some());
2017 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
2019 }
2020
2021 #[tokio::test]
2022 async fn leaving_it_off_writes_no_timestamps() {
2023 let (db, _guard) = test_db().await;
2024 let rec = create_through_pipeline(&db, false).await;
2025 assert!(rec.get(CREATED_AT).is_none());
2026 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
2027 }
2028}
2029
2030#[cfg(test)]
2031mod htmx_tests {
2032 use super::tests::*;
2033 use super::*;
2034 use axum::http::StatusCode;
2035
2036 fn invalid() -> HashMap<String, String> {
2038 let mut d = HashMap::new();
2039 d.insert("code".to_string(), String::new());
2040 d.insert("name".to_string(), "No code".to_string());
2041 d
2042 }
2043
2044 fn valid() -> HashMap<String, String> {
2045 let mut d = HashMap::new();
2046 d.insert("code".to_string(), "c1".to_string());
2047 d.insert("name".to_string(), "Chair".to_string());
2048 d
2049 }
2050
2051 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
2052 let (db, _guard) = test_db().await;
2053 let st = state(db);
2054 let form = PreparedForm::prepare(
2055 FormConfig {
2056 enter: Default::default(),
2057 confirm_leave: true,
2058 focus: Default::default(),
2059 entity: "samples".to_string(),
2060 title: "Sample".into(),
2061 base_path: "/admin/samples".to_string(),
2062 id_field: "id".to_string(),
2063 fields: vec![
2064 FormField::text("code", "Code").required(),
2065 FormField::text("name", "Name").required(),
2066 ],
2067 persist: None,
2068 timestamps: false,
2069 },
2070 &st.field_types,
2071 &PersisterRegistry::new(),
2072 &[],
2073 )
2074 .unwrap();
2075 create(
2076 &st,
2077 &form,
2078 data,
2079 crate::Shell::test(),
2080 &crate::audit::test_actor(),
2081 &crate::session::SessionHandle::from_blob(None),
2082 &headers,
2083 )
2084 .await
2085 }
2086
2087 async fn body_of(resp: Response) -> String {
2088 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2089 .await
2090 .unwrap();
2091 String::from_utf8(bytes.to_vec()).unwrap()
2092 }
2093
2094 #[tokio::test]
2095 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
2096 let resp = post(invalid(), htmx_headers()).await;
2097 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2098 let html = body_of(resp).await;
2099 assert!(html.contains("<form"), "the form comes back");
2100 assert!(html.contains("lat-field__error"), "with its errors");
2101 assert!(!html.contains("<body"), "no page chrome");
2104 }
2105
2106 #[tokio::test]
2107 async fn a_plain_submit_that_fails_returns_the_whole_page() {
2108 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
2109 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
2110 let html = body_of(resp).await;
2111 assert!(html.contains("<body"), "the admin still works without htmx");
2112 assert!(html.contains("lat-field__error"));
2113 }
2114
2115 #[tokio::test]
2116 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
2117 let resp = post(valid(), htmx_headers()).await;
2118 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2120 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
2121 }
2122
2123 #[tokio::test]
2124 async fn a_plain_submit_that_succeeds_still_redirects() {
2125 let resp = post(valid(), axum::http::HeaderMap::new()).await;
2126 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
2127 }
2128}