Skip to main content

laterite_admin/
list.rs

1//! Descriptor-driven list views.
2//!
3//! A [`ListConfig`] describes a table and the columns to show. A generic handler
4//! renders it, fetching rows with dynamic SQL built from the descriptor. This is
5//! the first slice of the descriptor system: admin screens are data, rendered by
6//! generic code, not hand-written per entity.
7//!
8//! The admin is inherently generic, so unlike the typed, compile-time-checked
9//! queries in `laterite-auth`, list queries are built and checked at runtime.
10
11use askama::Template;
12use axum::response::Response;
13use chrono::DateTime;
14use chrono_tz::Tz;
15use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
16use laterite_core::{AnyRowExt, Db};
17use sea_query::{Alias, Expr, Order, Query};
18use serde::Deserialize;
19
20use crate::sql::valid_ident;
21use crate::{render, render_error, AdminState};
22
23const ID_ALIAS: &str = "_lat_id";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SortDir {
27    Asc,
28    Desc,
29}
30
31/// How a list column's raw value is rendered.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ColumnKind {
34    /// A plain string (the default).
35    #[default]
36    Text,
37    /// A UTC timestamp shown as date and time in the display timezone.
38    DateTime,
39    /// A UTC timestamp shown as a date in the display timezone.
40    Date,
41    /// A UTC timestamp shown as a time in the display timezone.
42    Time,
43    /// A boolean shown as Yes/No.
44    Bool,
45}
46
47/// One column of a list view: the source field, its display label, and how the
48/// value is rendered.
49#[derive(Debug, Clone)]
50pub struct ListColumn {
51    pub field: String,
52    pub label: String,
53    pub kind: ColumnKind,
54}
55
56impl ListColumn {
57    pub fn new(field: &str, label: &str) -> Self {
58        Self {
59            field: field.to_string(),
60            label: label.to_string(),
61            kind: ColumnKind::Text,
62        }
63    }
64
65    /// Render this column as a date and time in the display timezone.
66    pub fn datetime(mut self) -> Self {
67        self.kind = ColumnKind::DateTime;
68        self
69    }
70
71    /// Render this column as a date in the display timezone.
72    pub fn date(mut self) -> Self {
73        self.kind = ColumnKind::Date;
74        self
75    }
76
77    /// Render this column as a time in the display timezone.
78    pub fn time(mut self) -> Self {
79        self.kind = ColumnKind::Time;
80        self
81    }
82
83    /// Render this boolean column as Yes/No.
84    pub fn yes_no(mut self) -> Self {
85        self.kind = ColumnKind::Bool;
86        self
87    }
88}
89
90/// A list view descriptor: which table, which columns, default ordering, page
91/// size, and (optionally) where per-row edit links point.
92#[derive(Debug, Clone)]
93pub struct ListConfig {
94    pub entity: String,
95    pub title: String,
96    pub columns: Vec<ListColumn>,
97    pub order_by: String,
98    pub order_dir: SortDir,
99    pub per_page: i64,
100    pub id_field: String,
101    /// When set, rows link to `{edit_base}/{id}/edit`.
102    pub edit_base: Option<String>,
103    /// Whether to offer a "New" link to `{edit_base}/new`. A resource that only
104    /// edits existing records (no create screen) sets this false.
105    pub creatable: bool,
106}
107
108/// Query-string parameters for a list view.
109#[derive(Deserialize)]
110pub struct ListParams {
111    page: Option<i64>,
112}
113
114/// One rendered row: its id (for edit links) and its display cells.
115pub struct RowView {
116    pub id: String,
117    pub cells: Vec<String>,
118}
119
120/// Display-ready rows plus the total row count for the pager.
121pub struct ListPage {
122    pub rows: Vec<RowView>,
123    pub total: i64,
124}
125
126/// Runs the list query for a config, returning display-ready rows and the total.
127/// Built with `sea-query` and dynamic identifiers (`Alias`), and every selected
128/// column is cast to text so a value of any type reads back uniformly as a
129/// string for display, without a Postgres-specific `row_to_json`.
130pub(crate) async fn query(db: &Db, config: &ListConfig, offset: i64) -> anyhow::Result<ListPage> {
131    if !valid_ident(&config.entity)
132        || !valid_ident(&config.order_by)
133        || !valid_ident(&config.id_field)
134        || !config.columns.iter().all(|c| valid_ident(&c.field))
135    {
136        anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
137    }
138
139    let dir = match config.order_dir {
140        SortDir::Asc => Order::Asc,
141        SortDir::Desc => Order::Desc,
142    };
143    // Scope each sea-query builder so it drops before the await that follows: its
144    // identifiers are reference-counted (not `Send`), and a live builder across
145    // the await would make this future non-`Send`.
146    let (sql, values) = {
147        let mut select = Query::select();
148        for column in &config.columns {
149            select.expr_as(
150                Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
151                Alias::new(&column.field),
152            );
153        }
154        select
155            .expr_as(
156                Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
157                Alias::new(ID_ALIAS),
158            )
159            .from(Alias::new(&config.entity))
160            .order_by(Alias::new(&config.order_by), dir)
161            .limit(config.per_page.max(0) as u64)
162            .offset(offset.max(0) as u64);
163        build(db.backend, select)
164    };
165    let raw = bind_values(sqlx::query(&sql), values)
166        .fetch_all(&db.pool)
167        .await?;
168
169    let (csql, cvalues) = {
170        let count = Query::select()
171            .expr(Expr::col(Alias::new(&config.id_field)).count())
172            .from(Alias::new(&config.entity))
173            .to_owned();
174        build(db.backend, count)
175    };
176    let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
177        .fetch_one(&db.pool)
178        .await?
179        .0;
180
181    let rows = raw
182        .iter()
183        .map(|row| RowView {
184            id: get_text(row, ID_ALIAS),
185            cells: config
186                .columns
187                .iter()
188                .map(|c| get_text(row, &c.field))
189                .collect(),
190        })
191        .collect();
192    Ok(ListPage { rows, total })
193}
194
195/// Reads a text-cast column as a display string, treating null or a decode
196/// error as empty.
197fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
198    // `get_text_opt` falls back to a byte read for MySQL, where a cast-to-char of
199    // a `text` column still comes back typed as BLOB.
200    row.get_text_opt(column).ok().flatten().unwrap_or_default()
201}
202
203/// Formats a raw cell value for display according to its column kind. Timestamps
204/// are stored UTC; date/time kinds convert to `tz` and format human-readably.
205/// Unparseable values fall through unchanged.
206fn format_cell(raw: &str, kind: ColumnKind, tz: Tz) -> String {
207    match kind {
208        ColumnKind::Text => raw.to_string(),
209        ColumnKind::Bool => match raw {
210            "1" | "true" => "Yes".to_string(),
211            "0" | "false" | "" => "No".to_string(),
212            other => other.to_string(),
213        },
214        ColumnKind::DateTime | ColumnKind::Date | ColumnKind::Time => {
215            match DateTime::parse_from_rfc3339(raw) {
216                Ok(dt) => {
217                    let local = dt.with_timezone(&tz);
218                    let pattern = match kind {
219                        ColumnKind::Date => "%-d %b %Y",
220                        ColumnKind::Time => "%H:%M",
221                        _ => "%-d %b %Y, %H:%M",
222                    };
223                    local.format(pattern).to_string()
224                }
225                Err(_) => raw.to_string(),
226            }
227        }
228    }
229}
230
231/// Renders a list view for the given config.
232pub(crate) async fn handle(
233    state: &AdminState,
234    config: &ListConfig,
235    params: ListParams,
236    shell: crate::Shell,
237) -> Response {
238    let page = params.page.unwrap_or(1).max(1);
239    let offset = (page - 1) * config.per_page;
240    match query(&state.db, config, offset).await {
241        Ok(result) => {
242            let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
243            let rows = result
244                .rows
245                .into_iter()
246                .map(|row| RowView {
247                    id: row.id,
248                    cells: row
249                        .cells
250                        .iter()
251                        .zip(&config.columns)
252                        .map(|(raw, col)| format_cell(raw, col.kind, shell.tz))
253                        .collect(),
254                })
255                .collect();
256            render(ListTemplate {
257                shell,
258                title: config.title.clone(),
259                columns: config.columns.iter().map(|c| c.label.clone()).collect(),
260                rows,
261                page,
262                total: result.total,
263                total_pages,
264                edit_base: config.edit_base.clone(),
265                creatable: config.creatable,
266            })
267        }
268        Err(_) => render_error(),
269    }
270}
271
272#[derive(Template)]
273#[template(path = "list.html")]
274struct ListTemplate {
275    shell: crate::Shell,
276    title: String,
277    columns: Vec<String>,
278    rows: Vec<RowView>,
279    page: i64,
280    total: i64,
281    total_pages: i64,
282    edit_base: Option<String>,
283    creatable: bool,
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn config() -> ListConfig {
291        ListConfig {
292            entity: "backend_users".to_string(),
293            title: "Users".to_string(),
294            columns: vec![
295                ListColumn::new("username", "Username"),
296                ListColumn::new("is_superuser", "Superuser"),
297            ],
298            order_by: "created_at".to_string(),
299            order_dir: SortDir::Desc,
300            per_page: 25,
301            id_field: "id".to_string(),
302            edit_base: None,
303            creatable: false,
304        }
305    }
306
307    #[test]
308    fn formats_cells_by_kind() {
309        let ist: Tz = "Asia/Kolkata".parse().unwrap();
310        // 10:00 UTC is 15:30 in Asia/Kolkata (UTC+5:30)
311        assert_eq!(
312            format_cell("2026-08-13T10:00:00+00:00", ColumnKind::DateTime, ist),
313            "13 Aug 2026, 15:30"
314        );
315        assert_eq!(
316            format_cell("2026-08-13T10:00:00+00:00", ColumnKind::Date, Tz::UTC),
317            "13 Aug 2026"
318        );
319        assert_eq!(
320            format_cell("2026-08-13T10:00:00+00:00", ColumnKind::Time, ist),
321            "15:30"
322        );
323        assert_eq!(format_cell("true", ColumnKind::Bool, Tz::UTC), "Yes");
324        assert_eq!(format_cell("false", ColumnKind::Bool, Tz::UTC), "No");
325        assert_eq!(format_cell("root", ColumnKind::Text, Tz::UTC), "root");
326        // unparseable timestamp falls through unchanged
327        assert_eq!(format_cell("n/a", ColumnKind::DateTime, Tz::UTC), "n/a");
328    }
329
330    /// A fresh test database with the auth tables migrated in, on whichever
331    /// backend the run targets. Hold the returned guard for the test's lifetime.
332    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
333        laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
334    }
335
336    #[tokio::test]
337    async fn query_returns_display_rows() {
338        let (db, _guard) = test_db().await;
339        let hash = laterite_auth::password::hash_password("pw").unwrap();
340        laterite_auth::store::create_user(
341            &db,
342            "root",
343            "root@example.test",
344            "Ada",
345            None,
346            &hash,
347            true,
348        )
349        .await
350        .unwrap();
351
352        let result = query(&db, &config(), 0).await.unwrap();
353        assert_eq!(result.total, 1);
354        assert_eq!(result.rows.len(), 1);
355        assert_eq!(result.rows[0].cells[0], "root");
356        // Booleans store as 0/1 integers everywhere, so a cast-to-text superuser
357        // flag reads back as "1"; the display layer maps it to "Yes".
358        assert_eq!(result.rows[0].cells[1], "1");
359        assert!(!result.rows[0].id.is_empty());
360    }
361
362    #[tokio::test]
363    async fn query_rejects_bad_identifiers() {
364        let (db, _guard) = test_db().await;
365        let mut bad = config();
366        bad.entity = "backend_users; drop table backend_users".to_string();
367        assert!(query(&db, &bad, 0).await.is_err());
368    }
369}