Skip to main content

laterite_admin/
form.rs

1//! Descriptor-driven create and edit forms.
2//!
3//! A [`FormConfig`] describes a table and its editable fields. Generic handlers
4//! render an empty form (new), a populated form (edit), and persist via dynamic
5//! insert/update SQL built from the descriptor. Values are always parameterized.
6//!
7//! This first slice supports scalar text fields (text and textarea widgets),
8//! which is enough for the roles screen it is dogfooded on. Typed and
9//! transforming fields (switches, selects, password hashing) are later widgets.
10//!
11//! The primary key is a `bigint` auto-increment column the database assigns, so
12//! create inserts only the descriptor's fields and never sets the id. An entity
13//! with other required columns that lack defaults (for example audit timestamps)
14//! is beyond this slice; those are filled by a later timestamp-aware widget.
15
16use 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/// One editable field: the column, its label, its widget, and whether required.
34#[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/// A form descriptor: which table, its editable fields, the id column, and the
66/// base path the form lives under (`{base_path}/new`, `{base_path}/{id}/edit`).
67#[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
94/// Renders an empty create form.
95pub(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
105/// Persists a new record, then redirects to the list.
106pub(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    // The primary key is a database-assigned auto-increment id, so the insert
126    // lists only the descriptor's fields. The builder is scoped so it drops
127    // before the await, keeping the handler future `Send`.
128    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
156/// Renders a form populated with an existing record.
157pub(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    // Scope the sea-query builder so it is dropped before the await below: its
167    // identifiers are reference-counted (not `Send`), and a live builder across
168    // the await would make this handler's future non-`Send`.
169    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
218/// Persists an edited record, then redirects to the list.
219pub(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    // Scope the builder so it drops before the await, keeping the future `Send`.
240    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    /// A minimal table for exercising the generic insert/update path in isolation,
327    /// defined as a portable migration so the test runs on any backend.
328    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    /// A fresh test database holding a minimal `samples` table, on whichever
375    /// backend the run targets. Hold the returned guard for the test's lifetime.
376    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    /// Reads a single text column from the one row matching `code`, so a test can
382    /// assert what was persisted without depending on the read path under test.
383    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        // Re-renders the form (200), does not redirect, and inserts nothing.
475        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}