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