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 FieldEnter {
187 #[default]
189 Submit,
190 Off,
192 Next,
194}
195
196impl FieldEnter {
197 fn attribute(self) -> &'static str {
200 match self {
201 FieldEnter::Submit => "",
202 FieldEnter::Off => "off",
203 FieldEnter::Next => "next",
204 }
205 }
206}
207
208impl Default for FormConfig {
209 fn default() -> Self {
212 Self {
213 entity: String::new(),
214 title: Text::new(""),
215 base_path: String::new(),
216 id_field: "id".to_string(),
217 fields: Vec::new(),
218 enter: EnterPolicy::default(),
219 persist: None,
220 timestamps: false,
221 }
222 }
223}
224
225impl Default for FormField {
226 fn default() -> Self {
228 Self::of("", Text::new(""), "text")
229 }
230}
231
232impl<'de> Deserialize<'de> for FormField {
233 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
234 struct Entry;
235 impl<'de> serde::de::Visitor<'de> for Entry {
236 type Value = FormField;
237
238 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
239 f.write_str("a field: its own keys and its type's")
240 }
241
242 fn visit_map<A: serde::de::MapAccess<'de>>(
243 self,
244 map: A,
245 ) -> Result<FormField, A::Error> {
246 let mut field = FormField::default();
247 let theirs = crate::keyed::split_entry(map, |key, map| {
248 match key {
249 "name" => field.name = map.next_value()?,
250 "label" => field.label = map.next_value()?,
251 "type" => field.field_type = map.next_value()?,
252 "rules" => field.rules = map.next_value()?,
253 "translatable" => field.translatable = map.next_value()?,
254 "help" => field.help = map.next_value()?,
255 "enter" => field.enter = map.next_value()?,
256 "span" => field.span = map.next_value()?,
257 "break" => field.break_row = map.next_value()?,
258 _ => return Ok(false),
259 }
260 Ok(true)
261 })?;
262 field.options = theirs;
263 Ok(field)
264 }
265
266 fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
268 Ok(FormField::default())
269 }
270 }
271 de.deserialize_map(Entry)
272 }
273}
274
275impl Serialize for FormField {
276 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
277 use serde::ser::SerializeMap;
278 let mut map = ser.serialize_map(None)?;
279 map.serialize_entry("name", &self.name)?;
280 map.serialize_entry("label", &self.label)?;
281 map.serialize_entry("type", &self.field_type)?;
282 map.serialize_entry("rules", &self.rules)?;
283 map.serialize_entry("translatable", &self.translatable)?;
284 map.serialize_entry("help", &self.help)?;
285 map.serialize_entry("enter", &self.enter)?;
286 map.serialize_entry("span", &self.span)?;
287 map.serialize_entry("break", &self.break_row)?;
288 crate::keyed::serialize_theirs(&mut map, &self.options)?;
289 map.end()
290 }
291}
292
293impl crate::keyed::Keyed for FormField {
294 fn key(&self) -> &str {
295 &self.name
296 }
297 fn set_key(&mut self, key: String) {
298 self.name = key;
299 }
300}
301
302impl FormField {
303 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
305 Self {
306 name: name.to_string(),
307 label: label.into(),
308 field_type: field_type.to_string(),
309 options: serde_json::Value::Null,
310 rules: Vec::new(),
311 translatable: false,
312 help: None,
313 enter: FieldEnter::default(),
314 span: Span::default(),
315 break_row: false,
316 }
317 }
318
319 pub fn text(name: &str, label: impl Into<Text>) -> Self {
320 Self::of(name, label, "text")
321 }
322
323 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
324 Self::of(name, label, "textarea")
325 }
326
327 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
331 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
332 }
333
334 pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
336 Self::of(name, label, "select").options(Self::choices(options))
337 }
338
339 pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
341 Self::of(name, label, "radio").options(Self::choices(options))
342 }
343
344 pub fn switch(name: &str, label: impl Into<Text>) -> Self {
346 Self::of(name, label, "switch")
347 }
348
349 pub fn date(name: &str, label: impl Into<Text>) -> Self {
351 Self::of(name, label, "date")
352 }
353
354 pub fn password(name: &str, label: impl Into<Text>) -> Self {
357 Self::of(name, label, "password")
358 }
359
360 pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
362 Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
363 }
364
365 pub fn repeater_list(
374 name: &str,
375 label: impl Into<Text>,
376 fields: Vec<FormField>,
377 summary_field: Option<&str>,
378 ) -> Self {
379 Self::of(name, label, "repeater").options(serde_json::json!({
380 "fields": fields,
381 "display": "list",
382 "summary_field": summary_field,
383 }))
384 }
385
386 fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
388 let options: Vec<serde_json::Value> = options
389 .into_iter()
390 .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
391 .collect();
392 serde_json::json!({ "options": options })
393 }
394
395 pub fn options(mut self, options: serde_json::Value) -> Self {
397 self.options = options;
398 self
399 }
400
401 pub fn required(mut self) -> Self {
403 self.rules.push(Rule::Required);
404 self
405 }
406
407 pub fn required_on(mut self, mode: Mode) -> Self {
410 self.rules.push(Rule::RequiredOn(mode));
411 self
412 }
413
414 pub fn unique(mut self) -> Self {
417 self.rules.push(Rule::Unique);
418 self
419 }
420
421 pub fn max_length(mut self, n: usize) -> Self {
423 self.rules.push(Rule::MaxLength(n));
424 self
425 }
426
427 pub fn min_length(mut self, n: usize) -> Self {
429 self.rules.push(Rule::MinLength(n));
430 self
431 }
432
433 pub fn email(mut self) -> Self {
435 self.rules.push(Rule::Email);
436 self
437 }
438
439 pub fn translatable(mut self) -> Self {
441 self.translatable = true;
442 self
443 }
444
445 pub fn help(mut self, text: impl Into<Text>) -> Self {
447 self.help = Some(text.into());
448 self
449 }
450
451 pub fn span(mut self, span: Span) -> Self {
453 self.span = span;
454 self
455 }
456
457 pub fn break_row(mut self) -> Self {
459 self.break_row = true;
460 self
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(deny_unknown_fields, default)]
471#[non_exhaustive]
472pub struct FormConfig {
473 pub entity: String,
474 pub title: Text,
476 pub base_path: String,
477 pub id_field: String,
478 #[serde(
479 deserialize_with = "crate::keyed::deserialize",
480 serialize_with = "crate::keyed::serialize"
481 )]
482 pub fields: Vec<FormField>,
483 #[serde(default)]
485 pub enter: EnterPolicy,
486 pub persist: Option<String>,
489 #[serde(default)]
492 pub timestamps: bool,
493}
494
495impl FormConfig {
496 pub fn new(
501 entity: impl Into<String>,
502 title: impl Into<Text>,
503 base_path: impl Into<String>,
504 id_field: impl Into<String>,
505 fields: Vec<FormField>,
506 ) -> Self {
507 Self {
508 enter: Default::default(),
509 entity: entity.into(),
510 title: title.into(),
511 base_path: base_path.into(),
512 id_field: id_field.into(),
513 fields,
514 persist: None,
515 timestamps: false,
516 }
517 }
518
519 pub fn persist(mut self, name: impl Into<String>) -> Self {
522 self.persist = Some(name.into());
523 self
524 }
525
526 pub fn timestamps(mut self) -> Self {
529 self.timestamps = true;
530 self
531 }
532
533 fn idents_valid(&self) -> bool {
534 valid_ident(&self.entity)
535 && valid_ident(&self.id_field)
536 && self.fields.iter().all(|f| valid_ident(&f.name))
537 }
538}
539
540pub(crate) struct PreparedForm {
545 config: FormConfig,
546 fields: Vec<PreparedField>,
548 persister: Arc<dyn Persister>,
550 listeners: Vec<Arc<dyn ModelListener>>,
552}
553
554struct PreparedField {
557 field_type: Arc<dyn crate::field::FieldType>,
559 opts: ResolvedOptions,
560 rules: Vec<Rule>,
561}
562
563impl PreparedForm {
564 pub(crate) fn prepare(
568 config: FormConfig,
569 field_types: &crate::field::FieldRegistry,
570 persisters: &PersisterRegistry,
571 listeners: &[ModelListenerReg],
572 ) -> Result<Self, String> {
573 let mut fields = Vec::with_capacity(config.fields.len());
574 for f in &config.fields {
575 let ft = field_types.get(&f.field_type).ok_or_else(|| {
576 format!(
577 "field `{}` uses unregistered type `{}`",
578 f.name, f.field_type
579 )
580 })?;
581 crate::field::check_field_keys(f, ft.as_ref())
582 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
583 let opts = ft
584 .resolve_options(&f.options, field_types)
585 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
586 let mut rules = ft.intrinsic_rules(&opts);
587 rules.extend(f.rules.clone());
588 fields.push(PreparedField {
589 field_type: ft.clone(),
590 opts,
591 rules,
592 });
593 }
594 let persister: Arc<dyn Persister> = match &config.persist {
595 Some(key) => persisters
596 .get(key)
597 .cloned()
598 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
599 None => Arc::new(DefaultPersister::from_config(&config)),
600 };
601 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
604 if config.timestamps {
605 resolved.push(Arc::new(laterite_core::Timestamps));
606 }
607 resolved.extend(
608 listeners
609 .iter()
610 .filter(|reg| reg.matches(&config.entity))
611 .map(|reg| reg.listener.clone()),
612 );
613 let listeners = resolved;
614 Ok(Self {
615 config,
616 fields,
617 persister,
618 listeners,
619 })
620 }
621}
622
623fn declared_record(
630 form: &PreparedForm,
631 data: &HashMap<String, String>,
632 mode: Mode,
633) -> Result<Record, String> {
634 let mut rec = Record::new(&form.config.entity);
635 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
636 let submitted = crate::field::SubmittedField::new(&field.name, data);
637 if let Some(value) = prepared
638 .field_type
639 .to_attr(&submitted, &prepared.opts, mode)?
640 {
641 rec.set(field.name.clone(), value);
642 }
643 }
644 Ok(rec)
645}
646
647fn gathered(form: &PreparedForm, mut data: HashMap<String, String>) -> HashMap<String, String> {
654 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
655 if data.contains_key(&field.name) {
656 continue;
657 }
658 let value = prepared.field_type.submitted(
659 &crate::field::SubmittedField::new(&field.name, &data),
660 &prepared.opts,
661 );
662 if let Some(value) = value {
663 data.insert(field.name.clone(), value);
664 }
665 }
666 data
667}
668
669fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
671 form.config
672 .fields
673 .iter()
674 .zip(&form.fields)
675 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
678 .collect()
679}
680
681fn has_required(rules: &[Rule]) -> bool {
683 rules
684 .iter()
685 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
686}
687
688pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
690 render(build(
691 state,
692 form,
693 &format!("{}/new", form.config.base_path),
694 None,
695 &HashMap::new(),
696 &ErrorBag::default(),
697 &shell,
698 ))
699}
700
701pub(crate) async fn create(
704 state: &AdminState,
705 form: &PreparedForm,
706 data: HashMap<String, String>,
707 shell: crate::Shell,
708 user: &laterite_auth::AuthenticatedUser,
709 session: &crate::session::SessionHandle,
710 headers: &axum::http::HeaderMap,
711) -> Response {
712 if !form.config.idents_valid() {
713 return render_error();
714 }
715 let htmx = is_htmx(headers);
716 let action = format!("{}/new", form.config.base_path);
717 let data = gathered(form, data);
718
719 let bag = match validate(
720 &state.db,
721 &form.config.entity,
722 &form.config.id_field,
723 &merged_field_rules(form),
724 &data,
725 Mode::Create,
726 None,
727 )
728 .await
729 {
730 Ok(bag) => bag,
731 Err(_) => return render_error(),
732 };
733 if !bag.is_empty() {
734 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
737 }
738
739 let mut rec = match declared_record(form, &data, Mode::Create) {
740 Ok(rec) => rec,
741 Err(e) => {
742 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
743 return render_error();
744 }
745 };
746 let actor = Actor::from(user);
747 match persist::save(
748 persist::SaveRequest {
749 db: &state.db,
750 listeners: &form.listeners,
751 persister: form.persister.as_ref(),
752 actor: &actor,
753 op: Op::Create,
754 id: None,
755 },
756 &mut rec,
757 )
758 .await
759 {
760 Ok(()) => {
763 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
764 saved_response(htmx, &form.config.base_path)
765 }
766 Err(SaveError::Invalid(bag)) => {
768 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
769 }
770 Err(SaveError::Failed(msg)) => {
771 tracing::error!(error = %msg, "admin create failed");
772 render(build(
773 state,
774 form,
775 &action,
776 Some(t!("Could not save. Check the values and try again.")),
777 &data,
778 &ErrorBag::default(),
779 &shell,
780 ))
781 }
782 }
783}
784
785pub(crate) async fn edit_form(
787 state: &AdminState,
788 form: &PreparedForm,
789 id: String,
790 shell: crate::Shell,
791) -> Response {
792 if !form.config.idents_valid() {
793 return render_error();
794 }
795 let (sql, values) = {
799 let cast = text_cast(state.db.backend);
800 let mut select = Query::select();
801 for field in &form.config.fields {
802 select.expr_as(
803 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
804 Alias::new(&field.name),
805 );
806 }
807 select.from(Alias::new(&form.config.entity)).and_where(
808 Expr::col(Alias::new(&form.config.id_field))
809 .cast_as(Alias::new(cast))
810 .eq(id.clone()),
811 );
812 to_sql(state.db.backend, select)
813 };
814 let row = match bind_values(sqlx::query(&sql), values)
815 .fetch_optional(&state.db.pool)
816 .await
817 {
818 Ok(row) => row,
819 Err(_) => return render_error(),
820 };
821 let Some(row) = row else {
822 return not_found();
823 };
824
825 let values = form
828 .config
829 .fields
830 .iter()
831 .zip(&form.fields)
832 .map(|(f, prepared)| {
833 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
834 let value = prepared
835 .field_type
836 .to_control(stored.as_deref(), &prepared.opts);
837 (f.name.clone(), value)
838 })
839 .collect();
840
841 render(build(
842 state,
843 form,
844 &format!("{}/{}/edit", form.config.base_path, id),
845 None,
846 &values,
847 &ErrorBag::default(),
848 &shell,
849 ))
850}
851
852#[allow(clippy::too_many_arguments)]
857pub(crate) async fn update(
858 state: &AdminState,
859 form: &PreparedForm,
860 id: String,
861 data: HashMap<String, String>,
862 shell: crate::Shell,
863 user: &laterite_auth::AuthenticatedUser,
864 session: &crate::session::SessionHandle,
865 headers: &axum::http::HeaderMap,
866) -> Response {
867 if !form.config.idents_valid() {
868 return render_error();
869 }
870 let htmx = is_htmx(headers);
871 let action = format!("{}/{}/edit", form.config.base_path, id);
872 let data = gathered(form, data);
873
874 let bag = match validate(
875 &state.db,
876 &form.config.entity,
877 &form.config.id_field,
878 &merged_field_rules(form),
879 &data,
880 Mode::Update,
881 Some(&id),
882 )
883 .await
884 {
885 Ok(bag) => bag,
886 Err(_) => return render_error(),
887 };
888 if !bag.is_empty() {
889 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
892 }
893
894 let mut rec = match declared_record(form, &data, Mode::Update) {
895 Ok(rec) => rec,
896 Err(e) => {
897 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
898 return render_error();
899 }
900 };
901 if let Ok(n) = id.parse::<i64>() {
902 rec.set_id(n);
903 }
904 let actor = Actor::from(user);
905 match persist::save(
906 persist::SaveRequest {
907 db: &state.db,
908 listeners: &form.listeners,
909 persister: form.persister.as_ref(),
910 actor: &actor,
911 op: Op::Update,
912 id: Some(&id),
913 },
914 &mut rec,
915 )
916 .await
917 {
918 Ok(()) => {
919 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
920 saved_response(htmx, &form.config.base_path)
921 }
922 Err(SaveError::Invalid(bag)) => {
923 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
924 }
925 Err(SaveError::Failed(msg)) => {
926 tracing::error!(error = %msg, "admin update failed");
927 render(build(
928 state,
929 form,
930 &action,
931 Some(t!("Could not save. Check the values and try again.")),
932 &data,
933 &ErrorBag::default(),
934 &shell,
935 ))
936 }
937 }
938}
939
940#[allow(clippy::too_many_arguments)]
944fn invalid_response(
945 htmx: bool,
946 state: &AdminState,
947 form: &PreparedForm,
948 action: &str,
949 error: Option<Text>,
950 values: &HashMap<String, String>,
951 bag: &ErrorBag,
952 shell: &crate::Shell,
953) -> Response {
954 let page = build(state, form, action, error, values, bag, shell);
955 let body = if htmx {
956 render(FormFragment {
957 shell: page.shell,
958 action: page.action,
959 cancel_path: page.cancel_path,
960 error: page.error,
961 fields: page.fields,
962 enter_off: page.enter_off,
963 })
964 } else {
965 render(page)
966 };
967 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
968}
969
970pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
974 if htmx {
975 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
976 } else {
977 Redirect::to(to).into_response()
978 }
979}
980
981pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
983 headers.contains_key("hx-request")
984}
985
986fn build(
987 state: &AdminState,
988 form: &PreparedForm,
989 action: &str,
990 error: Option<Text>,
991 values: &HashMap<String, String>,
992 bag: &ErrorBag,
993 shell: &crate::Shell,
994) -> FormTemplate {
995 let fields = form
996 .config
997 .fields
998 .iter()
999 .zip(&form.fields)
1000 .map(|(f, pf)| {
1001 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
1002 let required = has_required(&pf.rules);
1003 let label = shell.tt(&f.label);
1006 let cx = FieldCx {
1007 name: &f.name,
1008 id: &f.name,
1009 label: &label,
1010 value: &value,
1011 required,
1012 opts: &pf.opts,
1013 base: &shell.base,
1014 i18n: shell.i18n(),
1015 };
1016 let control = match state.field_types.get(&f.field_type) {
1019 Some(ft) => {
1020 let scope = OverrideScope {
1021 surface: Surface::Field,
1022 view_key: &f.field_type,
1023 resource: Some(&form.config.base_path),
1024 field: Some(&f.name),
1025 };
1026 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1027 }
1028 None => String::new(),
1029 };
1030 FieldView {
1031 id: f.name.clone(),
1032 label,
1033 control,
1034 required,
1035 enter: f.enter.attribute(),
1036 layout: layout_classes(f.span, f.break_row),
1037 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1039 }
1040 })
1041 .collect();
1042 let keys: Vec<&str> = form
1046 .config
1047 .fields
1048 .iter()
1049 .zip(&form.fields)
1050 .filter_map(|(f, pf)| {
1051 state
1052 .field_types
1053 .get(&f.field_type)
1054 .map(|ft| ft.assets(&pf.opts))
1055 })
1056 .flatten()
1057 .collect();
1058 let shell_title = shell.tt(&form.config.title);
1060 let error = error.map(|m| shell.tt(&m));
1061 let mut shell = shell.clone();
1062 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1063 FormTemplate {
1064 shell,
1065 title: shell_title,
1066 action: action.to_string(),
1067 cancel_path: form.config.base_path.clone(),
1068 error,
1069 fields,
1070 enter_off: form.config.enter == EnterPolicy::Off,
1071 }
1072}
1073
1074struct FieldView {
1075 id: String,
1077 label: String,
1078 control: String,
1081 required: bool,
1082 errors: Vec<String>,
1083 enter: &'static str,
1085 layout: String,
1087}
1088
1089#[derive(Template)]
1092#[template(path = "_form_fields.html")]
1093struct FormFragment {
1094 shell: crate::Shell,
1095 action: String,
1096 cancel_path: String,
1097 error: Option<String>,
1098 fields: Vec<FieldView>,
1099 enter_off: bool,
1101}
1102
1103#[derive(Template)]
1104#[template(path = "form.html")]
1105struct FormTemplate {
1106 shell: crate::Shell,
1107 title: String,
1108 action: String,
1109 cancel_path: String,
1110 error: Option<String>,
1111 fields: Vec<FieldView>,
1112 enter_off: bool,
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use axum::http::StatusCode;
1120 use laterite_core::strata::{
1121 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1122 };
1123 use laterite_core::testing::{connect_test, TestGuard};
1124 use laterite_core::Db;
1125 use laterite_core::SaveCx;
1126
1127 struct CreateSamples;
1130 #[async_trait(?Send)]
1131 impl Migration for CreateSamples {
1132 fn name(&self) -> &str {
1133 "0001_create_samples"
1134 }
1135 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1136 s.exec(
1137 Table::create()
1138 .table(Alias::new("samples"))
1139 .if_not_exists()
1140 .col(
1141 ColumnDef::new(Alias::new("id"))
1142 .big_integer()
1143 .not_null()
1144 .auto_increment()
1145 .primary_key(),
1146 )
1147 .col(ColumnDef::new(Alias::new("code")).text().not_null())
1148 .col(ColumnDef::new(Alias::new("name")).text().not_null())
1149 .col(ColumnDef::new(Alias::new("created_at")).text())
1152 .col(ColumnDef::new(Alias::new("updated_at")).text())
1153 .to_owned(),
1154 )
1155 .await
1156 }
1157 }
1158
1159 fn config() -> PreparedForm {
1160 let config = FormConfig {
1161 enter: Default::default(),
1162 entity: "samples".to_string(),
1163 title: "Sample".into(),
1164 base_path: "/admin/samples".to_string(),
1165 id_field: "id".to_string(),
1166 fields: vec![
1167 FormField::text("code", "Code").required().unique(),
1168 FormField::text("name", "Name").required(),
1169 ],
1170 persist: None,
1171 timestamps: false,
1172 };
1173 PreparedForm::prepare(
1174 config,
1175 &crate::field::builtin_registry(),
1176 &PersisterRegistry::new(),
1177 &[],
1178 )
1179 .unwrap()
1180 }
1181
1182 #[tokio::test]
1185 async fn a_delete_removes_the_row_through_the_pipeline() {
1186 use crate::persist::{delete, DefaultPersister, DeleteRequest};
1187
1188 let (db, _guard) = test_db().await;
1189 let st = state(db.clone());
1190 let form = config();
1191
1192 let mut data = HashMap::new();
1193 data.insert("code".to_string(), "c1".to_string());
1194 data.insert("name".to_string(), "Chair".to_string());
1195 create(
1196 &st,
1197 &form,
1198 data,
1199 crate::Shell::test(),
1200 &crate::audit::test_actor(),
1201 &crate::session::SessionHandle::from_blob(None),
1202 &axum::http::HeaderMap::new(),
1203 )
1204 .await;
1205 assert_eq!(
1206 fetch_text(&db, "name", "c1").await.as_deref(),
1207 Some("Chair")
1208 );
1209
1210 let id = row_id(&db, "c1").await;
1211 let persister = DefaultPersister::from_config(&form.config);
1212 let rec = delete(DeleteRequest {
1213 db: &db,
1214 listeners: &[],
1215 persister: &persister,
1216 actor: &laterite_core::Actor::system("test"),
1217 entity: "samples",
1218 id: &id,
1219 })
1220 .await
1221 .expect("deleted");
1222
1223 assert_eq!(fetch_text(&db, "name", "c1").await, None);
1225 assert_eq!(rec.text("code"), Some("c1"));
1226 }
1227
1228 async fn row_id(db: &Db, code: &str) -> String {
1230 let stmt = Query::select()
1231 .expr_as(
1232 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1233 Alias::new("v"),
1234 )
1235 .from(Alias::new("samples"))
1236 .and_where(Expr::col(Alias::new("code")).eq(code))
1237 .to_owned();
1238 let (sql, values) = laterite_core::query::build(db.backend, stmt);
1239 let row = bind_values(sqlx::query(&sql), values)
1240 .fetch_one(&db.pool)
1241 .await
1242 .unwrap();
1243 row.get_text("v").unwrap()
1244 }
1245
1246 pub(super) fn state(db: Db) -> AdminState {
1247 AdminState::new(
1248 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1249 db,
1250 )
1251 }
1252
1253 pub(super) async fn test_db() -> (Db, TestGuard) {
1256 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1257 connect_test(&[samples]).await
1258 }
1259
1260 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1263 let stmt = Query::select()
1264 .expr_as(
1265 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1266 Alias::new("v"),
1267 )
1268 .from(Alias::new("samples"))
1269 .and_where(Expr::col(Alias::new("code")).eq(code))
1270 .to_owned();
1271 let (sql, values) = to_sql(db.backend, stmt);
1272 let row = bind_values(sqlx::query(&sql), values)
1273 .fetch_optional(&db.pool)
1274 .await
1275 .unwrap()?;
1276 row.get_text_opt("v").ok().flatten()
1277 }
1278
1279 async fn body_of(resp: Response) -> String {
1280 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1281 .await
1282 .unwrap();
1283 String::from_utf8(bytes.to_vec()).unwrap()
1284 }
1285
1286 async fn count(db: &Db) -> i64 {
1287 sqlx::query_scalar("select count(*) from samples")
1288 .fetch_one(&db.pool)
1289 .await
1290 .unwrap()
1291 }
1292
1293 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1294 pairs
1295 .iter()
1296 .map(|(k, v)| (k.to_string(), v.to_string()))
1297 .collect()
1298 }
1299
1300 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1302 let mut h = axum::http::HeaderMap::new();
1303 h.insert("hx-request", "true".parse().unwrap());
1304 h
1305 }
1306
1307 #[tokio::test]
1308 async fn create_then_fetch() {
1309 let (db, _guard) = test_db().await;
1310 let cfg = config();
1311 let st = state(db.clone());
1312
1313 let resp = create(
1314 &st,
1315 &cfg,
1316 data(&[("code", "editor"), ("name", "Content Editor")]),
1317 crate::Shell::test(),
1318 &crate::audit::test_actor(),
1319 &crate::session::SessionHandle::from_blob(None),
1320 &axum::http::HeaderMap::new(),
1321 )
1322 .await;
1323 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1324
1325 assert_eq!(
1326 fetch_text(&db, "name", "editor").await.as_deref(),
1327 Some("Content Editor")
1328 );
1329 }
1330
1331 #[tokio::test]
1332 async fn update_changes_the_row() {
1333 let (db, _guard) = test_db().await;
1334 let cfg = config();
1335 let st = state(db.clone());
1336 create(
1337 &st,
1338 &cfg,
1339 data(&[("code", "editor"), ("name", "Editor")]),
1340 crate::Shell::test(),
1341 &crate::audit::test_actor(),
1342 &crate::session::SessionHandle::from_blob(None),
1343 &axum::http::HeaderMap::new(),
1344 )
1345 .await;
1346
1347 let id = fetch_text(&db, "id", "editor")
1348 .await
1349 .expect("row should exist after create");
1350
1351 let resp = update(
1353 &st,
1354 &cfg,
1355 id,
1356 data(&[("code", "editor"), ("name", "Senior Editor")]),
1357 crate::Shell::test(),
1358 &crate::audit::test_actor(),
1359 &crate::session::SessionHandle::from_blob(None),
1360 &axum::http::HeaderMap::new(),
1361 )
1362 .await;
1363 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1364
1365 assert_eq!(
1366 fetch_text(&db, "name", "editor").await.as_deref(),
1367 Some("Senior Editor")
1368 );
1369 }
1370
1371 #[tokio::test]
1372 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1373 let (db, _guard) = test_db().await;
1374 let cfg = config();
1375 let st = state(db.clone());
1376
1377 let resp = create(
1378 &st,
1379 &cfg,
1380 data(&[("code", ""), ("name", "No Code")]),
1381 crate::Shell::test(),
1382 &crate::audit::test_actor(),
1383 &crate::session::SessionHandle::from_blob(None),
1384 &axum::http::HeaderMap::new(),
1385 )
1386 .await;
1387 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1389 assert_eq!(count(&db).await, 0);
1390 assert!(body_of(resp).await.contains("Code is required."));
1392 }
1393
1394 #[tokio::test]
1395 async fn create_rejects_a_duplicate_unique_value() {
1396 let (db, _guard) = test_db().await;
1397 let cfg = config();
1398 let st = state(db.clone());
1399 create(
1400 &st,
1401 &cfg,
1402 data(&[("code", "editor"), ("name", "Editor")]),
1403 crate::Shell::test(),
1404 &crate::audit::test_actor(),
1405 &crate::session::SessionHandle::from_blob(None),
1406 &axum::http::HeaderMap::new(),
1407 )
1408 .await;
1409
1410 let resp = create(
1412 &st,
1413 &cfg,
1414 data(&[("code", "editor"), ("name", "Other")]),
1415 crate::Shell::test(),
1416 &crate::audit::test_actor(),
1417 &crate::session::SessionHandle::from_blob(None),
1418 &axum::http::HeaderMap::new(),
1419 )
1420 .await;
1421 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1422 assert_eq!(count(&db).await, 1);
1423 assert!(body_of(resp).await.contains("already taken"));
1424 }
1425
1426 #[tokio::test]
1427 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1428 let (db, _guard) = test_db().await;
1429 let st = state(db.clone());
1430 let cfg = PreparedForm::prepare(
1433 FormConfig {
1434 enter: Default::default(),
1435 entity: "samples".to_string(),
1436 title: "Sample".into(),
1437 base_path: "/admin/samples".to_string(),
1438 id_field: "id".to_string(),
1439 fields: vec![
1440 FormField::text("code", "Code").required(),
1441 FormField::of("name", "Email", "text")
1442 .options(serde_json::json!({ "input": "email" }))
1443 .required(),
1444 ],
1445 persist: None,
1446 timestamps: false,
1447 },
1448 &st.field_types,
1449 &PersisterRegistry::new(),
1450 &[],
1451 )
1452 .unwrap();
1453
1454 let resp = create(
1455 &st,
1456 &cfg,
1457 data(&[("code", "c1"), ("name", "nope")]),
1458 crate::Shell::test(),
1459 &crate::audit::test_actor(),
1460 &crate::session::SessionHandle::from_blob(None),
1461 &axum::http::HeaderMap::new(),
1462 )
1463 .await;
1464 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1465 assert_eq!(count(&db).await, 0);
1466
1467 let resp = create(
1468 &st,
1469 &cfg,
1470 data(&[("code", "c1"), ("name", "a@b.test")]),
1471 crate::Shell::test(),
1472 &crate::audit::test_actor(),
1473 &crate::session::SessionHandle::from_blob(None),
1474 &axum::http::HeaderMap::new(),
1475 )
1476 .await;
1477 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1478 assert_eq!(count(&db).await, 1);
1479 }
1480
1481 #[test]
1482 fn prepare_rejects_an_unregistered_field_type() {
1483 let config = FormConfig {
1484 enter: Default::default(),
1485 entity: "samples".to_string(),
1486 title: "Sample".into(),
1487 base_path: "/admin/samples".to_string(),
1488 id_field: "id".to_string(),
1489 fields: vec![FormField::of("place", "Place", "no.such.type")],
1490 persist: None,
1491 timestamps: false,
1492 };
1493 let Err(err) = PreparedForm::prepare(
1494 config,
1495 &crate::field::builtin_registry(),
1496 &PersisterRegistry::new(),
1497 &[],
1498 ) else {
1499 panic!("expected prepare to reject an unregistered type");
1500 };
1501 assert!(err.contains("no.such.type"), "{err}");
1502 }
1503
1504 #[test]
1505 fn prepare_rejects_a_malformed_option() {
1506 let config = FormConfig {
1509 enter: Default::default(),
1510 entity: "samples".to_string(),
1511 title: "Sample".into(),
1512 base_path: "/admin/samples".to_string(),
1513 id_field: "id".to_string(),
1514 fields: vec![
1515 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1516 ],
1517 persist: None,
1518 timestamps: false,
1519 };
1520 let Err(err) = PreparedForm::prepare(
1521 config,
1522 &crate::field::builtin_registry(),
1523 &PersisterRegistry::new(),
1524 &[],
1525 ) else {
1526 panic!("expected prepare to reject a malformed option");
1527 };
1528 assert!(err.contains("`email`"), "{err}");
1529 }
1530
1531 struct RollbackPersister;
1534 #[laterite_core::strata::async_trait]
1535 impl Persister for RollbackPersister {
1536 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1537 let backend = cx.backend();
1538 let insert = Query::insert()
1539 .into_table(Alias::new("samples"))
1540 .columns([Alias::new("code"), Alias::new("name")])
1541 .values_panic(["rolled".into(), "back".into()])
1542 .to_owned();
1543 let (sql, values) = to_sql(backend, insert);
1544 bind_values(sqlx::query(&sql), values)
1545 .execute(cx.conn())
1546 .await
1547 .map_err(|e| SaveError::Failed(e.to_string()))?;
1548 Err(SaveError::Failed("deliberate".to_string()))
1549 }
1550 async fn update(
1551 &self,
1552 _cx: &mut SaveCx<'_>,
1553 _id: &str,
1554 _rec: &Record,
1555 ) -> Result<(), SaveError> {
1556 Ok(())
1557 }
1558 }
1559
1560 struct RejectingPersister;
1562 #[laterite_core::strata::async_trait]
1563 impl Persister for RejectingPersister {
1564 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1565 let mut bag = ErrorBag::default();
1566 bag.add("code", t!("Code is not allowed here."));
1567 Err(SaveError::Invalid(bag))
1568 }
1569 async fn update(
1570 &self,
1571 _cx: &mut SaveCx<'_>,
1572 _id: &str,
1573 _rec: &Record,
1574 ) -> Result<(), SaveError> {
1575 Ok(())
1576 }
1577 }
1578
1579 fn config_with(persister: &str) -> FormConfig {
1580 FormConfig {
1581 enter: Default::default(),
1582 entity: "samples".to_string(),
1583 title: "Sample".into(),
1584 base_path: "/admin/samples".to_string(),
1585 id_field: "id".to_string(),
1586 fields: vec![
1587 FormField::text("code", "Code").required(),
1588 FormField::text("name", "Name").required(),
1589 ],
1590 persist: Some(persister.to_string()),
1591 timestamps: false,
1592 }
1593 }
1594
1595 #[test]
1596 fn prepare_rejects_an_unregistered_persister() {
1597 let Err(err) = PreparedForm::prepare(
1598 config_with("no.such.persister"),
1599 &crate::field::builtin_registry(),
1600 &PersisterRegistry::new(),
1601 &[],
1602 ) else {
1603 panic!("expected prepare to reject an unregistered persister");
1604 };
1605 assert!(err.contains("no.such.persister"), "{err}");
1606 }
1607
1608 #[tokio::test]
1609 async fn a_persister_transaction_rolls_back_on_error() {
1610 let (db, _guard) = test_db().await;
1611 let st = state(db.clone());
1612 let mut persisters = PersisterRegistry::new();
1613 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1614 let form = PreparedForm::prepare(
1615 config_with("test.rollback"),
1616 &st.field_types,
1617 &persisters,
1618 &[],
1619 )
1620 .unwrap();
1621
1622 let resp = create(
1623 &st,
1624 &form,
1625 data(&[("code", "rolled"), ("name", "back")]),
1626 crate::Shell::test(),
1627 &crate::audit::test_actor(),
1628 &crate::session::SessionHandle::from_blob(None),
1629 &axum::http::HeaderMap::new(),
1630 )
1631 .await;
1632 assert_eq!(resp.status(), StatusCode::OK);
1635 assert_eq!(count(&db).await, 0);
1636 }
1637
1638 #[tokio::test]
1639 async fn a_persister_invalid_error_re_renders_422() {
1640 let (db, _guard) = test_db().await;
1641 let st = state(db.clone());
1642 let mut persisters = PersisterRegistry::new();
1643 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1644 let form = PreparedForm::prepare(
1645 config_with("test.reject"),
1646 &st.field_types,
1647 &persisters,
1648 &[],
1649 )
1650 .unwrap();
1651
1652 let resp = create(
1653 &st,
1654 &form,
1655 data(&[("code", "editor"), ("name", "Editor")]),
1656 crate::Shell::test(),
1657 &crate::audit::test_actor(),
1658 &crate::session::SessionHandle::from_blob(None),
1659 &axum::http::HeaderMap::new(),
1660 )
1661 .await;
1662 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1663 assert!(body_of(resp).await.contains("Code is not allowed here."));
1664 }
1665}
1666
1667#[cfg(test)]
1668mod timestamp_tests {
1669 use super::tests::*;
1670 use super::*;
1671 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1672 use laterite_core::Actor;
1673
1674 fn timestamped(on: bool) -> FormConfig {
1675 FormConfig {
1676 enter: Default::default(),
1677 entity: "samples".to_string(),
1678 title: "Sample".into(),
1679 base_path: "/admin/samples".to_string(),
1680 id_field: "id".to_string(),
1681 fields: vec![
1682 FormField::text("code", "Code").required(),
1683 FormField::text("name", "Name").required(),
1684 ],
1685 persist: None,
1686 timestamps: on,
1687 }
1688 }
1689
1690 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1691 let form = PreparedForm::prepare(
1692 timestamped(on),
1693 &crate::field::builtin_registry(),
1694 &PersisterRegistry::new(),
1695 &[],
1696 )
1697 .unwrap();
1698 let mut data = HashMap::new();
1699 data.insert("code".to_string(), "c1".to_string());
1700 data.insert("name".to_string(), "Chair".to_string());
1701 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1702 let actor = Actor::system("test");
1703 persist::save(
1704 persist::SaveRequest {
1705 db,
1706 listeners: &form.listeners,
1707 persister: form.persister.as_ref(),
1708 actor: &actor,
1709 op: Op::Create,
1710 id: None,
1711 },
1712 &mut rec,
1713 )
1714 .await
1715 .expect("the insert should succeed");
1716 rec
1717 }
1718
1719 #[tokio::test]
1720 async fn opting_in_stamps_the_row() {
1721 let (db, _guard) = test_db().await;
1722 let rec = create_through_pipeline(&db, true).await;
1723 assert!(rec.datetime(CREATED_AT).is_some());
1724 assert!(rec.datetime(UPDATED_AT).is_some());
1725 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
1727 }
1728
1729 #[tokio::test]
1730 async fn leaving_it_off_writes_no_timestamps() {
1731 let (db, _guard) = test_db().await;
1732 let rec = create_through_pipeline(&db, false).await;
1733 assert!(rec.get(CREATED_AT).is_none());
1734 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
1735 }
1736}
1737
1738#[cfg(test)]
1739mod htmx_tests {
1740 use super::tests::*;
1741 use super::*;
1742 use axum::http::StatusCode;
1743
1744 fn invalid() -> HashMap<String, String> {
1746 let mut d = HashMap::new();
1747 d.insert("code".to_string(), String::new());
1748 d.insert("name".to_string(), "No code".to_string());
1749 d
1750 }
1751
1752 fn valid() -> HashMap<String, String> {
1753 let mut d = HashMap::new();
1754 d.insert("code".to_string(), "c1".to_string());
1755 d.insert("name".to_string(), "Chair".to_string());
1756 d
1757 }
1758
1759 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
1760 let (db, _guard) = test_db().await;
1761 let st = state(db);
1762 let form = PreparedForm::prepare(
1763 FormConfig {
1764 enter: Default::default(),
1765 entity: "samples".to_string(),
1766 title: "Sample".into(),
1767 base_path: "/admin/samples".to_string(),
1768 id_field: "id".to_string(),
1769 fields: vec![
1770 FormField::text("code", "Code").required(),
1771 FormField::text("name", "Name").required(),
1772 ],
1773 persist: None,
1774 timestamps: false,
1775 },
1776 &st.field_types,
1777 &PersisterRegistry::new(),
1778 &[],
1779 )
1780 .unwrap();
1781 create(
1782 &st,
1783 &form,
1784 data,
1785 crate::Shell::test(),
1786 &crate::audit::test_actor(),
1787 &crate::session::SessionHandle::from_blob(None),
1788 &headers,
1789 )
1790 .await
1791 }
1792
1793 async fn body_of(resp: Response) -> String {
1794 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1795 .await
1796 .unwrap();
1797 String::from_utf8(bytes.to_vec()).unwrap()
1798 }
1799
1800 #[tokio::test]
1801 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
1802 let resp = post(invalid(), htmx_headers()).await;
1803 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1804 let html = body_of(resp).await;
1805 assert!(html.contains("<form"), "the form comes back");
1806 assert!(html.contains("lat-field__error"), "with its errors");
1807 assert!(!html.contains("<body"), "no page chrome");
1810 }
1811
1812 #[tokio::test]
1813 async fn a_plain_submit_that_fails_returns_the_whole_page() {
1814 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
1815 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1816 let html = body_of(resp).await;
1817 assert!(html.contains("<body"), "the admin still works without htmx");
1818 assert!(html.contains("lat-field__error"));
1819 }
1820
1821 #[tokio::test]
1822 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
1823 let resp = post(valid(), htmx_headers()).await;
1824 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1826 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
1827 }
1828
1829 #[tokio::test]
1830 async fn a_plain_submit_that_succeeds_still_redirects() {
1831 let resp = post(valid(), axum::http::HeaderMap::new()).await;
1832 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1833 }
1834}