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, Serialize, Deserialize)]
45#[serde(deny_unknown_fields, default)]
46pub struct FormField {
47 pub name: String,
48 pub label: Text,
50 #[serde(rename = "type")]
53 pub field_type: String,
54 #[serde(default)]
56 pub options: serde_json::Value,
57 #[serde(default)]
59 pub rules: Vec<Rule>,
60 #[serde(default)]
64 pub translatable: bool,
65 #[serde(default)]
67 pub help: Option<Text>,
68}
69
70impl Default for FormConfig {
71 fn default() -> Self {
74 Self {
75 entity: String::new(),
76 title: Text::new(""),
77 base_path: String::new(),
78 id_field: "id".to_string(),
79 fields: Vec::new(),
80 persist: None,
81 timestamps: false,
82 }
83 }
84}
85
86impl Default for FormField {
87 fn default() -> Self {
89 Self::of("", Text::new(""), "text")
90 }
91}
92
93impl crate::keyed::Keyed for FormField {
94 fn key(&self) -> &str {
95 &self.name
96 }
97 fn set_key(&mut self, key: String) {
98 self.name = key;
99 }
100}
101
102impl FormField {
103 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
105 Self {
106 name: name.to_string(),
107 label: label.into(),
108 field_type: field_type.to_string(),
109 options: serde_json::Value::Null,
110 rules: Vec::new(),
111 translatable: false,
112 help: None,
113 }
114 }
115
116 pub fn text(name: &str, label: impl Into<Text>) -> Self {
117 Self::of(name, label, "text")
118 }
119
120 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
121 Self::of(name, label, "textarea")
122 }
123
124 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
128 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
129 }
130
131 pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
133 Self::of(name, label, "select").options(Self::choices(options))
134 }
135
136 pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
138 Self::of(name, label, "radio").options(Self::choices(options))
139 }
140
141 pub fn switch(name: &str, label: impl Into<Text>) -> Self {
143 Self::of(name, label, "switch")
144 }
145
146 pub fn date(name: &str, label: impl Into<Text>) -> Self {
148 Self::of(name, label, "date")
149 }
150
151 pub fn password(name: &str, label: impl Into<Text>) -> Self {
154 Self::of(name, label, "password")
155 }
156
157 pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
159 Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
160 }
161
162 pub fn repeater_list(
171 name: &str,
172 label: impl Into<Text>,
173 fields: Vec<FormField>,
174 summary_field: Option<&str>,
175 ) -> Self {
176 Self::of(name, label, "repeater").options(serde_json::json!({
177 "fields": fields,
178 "display": "list",
179 "summary_field": summary_field,
180 }))
181 }
182
183 fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
185 let options: Vec<serde_json::Value> = options
186 .into_iter()
187 .map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
188 .collect();
189 serde_json::json!({ "options": options })
190 }
191
192 pub fn options(mut self, options: serde_json::Value) -> Self {
194 self.options = options;
195 self
196 }
197
198 pub fn required(mut self) -> Self {
200 self.rules.push(Rule::Required);
201 self
202 }
203
204 pub fn required_on(mut self, mode: Mode) -> Self {
207 self.rules.push(Rule::RequiredOn(mode));
208 self
209 }
210
211 pub fn unique(mut self) -> Self {
214 self.rules.push(Rule::Unique);
215 self
216 }
217
218 pub fn max_length(mut self, n: usize) -> Self {
220 self.rules.push(Rule::MaxLength(n));
221 self
222 }
223
224 pub fn min_length(mut self, n: usize) -> Self {
226 self.rules.push(Rule::MinLength(n));
227 self
228 }
229
230 pub fn email(mut self) -> Self {
232 self.rules.push(Rule::Email);
233 self
234 }
235
236 pub fn translatable(mut self) -> Self {
238 self.translatable = true;
239 self
240 }
241
242 pub fn help(mut self, text: impl Into<Text>) -> Self {
244 self.help = Some(text.into());
245 self
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(deny_unknown_fields, default)]
256#[non_exhaustive]
257pub struct FormConfig {
258 pub entity: String,
259 pub title: Text,
261 pub base_path: String,
262 pub id_field: String,
263 #[serde(
264 deserialize_with = "crate::keyed::deserialize",
265 serialize_with = "crate::keyed::serialize"
266 )]
267 pub fields: Vec<FormField>,
268 pub persist: Option<String>,
271 #[serde(default)]
274 pub timestamps: bool,
275}
276
277impl FormConfig {
278 pub fn new(
283 entity: impl Into<String>,
284 title: impl Into<Text>,
285 base_path: impl Into<String>,
286 id_field: impl Into<String>,
287 fields: Vec<FormField>,
288 ) -> Self {
289 Self {
290 entity: entity.into(),
291 title: title.into(),
292 base_path: base_path.into(),
293 id_field: id_field.into(),
294 fields,
295 persist: None,
296 timestamps: false,
297 }
298 }
299
300 pub fn persist(mut self, name: impl Into<String>) -> Self {
303 self.persist = Some(name.into());
304 self
305 }
306
307 pub fn timestamps(mut self) -> Self {
310 self.timestamps = true;
311 self
312 }
313
314 fn idents_valid(&self) -> bool {
315 valid_ident(&self.entity)
316 && valid_ident(&self.id_field)
317 && self.fields.iter().all(|f| valid_ident(&f.name))
318 }
319}
320
321pub(crate) struct PreparedForm {
326 config: FormConfig,
327 fields: Vec<PreparedField>,
329 persister: Arc<dyn Persister>,
331 listeners: Vec<Arc<dyn ModelListener>>,
333}
334
335struct PreparedField {
338 field_type: Arc<dyn crate::field::FieldType>,
340 opts: ResolvedOptions,
341 rules: Vec<Rule>,
342}
343
344impl PreparedForm {
345 pub(crate) fn prepare(
349 config: FormConfig,
350 field_types: &crate::field::FieldRegistry,
351 persisters: &PersisterRegistry,
352 listeners: &[ModelListenerReg],
353 ) -> Result<Self, String> {
354 let mut fields = Vec::with_capacity(config.fields.len());
355 for f in &config.fields {
356 let ft = field_types.get(&f.field_type).ok_or_else(|| {
357 format!(
358 "field `{}` uses unregistered type `{}`",
359 f.name, f.field_type
360 )
361 })?;
362 let opts = ft
363 .resolve_options(&f.options, field_types)
364 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
365 let mut rules = ft.intrinsic_rules(&opts);
366 rules.extend(f.rules.clone());
367 fields.push(PreparedField {
368 field_type: ft.clone(),
369 opts,
370 rules,
371 });
372 }
373 let persister: Arc<dyn Persister> = match &config.persist {
374 Some(key) => persisters
375 .get(key)
376 .cloned()
377 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
378 None => Arc::new(DefaultPersister::from_config(&config)),
379 };
380 let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
383 if config.timestamps {
384 resolved.push(Arc::new(laterite_core::Timestamps));
385 }
386 resolved.extend(
387 listeners
388 .iter()
389 .filter(|reg| reg.matches(&config.entity))
390 .map(|reg| reg.listener.clone()),
391 );
392 let listeners = resolved;
393 Ok(Self {
394 config,
395 fields,
396 persister,
397 listeners,
398 })
399 }
400}
401
402fn declared_record(
409 form: &PreparedForm,
410 data: &HashMap<String, String>,
411 mode: Mode,
412) -> Result<Record, String> {
413 let mut rec = Record::new(&form.config.entity);
414 for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
415 let submitted = crate::field::SubmittedField::new(&field.name, data);
416 if let Some(value) = prepared
417 .field_type
418 .to_attr(&submitted, &prepared.opts, mode)?
419 {
420 rec.set(field.name.clone(), value);
421 }
422 }
423 Ok(rec)
424}
425
426fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
428 form.config
429 .fields
430 .iter()
431 .zip(&form.fields)
432 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
435 .collect()
436}
437
438fn has_required(rules: &[Rule]) -> bool {
440 rules
441 .iter()
442 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
443}
444
445pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
447 render(build(
448 state,
449 form,
450 &format!("{}/new", form.config.base_path),
451 None,
452 &HashMap::new(),
453 &ErrorBag::default(),
454 &shell,
455 ))
456}
457
458pub(crate) async fn create(
461 state: &AdminState,
462 form: &PreparedForm,
463 data: HashMap<String, String>,
464 shell: crate::Shell,
465 user: &laterite_auth::AuthenticatedUser,
466 session: &crate::session::SessionHandle,
467 headers: &axum::http::HeaderMap,
468) -> Response {
469 if !form.config.idents_valid() {
470 return render_error();
471 }
472 let htmx = is_htmx(headers);
473 let action = format!("{}/new", form.config.base_path);
474
475 let bag = match validate(
476 &state.db,
477 &form.config.entity,
478 &form.config.id_field,
479 &merged_field_rules(form),
480 &data,
481 Mode::Create,
482 None,
483 )
484 .await
485 {
486 Ok(bag) => bag,
487 Err(_) => return render_error(),
488 };
489 if !bag.is_empty() {
490 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
493 }
494
495 let mut rec = match declared_record(form, &data, Mode::Create) {
496 Ok(rec) => rec,
497 Err(e) => {
498 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
499 return render_error();
500 }
501 };
502 let actor = Actor::from(user);
503 match persist::save(
504 persist::SaveRequest {
505 db: &state.db,
506 listeners: &form.listeners,
507 persister: form.persister.as_ref(),
508 actor: &actor,
509 op: Op::Create,
510 id: None,
511 },
512 &mut rec,
513 )
514 .await
515 {
516 Ok(()) => {
519 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
520 saved_response(htmx, &form.config.base_path)
521 }
522 Err(SaveError::Invalid(bag)) => {
524 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
525 }
526 Err(SaveError::Failed(msg)) => {
527 tracing::error!(error = %msg, "admin create failed");
528 render(build(
529 state,
530 form,
531 &action,
532 Some(t!("Could not save. Check the values and try again.")),
533 &data,
534 &ErrorBag::default(),
535 &shell,
536 ))
537 }
538 }
539}
540
541pub(crate) async fn edit_form(
543 state: &AdminState,
544 form: &PreparedForm,
545 id: String,
546 shell: crate::Shell,
547) -> Response {
548 if !form.config.idents_valid() {
549 return render_error();
550 }
551 let (sql, values) = {
555 let cast = text_cast(state.db.backend);
556 let mut select = Query::select();
557 for field in &form.config.fields {
558 select.expr_as(
559 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
560 Alias::new(&field.name),
561 );
562 }
563 select.from(Alias::new(&form.config.entity)).and_where(
564 Expr::col(Alias::new(&form.config.id_field))
565 .cast_as(Alias::new(cast))
566 .eq(id.clone()),
567 );
568 to_sql(state.db.backend, select)
569 };
570 let row = match bind_values(sqlx::query(&sql), values)
571 .fetch_optional(&state.db.pool)
572 .await
573 {
574 Ok(row) => row,
575 Err(_) => return render_error(),
576 };
577 let Some(row) = row else {
578 return not_found();
579 };
580
581 let values = form
584 .config
585 .fields
586 .iter()
587 .zip(&form.fields)
588 .map(|(f, prepared)| {
589 let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
590 let value = prepared
591 .field_type
592 .to_control(stored.as_deref(), &prepared.opts);
593 (f.name.clone(), value)
594 })
595 .collect();
596
597 render(build(
598 state,
599 form,
600 &format!("{}/{}/edit", form.config.base_path, id),
601 None,
602 &values,
603 &ErrorBag::default(),
604 &shell,
605 ))
606}
607
608#[allow(clippy::too_many_arguments)]
613pub(crate) async fn update(
614 state: &AdminState,
615 form: &PreparedForm,
616 id: String,
617 data: HashMap<String, String>,
618 shell: crate::Shell,
619 user: &laterite_auth::AuthenticatedUser,
620 session: &crate::session::SessionHandle,
621 headers: &axum::http::HeaderMap,
622) -> Response {
623 if !form.config.idents_valid() {
624 return render_error();
625 }
626 let htmx = is_htmx(headers);
627 let action = format!("{}/{}/edit", form.config.base_path, id);
628
629 let bag = match validate(
630 &state.db,
631 &form.config.entity,
632 &form.config.id_field,
633 &merged_field_rules(form),
634 &data,
635 Mode::Update,
636 Some(&id),
637 )
638 .await
639 {
640 Ok(bag) => bag,
641 Err(_) => return render_error(),
642 };
643 if !bag.is_empty() {
644 return invalid_response(htmx, state, form, &action, None, &data, &bag, &shell);
647 }
648
649 let mut rec = match declared_record(form, &data, Mode::Update) {
650 Ok(rec) => rec,
651 Err(e) => {
652 tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
653 return render_error();
654 }
655 };
656 if let Ok(n) = id.parse::<i64>() {
657 rec.set_id(n);
658 }
659 let actor = Actor::from(user);
660 match persist::save(
661 persist::SaveRequest {
662 db: &state.db,
663 listeners: &form.listeners,
664 persister: form.persister.as_ref(),
665 actor: &actor,
666 op: Op::Update,
667 id: Some(&id),
668 },
669 &mut rec,
670 )
671 .await
672 {
673 Ok(()) => {
674 session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
675 saved_response(htmx, &form.config.base_path)
676 }
677 Err(SaveError::Invalid(bag)) => {
678 invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
679 }
680 Err(SaveError::Failed(msg)) => {
681 tracing::error!(error = %msg, "admin update failed");
682 render(build(
683 state,
684 form,
685 &action,
686 Some(t!("Could not save. Check the values and try again.")),
687 &data,
688 &ErrorBag::default(),
689 &shell,
690 ))
691 }
692 }
693}
694
695#[allow(clippy::too_many_arguments)]
699fn invalid_response(
700 htmx: bool,
701 state: &AdminState,
702 form: &PreparedForm,
703 action: &str,
704 error: Option<Text>,
705 values: &HashMap<String, String>,
706 bag: &ErrorBag,
707 shell: &crate::Shell,
708) -> Response {
709 let page = build(state, form, action, error, values, bag, shell);
710 let body = if htmx {
711 render(FormFragment {
712 shell: page.shell,
713 action: page.action,
714 cancel_path: page.cancel_path,
715 error: page.error,
716 fields: page.fields,
717 })
718 } else {
719 render(page)
720 };
721 (StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
722}
723
724pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
728 if htmx {
729 ([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
730 } else {
731 Redirect::to(to).into_response()
732 }
733}
734
735pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
737 headers.contains_key("hx-request")
738}
739
740fn build(
741 state: &AdminState,
742 form: &PreparedForm,
743 action: &str,
744 error: Option<Text>,
745 values: &HashMap<String, String>,
746 bag: &ErrorBag,
747 shell: &crate::Shell,
748) -> FormTemplate {
749 let fields = form
750 .config
751 .fields
752 .iter()
753 .zip(&form.fields)
754 .map(|(f, pf)| {
755 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
756 let required = has_required(&pf.rules);
757 let label = shell.tt(&f.label);
760 let cx = FieldCx {
761 name: &f.name,
762 id: &f.name,
763 label: &label,
764 value: &value,
765 required,
766 opts: &pf.opts,
767 base: &shell.base,
768 i18n: shell.i18n(),
769 };
770 let control = match state.field_types.get(&f.field_type) {
773 Some(ft) => {
774 let scope = OverrideScope {
775 surface: Surface::Field,
776 view_key: &f.field_type,
777 resource: Some(&form.config.base_path),
778 field: Some(&f.name),
779 };
780 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
781 }
782 None => String::new(),
783 };
784 FieldView {
785 id: f.name.clone(),
786 label,
787 control,
788 required,
789 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
791 }
792 })
793 .collect();
794 let keys: Vec<&str> = form
798 .config
799 .fields
800 .iter()
801 .zip(&form.fields)
802 .filter_map(|(f, pf)| {
803 state
804 .field_types
805 .get(&f.field_type)
806 .map(|ft| ft.assets(&pf.opts))
807 })
808 .flatten()
809 .collect();
810 let shell_title = shell.tt(&form.config.title);
812 let error = error.map(|m| shell.tt(&m));
813 let mut shell = shell.clone();
814 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
815 FormTemplate {
816 shell,
817 title: shell_title,
818 action: action.to_string(),
819 cancel_path: form.config.base_path.clone(),
820 error,
821 fields,
822 }
823}
824
825struct FieldView {
826 id: String,
828 label: String,
829 control: String,
832 required: bool,
833 errors: Vec<String>,
834}
835
836#[derive(Template)]
839#[template(path = "_form_fields.html")]
840struct FormFragment {
841 shell: crate::Shell,
842 action: String,
843 cancel_path: String,
844 error: Option<String>,
845 fields: Vec<FieldView>,
846}
847
848#[derive(Template)]
849#[template(path = "form.html")]
850struct FormTemplate {
851 shell: crate::Shell,
852 title: String,
853 action: String,
854 cancel_path: String,
855 error: Option<String>,
856 fields: Vec<FieldView>,
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use axum::http::StatusCode;
863 use laterite_core::strata::{
864 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
865 };
866 use laterite_core::testing::{connect_test, TestGuard};
867 use laterite_core::Db;
868 use laterite_core::SaveCx;
869
870 struct CreateSamples;
873 #[async_trait(?Send)]
874 impl Migration for CreateSamples {
875 fn name(&self) -> &str {
876 "0001_create_samples"
877 }
878 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
879 s.exec(
880 Table::create()
881 .table(Alias::new("samples"))
882 .if_not_exists()
883 .col(
884 ColumnDef::new(Alias::new("id"))
885 .big_integer()
886 .not_null()
887 .auto_increment()
888 .primary_key(),
889 )
890 .col(ColumnDef::new(Alias::new("code")).text().not_null())
891 .col(ColumnDef::new(Alias::new("name")).text().not_null())
892 .col(ColumnDef::new(Alias::new("created_at")).text())
895 .col(ColumnDef::new(Alias::new("updated_at")).text())
896 .to_owned(),
897 )
898 .await
899 }
900 }
901
902 fn config() -> PreparedForm {
903 let config = FormConfig {
904 entity: "samples".to_string(),
905 title: "Sample".into(),
906 base_path: "/admin/samples".to_string(),
907 id_field: "id".to_string(),
908 fields: vec![
909 FormField::text("code", "Code").required().unique(),
910 FormField::text("name", "Name").required(),
911 ],
912 persist: None,
913 timestamps: false,
914 };
915 PreparedForm::prepare(
916 config,
917 &crate::field::builtin_registry(),
918 &PersisterRegistry::new(),
919 &[],
920 )
921 .unwrap()
922 }
923
924 #[tokio::test]
927 async fn a_delete_removes_the_row_through_the_pipeline() {
928 use crate::persist::{delete, DefaultPersister, DeleteRequest};
929
930 let (db, _guard) = test_db().await;
931 let st = state(db.clone());
932 let form = config();
933
934 let mut data = HashMap::new();
935 data.insert("code".to_string(), "c1".to_string());
936 data.insert("name".to_string(), "Chair".to_string());
937 create(
938 &st,
939 &form,
940 data,
941 crate::Shell::test(),
942 &crate::audit::test_actor(),
943 &crate::session::SessionHandle::from_blob(None),
944 &axum::http::HeaderMap::new(),
945 )
946 .await;
947 assert_eq!(
948 fetch_text(&db, "name", "c1").await.as_deref(),
949 Some("Chair")
950 );
951
952 let id = row_id(&db, "c1").await;
953 let persister = DefaultPersister::from_config(&form.config);
954 let rec = delete(DeleteRequest {
955 db: &db,
956 listeners: &[],
957 persister: &persister,
958 actor: &laterite_core::Actor::system("test"),
959 entity: "samples",
960 id: &id,
961 })
962 .await
963 .expect("deleted");
964
965 assert_eq!(fetch_text(&db, "name", "c1").await, None);
967 assert_eq!(rec.text("code"), Some("c1"));
968 }
969
970 async fn row_id(db: &Db, code: &str) -> String {
972 let stmt = Query::select()
973 .expr_as(
974 Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
975 Alias::new("v"),
976 )
977 .from(Alias::new("samples"))
978 .and_where(Expr::col(Alias::new("code")).eq(code))
979 .to_owned();
980 let (sql, values) = laterite_core::query::build(db.backend, stmt);
981 let row = bind_values(sqlx::query(&sql), values)
982 .fetch_one(&db.pool)
983 .await
984 .unwrap();
985 row.get_text("v").unwrap()
986 }
987
988 pub(super) fn state(db: Db) -> AdminState {
989 AdminState::new(
990 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
991 db,
992 )
993 }
994
995 pub(super) async fn test_db() -> (Db, TestGuard) {
998 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
999 connect_test(&[samples]).await
1000 }
1001
1002 pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
1005 let stmt = Query::select()
1006 .expr_as(
1007 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
1008 Alias::new("v"),
1009 )
1010 .from(Alias::new("samples"))
1011 .and_where(Expr::col(Alias::new("code")).eq(code))
1012 .to_owned();
1013 let (sql, values) = to_sql(db.backend, stmt);
1014 let row = bind_values(sqlx::query(&sql), values)
1015 .fetch_optional(&db.pool)
1016 .await
1017 .unwrap()?;
1018 row.get_text_opt("v").ok().flatten()
1019 }
1020
1021 async fn body_of(resp: Response) -> String {
1022 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1023 .await
1024 .unwrap();
1025 String::from_utf8(bytes.to_vec()).unwrap()
1026 }
1027
1028 async fn count(db: &Db) -> i64 {
1029 sqlx::query_scalar("select count(*) from samples")
1030 .fetch_one(&db.pool)
1031 .await
1032 .unwrap()
1033 }
1034
1035 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1036 pairs
1037 .iter()
1038 .map(|(k, v)| (k.to_string(), v.to_string()))
1039 .collect()
1040 }
1041
1042 pub(super) fn htmx_headers() -> axum::http::HeaderMap {
1044 let mut h = axum::http::HeaderMap::new();
1045 h.insert("hx-request", "true".parse().unwrap());
1046 h
1047 }
1048
1049 #[tokio::test]
1050 async fn create_then_fetch() {
1051 let (db, _guard) = test_db().await;
1052 let cfg = config();
1053 let st = state(db.clone());
1054
1055 let resp = create(
1056 &st,
1057 &cfg,
1058 data(&[("code", "editor"), ("name", "Content Editor")]),
1059 crate::Shell::test(),
1060 &crate::audit::test_actor(),
1061 &crate::session::SessionHandle::from_blob(None),
1062 &axum::http::HeaderMap::new(),
1063 )
1064 .await;
1065 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1066
1067 assert_eq!(
1068 fetch_text(&db, "name", "editor").await.as_deref(),
1069 Some("Content Editor")
1070 );
1071 }
1072
1073 #[tokio::test]
1074 async fn update_changes_the_row() {
1075 let (db, _guard) = test_db().await;
1076 let cfg = config();
1077 let st = state(db.clone());
1078 create(
1079 &st,
1080 &cfg,
1081 data(&[("code", "editor"), ("name", "Editor")]),
1082 crate::Shell::test(),
1083 &crate::audit::test_actor(),
1084 &crate::session::SessionHandle::from_blob(None),
1085 &axum::http::HeaderMap::new(),
1086 )
1087 .await;
1088
1089 let id = fetch_text(&db, "id", "editor")
1090 .await
1091 .expect("row should exist after create");
1092
1093 let resp = update(
1095 &st,
1096 &cfg,
1097 id,
1098 data(&[("code", "editor"), ("name", "Senior Editor")]),
1099 crate::Shell::test(),
1100 &crate::audit::test_actor(),
1101 &crate::session::SessionHandle::from_blob(None),
1102 &axum::http::HeaderMap::new(),
1103 )
1104 .await;
1105 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1106
1107 assert_eq!(
1108 fetch_text(&db, "name", "editor").await.as_deref(),
1109 Some("Senior Editor")
1110 );
1111 }
1112
1113 #[tokio::test]
1114 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
1115 let (db, _guard) = test_db().await;
1116 let cfg = config();
1117 let st = state(db.clone());
1118
1119 let resp = create(
1120 &st,
1121 &cfg,
1122 data(&[("code", ""), ("name", "No Code")]),
1123 crate::Shell::test(),
1124 &crate::audit::test_actor(),
1125 &crate::session::SessionHandle::from_blob(None),
1126 &axum::http::HeaderMap::new(),
1127 )
1128 .await;
1129 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1131 assert_eq!(count(&db).await, 0);
1132 assert!(body_of(resp).await.contains("Code is required."));
1134 }
1135
1136 #[tokio::test]
1137 async fn create_rejects_a_duplicate_unique_value() {
1138 let (db, _guard) = test_db().await;
1139 let cfg = config();
1140 let st = state(db.clone());
1141 create(
1142 &st,
1143 &cfg,
1144 data(&[("code", "editor"), ("name", "Editor")]),
1145 crate::Shell::test(),
1146 &crate::audit::test_actor(),
1147 &crate::session::SessionHandle::from_blob(None),
1148 &axum::http::HeaderMap::new(),
1149 )
1150 .await;
1151
1152 let resp = create(
1154 &st,
1155 &cfg,
1156 data(&[("code", "editor"), ("name", "Other")]),
1157 crate::Shell::test(),
1158 &crate::audit::test_actor(),
1159 &crate::session::SessionHandle::from_blob(None),
1160 &axum::http::HeaderMap::new(),
1161 )
1162 .await;
1163 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1164 assert_eq!(count(&db).await, 1);
1165 assert!(body_of(resp).await.contains("already taken"));
1166 }
1167
1168 #[tokio::test]
1169 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
1170 let (db, _guard) = test_db().await;
1171 let st = state(db.clone());
1172 let cfg = PreparedForm::prepare(
1175 FormConfig {
1176 entity: "samples".to_string(),
1177 title: "Sample".into(),
1178 base_path: "/admin/samples".to_string(),
1179 id_field: "id".to_string(),
1180 fields: vec![
1181 FormField::text("code", "Code").required(),
1182 FormField::of("name", "Email", "text")
1183 .options(serde_json::json!({ "input": "email" }))
1184 .required(),
1185 ],
1186 persist: None,
1187 timestamps: false,
1188 },
1189 &st.field_types,
1190 &PersisterRegistry::new(),
1191 &[],
1192 )
1193 .unwrap();
1194
1195 let resp = create(
1196 &st,
1197 &cfg,
1198 data(&[("code", "c1"), ("name", "nope")]),
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!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1206 assert_eq!(count(&db).await, 0);
1207
1208 let resp = create(
1209 &st,
1210 &cfg,
1211 data(&[("code", "c1"), ("name", "a@b.test")]),
1212 crate::Shell::test(),
1213 &crate::audit::test_actor(),
1214 &crate::session::SessionHandle::from_blob(None),
1215 &axum::http::HeaderMap::new(),
1216 )
1217 .await;
1218 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1219 assert_eq!(count(&db).await, 1);
1220 }
1221
1222 #[test]
1223 fn prepare_rejects_an_unregistered_field_type() {
1224 let config = FormConfig {
1225 entity: "samples".to_string(),
1226 title: "Sample".into(),
1227 base_path: "/admin/samples".to_string(),
1228 id_field: "id".to_string(),
1229 fields: vec![FormField::of("place", "Place", "no.such.type")],
1230 persist: None,
1231 timestamps: false,
1232 };
1233 let Err(err) = PreparedForm::prepare(
1234 config,
1235 &crate::field::builtin_registry(),
1236 &PersisterRegistry::new(),
1237 &[],
1238 ) else {
1239 panic!("expected prepare to reject an unregistered type");
1240 };
1241 assert!(err.contains("no.such.type"), "{err}");
1242 }
1243
1244 #[test]
1245 fn prepare_rejects_a_malformed_option() {
1246 let config = FormConfig {
1249 entity: "samples".to_string(),
1250 title: "Sample".into(),
1251 base_path: "/admin/samples".to_string(),
1252 id_field: "id".to_string(),
1253 fields: vec![
1254 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
1255 ],
1256 persist: None,
1257 timestamps: false,
1258 };
1259 let Err(err) = PreparedForm::prepare(
1260 config,
1261 &crate::field::builtin_registry(),
1262 &PersisterRegistry::new(),
1263 &[],
1264 ) else {
1265 panic!("expected prepare to reject a malformed option");
1266 };
1267 assert!(err.contains("`email`"), "{err}");
1268 }
1269
1270 struct RollbackPersister;
1273 #[laterite_core::strata::async_trait]
1274 impl Persister for RollbackPersister {
1275 async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1276 let backend = cx.backend();
1277 let insert = Query::insert()
1278 .into_table(Alias::new("samples"))
1279 .columns([Alias::new("code"), Alias::new("name")])
1280 .values_panic(["rolled".into(), "back".into()])
1281 .to_owned();
1282 let (sql, values) = to_sql(backend, insert);
1283 bind_values(sqlx::query(&sql), values)
1284 .execute(cx.conn())
1285 .await
1286 .map_err(|e| SaveError::Failed(e.to_string()))?;
1287 Err(SaveError::Failed("deliberate".to_string()))
1288 }
1289 async fn update(
1290 &self,
1291 _cx: &mut SaveCx<'_>,
1292 _id: &str,
1293 _rec: &Record,
1294 ) -> Result<(), SaveError> {
1295 Ok(())
1296 }
1297 }
1298
1299 struct RejectingPersister;
1301 #[laterite_core::strata::async_trait]
1302 impl Persister for RejectingPersister {
1303 async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1304 let mut bag = ErrorBag::default();
1305 bag.add("code", t!("Code is not allowed here."));
1306 Err(SaveError::Invalid(bag))
1307 }
1308 async fn update(
1309 &self,
1310 _cx: &mut SaveCx<'_>,
1311 _id: &str,
1312 _rec: &Record,
1313 ) -> Result<(), SaveError> {
1314 Ok(())
1315 }
1316 }
1317
1318 fn config_with(persister: &str) -> FormConfig {
1319 FormConfig {
1320 entity: "samples".to_string(),
1321 title: "Sample".into(),
1322 base_path: "/admin/samples".to_string(),
1323 id_field: "id".to_string(),
1324 fields: vec![
1325 FormField::text("code", "Code").required(),
1326 FormField::text("name", "Name").required(),
1327 ],
1328 persist: Some(persister.to_string()),
1329 timestamps: false,
1330 }
1331 }
1332
1333 #[test]
1334 fn prepare_rejects_an_unregistered_persister() {
1335 let Err(err) = PreparedForm::prepare(
1336 config_with("no.such.persister"),
1337 &crate::field::builtin_registry(),
1338 &PersisterRegistry::new(),
1339 &[],
1340 ) else {
1341 panic!("expected prepare to reject an unregistered persister");
1342 };
1343 assert!(err.contains("no.such.persister"), "{err}");
1344 }
1345
1346 #[tokio::test]
1347 async fn a_persister_transaction_rolls_back_on_error() {
1348 let (db, _guard) = test_db().await;
1349 let st = state(db.clone());
1350 let mut persisters = PersisterRegistry::new();
1351 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1352 let form = PreparedForm::prepare(
1353 config_with("test.rollback"),
1354 &st.field_types,
1355 &persisters,
1356 &[],
1357 )
1358 .unwrap();
1359
1360 let resp = create(
1361 &st,
1362 &form,
1363 data(&[("code", "rolled"), ("name", "back")]),
1364 crate::Shell::test(),
1365 &crate::audit::test_actor(),
1366 &crate::session::SessionHandle::from_blob(None),
1367 &axum::http::HeaderMap::new(),
1368 )
1369 .await;
1370 assert_eq!(resp.status(), StatusCode::OK);
1373 assert_eq!(count(&db).await, 0);
1374 }
1375
1376 #[tokio::test]
1377 async fn a_persister_invalid_error_re_renders_422() {
1378 let (db, _guard) = test_db().await;
1379 let st = state(db.clone());
1380 let mut persisters = PersisterRegistry::new();
1381 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1382 let form = PreparedForm::prepare(
1383 config_with("test.reject"),
1384 &st.field_types,
1385 &persisters,
1386 &[],
1387 )
1388 .unwrap();
1389
1390 let resp = create(
1391 &st,
1392 &form,
1393 data(&[("code", "editor"), ("name", "Editor")]),
1394 crate::Shell::test(),
1395 &crate::audit::test_actor(),
1396 &crate::session::SessionHandle::from_blob(None),
1397 &axum::http::HeaderMap::new(),
1398 )
1399 .await;
1400 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1401 assert!(body_of(resp).await.contains("Code is not allowed here."));
1402 }
1403}
1404
1405#[cfg(test)]
1406mod timestamp_tests {
1407 use super::tests::*;
1408 use super::*;
1409 use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1410 use laterite_core::Actor;
1411
1412 fn timestamped(on: bool) -> FormConfig {
1413 FormConfig {
1414 entity: "samples".to_string(),
1415 title: "Sample".into(),
1416 base_path: "/admin/samples".to_string(),
1417 id_field: "id".to_string(),
1418 fields: vec![
1419 FormField::text("code", "Code").required(),
1420 FormField::text("name", "Name").required(),
1421 ],
1422 persist: None,
1423 timestamps: on,
1424 }
1425 }
1426
1427 async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1428 let form = PreparedForm::prepare(
1429 timestamped(on),
1430 &crate::field::builtin_registry(),
1431 &PersisterRegistry::new(),
1432 &[],
1433 )
1434 .unwrap();
1435 let mut data = HashMap::new();
1436 data.insert("code".to_string(), "c1".to_string());
1437 data.insert("name".to_string(), "Chair".to_string());
1438 let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1439 let actor = Actor::system("test");
1440 persist::save(
1441 persist::SaveRequest {
1442 db,
1443 listeners: &form.listeners,
1444 persister: form.persister.as_ref(),
1445 actor: &actor,
1446 op: Op::Create,
1447 id: None,
1448 },
1449 &mut rec,
1450 )
1451 .await
1452 .expect("the insert should succeed");
1453 rec
1454 }
1455
1456 #[tokio::test]
1457 async fn opting_in_stamps_the_row() {
1458 let (db, _guard) = test_db().await;
1459 let rec = create_through_pipeline(&db, true).await;
1460 assert!(rec.datetime(CREATED_AT).is_some());
1461 assert!(rec.datetime(UPDATED_AT).is_some());
1462 assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
1464 }
1465
1466 #[tokio::test]
1467 async fn leaving_it_off_writes_no_timestamps() {
1468 let (db, _guard) = test_db().await;
1469 let rec = create_through_pipeline(&db, false).await;
1470 assert!(rec.get(CREATED_AT).is_none());
1471 assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
1472 }
1473}
1474
1475#[cfg(test)]
1476mod htmx_tests {
1477 use super::tests::*;
1478 use super::*;
1479 use axum::http::StatusCode;
1480
1481 fn invalid() -> HashMap<String, String> {
1483 let mut d = HashMap::new();
1484 d.insert("code".to_string(), String::new());
1485 d.insert("name".to_string(), "No code".to_string());
1486 d
1487 }
1488
1489 fn valid() -> HashMap<String, String> {
1490 let mut d = HashMap::new();
1491 d.insert("code".to_string(), "c1".to_string());
1492 d.insert("name".to_string(), "Chair".to_string());
1493 d
1494 }
1495
1496 async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
1497 let (db, _guard) = test_db().await;
1498 let st = state(db);
1499 let form = PreparedForm::prepare(
1500 FormConfig {
1501 entity: "samples".to_string(),
1502 title: "Sample".into(),
1503 base_path: "/admin/samples".to_string(),
1504 id_field: "id".to_string(),
1505 fields: vec![
1506 FormField::text("code", "Code").required(),
1507 FormField::text("name", "Name").required(),
1508 ],
1509 persist: None,
1510 timestamps: false,
1511 },
1512 &st.field_types,
1513 &PersisterRegistry::new(),
1514 &[],
1515 )
1516 .unwrap();
1517 create(
1518 &st,
1519 &form,
1520 data,
1521 crate::Shell::test(),
1522 &crate::audit::test_actor(),
1523 &crate::session::SessionHandle::from_blob(None),
1524 &headers,
1525 )
1526 .await
1527 }
1528
1529 async fn body_of(resp: Response) -> String {
1530 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1531 .await
1532 .unwrap();
1533 String::from_utf8(bytes.to_vec()).unwrap()
1534 }
1535
1536 #[tokio::test]
1537 async fn an_htmx_submit_that_fails_returns_the_form_alone() {
1538 let resp = post(invalid(), htmx_headers()).await;
1539 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1540 let html = body_of(resp).await;
1541 assert!(html.contains("<form"), "the form comes back");
1542 assert!(html.contains("lat-field__error"), "with its errors");
1543 assert!(!html.contains("<body"), "no page chrome");
1546 }
1547
1548 #[tokio::test]
1549 async fn a_plain_submit_that_fails_returns_the_whole_page() {
1550 let resp = post(invalid(), axum::http::HeaderMap::new()).await;
1551 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1552 let html = body_of(resp).await;
1553 assert!(html.contains("<body"), "the admin still works without htmx");
1554 assert!(html.contains("lat-field__error"));
1555 }
1556
1557 #[tokio::test]
1558 async fn an_htmx_submit_that_succeeds_redirects_by_header() {
1559 let resp = post(valid(), htmx_headers()).await;
1560 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1562 assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
1563 }
1564
1565 #[tokio::test]
1566 async fn a_plain_submit_that_succeeds_still_redirects() {
1567 let resp = post(valid(), axum::http::HeaderMap::new()).await;
1568 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1569 }
1570}