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 merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
649 form.config
650 .fields
651 .iter()
652 .zip(&form.fields)
653 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
656 .collect()
657}
658
659fn has_required(rules: &[Rule]) -> bool {
661 rules
662 .iter()
663 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
664}
665
666pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
668 render(build(
669 state,
670 form,
671 &format!("{}/new", form.config.base_path),
672 None,
673 &HashMap::new(),
674 &ErrorBag::default(),
675 &shell,
676 ))
677}
678
679pub(crate) async fn create(
682 state: &AdminState,
683 form: &PreparedForm,
684 data: HashMap<String, String>,
685 shell: crate::Shell,
686 user: &laterite_auth::AuthenticatedUser,
687 session: &crate::session::SessionHandle,
688 headers: &axum::http::HeaderMap,
689) -> Response {
690 if !form.config.idents_valid() {
691 return render_error();
692 }
693 let htmx = is_htmx(headers);
694 let action = format!("{}/new", form.config.base_path);
695
696 let bag = match validate(
697 &state.db,
698 &form.config.entity,
699 &form.config.id_field,
700 &merged_field_rules(form),
701 &data,
702 Mode::Create,
703 None,
704 )
705 .await
706 {
707 Ok(bag) => bag,
708 Err(_) => return render_error(),
709 };
710 if !bag.is_empty() {
711 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
714 }
715
716 let mut rec = match declared_record(form, &data, Mode::Create) {
717 Ok(rec) => rec,
718 Err(e) => {
719 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
720 return render_error();
721 }
722 };
723 let actor = Actor::from(user);
724 match persist::save(
725 persist::SaveRequest {
726 db: &state.db,
727 listeners: &form.listeners,
728 persister: form.persister.as_ref(),
729 actor: &actor,
730 op: Op::Create,
731 id: None,
732 },
733 &mut rec,
734 )
735 .await
736 {
737 Ok(()) => {
740 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
741 saved_response(htmx, &form.config.base_path)
742 }
743 Err(SaveError::Invalid(bag)) => {
745 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
746 }
747 Err(SaveError::Failed(msg)) => {
748 tracing::error!(error = %msg, "admin create failed");
749 render(build(
750 state,
751 form,
752 &action,
753 Some(t!("Could not save. Check the values and try again.")),
754 &data,
755 &ErrorBag::default(),
756 &shell,
757 ))
758 }
759 }
760}
761
762pub(crate) async fn edit_form(
764 state: &AdminState,
765 form: &PreparedForm,
766 id: String,
767 shell: crate::Shell,
768) -> Response {
769 if !form.config.idents_valid() {
770 return render_error();
771 }
772 let (sql, values) = {
776 let cast = text_cast(state.db.backend);
777 let mut select = Query::select();
778 for field in &form.config.fields {
779 select.expr_as(
780 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
781 Alias::new(&field.name),
782 );
783 }
784 select.from(Alias::new(&form.config.entity)).and_where(
785 Expr::col(Alias::new(&form.config.id_field))
786 .cast_as(Alias::new(cast))
787 .eq(id.clone()),
788 );
789 to_sql(state.db.backend, select)
790 };
791 let row = match bind_values(sqlx::query(&sql), values)
792 .fetch_optional(&state.db.pool)
793 .await
794 {
795 Ok(row) => row,
796 Err(_) => return render_error(),
797 };
798 let Some(row) = row else {
799 return not_found();
800 };
801
802 let values = form
805 .config
806 .fields
807 .iter()
808 .zip(&form.fields)
809 .map(|(f, prepared)| {
810 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
811 let value = prepared
812 .field_type
813 .to_control(stored.as_deref(), &prepared.opts);
814 (f.name.clone(), value)
815 })
816 .collect();
817
818 render(build(
819 state,
820 form,
821 &format!("{}/{}/edit", form.config.base_path, id),
822 None,
823 &values,
824 &ErrorBag::default(),
825 &shell,
826 ))
827}
828
829#[allow(clippy::too_many_arguments)]
834pub(crate) async fn update(
835 state: &AdminState,
836 form: &PreparedForm,
837 id: String,
838 data: HashMap<String, String>,
839 shell: crate::Shell,
840 user: &laterite_auth::AuthenticatedUser,
841 session: &crate::session::SessionHandle,
842 headers: &axum::http::HeaderMap,
843) -> Response {
844 if !form.config.idents_valid() {
845 return render_error();
846 }
847 let htmx = is_htmx(headers);
848 let action = format!("{}/{}/edit", form.config.base_path, id);
849
850 let bag = match validate(
851 &state.db,
852 &form.config.entity,
853 &form.config.id_field,
854 &merged_field_rules(form),
855 &data,
856 Mode::Update,
857 Some(&id),
858 )
859 .await
860 {
861 Ok(bag) => bag,
862 Err(_) => return render_error(),
863 };
864 if !bag.is_empty() {
865 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
868 }
869
870 let mut rec = match declared_record(form, &data, Mode::Update) {
871 Ok(rec) => rec,
872 Err(e) => {
873 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
874 return render_error();
875 }
876 };
877 if let Ok(n) = id.parse::<i64>() {
878 rec.set_id(n);
879 }
880 let actor = Actor::from(user);
881 match persist::save(
882 persist::SaveRequest {
883 db: &state.db,
884 listeners: &form.listeners,
885 persister: form.persister.as_ref(),
886 actor: &actor,
887 op: Op::Update,
888 id: Some(&id),
889 },
890 &mut rec,
891 )
892 .await
893 {
894 Ok(()) => {
895 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
896 saved_response(htmx, &form.config.base_path)
897 }
898 Err(SaveError::Invalid(bag)) => {
899 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
900 }
901 Err(SaveError::Failed(msg)) => {
902 tracing::error!(error = %msg, "admin update failed");
903 render(build(
904 state,
905 form,
906 &action,
907 Some(t!("Could not save. Check the values and try again.")),
908 &data,
909 &ErrorBag::default(),
910 &shell,
911 ))
912 }
913 }
914}
915
916#[allow(clippy::too_many_arguments)]
920fn invalid_response(
921 htmx: bool,
922 state: &AdminState,
923 form: &PreparedForm,
924 action: &str,
925 error: Option<Text>,
926 values: &HashMap<String, String>,
927 bag: &ErrorBag,
928 shell: &crate::Shell,
929) -> Response {
930 let page = build(state, form, action, error, values, bag, shell);
931 let body = if htmx {
932 render(FormFragment {
933 shell: page.shell,
934 action: page.action,
935 cancel_path: page.cancel_path,
936 error: page.error,
937 fields: page.fields,
938 enter_off: page.enter_off,
939 })
940 } else {
941 render(page)
942 };
943 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
944}
945
946pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
950 if htmx {
951 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
952 } else {
953 Redirect::to(to).into_response()
954 }
955}
956
957pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
959 headers.contains_key("hx-request")
960}
961
962fn build(
963 state: &AdminState,
964 form: &PreparedForm,
965 action: &str,
966 error: Option<Text>,
967 values: &HashMap<String, String>,
968 bag: &ErrorBag,
969 shell: &crate::Shell,
970) -> FormTemplate {
971 let fields = form
972 .config
973 .fields
974 .iter()
975 .zip(&form.fields)
976 .map(|(f, pf)| {
977 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
978 let required = has_required(&pf.rules);
979 let label = shell.tt(&f.label);
982 let cx = FieldCx {
983 name: &f.name,
984 id: &f.name,
985 label: &label,
986 value: &value,
987 required,
988 opts: &pf.opts,
989 base: &shell.base,
990 i18n: shell.i18n(),
991 };
992 let control = match state.field_types.get(&f.field_type) {
995 Some(ft) => {
996 let scope = OverrideScope {
997 surface: Surface::Field,
998 view_key: &f.field_type,
999 resource: Some(&form.config.base_path),
1000 field: Some(&f.name),
1001 };
1002 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
1003 }
1004 None => String::new(),
1005 };
1006 FieldView {
1007 id: f.name.clone(),
1008 label,
1009 control,
1010 required,
1011 enter: f.enter.attribute(),
1012 layout: layout_classes(f.span, f.break_row),
1013 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
1015 }
1016 })
1017 .collect();
1018 let keys: Vec<&str> = form
1022 .config
1023 .fields
1024 .iter()
1025 .zip(&form.fields)
1026 .filter_map(|(f, pf)| {
1027 state
1028 .field_types
1029 .get(&f.field_type)
1030 .map(|ft| ft.assets(&pf.opts))
1031 })
1032 .flatten()
1033 .collect();
1034 let shell_title = shell.tt(&form.config.title);
1036 let error = error.map(|m| shell.tt(&m));
1037 let mut shell = shell.clone();
1038 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1039 FormTemplate {
1040 shell,
1041 title: shell_title,
1042 action: action.to_string(),
1043 cancel_path: form.config.base_path.clone(),
1044 error,
1045 fields,
1046 enter_off: form.config.enter == EnterPolicy::Off,
1047 }
1048}
1049
1050struct FieldView {
1051 id: String,
1053 label: String,
1054 control: String,
1057 required: bool,
1058 errors: Vec<String>,
1059 enter: &'static str,
1061 layout: String,
1063}
1064
1065#[derive(Template)]
1068#[template(path = "_form_fields.html")]
1069struct FormFragment {
1070 shell: crate::Shell,
1071 action: String,
1072 cancel_path: String,
1073 error: Option<String>,
1074 fields: Vec<FieldView>,
1075 enter_off: bool,
1077}
1078
1079#[derive(Template)]
1080#[template(path = "form.html")]
1081struct FormTemplate {
1082 shell: crate::Shell,
1083 title: String,
1084 action: String,
1085 cancel_path: String,
1086 error: Option<String>,
1087 fields: Vec<FieldView>,
1088 enter_off: bool,
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use axum::http::StatusCode;
1096 use laterite_core::strata::{
1097 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
1098 };
1099 use laterite_core::testing::{connect_test, TestGuard};
1100 use laterite_core::Db;
1101 use laterite_core::SaveCx;
1102
1103 struct CreateSamples;
1106 #[async_trait(?Send)]
1107 impl Migration for CreateSamples {
1108 fn name(&self) -> &str {
1109 "0001_create_samples"
1110 }
1111 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
1112 s.exec(
1113 Table::create()
1114 .table(Alias::new("samples"))
1115 .if_not_exists()
1116 .col(
1117 ColumnDef::new(Alias::new("id"))
1118 .big_integer()
1119 .not_null()
1120 .auto_increment()
1121 .primary_key(),
1122 )
1123 .col(ColumnDef::new(Alias::new("code")).text().not_null())
1124 .col(ColumnDef::new(Alias::new("name")).text().not_null())
1125 .col(ColumnDef::new(Alias::new("created_at")).text())
1128 .col(ColumnDef::new(Alias::new("updated_at")).text())
1129 .to_owned(),
1130 )
1131 .await
1132 }
1133 }
1134
1135 fn config() -> PreparedForm {
1136 let config = FormConfig {
1137 enter: Default::default(),
1138 entity: "samples".to_string(),
1139 title: "Sample".into(),
1140 base_path: "/admin/samples".to_string(),
1141 id_field: "id".to_string(),
1142 fields: vec![
1143 FormField::text("code", "Code").required().unique(),
1144 FormField::text("name", "Name").required(),
1145 ],
1146 persist: None,
1147 timestamps: false,
1148 };
1149 PreparedForm::prepare(
1150 config,
1151 &crate::field::builtin_registry(),
1152 &PersisterRegistry::new(),
1153 &[],
1154 )
1155 .unwrap()
1156 }
1157
1158 #[tokio::test]
1161 async fn a_delete_removes_the_row_through_the_pipeline() {
1162 use crate::persist::{delete, DefaultPersister, DeleteRequest};
1163
1164 let (db, _guard) = test_db().await;
1165 let st = state(db.clone());
1166 let form = config();
1167
1168 let mut data = HashMap::new();
1169 data.insert("code".to_string(), "c1".to_string());
1170 data.insert("name".to_string(), "Chair".to_string());
1171 create(
1172 &st,
1173 &form,
1174 data,
1175 crate::Shell::test(),
1176 &crate::audit::test_actor(),
1177 &crate::session::SessionHandle::from_blob(None),
1178 &axum::http::HeaderMap::new(),
1179 )
1180 .await;
1181 assert_eq!(
1182 fetch_text(&db, "name", "c1").await.as_deref(),
1183 Some("Chair")
1184 );
1185
1186 let id = row_id(&db, "c1").await;
1187 let persister = DefaultPersister::from_config(&form.config);
1188 let rec = delete(DeleteRequest {
1189 db: &db,
1190 listeners: &[],
1191 persister: &persister,
1192 actor: &laterite_core::Actor::system("test"),
1193 entity: "samples",
1194 id: &id,
1195 })
1196 .await
1197 .expect("deleted");
1198
1199 assert_eq!(fetch_text(&db, "name", "c1").await, None);
1201 assert_eq!(rec.text("code"), Some("c1"));
1202 }
1203
1204 async fn row_id(db: &Db, code: &str) -> String {
1206 let stmt = Query::select()
1207 .expr_as(
1208 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
1209 Alias::new("v"),
1210 )
1211 .from(Alias::new("samples"))
1212 .and_where(Expr::col(Alias::new("code")).eq(code))
1213 .to_owned();
1214 let (sql, values) = laterite_core::query::build(db.backend, stmt);
1215 let row = bind_values(sqlx::query(&sql), values)
1216 .fetch_one(&db.pool)
1217 .await
1218 .unwrap();
1219 row.get_text("v").unwrap()
1220 }
1221
1222 pub(super) fn state(db: Db) -> AdminState {
1223 AdminState::new(
1224 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1225 db,
1226 )
1227 }
1228
1229 pub(super) async fn test_db() -> (Db, TestGuard) {
1232 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
1233 connect_test(&[samples]).await
1234 }
1235
1236 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1239 let stmt = Query::select()
1240 .expr_as(
1241 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1242 Alias::new("v"),
1243 )
1244 .from(Alias::new("samples"))
1245 .and_where(Expr::col(Alias::new("code")).eq(code))
1246 .to_owned();
1247 let (sql, values) = to_sql(db.backend, stmt);
1248 let row = bind_values(sqlx::query(&sql), values)
1249 .fetch_optional(&db.pool)
1250 .await
1251 .unwrap()?;
1252 row.get_text_opt("v").ok().flatten()
1253 }
1254
1255 async fn body_of(resp: Response) -> String {
1256 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1257 .await
1258 .unwrap();
1259 String::from_utf8(bytes.to_vec()).unwrap()
1260 }
1261
1262 async fn count(db: &Db) -> i64 {
1263 sqlx::query_scalar("select count(*) from samples")
1264 .fetch_one(&db.pool)
1265 .await
1266 .unwrap()
1267 }
1268
1269 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1270 pairs
1271 .iter()
1272 .map(|(k, v)| (k.to_string(), v.to_string()))
1273 .collect()
1274 }
1275
1276 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1278 let mut h = axum::http::HeaderMap::new();
1279 h.insert("hx-request", "true".parse().unwrap());
1280 h
1281 }
1282
1283 #[tokio::test]
1284 async fn create_then_fetch() {
1285 let (db, _guard) = test_db().await;
1286 let cfg = config();
1287 let st = state(db.clone());
1288
1289 let resp = create(
1290 &st,
1291 &cfg,
1292 data(&[("code", "editor"), ("name", "Content Editor")]),
1293 crate::Shell::test(),
1294 &crate::audit::test_actor(),
1295 &crate::session::SessionHandle::from_blob(None),
1296 &axum::http::HeaderMap::new(),
1297 )
1298 .await;
1299 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1300
1301 assert_eq!(
1302 fetch_text(&db, "name", "editor").await.as_deref(),
1303 Some("Content Editor")
1304 );
1305 }
1306
1307 #[tokio::test]
1308 async fn update_changes_the_row() {
1309 let (db, _guard) = test_db().await;
1310 let cfg = config();
1311 let st = state(db.clone());
1312 create(
1313 &st,
1314 &cfg,
1315 data(&[("code", "editor"), ("name", "Editor")]),
1316 crate::Shell::test(),
1317 &crate::audit::test_actor(),
1318 &crate::session::SessionHandle::from_blob(None),
1319 &axum::http::HeaderMap::new(),
1320 )
1321 .await;
1322
1323 let id = fetch_text(&db, "id", "editor")
1324 .await
1325 .expect("row should exist after create");
1326
1327 let resp = update(
1329 &st,
1330 &cfg,
1331 id,
1332 data(&[("code", "editor"), ("name", "Senior Editor")]),
1333 crate::Shell::test(),
1334 &crate::audit::test_actor(),
1335 &crate::session::SessionHandle::from_blob(None),
1336 &axum::http::HeaderMap::new(),
1337 )
1338 .await;
1339 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1340
1341 assert_eq!(
1342 fetch_text(&db, "name", "editor").await.as_deref(),
1343 Some("Senior Editor")
1344 );
1345 }
1346
1347 #[tokio::test]
1348 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1349 let (db, _guard) = test_db().await;
1350 let cfg = config();
1351 let st = state(db.clone());
1352
1353 let resp = create(
1354 &st,
1355 &cfg,
1356 data(&[("code", ""), ("name", "No Code")]),
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::UNPROCESSABLE_ENTITY);
1365 assert_eq!(count(&db).await, 0);
1366 assert!(body_of(resp).await.contains("Code is required."));
1368 }
1369
1370 #[tokio::test]
1371 async fn create_rejects_a_duplicate_unique_value() {
1372 let (db, _guard) = test_db().await;
1373 let cfg = config();
1374 let st = state(db.clone());
1375 create(
1376 &st,
1377 &cfg,
1378 data(&[("code", "editor"), ("name", "Editor")]),
1379 crate::Shell::test(),
1380 &crate::audit::test_actor(),
1381 &crate::session::SessionHandle::from_blob(None),
1382 &axum::http::HeaderMap::new(),
1383 )
1384 .await;
1385
1386 let resp = create(
1388 &st,
1389 &cfg,
1390 data(&[("code", "editor"), ("name", "Other")]),
1391 crate::Shell::test(),
1392 &crate::audit::test_actor(),
1393 &crate::session::SessionHandle::from_blob(None),
1394 &axum::http::HeaderMap::new(),
1395 )
1396 .await;
1397 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1398 assert_eq!(count(&db).await, 1);
1399 assert!(body_of(resp).await.contains("already taken"));
1400 }
1401
1402 #[tokio::test]
1403 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1404 let (db, _guard) = test_db().await;
1405 let st = state(db.clone());
1406 let cfg = PreparedForm::prepare(
1409 FormConfig {
1410 enter: Default::default(),
1411 entity: "samples".to_string(),
1412 title: "Sample".into(),
1413 base_path: "/admin/samples".to_string(),
1414 id_field: "id".to_string(),
1415 fields: vec![
1416 FormField::text("code", "Code").required(),
1417 FormField::of("name", "Email", "text")
1418 .options(serde_json::json!({ "input": "email" }))
1419 .required(),
1420 ],
1421 persist: None,
1422 timestamps: false,
1423 },
1424 &st.field_types,
1425 &PersisterRegistry::new(),
1426 &[],
1427 )
1428 .unwrap();
1429
1430 let resp = create(
1431 &st,
1432 &cfg,
1433 data(&[("code", "c1"), ("name", "nope")]),
1434 crate::Shell::test(),
1435 &crate::audit::test_actor(),
1436 &crate::session::SessionHandle::from_blob(None),
1437 &axum::http::HeaderMap::new(),
1438 )
1439 .await;
1440 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1441 assert_eq!(count(&db).await, 0);
1442
1443 let resp = create(
1444 &st,
1445 &cfg,
1446 data(&[("code", "c1"), ("name", "a@b.test")]),
1447 crate::Shell::test(),
1448 &crate::audit::test_actor(),
1449 &crate::session::SessionHandle::from_blob(None),
1450 &axum::http::HeaderMap::new(),
1451 )
1452 .await;
1453 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1454 assert_eq!(count(&db).await, 1);
1455 }
1456
1457 #[test]
1458 fn prepare_rejects_an_unregistered_field_type() {
1459 let config = FormConfig {
1460 enter: Default::default(),
1461 entity: "samples".to_string(),
1462 title: "Sample".into(),
1463 base_path: "/admin/samples".to_string(),
1464 id_field: "id".to_string(),
1465 fields: vec![FormField::of("place", "Place", "no.such.type")],
1466 persist: None,
1467 timestamps: false,
1468 };
1469 let Err(err) = PreparedForm::prepare(
1470 config,
1471 &crate::field::builtin_registry(),
1472 &PersisterRegistry::new(),
1473 &[],
1474 ) else {
1475 panic!("expected prepare to reject an unregistered type");
1476 };
1477 assert!(err.contains("no.such.type"), "{err}");
1478 }
1479
1480 #[test]
1481 fn prepare_rejects_a_malformed_option() {
1482 let config = FormConfig {
1485 enter: Default::default(),
1486 entity: "samples".to_string(),
1487 title: "Sample".into(),
1488 base_path: "/admin/samples".to_string(),
1489 id_field: "id".to_string(),
1490 fields: vec![
1491 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1492 ],
1493 persist: None,
1494 timestamps: false,
1495 };
1496 let Err(err) = PreparedForm::prepare(
1497 config,
1498 &crate::field::builtin_registry(),
1499 &PersisterRegistry::new(),
1500 &[],
1501 ) else {
1502 panic!("expected prepare to reject a malformed option");
1503 };
1504 assert!(err.contains("`email`"), "{err}");
1505 }
1506
1507 struct RollbackPersister;
1510 #[laterite_core::strata::async_trait]
1511 impl Persister for RollbackPersister {
1512 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1513 let backend = cx.backend();
1514 let insert = Query::insert()
1515 .into_table(Alias::new("samples"))
1516 .columns([Alias::new("code"), Alias::new("name")])
1517 .values_panic(["rolled".into(), "back".into()])
1518 .to_owned();
1519 let (sql, values) = to_sql(backend, insert);
1520 bind_values(sqlx::query(&sql), values)
1521 .execute(cx.conn())
1522 .await
1523 .map_err(|e| SaveError::Failed(e.to_string()))?;
1524 Err(SaveError::Failed("deliberate".to_string()))
1525 }
1526 async fn update(
1527 &self,
1528 _cx: &mut SaveCx<'_>,
1529 _id: &str,
1530 _rec: &Record,
1531 ) -> Result<(), SaveError> {
1532 Ok(())
1533 }
1534 }
1535
1536 struct RejectingPersister;
1538 #[laterite_core::strata::async_trait]
1539 impl Persister for RejectingPersister {
1540 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1541 let mut bag = ErrorBag::default();
1542 bag.add("code", t!("Code is not allowed here."));
1543 Err(SaveError::Invalid(bag))
1544 }
1545 async fn update(
1546 &self,
1547 _cx: &mut SaveCx<'_>,
1548 _id: &str,
1549 _rec: &Record,
1550 ) -> Result<(), SaveError> {
1551 Ok(())
1552 }
1553 }
1554
1555 fn config_with(persister: &str) -> FormConfig {
1556 FormConfig {
1557 enter: Default::default(),
1558 entity: "samples".to_string(),
1559 title: "Sample".into(),
1560 base_path: "/admin/samples".to_string(),
1561 id_field: "id".to_string(),
1562 fields: vec![
1563 FormField::text("code", "Code").required(),
1564 FormField::text("name", "Name").required(),
1565 ],
1566 persist: Some(persister.to_string()),
1567 timestamps: false,
1568 }
1569 }
1570
1571 #[test]
1572 fn prepare_rejects_an_unregistered_persister() {
1573 let Err(err) = PreparedForm::prepare(
1574 config_with("no.such.persister"),
1575 &crate::field::builtin_registry(),
1576 &PersisterRegistry::new(),
1577 &[],
1578 ) else {
1579 panic!("expected prepare to reject an unregistered persister");
1580 };
1581 assert!(err.contains("no.such.persister"), "{err}");
1582 }
1583
1584 #[tokio::test]
1585 async fn a_persister_transaction_rolls_back_on_error() {
1586 let (db, _guard) = test_db().await;
1587 let st = state(db.clone());
1588 let mut persisters = PersisterRegistry::new();
1589 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1590 let form = PreparedForm::prepare(
1591 config_with("test.rollback"),
1592 &st.field_types,
1593 &persisters,
1594 &[],
1595 )
1596 .unwrap();
1597
1598 let resp = create(
1599 &st,
1600 &form,
1601 data(&[("code", "rolled"), ("name", "back")]),
1602 crate::Shell::test(),
1603 &crate::audit::test_actor(),
1604 &crate::session::SessionHandle::from_blob(None),
1605 &axum::http::HeaderMap::new(),
1606 )
1607 .await;
1608 assert_eq!(resp.status(), StatusCode::OK);
1611 assert_eq!(count(&db).await, 0);
1612 }
1613
1614 #[tokio::test]
1615 async fn a_persister_invalid_error_re_renders_422() {
1616 let (db, _guard) = test_db().await;
1617 let st = state(db.clone());
1618 let mut persisters = PersisterRegistry::new();
1619 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1620 let form = PreparedForm::prepare(
1621 config_with("test.reject"),
1622 &st.field_types,
1623 &persisters,
1624 &[],
1625 )
1626 .unwrap();
1627
1628 let resp = create(
1629 &st,
1630 &form,
1631 data(&[("code", "editor"), ("name", "Editor")]),
1632 crate::Shell::test(),
1633 &crate::audit::test_actor(),
1634 &crate::session::SessionHandle::from_blob(None),
1635 &axum::http::HeaderMap::new(),
1636 )
1637 .await;
1638 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1639 assert!(body_of(resp).await.contains("Code is not allowed here."));
1640 }
1641}
1642
1643#[cfg(test)]
1644mod timestamp_tests {
1645 use super::tests::*;
1646 use super::*;
1647 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1648 use laterite_core::Actor;
1649
1650 fn timestamped(on: bool) -> FormConfig {
1651 FormConfig {
1652 enter: Default::default(),
1653 entity: "samples".to_string(),
1654 title: "Sample".into(),
1655 base_path: "/admin/samples".to_string(),
1656 id_field: "id".to_string(),
1657 fields: vec![
1658 FormField::text("code", "Code").required(),
1659 FormField::text("name", "Name").required(),
1660 ],
1661 persist: None,
1662 timestamps: on,
1663 }
1664 }
1665
1666 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1667 let form = PreparedForm::prepare(
1668 timestamped(on),
1669 &crate::field::builtin_registry(),
1670 &PersisterRegistry::new(),
1671 &[],
1672 )
1673 .unwrap();
1674 let mut data = HashMap::new();
1675 data.insert("code".to_string(), "c1".to_string());
1676 data.insert("name".to_string(), "Chair".to_string());
1677 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1678 let actor = Actor::system("test");
1679 persist::save(
1680 persist::SaveRequest {
1681 db,
1682 listeners: &form.listeners,
1683 persister: form.persister.as_ref(),
1684 actor: &actor,
1685 op: Op::Create,
1686 id: None,
1687 },
1688 &mut rec,
1689 )
1690 .await
1691 .expect("the insert should succeed");
1692 rec
1693 }
1694
1695 #[tokio::test]
1696 async fn opting_in_stamps_the_row() {
1697 let (db, _guard) = test_db().await;
1698 let rec = create_through_pipeline(&db, true).await;
1699 assert!(rec.datetime(CREATED_AT).is_some());
1700 assert!(rec.datetime(UPDATED_AT).is_some());
1701 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
1703 }
1704
1705 #[tokio::test]
1706 async fn leaving_it_off_writes_no_timestamps() {
1707 let (db, _guard) = test_db().await;
1708 let rec = create_through_pipeline(&db, false).await;
1709 assert!(rec.get(CREATED_AT).is_none());
1710 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
1711 }
1712}
1713
1714#[cfg(test)]
1715mod htmx_tests {
1716 use super::tests::*;
1717 use super::*;
1718 use axum::http::StatusCode;
1719
1720 fn invalid() -> HashMap<String, String> {
1722 let mut d = HashMap::new();
1723 d.insert("code".to_string(), String::new());
1724 d.insert("name".to_string(), "No code".to_string());
1725 d
1726 }
1727
1728 fn valid() -> HashMap<String, String> {
1729 let mut d = HashMap::new();
1730 d.insert("code".to_string(), "c1".to_string());
1731 d.insert("name".to_string(), "Chair".to_string());
1732 d
1733 }
1734
1735 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
1736 let (db, _guard) = test_db().await;
1737 let st = state(db);
1738 let form = PreparedForm::prepare(
1739 FormConfig {
1740 enter: Default::default(),
1741 entity: "samples".to_string(),
1742 title: "Sample".into(),
1743 base_path: "/admin/samples".to_string(),
1744 id_field: "id".to_string(),
1745 fields: vec![
1746 FormField::text("code", "Code").required(),
1747 FormField::text("name", "Name").required(),
1748 ],
1749 persist: None,
1750 timestamps: false,
1751 },
1752 &st.field_types,
1753 &PersisterRegistry::new(),
1754 &[],
1755 )
1756 .unwrap();
1757 create(
1758 &st,
1759 &form,
1760 data,
1761 crate::Shell::test(),
1762 &crate::audit::test_actor(),
1763 &crate::session::SessionHandle::from_blob(None),
1764 &headers,
1765 )
1766 .await
1767 }
1768
1769 async fn body_of(resp: Response) -> String {
1770 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1771 .await
1772 .unwrap();
1773 String::from_utf8(bytes.to_vec()).unwrap()
1774 }
1775
1776 #[tokio::test]
1777 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
1778 let resp = post(invalid(), htmx_headers()).await;
1779 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1780 let html = body_of(resp).await;
1781 assert!(html.contains("<form"), "the form comes back");
1782 assert!(html.contains("lat-field__error"), "with its errors");
1783 assert!(!html.contains("<body"), "no page chrome");
1786 }
1787
1788 #[tokio::test]
1789 async fn a_plain_submit_that_fails_returns_the_whole_page() {
1790 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
1791 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1792 let html = body_of(resp).await;
1793 assert!(html.contains("<body"), "the admin still works without htmx");
1794 assert!(html.contains("lat-field__error"));
1795 }
1796
1797 #[tokio::test]
1798 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
1799 let resp = post(valid(), htmx_headers()).await;
1800 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1802 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
1803 }
1804
1805 #[tokio::test]
1806 async fn a_plain_submit_that_succeeds_still_redirects() {
1807 let resp = post(valid(), axum::http::HeaderMap::new()).await;
1808 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1809 }
1810}