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