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}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct Span(u8);
79
80impl Span {
81 pub const FULL: Span = Span(12);
83
84 pub fn columns(columns: u8) -> Self {
86 Span(columns.clamp(1, 12))
87 }
88
89 pub fn width(self) -> u8 {
91 self.0
92 }
93
94 pub fn parse(text: &str) -> Result<Self, String> {
96 let columns = match text.trim() {
97 "full" | "12/12" => 12,
98 "1/2" | "half" | "left" | "right" | "auto" => 6,
99 "1/3" | "third" => 4,
100 "2/3" => 8,
101 "1/4" | "quarter" => 3,
102 "3/4" => 9,
103 other => match other.parse::<u8>() {
104 Ok(n) if (1..=12).contains(&n) => n,
105 _ => {
106 return Err(format!(
107 "unknown span `{other}`, expected full, 1/2, 1/3, 2/3, 1/4, 3/4 or 1 to 12"
108 ))
109 }
110 },
111 };
112 Ok(Span(columns))
113 }
114
115 fn class(self) -> String {
117 if self.0 == 12 {
118 String::new()
119 } else {
120 format!(" lat-field--{}", self.0)
121 }
122 }
123}
124
125impl Default for Span {
126 fn default() -> Self {
127 Span::FULL
128 }
129}
130
131impl Serialize for Span {
132 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
133 match self.0 {
134 12 => serializer.serialize_str("full"),
135 6 => serializer.serialize_str("1/2"),
136 4 => serializer.serialize_str("1/3"),
137 8 => serializer.serialize_str("2/3"),
138 3 => serializer.serialize_str("1/4"),
139 9 => serializer.serialize_str("3/4"),
140 n => serializer.serialize_u8(n),
141 }
142 }
143}
144
145impl<'de> Deserialize<'de> for Span {
146 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
147 #[derive(Deserialize)]
148 #[serde(untagged)]
149 enum Raw {
150 Columns(u8),
151 Text(String),
152 }
153 match Raw::deserialize(deserializer)? {
154 Raw::Columns(n) if (1..=12).contains(&n) => Ok(Span(n)),
155 Raw::Columns(n) => Err(serde::de::Error::custom(format!(
156 "unknown span `{n}`, expected 1 to 12"
157 ))),
158 Raw::Text(text) => Span::parse(&text).map_err(serde::de::Error::custom),
159 }
160 }
161}
162
163pub(crate) fn layout_classes(span: Span, break_row: bool) -> String {
165 let mut classes = span.class();
166 if break_row {
167 classes.push_str(" lat-field--break");
168 }
169 classes
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
174#[serde(rename_all = "lowercase")]
175pub enum EnterPolicy {
176 #[default]
178 Submit,
179 Off,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
185#[serde(rename_all = "lowercase")]
186pub enum FormFocus {
187 #[default]
190 Auto,
191 Off,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum FieldEnter {
199 #[default]
201 Submit,
202 Off,
204 Next,
206}
207
208impl FieldEnter {
209 fn attribute(self) -> &'static str {
212 match self {
213 FieldEnter::Submit => "",
214 FieldEnter::Off => "off",
215 FieldEnter::Next => "next",
216 }
217 }
218}
219
220impl Default for FormConfig {
221 fn default() -> Self {
224 Self {
225 entity: String::new(),
226 title: Text::new(""),
227 base_path: String::new(),
228 id_field: "id".to_string(),
229 fields: Vec::new(),
230 enter: EnterPolicy::default(),
231 confirm_leave: true,
232 focus: FormFocus::default(),
233 persist: None,
234 timestamps: false,
235 }
236 }
237}
238
239impl Default for FormField {
240 fn default() -> Self {
242 Self::of("", Text::new(""), "text")
243 }
244}
245
246impl<'de> Deserialize<'de> for FormField {
247 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
248 struct Entry;
249 impl<'de> serde::de::Visitor<'de> for Entry {
250 type Value = FormField;
251
252 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
253 f.write_str("a field: its own keys and its type's")
254 }
255
256 fn visit_map<A: serde::de::MapAccess<'de>>(
257 self,
258 map: A,
259 ) -> Result<FormField, A::Error> {
260 let mut field = FormField::default();
261 let theirs = crate::keyed::split_entry(map, |key, map| {
262 match key {
263 "name" => field.name = map.next_value()?,
264 "label" => field.label = map.next_value()?,
265 "type" => field.field_type = map.next_value()?,
266 "rules" => field.rules = map.next_value()?,
267 "translatable" => field.translatable = map.next_value()?,
268 "help" => field.help = map.next_value()?,
269 "enter" => field.enter = map.next_value()?,
270 "span" => field.span = map.next_value()?,
271 "break" => field.break_row = map.next_value()?,
272 _ => return Ok(false),
273 }
274 Ok(true)
275 })?;
276 field.options = theirs;
277 Ok(field)
278 }
279
280 fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
282 Ok(FormField::default())
283 }
284 }
285 de.deserialize_map(Entry)
286 }
287}
288
289impl Serialize for FormField {
290 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
291 use serde::ser::SerializeMap;
292 let mut map = ser.serialize_map(None)?;
293 map.serialize_entry("name", &self.name)?;
294 map.serialize_entry("label", &self.label)?;
295 map.serialize_entry("type", &self.field_type)?;
296 map.serialize_entry("rules", &self.rules)?;
297 map.serialize_entry("translatable", &self.translatable)?;
298 map.serialize_entry("help", &self.help)?;
299 map.serialize_entry("enter", &self.enter)?;
300 map.serialize_entry("span", &self.span)?;
301 map.serialize_entry("break", &self.break_row)?;
302 crate::keyed::serialize_theirs(&mut map, &self.options)?;
303 map.end()
304 }
305}
306
307impl crate::keyed::Keyed for FormField {
308 fn key(&self) -> &str {
309 &self.name
310 }
311 fn set_key(&mut self, key: String) {
312 self.name = key;
313 }
314}
315
316impl FormField {
317 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
319 Self {
320 name: name.to_string(),
321 label: label.into(),
322 field_type: field_type.to_string(),
323 options: serde_json::Value::Null,
324 rules: Vec::new(),
325 translatable: false,
326 help: None,
327 enter: FieldEnter::default(),
328 span: Span::default(),
329 break_row: false,
330 }
331 }
332
333 pub fn text(name: &str, label: impl Into<Text>) -> Self {
334 Self::of(name, label, "text")
335 }
336
337 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
338 Self::of(name, label, "textarea")
339 }
340
341 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
345 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
346 }
347
348 pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
350 Self::of(name, label, "select").options(Self::choices(options))
351 }
352
353 pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
355 Self::of(name, label, "radio").options(Self::choices(options))
356 }
357
358 pub fn switch(name: &str, label: impl Into<Text>) -> Self {
360 Self::of(name, label, "switch")
361 }
362
363 pub fn date(name: &str, label: impl Into<Text>) -> Self {
365 Self::of(name, label, "date")
366 }
367
368 pub fn password(name: &str, label: impl Into<Text>) -> Self {
371 Self::of(name, label, "password")
372 }
373
374 pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
376 Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
377 }
378
379 pub fn repeater_list(
388 name: &str,
389 label: impl Into<Text>,
390 fields: Vec<FormField>,
391 summary_field: Option<&str>,
392 ) -> Self {
393 Self::of(name, label, "repeater").options(serde_json::json!({
394 "fields": fields,
395 "display": "list",
396 "summary_field": summary_field,
397 }))
398 }
399
400 fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
402 let options: Vec<serde_json::Value> = options
403 .into_iter()
404 .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
405 .collect();
406 serde_json::json!({ "options": options })
407 }
408
409 pub fn options(mut self, options: serde_json::Value) -> Self {
411 self.options = options;
412 self
413 }
414
415 pub fn required(mut self) -> Self {
417 self.rules.push(Rule::Required);
418 self
419 }
420
421 pub fn required_on(mut self, mode: Mode) -> Self {
424 self.rules.push(Rule::RequiredOn(mode));
425 self
426 }
427
428 pub fn unique(mut self) -> Self {
431 self.rules.push(Rule::Unique);
432 self
433 }
434
435 pub fn max_length(mut self, n: usize) -> Self {
437 self.rules.push(Rule::MaxLength(n));
438 self
439 }
440
441 pub fn min_length(mut self, n: usize) -> Self {
443 self.rules.push(Rule::MinLength(n));
444 self
445 }
446
447 pub fn email(mut self) -> Self {
449 self.rules.push(Rule::Email);
450 self
451 }
452
453 pub fn translatable(mut self) -> Self {
455 self.translatable = true;
456 self
457 }
458
459 pub fn help(mut self, text: impl Into<Text>) -> Self {
461 self.help = Some(text.into());
462 self
463 }
464
465 pub fn span(mut self, span: Span) -> Self {
467 self.span = span;
468 self
469 }
470
471 pub fn break_row(mut self) -> Self {
473 self.break_row = true;
474 self
475 }
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(deny_unknown_fields, default)]
485#[non_exhaustive]
486pub struct FormConfig {
487 pub entity: String,
488 pub title: Text,
490 pub base_path: String,
491 pub id_field: String,
492 #[serde(
493 deserialize_with = "crate::keyed::deserialize",
494 serialize_with = "crate::keyed::serialize"
495 )]
496 pub fields: Vec<FormField>,
497 #[serde(default)]
499 pub enter: EnterPolicy,
500 pub confirm_leave: bool,
502 pub focus: FormFocus,
504 pub persist: Option<String>,
507 #[serde(default)]
510 pub timestamps: bool,
511}
512
513impl FormConfig {
514 pub fn new(
519 entity: impl Into<String>,
520 title: impl Into<Text>,
521 base_path: impl Into<String>,
522 id_field: impl Into<String>,
523 fields: Vec<FormField>,
524 ) -> Self {
525 Self {
526 enter: Default::default(),
527 confirm_leave: true,
528 focus: FormFocus::default(),
529 entity: entity.into(),
530 title: title.into(),
531 base_path: base_path.into(),
532 id_field: id_field.into(),
533 fields,
534 persist: None,
535 timestamps: false,
536 }
537 }
538
539 pub fn persist(mut self, name: impl Into<String>) -> Self {
542 self.persist = Some(name.into());
543 self
544 }
545
546 pub fn confirm_leave(mut self, ask: bool) -> Self {
548 self.confirm_leave = ask;
549 self
550 }
551
552 pub fn focus(mut self, focus: FormFocus) -> Self {
554 self.focus = focus;
555 self
556 }
557
558 pub fn timestamps(mut self) -> Self {
561 self.timestamps = true;
562 self
563 }
564
565 fn idents_valid(&self) -> bool {
566 valid_ident(&self.entity)
567 && valid_ident(&self.id_field)
568 && self.fields.iter().all(|f| valid_ident(&f.name))
569 }
570}
571
572pub(crate) struct PreparedForm {
577 config: FormConfig,
578 fields: Vec<PreparedField>,
580 persister: Arc<dyn Persister>,
582 listeners: Vec<Arc<dyn ModelListener>>,
584}
585
586struct PreparedField {
589 field_type: Arc<dyn crate::field::FieldType>,
591 opts: ResolvedOptions,
592 rules: Vec<Rule>,
593}
594
595impl PreparedForm {
596 pub(crate) fn prepare(
600 config: FormConfig,
601 field_types: &crate::field::FieldRegistry,
602 persisters: &PersisterRegistry,
603 listeners: &[ModelListenerReg],
604 ) -> Result<Self, String> {
605 let mut fields = Vec::with_capacity(config.fields.len());
606 for f in &config.fields {
607 let ft = field_types.get(&f.field_type).ok_or_else(|| {
608 format!(
609 "field `{}` uses unregistered type `{}`",
610 f.name, f.field_type
611 )
612 })?;
613 crate::field::check_field_keys(f, ft.as_ref())
614 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
615 let opts = ft
616 .resolve_options(&f.options, field_types)
617 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
618 let mut rules = ft.intrinsic_rules(&opts);
619 rules.extend(f.rules.clone());
620 fields.push(PreparedField {
621 field_type: ft.clone(),
622 opts,
623 rules,
624 });
625 }
626 let persister: Arc<dyn Persister> = match &config.persist {
627 Some(key) => persisters
628 .get(key)
629 .cloned()
630 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
631 None => Arc::new(DefaultPersister::from_config(&config)),
632 };
633 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
636 if config.timestamps {
637 resolved.push(Arc::new(laterite_core::Timestamps));
638 }
639 resolved.extend(
640 listeners
641 .iter()
642 .filter(|reg| reg.matches(&config.entity))
643 .map(|reg| reg.listener.clone()),
644 );
645 let listeners = resolved;
646 Ok(Self {
647 config,
648 fields,
649 persister,
650 listeners,
651 })
652 }
653}
654
655fn declared_record(
662 form: &PreparedForm,
663 data: &HashMap<String, String>,
664 mode: Mode,
665) -> Result<Record, String> {
666 let mut rec = Record::new(&form.config.entity);
667 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
668 let submitted = crate::field::SubmittedField::new(&field.name, data);
669 if let Some(value) = prepared
670 .field_type
671 .to_attr(&submitted, &prepared.opts, mode)?
672 {
673 rec.set(field.name.clone(), value);
674 }
675 }
676 Ok(rec)
677}
678
679fn gathered(form: &PreparedForm, mut data: HashMap<String, String>) -> HashMap<String, String> {
686 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
687 if data.contains_key(&field.name) {
688 continue;
689 }
690 let value = prepared.field_type.submitted(
691 &crate::field::SubmittedField::new(&field.name, &data),
692 &prepared.opts,
693 );
694 if let Some(value) = value {
695 data.insert(field.name.clone(), value);
696 }
697 }
698 data
699}
700
701fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
703 form.config
704 .fields
705 .iter()
706 .zip(&form.fields)
707 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
710 .collect()
711}
712
713pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
715 rules
716 .iter()
717 .filter_map(|r| match r {
718 Rule::MaxLength(n) => Some(*n),
719 _ => None,
720 })
721 .min()
722}
723
724fn has_required(rules: &[Rule]) -> bool {
726 rules
727 .iter()
728 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
729}
730
731pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
733 render(build(
734 state,
735 form,
736 &format!("{}/new", form.config.base_path),
737 None,
738 &HashMap::new(),
739 &ErrorBag::default(),
740 &shell,
741 ))
742}
743
744pub(crate) async fn create(
747 state: &AdminState,
748 form: &PreparedForm,
749 data: HashMap<String, String>,
750 shell: crate::Shell,
751 user: &laterite_auth::AuthenticatedUser,
752 session: &crate::session::SessionHandle,
753 headers: &axum::http::HeaderMap,
754) -> Response {
755 if !form.config.idents_valid() {
756 return render_error();
757 }
758 let htmx = is_htmx(headers);
759 let action = format!("{}/new", form.config.base_path);
760 let data = gathered(form, data);
761
762 let bag = match validate(
763 &state.db,
764 &form.config.entity,
765 &form.config.id_field,
766 &merged_field_rules(form),
767 &data,
768 Mode::Create,
769 None,
770 )
771 .await
772 {
773 Ok(bag) => bag,
774 Err(_) => return render_error(),
775 };
776 if !bag.is_empty() {
777 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
780 }
781
782 let mut rec = match declared_record(form, &data, Mode::Create) {
783 Ok(rec) => rec,
784 Err(e) => {
785 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
786 return render_error();
787 }
788 };
789 let actor = Actor::from(user);
790 match persist::save(
791 persist::SaveRequest {
792 db: &state.db,
793 listeners: &form.listeners,
794 persister: form.persister.as_ref(),
795 actor: &actor,
796 op: Op::Create,
797 id: None,
798 },
799 &mut rec,
800 )
801 .await
802 {
803 Ok(()) => {
806 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
807 saved_response(htmx, &form.config.base_path)
808 }
809 Err(SaveError::Invalid(bag)) => {
811 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
812 }
813 Err(SaveError::Failed(msg)) => {
814 tracing::error!(error = %msg, "admin create failed");
815 render(build(
816 state,
817 form,
818 &action,
819 Some(t!("Could not save. Check the values and try again.")),
820 &data,
821 &ErrorBag::default(),
822 &shell,
823 ))
824 }
825 }
826}
827
828pub(crate) async fn edit_form(
830 state: &AdminState,
831 form: &PreparedForm,
832 id: String,
833 shell: crate::Shell,
834) -> Response {
835 if !form.config.idents_valid() {
836 return render_error();
837 }
838 let (sql, values) = {
842 let cast = text_cast(state.db.backend);
843 let mut select = Query::select();
844 for field in &form.config.fields {
845 select.expr_as(
846 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
847 Alias::new(&field.name),
848 );
849 }
850 select.from(Alias::new(&form.config.entity)).and_where(
851 Expr::col(Alias::new(&form.config.id_field))
852 .cast_as(Alias::new(cast))
853 .eq(id.clone()),
854 );
855 to_sql(state.db.backend, select)
856 };
857 let row = match bind_values(sqlx::query(&sql), values)
858 .fetch_optional(&state.db.pool)
859 .await
860 {
861 Ok(row) => row,
862 Err(_) => return render_error(),
863 };
864 let Some(row) = row else {
865 return not_found();
866 };
867
868 let values = form
871 .config
872 .fields
873 .iter()
874 .zip(&form.fields)
875 .map(|(f, prepared)| {
876 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
877 let value = prepared
878 .field_type
879 .to_control(stored.as_deref(), &prepared.opts);
880 (f.name.clone(), value)
881 })
882 .collect();
883
884 render(build(
885 state,
886 form,
887 &format!("{}/{}/edit", form.config.base_path, id),
888 None,
889 &values,
890 &ErrorBag::default(),
891 &shell,
892 ))
893}
894
895#[allow(clippy::too_many_arguments)]
900pub(crate) async fn update(
901 state: &AdminState,
902 form: &PreparedForm,
903 id: String,
904 data: HashMap<String, String>,
905 shell: crate::Shell,
906 user: &laterite_auth::AuthenticatedUser,
907 session: &crate::session::SessionHandle,
908 headers: &axum::http::HeaderMap,
909) -> Response {
910 if !form.config.idents_valid() {
911 return render_error();
912 }
913 let htmx = is_htmx(headers);
914 let action = format!("{}/{}/edit", form.config.base_path, id);
915 let data = gathered(form, data);
916
917 let bag = match validate(
918 &state.db,
919 &form.config.entity,
920 &form.config.id_field,
921 &merged_field_rules(form),
922 &data,
923 Mode::Update,
924 Some(&id),
925 )
926 .await
927 {
928 Ok(bag) => bag,
929 Err(_) => return render_error(),
930 };
931 if !bag.is_empty() {
932 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
935 }
936
937 let mut rec = match declared_record(form, &data, Mode::Update) {
938 Ok(rec) => rec,
939 Err(e) => {
940 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
941 return render_error();
942 }
943 };
944 if let Ok(n) = id.parse::<i64>() {
945 rec.set_id(n);
946 }
947 let actor = Actor::from(user);
948 match persist::save(
949 persist::SaveRequest {
950 db: &state.db,
951 listeners: &form.listeners,
952 persister: form.persister.as_ref(),
953 actor: &actor,
954 op: Op::Update,
955 id: Some(&id),
956 },
957 &mut rec,
958 )
959 .await
960 {
961 Ok(()) => {
962 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
963 saved_response(htmx, &form.config.base_path)
964 }
965 Err(SaveError::Invalid(bag)) => {
966 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
967 }
968 Err(SaveError::Failed(msg)) => {
969 tracing::error!(error = %msg, "admin update failed");
970 render(build(
971 state,
972 form,
973 &action,
974 Some(t!("Could not save. Check the values and try again.")),
975 &data,
976 &ErrorBag::default(),
977 &shell,
978 ))
979 }
980 }
981}
982
983#[allow(clippy::too_many_arguments)]
987fn invalid_response(
988 htmx: bool,
989 state: &AdminState,
990 form: &PreparedForm,
991 action: &str,
992 error: Option<Text>,
993 values: &HashMap<String, String>,
994 bag: &ErrorBag,
995 shell: &crate::Shell,
996) -> Response {
997 let page = build(state, form, action, error, values, bag, shell);
998 let body = if htmx {
999 render(FormFragment {
1000 shell: page.shell,
1001 action: page.action,
1002 cancel_path: page.cancel_path,
1003 error: page.error,
1004 fields: page.fields,
1005 enter_off: page.enter_off,
1006 refused: page.refused,
1007 leave_off: page.leave_off,
1008 focus: page.focus,
1009 })
1010 } else {
1011 render(page)
1012 };
1013 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
1014}
1015
1016pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
1020 if htmx {
1021 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
1022 } else {
1023 Redirect::to(to).into_response()
1024 }
1025}
1026
1027pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
1029 headers.contains_key("hx-request")
1030}
1031
1032fn build(
1033 state: &AdminState,
1034 form: &PreparedForm,
1035 action: &str,
1036 error: Option<Text>,
1037 values: &HashMap<String, String>,
1038 bag: &ErrorBag,
1039 shell: &crate::Shell,
1040) -> FormTemplate {
1041 let fields = form
1042 .config
1043 .fields
1044 .iter()
1045 .zip(&form.fields)
1046 .map(|(f, pf)| {
1047 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1048 let required = has_required(&pf.rules);
1049 let label = shell.tt(&f.label);
1052 let cx = FieldCx {
1053 name: &f.name,
1054 id: &f.name,
1055 label: &label,
1056 value: &value,
1057 required,
1058 max_length: max_length(&pf.rules),
1059 opts: &pf.opts,
1060 base: &shell.base,
1061 i18n: shell.i18n(),
1062 };
1063 let control = match state.field_types.get(&f.field_type) {
1066 Some(ft) => {
1067 let scope = OverrideScope {
1068 surface: Surface::Field,
1069 view_key: &f.field_type,
1070 resource: Some(&form.config.base_path),
1071 field: Some(&f.name),
1072 };
1073 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1074 }
1075 None => String::new(),
1076 };
1077 FieldView {
1078 id: f.name.clone(),
1079 label,
1080 control,
1081 required,
1082 enter: f.enter.attribute(),
1083 layout: layout_classes(f.span, f.break_row),
1084 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1086 }
1087 })
1088 .collect();
1089 let keys: Vec<&str> = form
1093 .config
1094 .fields
1095 .iter()
1096 .zip(&form.fields)
1097 .filter_map(|(f, pf)| {
1098 state
1099 .field_types
1100 .get(&f.field_type)
1101 .map(|ft| ft.assets(&pf.opts))
1102 })
1103 .flatten()
1104 .collect();
1105 let shell_title = shell.tt(&form.config.title);
1107 let refused = error.is_some() || !bag.is_empty();
1108 let error = error.map(|m| shell.tt(&m));
1109 let mut shell = shell.clone();
1110 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1111 FormTemplate {
1112 shell,
1113 title: shell_title,
1114 action: action.to_string(),
1115 cancel_path: form.config.base_path.clone(),
1116 error,
1117 fields,
1118 enter_off: form.config.enter == EnterPolicy::Off,
1119 refused,
1120 leave_off: !form.config.confirm_leave,
1121 focus: match form.config.focus {
1122 FormFocus::Off => "off",
1123 FormFocus::Auto if action.ends_with("/new") => "first",
1126 FormFocus::Auto => "",
1127 },
1128 }
1129}
1130
1131struct FieldView {
1132 id: String,
1134 label: String,
1135 control: String,
1138 required: bool,
1139 errors: Vec<String>,
1140 enter: &'static str,
1142 layout: String,
1144}
1145
1146#[derive(Template)]
1149#[template(path = "_form_fields.html")]
1150struct FormFragment {
1151 shell: crate::Shell,
1152 action: String,
1153 cancel_path: String,
1154 error: Option<String>,
1155 fields: Vec<FieldView>,
1156 enter_off: bool,
1158 refused: bool,
1160 leave_off: bool,
1162 focus: &'static str,
1164}
1165
1166#[derive(Template)]
1167#[template(path = "form.html")]
1168struct FormTemplate {
1169 shell: crate::Shell,
1170 title: String,
1171 action: String,
1172 cancel_path: String,
1173 error: Option<String>,
1174 fields: Vec<FieldView>,
1175 enter_off: bool,
1177 refused: bool,
1179 leave_off: bool,
1181 focus: &'static str,
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::*;
1188 use axum::http::StatusCode;
1189 use laterite_core::strata::{
1190 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1191 };
1192 use laterite_core::testing::{connect_test, TestGuard};
1193 use laterite_core::Db;
1194 use laterite_core::SaveCx;
1195
1196 struct CreateSamples;
1199 #[async_trait(?Send)]
1200 impl Migration for CreateSamples {
1201 fn name(&self) -> &str {
1202 "0001_create_samples"
1203 }
1204 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1205 s.exec(
1206 Table::create()
1207 .table(Alias::new("samples"))
1208 .if_not_exists()
1209 .col(
1210 ColumnDef::new(Alias::new("id"))
1211 .big_integer()
1212 .not_null()
1213 .auto_increment()
1214 .primary_key(),
1215 )
1216 .col(ColumnDef::new(Alias::new("code")).text().not_null())
1217 .col(ColumnDef::new(Alias::new("name")).text().not_null())
1218 .col(ColumnDef::new(Alias::new("created_at")).text())
1221 .col(ColumnDef::new(Alias::new("updated_at")).text())
1222 .to_owned(),
1223 )
1224 .await
1225 }
1226 }
1227
1228 fn config() -> PreparedForm {
1229 let config = FormConfig {
1230 enter: Default::default(),
1231 confirm_leave: true,
1232 focus: Default::default(),
1233 entity: "samples".to_string(),
1234 title: "Sample".into(),
1235 base_path: "/admin/samples".to_string(),
1236 id_field: "id".to_string(),
1237 fields: vec![
1238 FormField::text("code", "Code").required().unique(),
1239 FormField::text("name", "Name").required(),
1240 ],
1241 persist: None,
1242 timestamps: false,
1243 };
1244 PreparedForm::prepare(
1245 config,
1246 &crate::field::builtin_registry(),
1247 &PersisterRegistry::new(),
1248 &[],
1249 )
1250 .unwrap()
1251 }
1252
1253 #[tokio::test]
1256 async fn a_delete_removes_the_row_through_the_pipeline() {
1257 use crate::persist::{delete, DefaultPersister, DeleteRequest};
1258
1259 let (db, _guard) = test_db().await;
1260 let st = state(db.clone());
1261 let form = config();
1262
1263 let mut data = HashMap::new();
1264 data.insert("code".to_string(), "c1".to_string());
1265 data.insert("name".to_string(), "Chair".to_string());
1266 create(
1267 &st,
1268 &form,
1269 data,
1270 crate::Shell::test(),
1271 &crate::audit::test_actor(),
1272 &crate::session::SessionHandle::from_blob(None),
1273 &axum::http::HeaderMap::new(),
1274 )
1275 .await;
1276 assert_eq!(
1277 fetch_text(&db, "name", "c1").await.as_deref(),
1278 Some("Chair")
1279 );
1280
1281 let id = row_id(&db, "c1").await;
1282 let persister = DefaultPersister::from_config(&form.config);
1283 let rec = delete(DeleteRequest {
1284 db: &db,
1285 listeners: &[],
1286 persister: &persister,
1287 actor: &laterite_core::Actor::system("test"),
1288 entity: "samples",
1289 id: &id,
1290 })
1291 .await
1292 .expect("deleted");
1293
1294 assert_eq!(fetch_text(&db, "name", "c1").await, None);
1296 assert_eq!(rec.text("code"), Some("c1"));
1297 }
1298
1299 async fn row_id(db: &Db, code: &str) -> String {
1301 let stmt = Query::select()
1302 .expr_as(
1303 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1304 Alias::new("v"),
1305 )
1306 .from(Alias::new("samples"))
1307 .and_where(Expr::col(Alias::new("code")).eq(code))
1308 .to_owned();
1309 let (sql, values) = laterite_core::query::build(db.backend, stmt);
1310 let row = bind_values(sqlx::query(&sql), values)
1311 .fetch_one(&db.pool)
1312 .await
1313 .unwrap();
1314 row.get_text("v").unwrap()
1315 }
1316
1317 pub(super) fn state(db: Db) -> AdminState {
1318 AdminState::new(
1319 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1320 db,
1321 )
1322 }
1323
1324 pub(super) async fn test_db() -> (Db, TestGuard) {
1327 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1328 connect_test(&[samples]).await
1329 }
1330
1331 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1334 let stmt = Query::select()
1335 .expr_as(
1336 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1337 Alias::new("v"),
1338 )
1339 .from(Alias::new("samples"))
1340 .and_where(Expr::col(Alias::new("code")).eq(code))
1341 .to_owned();
1342 let (sql, values) = to_sql(db.backend, stmt);
1343 let row = bind_values(sqlx::query(&sql), values)
1344 .fetch_optional(&db.pool)
1345 .await
1346 .unwrap()?;
1347 row.get_text_opt("v").ok().flatten()
1348 }
1349
1350 async fn body_of(resp: Response) -> String {
1351 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1352 .await
1353 .unwrap();
1354 String::from_utf8(bytes.to_vec()).unwrap()
1355 }
1356
1357 async fn count(db: &Db) -> i64 {
1358 sqlx::query_scalar("select count(*) from samples")
1359 .fetch_one(&db.pool)
1360 .await
1361 .unwrap()
1362 }
1363
1364 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1365 pairs
1366 .iter()
1367 .map(|(k, v)| (k.to_string(), v.to_string()))
1368 .collect()
1369 }
1370
1371 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1373 let mut h = axum::http::HeaderMap::new();
1374 h.insert("hx-request", "true".parse().unwrap());
1375 h
1376 }
1377
1378 #[tokio::test]
1379 async fn create_then_fetch() {
1380 let (db, _guard) = test_db().await;
1381 let cfg = config();
1382 let st = state(db.clone());
1383
1384 let resp = create(
1385 &st,
1386 &cfg,
1387 data(&[("code", "editor"), ("name", "Content Editor")]),
1388 crate::Shell::test(),
1389 &crate::audit::test_actor(),
1390 &crate::session::SessionHandle::from_blob(None),
1391 &axum::http::HeaderMap::new(),
1392 )
1393 .await;
1394 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1395
1396 assert_eq!(
1397 fetch_text(&db, "name", "editor").await.as_deref(),
1398 Some("Content Editor")
1399 );
1400 }
1401
1402 #[tokio::test]
1403 async fn update_changes_the_row() {
1404 let (db, _guard) = test_db().await;
1405 let cfg = config();
1406 let st = state(db.clone());
1407 create(
1408 &st,
1409 &cfg,
1410 data(&[("code", "editor"), ("name", "Editor")]),
1411 crate::Shell::test(),
1412 &crate::audit::test_actor(),
1413 &crate::session::SessionHandle::from_blob(None),
1414 &axum::http::HeaderMap::new(),
1415 )
1416 .await;
1417
1418 let id = fetch_text(&db, "id", "editor")
1419 .await
1420 .expect("row should exist after create");
1421
1422 let resp = update(
1424 &st,
1425 &cfg,
1426 id,
1427 data(&[("code", "editor"), ("name", "Senior Editor")]),
1428 crate::Shell::test(),
1429 &crate::audit::test_actor(),
1430 &crate::session::SessionHandle::from_blob(None),
1431 &axum::http::HeaderMap::new(),
1432 )
1433 .await;
1434 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1435
1436 assert_eq!(
1437 fetch_text(&db, "name", "editor").await.as_deref(),
1438 Some("Senior Editor")
1439 );
1440 }
1441
1442 #[tokio::test]
1443 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1444 let (db, _guard) = test_db().await;
1445 let cfg = config();
1446 let st = state(db.clone());
1447
1448 let resp = create(
1449 &st,
1450 &cfg,
1451 data(&[("code", ""), ("name", "No Code")]),
1452 crate::Shell::test(),
1453 &crate::audit::test_actor(),
1454 &crate::session::SessionHandle::from_blob(None),
1455 &axum::http::HeaderMap::new(),
1456 )
1457 .await;
1458 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1460 assert_eq!(count(&db).await, 0);
1461 assert!(body_of(resp).await.contains("Code is required."));
1463 }
1464
1465 #[tokio::test]
1466 async fn create_rejects_a_duplicate_unique_value() {
1467 let (db, _guard) = test_db().await;
1468 let cfg = config();
1469 let st = state(db.clone());
1470 create(
1471 &st,
1472 &cfg,
1473 data(&[("code", "editor"), ("name", "Editor")]),
1474 crate::Shell::test(),
1475 &crate::audit::test_actor(),
1476 &crate::session::SessionHandle::from_blob(None),
1477 &axum::http::HeaderMap::new(),
1478 )
1479 .await;
1480
1481 let resp = create(
1483 &st,
1484 &cfg,
1485 data(&[("code", "editor"), ("name", "Other")]),
1486 crate::Shell::test(),
1487 &crate::audit::test_actor(),
1488 &crate::session::SessionHandle::from_blob(None),
1489 &axum::http::HeaderMap::new(),
1490 )
1491 .await;
1492 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1493 assert_eq!(count(&db).await, 1);
1494 assert!(body_of(resp).await.contains("already taken"));
1495 }
1496
1497 #[tokio::test]
1498 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1499 let (db, _guard) = test_db().await;
1500 let st = state(db.clone());
1501 let cfg = PreparedForm::prepare(
1504 FormConfig {
1505 enter: Default::default(),
1506 confirm_leave: true,
1507 focus: Default::default(),
1508 entity: "samples".to_string(),
1509 title: "Sample".into(),
1510 base_path: "/admin/samples".to_string(),
1511 id_field: "id".to_string(),
1512 fields: vec![
1513 FormField::text("code", "Code").required(),
1514 FormField::of("name", "Email", "text")
1515 .options(serde_json::json!({ "input": "email" }))
1516 .required(),
1517 ],
1518 persist: None,
1519 timestamps: false,
1520 },
1521 &st.field_types,
1522 &PersisterRegistry::new(),
1523 &[],
1524 )
1525 .unwrap();
1526
1527 let resp = create(
1528 &st,
1529 &cfg,
1530 data(&[("code", "c1"), ("name", "nope")]),
1531 crate::Shell::test(),
1532 &crate::audit::test_actor(),
1533 &crate::session::SessionHandle::from_blob(None),
1534 &axum::http::HeaderMap::new(),
1535 )
1536 .await;
1537 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1538 assert_eq!(count(&db).await, 0);
1539
1540 let resp = create(
1541 &st,
1542 &cfg,
1543 data(&[("code", "c1"), ("name", "a@b.test")]),
1544 crate::Shell::test(),
1545 &crate::audit::test_actor(),
1546 &crate::session::SessionHandle::from_blob(None),
1547 &axum::http::HeaderMap::new(),
1548 )
1549 .await;
1550 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1551 assert_eq!(count(&db).await, 1);
1552 }
1553
1554 #[test]
1555 fn prepare_rejects_an_unregistered_field_type() {
1556 let config = FormConfig {
1557 enter: Default::default(),
1558 confirm_leave: true,
1559 focus: Default::default(),
1560 entity: "samples".to_string(),
1561 title: "Sample".into(),
1562 base_path: "/admin/samples".to_string(),
1563 id_field: "id".to_string(),
1564 fields: vec![FormField::of("place", "Place", "no.such.type")],
1565 persist: None,
1566 timestamps: false,
1567 };
1568 let Err(err) = PreparedForm::prepare(
1569 config,
1570 &crate::field::builtin_registry(),
1571 &PersisterRegistry::new(),
1572 &[],
1573 ) else {
1574 panic!("expected prepare to reject an unregistered type");
1575 };
1576 assert!(err.contains("no.such.type"), "{err}");
1577 }
1578
1579 #[test]
1580 fn prepare_rejects_a_malformed_option() {
1581 let config = FormConfig {
1584 enter: Default::default(),
1585 confirm_leave: true,
1586 focus: Default::default(),
1587 entity: "samples".to_string(),
1588 title: "Sample".into(),
1589 base_path: "/admin/samples".to_string(),
1590 id_field: "id".to_string(),
1591 fields: vec![
1592 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1593 ],
1594 persist: None,
1595 timestamps: false,
1596 };
1597 let Err(err) = PreparedForm::prepare(
1598 config,
1599 &crate::field::builtin_registry(),
1600 &PersisterRegistry::new(),
1601 &[],
1602 ) else {
1603 panic!("expected prepare to reject a malformed option");
1604 };
1605 assert!(err.contains("`email`"), "{err}");
1606 }
1607
1608 struct RollbackPersister;
1611 #[laterite_core::strata::async_trait]
1612 impl Persister for RollbackPersister {
1613 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1614 let backend = cx.backend();
1615 let insert = Query::insert()
1616 .into_table(Alias::new("samples"))
1617 .columns([Alias::new("code"), Alias::new("name")])
1618 .values_panic(["rolled".into(), "back".into()])
1619 .to_owned();
1620 let (sql, values) = to_sql(backend, insert);
1621 bind_values(sqlx::query(&sql), values)
1622 .execute(cx.conn())
1623 .await
1624 .map_err(|e| SaveError::Failed(e.to_string()))?;
1625 Err(SaveError::Failed("deliberate".to_string()))
1626 }
1627 async fn update(
1628 &self,
1629 _cx: &mut SaveCx<'_>,
1630 _id: &str,
1631 _rec: &Record,
1632 ) -> Result<(), SaveError> {
1633 Ok(())
1634 }
1635 }
1636
1637 struct RejectingPersister;
1639 #[laterite_core::strata::async_trait]
1640 impl Persister for RejectingPersister {
1641 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1642 let mut bag = ErrorBag::default();
1643 bag.add("code", t!("Code is not allowed here."));
1644 Err(SaveError::Invalid(bag))
1645 }
1646 async fn update(
1647 &self,
1648 _cx: &mut SaveCx<'_>,
1649 _id: &str,
1650 _rec: &Record,
1651 ) -> Result<(), SaveError> {
1652 Ok(())
1653 }
1654 }
1655
1656 fn config_with(persister: &str) -> FormConfig {
1657 FormConfig {
1658 enter: Default::default(),
1659 confirm_leave: true,
1660 focus: Default::default(),
1661 entity: "samples".to_string(),
1662 title: "Sample".into(),
1663 base_path: "/admin/samples".to_string(),
1664 id_field: "id".to_string(),
1665 fields: vec![
1666 FormField::text("code", "Code").required(),
1667 FormField::text("name", "Name").required(),
1668 ],
1669 persist: Some(persister.to_string()),
1670 timestamps: false,
1671 }
1672 }
1673
1674 #[test]
1675 fn prepare_rejects_an_unregistered_persister() {
1676 let Err(err) = PreparedForm::prepare(
1677 config_with("no.such.persister"),
1678 &crate::field::builtin_registry(),
1679 &PersisterRegistry::new(),
1680 &[],
1681 ) else {
1682 panic!("expected prepare to reject an unregistered persister");
1683 };
1684 assert!(err.contains("no.such.persister"), "{err}");
1685 }
1686
1687 #[tokio::test]
1688 async fn a_persister_transaction_rolls_back_on_error() {
1689 let (db, _guard) = test_db().await;
1690 let st = state(db.clone());
1691 let mut persisters = PersisterRegistry::new();
1692 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1693 let form = PreparedForm::prepare(
1694 config_with("test.rollback"),
1695 &st.field_types,
1696 &persisters,
1697 &[],
1698 )
1699 .unwrap();
1700
1701 let resp = create(
1702 &st,
1703 &form,
1704 data(&[("code", "rolled"), ("name", "back")]),
1705 crate::Shell::test(),
1706 &crate::audit::test_actor(),
1707 &crate::session::SessionHandle::from_blob(None),
1708 &axum::http::HeaderMap::new(),
1709 )
1710 .await;
1711 assert_eq!(resp.status(), StatusCode::OK);
1714 assert_eq!(count(&db).await, 0);
1715 }
1716
1717 #[tokio::test]
1718 async fn a_persister_invalid_error_re_renders_422() {
1719 let (db, _guard) = test_db().await;
1720 let st = state(db.clone());
1721 let mut persisters = PersisterRegistry::new();
1722 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1723 let form = PreparedForm::prepare(
1724 config_with("test.reject"),
1725 &st.field_types,
1726 &persisters,
1727 &[],
1728 )
1729 .unwrap();
1730
1731 let resp = create(
1732 &st,
1733 &form,
1734 data(&[("code", "editor"), ("name", "Editor")]),
1735 crate::Shell::test(),
1736 &crate::audit::test_actor(),
1737 &crate::session::SessionHandle::from_blob(None),
1738 &axum::http::HeaderMap::new(),
1739 )
1740 .await;
1741 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1742 assert!(body_of(resp).await.contains("Code is not allowed here."));
1743 }
1744}
1745
1746#[cfg(test)]
1747mod timestamp_tests {
1748 use super::tests::*;
1749 use super::*;
1750 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1751 use laterite_core::Actor;
1752
1753 fn timestamped(on: bool) -> FormConfig {
1754 FormConfig {
1755 enter: Default::default(),
1756 confirm_leave: true,
1757 focus: Default::default(),
1758 entity: "samples".to_string(),
1759 title: "Sample".into(),
1760 base_path: "/admin/samples".to_string(),
1761 id_field: "id".to_string(),
1762 fields: vec![
1763 FormField::text("code", "Code").required(),
1764 FormField::text("name", "Name").required(),
1765 ],
1766 persist: None,
1767 timestamps: on,
1768 }
1769 }
1770
1771 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1772 let form = PreparedForm::prepare(
1773 timestamped(on),
1774 &crate::field::builtin_registry(),
1775 &PersisterRegistry::new(),
1776 &[],
1777 )
1778 .unwrap();
1779 let mut data = HashMap::new();
1780 data.insert("code".to_string(), "c1".to_string());
1781 data.insert("name".to_string(), "Chair".to_string());
1782 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1783 let actor = Actor::system("test");
1784 persist::save(
1785 persist::SaveRequest {
1786 db,
1787 listeners: &form.listeners,
1788 persister: form.persister.as_ref(),
1789 actor: &actor,
1790 op: Op::Create,
1791 id: None,
1792 },
1793 &mut rec,
1794 )
1795 .await
1796 .expect("the insert should succeed");
1797 rec
1798 }
1799
1800 #[tokio::test]
1801 async fn opting_in_stamps_the_row() {
1802 let (db, _guard) = test_db().await;
1803 let rec = create_through_pipeline(&db, true).await;
1804 assert!(rec.datetime(CREATED_AT).is_some());
1805 assert!(rec.datetime(UPDATED_AT).is_some());
1806 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
1808 }
1809
1810 #[tokio::test]
1811 async fn leaving_it_off_writes_no_timestamps() {
1812 let (db, _guard) = test_db().await;
1813 let rec = create_through_pipeline(&db, false).await;
1814 assert!(rec.get(CREATED_AT).is_none());
1815 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
1816 }
1817}
1818
1819#[cfg(test)]
1820mod htmx_tests {
1821 use super::tests::*;
1822 use super::*;
1823 use axum::http::StatusCode;
1824
1825 fn invalid() -> HashMap<String, String> {
1827 let mut d = HashMap::new();
1828 d.insert("code".to_string(), String::new());
1829 d.insert("name".to_string(), "No code".to_string());
1830 d
1831 }
1832
1833 fn valid() -> HashMap<String, String> {
1834 let mut d = HashMap::new();
1835 d.insert("code".to_string(), "c1".to_string());
1836 d.insert("name".to_string(), "Chair".to_string());
1837 d
1838 }
1839
1840 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
1841 let (db, _guard) = test_db().await;
1842 let st = state(db);
1843 let form = PreparedForm::prepare(
1844 FormConfig {
1845 enter: Default::default(),
1846 confirm_leave: true,
1847 focus: Default::default(),
1848 entity: "samples".to_string(),
1849 title: "Sample".into(),
1850 base_path: "/admin/samples".to_string(),
1851 id_field: "id".to_string(),
1852 fields: vec![
1853 FormField::text("code", "Code").required(),
1854 FormField::text("name", "Name").required(),
1855 ],
1856 persist: None,
1857 timestamps: false,
1858 },
1859 &st.field_types,
1860 &PersisterRegistry::new(),
1861 &[],
1862 )
1863 .unwrap();
1864 create(
1865 &st,
1866 &form,
1867 data,
1868 crate::Shell::test(),
1869 &crate::audit::test_actor(),
1870 &crate::session::SessionHandle::from_blob(None),
1871 &headers,
1872 )
1873 .await
1874 }
1875
1876 async fn body_of(resp: Response) -> String {
1877 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1878 .await
1879 .unwrap();
1880 String::from_utf8(bytes.to_vec()).unwrap()
1881 }
1882
1883 #[tokio::test]
1884 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
1885 let resp = post(invalid(), htmx_headers()).await;
1886 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1887 let html = body_of(resp).await;
1888 assert!(html.contains("<form"), "the form comes back");
1889 assert!(html.contains("lat-field__error"), "with its errors");
1890 assert!(!html.contains("<body"), "no page chrome");
1893 }
1894
1895 #[tokio::test]
1896 async fn a_plain_submit_that_fails_returns_the_whole_page() {
1897 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
1898 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1899 let html = body_of(resp).await;
1900 assert!(html.contains("<body"), "the admin still works without htmx");
1901 assert!(html.contains("lat-field__error"));
1902 }
1903
1904 #[tokio::test]
1905 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
1906 let resp = post(valid(), htmx_headers()).await;
1907 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1909 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
1910 }
1911
1912 #[tokio::test]
1913 async fn a_plain_submit_that_succeeds_still_redirects() {
1914 let resp = post(valid(), axum::http::HeaderMap::new()).await;
1915 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1916 }
1917}