1use std::collections::HashMap;
17
18use askama::Template;
19use axum::response::{IntoResponse, Redirect, Response};
20use laterite_core::query::{bind_values, build as to_sql, text_cast};
21use laterite_core::AnyRowExt;
22use sea_query::{Alias, Expr, Query, SimpleExpr};
23
24use crate::sql::valid_ident;
25use crate::{not_found, render, render_error, AdminState};
26
27#[derive(Debug, Clone, Copy)]
28pub enum WidgetKind {
29 Text,
30 Textarea,
31}
32
33#[derive(Debug, Clone)]
35pub struct FormField {
36 pub name: String,
37 pub label: String,
38 pub widget: WidgetKind,
39 pub required: bool,
40}
41
42impl FormField {
43 pub fn text(name: &str, label: &str) -> Self {
44 Self {
45 name: name.to_string(),
46 label: label.to_string(),
47 widget: WidgetKind::Text,
48 required: false,
49 }
50 }
51
52 pub fn textarea(name: &str, label: &str) -> Self {
53 Self {
54 widget: WidgetKind::Textarea,
55 ..Self::text(name, label)
56 }
57 }
58
59 pub fn required(mut self) -> Self {
60 self.required = true;
61 self
62 }
63}
64
65#[derive(Debug, Clone)]
68pub struct FormConfig {
69 pub entity: String,
70 pub title: String,
71 pub base_path: String,
72 pub id_field: String,
73 pub fields: Vec<FormField>,
74}
75
76impl FormConfig {
77 fn idents_valid(&self) -> bool {
78 valid_ident(&self.entity)
79 && valid_ident(&self.id_field)
80 && self.fields.iter().all(|f| valid_ident(&f.name))
81 }
82
83 fn missing_required(&self, data: &HashMap<String, String>) -> Option<&FormField> {
84 self.fields.iter().find(|f| {
85 f.required
86 && data
87 .get(&f.name)
88 .map(|v| v.trim().is_empty())
89 .unwrap_or(true)
90 })
91 }
92}
93
94pub(crate) fn new_form(config: &FormConfig, shell: crate::Shell) -> Response {
96 render(build(
97 config,
98 &format!("{}/new", config.base_path),
99 None,
100 &HashMap::new(),
101 &shell,
102 ))
103}
104
105pub(crate) async fn create(
107 state: &AdminState,
108 config: &FormConfig,
109 data: HashMap<String, String>,
110 shell: crate::Shell,
111) -> Response {
112 if !config.idents_valid() {
113 return render_error();
114 }
115 if let Some(field) = config.missing_required(&data) {
116 return render(build(
117 config,
118 &format!("{}/new", config.base_path),
119 Some(format!("{} is required.", field.label)),
120 &data,
121 &shell,
122 ));
123 }
124
125 let (sql, values) = {
129 let vals: Vec<SimpleExpr> = config
130 .fields
131 .iter()
132 .map(|f| data.get(&f.name).cloned().unwrap_or_default().into())
133 .collect();
134 let stmt = Query::insert()
135 .into_table(Alias::new(&config.entity))
136 .columns(config.fields.iter().map(|f| Alias::new(&f.name)))
137 .values_panic(vals)
138 .to_owned();
139 to_sql(state.db.backend, stmt)
140 };
141 match bind_values(sqlx::query(&sql), values)
142 .execute(&state.db.pool)
143 .await
144 {
145 Ok(_) => Redirect::to(&config.base_path).into_response(),
146 Err(_) => render(build(
147 config,
148 &format!("{}/new", config.base_path),
149 Some("Could not save. Check the values and try again.".to_string()),
150 &data,
151 &shell,
152 )),
153 }
154}
155
156pub(crate) async fn edit_form(
158 state: &AdminState,
159 config: &FormConfig,
160 id: String,
161 shell: crate::Shell,
162) -> Response {
163 if !config.idents_valid() {
164 return render_error();
165 }
166 let (sql, values) = {
170 let cast = text_cast(state.db.backend);
171 let mut select = Query::select();
172 for field in &config.fields {
173 select.expr_as(
174 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
175 Alias::new(&field.name),
176 );
177 }
178 select.from(Alias::new(&config.entity)).and_where(
179 Expr::col(Alias::new(&config.id_field))
180 .cast_as(Alias::new(cast))
181 .eq(id.clone()),
182 );
183 to_sql(state.db.backend, select)
184 };
185 let row = match bind_values(sqlx::query(&sql), values)
186 .fetch_optional(&state.db.pool)
187 .await
188 {
189 Ok(row) => row,
190 Err(_) => return render_error(),
191 };
192 let Some(row) = row else {
193 return not_found();
194 };
195
196 let values = config
197 .fields
198 .iter()
199 .map(|f| {
200 let value = row
201 .get_text_opt(f.name.as_str())
202 .ok()
203 .flatten()
204 .unwrap_or_default();
205 (f.name.clone(), value)
206 })
207 .collect();
208
209 render(build(
210 config,
211 &format!("{}/{}/edit", config.base_path, id),
212 None,
213 &values,
214 &shell,
215 ))
216}
217
218pub(crate) async fn update(
220 state: &AdminState,
221 config: &FormConfig,
222 id: String,
223 data: HashMap<String, String>,
224 shell: crate::Shell,
225) -> Response {
226 if !config.idents_valid() {
227 return render_error();
228 }
229 if let Some(field) = config.missing_required(&data) {
230 return render(build(
231 config,
232 &format!("{}/{}/edit", config.base_path, id),
233 Some(format!("{} is required.", field.label)),
234 &data,
235 &shell,
236 ));
237 }
238
239 let (sql, values) = {
241 let mut update = Query::update();
242 update.table(Alias::new(&config.entity));
243 for field in &config.fields {
244 update.value(
245 Alias::new(&field.name),
246 data.get(&field.name).cloned().unwrap_or_default(),
247 );
248 }
249 update.and_where(
250 Expr::col(Alias::new(&config.id_field))
251 .cast_as(Alias::new(text_cast(state.db.backend)))
252 .eq(id.clone()),
253 );
254 to_sql(state.db.backend, update)
255 };
256 match bind_values(sqlx::query(&sql), values)
257 .execute(&state.db.pool)
258 .await
259 {
260 Ok(_) => Redirect::to(&config.base_path).into_response(),
261 Err(_) => render(build(
262 config,
263 &format!("{}/{}/edit", config.base_path, id),
264 Some("Could not save. Check the values and try again.".to_string()),
265 &data,
266 &shell,
267 )),
268 }
269}
270
271fn build(
272 config: &FormConfig,
273 action: &str,
274 error: Option<String>,
275 values: &HashMap<String, String>,
276 shell: &crate::Shell,
277) -> FormTemplate {
278 FormTemplate {
279 shell: shell.clone(),
280 title: config.title.clone(),
281 action: action.to_string(),
282 cancel_path: config.base_path.clone(),
283 error,
284 fields: config
285 .fields
286 .iter()
287 .map(|f| FieldView {
288 name: f.name.clone(),
289 label: f.label.clone(),
290 value: values.get(&f.name).cloned().unwrap_or_default(),
291 textarea: matches!(f.widget, WidgetKind::Textarea),
292 required: f.required,
293 })
294 .collect(),
295 }
296}
297
298struct FieldView {
299 name: String,
300 label: String,
301 value: String,
302 textarea: bool,
303 required: bool,
304}
305
306#[derive(Template)]
307#[template(path = "form.html")]
308struct FormTemplate {
309 shell: crate::Shell,
310 title: String,
311 action: String,
312 cancel_path: String,
313 error: Option<String>,
314 fields: Vec<FieldView>,
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use laterite_core::strata::{
321 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
322 };
323 use laterite_core::testing::{connect_test, TestGuard};
324 use laterite_core::Db;
325
326 struct CreateSamples;
329 #[async_trait(?Send)]
330 impl Migration for CreateSamples {
331 fn name(&self) -> &str {
332 "0001_create_samples"
333 }
334 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
335 s.exec(
336 Table::create()
337 .table(Alias::new("samples"))
338 .if_not_exists()
339 .col(
340 ColumnDef::new(Alias::new("id"))
341 .big_integer()
342 .not_null()
343 .auto_increment()
344 .primary_key(),
345 )
346 .col(ColumnDef::new(Alias::new("code")).text().not_null())
347 .col(ColumnDef::new(Alias::new("name")).text().not_null())
348 .to_owned(),
349 )
350 .await
351 }
352 }
353
354 fn config() -> FormConfig {
355 FormConfig {
356 entity: "samples".to_string(),
357 title: "Sample".to_string(),
358 base_path: "/admin/samples".to_string(),
359 id_field: "id".to_string(),
360 fields: vec![
361 FormField::text("code", "Code").required(),
362 FormField::text("name", "Name").required(),
363 ],
364 }
365 }
366
367 fn state(db: Db) -> AdminState {
368 AdminState::new(
369 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
370 db,
371 )
372 }
373
374 async fn test_db() -> (Db, TestGuard) {
377 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
378 connect_test(&[samples]).await
379 }
380
381 async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
384 let stmt = Query::select()
385 .expr_as(
386 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
387 Alias::new("v"),
388 )
389 .from(Alias::new("samples"))
390 .and_where(Expr::col(Alias::new("code")).eq(code))
391 .to_owned();
392 let (sql, values) = to_sql(db.backend, stmt);
393 let row = bind_values(sqlx::query(&sql), values)
394 .fetch_optional(&db.pool)
395 .await
396 .unwrap()?;
397 row.get_text_opt("v").ok().flatten()
398 }
399
400 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
401 pairs
402 .iter()
403 .map(|(k, v)| (k.to_string(), v.to_string()))
404 .collect()
405 }
406
407 #[tokio::test]
408 async fn create_then_fetch() {
409 let (db, _guard) = test_db().await;
410 let cfg = config();
411 let st = state(db.clone());
412
413 let resp = create(
414 &st,
415 &cfg,
416 data(&[("code", "editor"), ("name", "Content Editor")]),
417 crate::Shell::test(),
418 )
419 .await;
420 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
421
422 assert_eq!(
423 fetch_text(&db, "name", "editor").await.as_deref(),
424 Some("Content Editor")
425 );
426 }
427
428 #[tokio::test]
429 async fn update_changes_the_row() {
430 let (db, _guard) = test_db().await;
431 let cfg = config();
432 let st = state(db.clone());
433 create(
434 &st,
435 &cfg,
436 data(&[("code", "editor"), ("name", "Editor")]),
437 crate::Shell::test(),
438 )
439 .await;
440
441 let id = fetch_text(&db, "id", "editor")
442 .await
443 .expect("row should exist after create");
444
445 let resp = update(
446 &st,
447 &cfg,
448 id,
449 data(&[("code", "editor"), ("name", "Senior Editor")]),
450 crate::Shell::test(),
451 )
452 .await;
453 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
454
455 assert_eq!(
456 fetch_text(&db, "name", "editor").await.as_deref(),
457 Some("Senior Editor")
458 );
459 }
460
461 #[tokio::test]
462 async fn create_requires_required_fields() {
463 let (db, _guard) = test_db().await;
464 let cfg = config();
465 let st = state(db.clone());
466
467 let resp = create(
468 &st,
469 &cfg,
470 data(&[("code", ""), ("name", "No Code")]),
471 crate::Shell::test(),
472 )
473 .await;
474 assert_eq!(resp.status(), axum::http::StatusCode::OK);
476 let count: i64 = sqlx::query_scalar("select count(*) from samples")
477 .fetch_one(&db.pool)
478 .await
479 .unwrap();
480 assert_eq!(count, 0);
481 }
482}