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 std::collections::HashMap;
12use std::sync::Arc;
13
14use askama::Template;
15use axum::response::Response;
16use chrono::DateTime;
17use chrono_tz::Tz;
18use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
19use laterite_core::{AnyRowExt, Db, Text};
20use sea_query::{Alias, Expr, Order, Query};
21use serde::{Deserialize, Serialize};
22
23use crate::field::{OverrideResolver, OverrideScope, Surface};
24use crate::html::Markup;
25use crate::sql::valid_ident;
26use crate::{render, render_error, AdminState};
27
28const ID_ALIAS: &str = "_lat_id";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31pub enum SortDir {
32    Asc,
33    Desc,
34}
35
36/// One column of a list view: the source field, its display label, and its
37/// column-type key (resolved through the column-type registry).
38#[derive(Debug, Clone, Serialize)]
39pub struct ListColumn {
40    pub field: String,
41    /// The column header, localized at render. Serde stays a plain string.
42    pub label: Text,
43    /// The column-type registry key (`text`, `date`, `boolean`, `status_pill`, ...).
44    pub column_type: String,
45}
46
47impl ListColumn {
48    pub fn new(field: &str, label: impl Into<Text>) -> Self {
49        Self {
50            field: field.to_string(),
51            label: label.into(),
52            column_type: "text".to_string(),
53        }
54    }
55
56    fn of(mut self, column_type: &str) -> Self {
57        self.column_type = column_type.to_string();
58        self
59    }
60
61    /// Render as a date and time in the display timezone.
62    pub fn datetime(self) -> Self {
63        self.of("datetime")
64    }
65    /// Render as a date in the display timezone.
66    pub fn date(self) -> Self {
67        self.of("date")
68    }
69    /// Render as a time in the display timezone.
70    pub fn time(self) -> Self {
71        self.of("time")
72    }
73    /// Render a boolean as Yes/No.
74    pub fn yes_no(self) -> Self {
75        self.of("boolean")
76    }
77    /// Render as a coloured status pill.
78    pub fn pill(self) -> Self {
79        self.of("status_pill")
80    }
81}
82
83/// The per-cell state a column type renders from: the raw (text-cast) value, the
84/// display timezone, and the locale for date month/day names.
85pub struct CellCx<'a> {
86    pub value: &'a str,
87    pub tz: Tz,
88    pub locale: chrono::Locale,
89}
90
91/// The chrono locale for month/day names, parsed from the operator's locale tag.
92/// chrono locales are language + territory (`kn_IN`, `de_DE`, `ar_EG`), so a tag with
93/// no territory (a bare `kn`) or one chrono does not know formats in neutral English;
94/// a deployment gets localized dates by carrying a territory-bearing locale tag. No
95/// language is special-cased: any locale the locale table knows resolves.
96pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
97    chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
98}
99
100/// A rendered cell's serialisable payload: raw value, display text, and any
101/// richer data (a status pill's slug), so an override presents the same data.
102#[derive(Serialize)]
103pub struct CellVm {
104    pub view_key: String,
105    pub value: String,
106    pub display: String,
107    pub data: serde_json::Value,
108}
109
110/// A column type: how a list cell renders. The list counterpart of a field type,
111/// sharing [`Markup`] and the override resolver ([`Surface::Column`]).
112pub trait ColumnType: Send + Sync + 'static {
113    fn view_key(&self) -> &'static str;
114    fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
115    fn render_default(&self, vm: &CellVm) -> Markup;
116    /// Asset-registry keys this column's cell needs (a heavy cell widget). The
117    /// page shell collects and emits them; keys must exist in the registry.
118    fn assets(&self) -> Vec<&'static str> {
119        Vec::new()
120    }
121}
122
123/// The column-type registry, keyed by [`ColumnType::view_key`].
124pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
125
126/// Renders a cell: an override if the resolver supplies one, else the default.
127pub(crate) fn render_cell(
128    ct: &dyn ColumnType,
129    resolver: &dyn OverrideResolver,
130    scope: &OverrideScope<'_>,
131    cx: &CellCx<'_>,
132) -> Markup {
133    let vm = ct.view_model(cx);
134    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
135        Some(Ok(html)) => Markup::from_override(html),
136        Some(Err(_)) | None => ct.render_default(&vm),
137    }
138}
139
140pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
141    vec![
142        Arc::new(TextColumn),
143        Arc::new(DateTimeColumn),
144        Arc::new(DateColumn),
145        Arc::new(TimeColumn),
146        Arc::new(BoolColumn),
147        Arc::new(StatusPillColumn),
148    ]
149}
150
151/// The column registry seeded with the built-in types.
152pub(crate) fn builtin_column_registry() -> ColumnRegistry {
153    builtin_column_types()
154        .into_iter()
155        .map(|c| (c.view_key().to_string(), c))
156        .collect()
157}
158
159#[derive(Template)]
160#[template(path = "cells/text.html")]
161struct CellTextTmpl<'a> {
162    display: &'a str,
163}
164
165/// The view-model for a text-like cell: raw value plus its display text.
166fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
167    CellVm {
168        view_key: view_key.to_string(),
169        value: value.to_string(),
170        display,
171        data: serde_json::Value::Null,
172    }
173}
174
175fn render_text(vm: &CellVm) -> Markup {
176    Markup::from_template(&CellTextTmpl {
177        display: &vm.display,
178    })
179    .unwrap_or_default()
180}
181
182/// Formats a stored UTC RFC3339 timestamp in `tz`, with month/day names in `locale`;
183/// an unparseable value passes through unchanged.
184fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
185    match DateTime::parse_from_rfc3339(raw) {
186        Ok(dt) => dt
187            .with_timezone(&tz)
188            .format_localized(pattern, locale)
189            .to_string(),
190        Err(_) => raw.to_string(),
191    }
192}
193
194struct TextColumn;
195impl ColumnType for TextColumn {
196    fn view_key(&self) -> &'static str {
197        "text"
198    }
199    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
200        text_cell("text", cx.value, cx.value.to_string())
201    }
202    fn render_default(&self, vm: &CellVm) -> Markup {
203        render_text(vm)
204    }
205}
206
207struct DateTimeColumn;
208impl ColumnType for DateTimeColumn {
209    fn view_key(&self) -> &'static str {
210        "datetime"
211    }
212    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
213        text_cell(
214            "datetime",
215            cx.value,
216            format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
217        )
218    }
219    fn render_default(&self, vm: &CellVm) -> Markup {
220        render_text(vm)
221    }
222}
223
224struct DateColumn;
225impl ColumnType for DateColumn {
226    fn view_key(&self) -> &'static str {
227        "date"
228    }
229    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
230        text_cell(
231            "date",
232            cx.value,
233            format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
234        )
235    }
236    fn render_default(&self, vm: &CellVm) -> Markup {
237        render_text(vm)
238    }
239}
240
241struct TimeColumn;
242impl ColumnType for TimeColumn {
243    fn view_key(&self) -> &'static str {
244        "time"
245    }
246    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
247        text_cell(
248            "time",
249            cx.value,
250            format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
251        )
252    }
253    fn render_default(&self, vm: &CellVm) -> Markup {
254        render_text(vm)
255    }
256}
257
258struct BoolColumn;
259impl ColumnType for BoolColumn {
260    fn view_key(&self) -> &'static str {
261        "boolean"
262    }
263    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
264        let display = match cx.value {
265            "1" | "true" => "Yes",
266            "0" | "false" | "" => "No",
267            other => other,
268        };
269        text_cell("boolean", cx.value, display.to_string())
270    }
271    fn render_default(&self, vm: &CellVm) -> Markup {
272        render_text(vm)
273    }
274}
275
276#[derive(Template)]
277#[template(path = "cells/status.html")]
278struct CellStatusTmpl<'a> {
279    label: &'a str,
280    slug: &'a str,
281}
282
283/// A status shown as a coloured pill: the first Markup-bearing cell.
284struct StatusPillColumn;
285impl ColumnType for StatusPillColumn {
286    fn view_key(&self) -> &'static str {
287        "status_pill"
288    }
289    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
290        CellVm {
291            view_key: "status_pill".to_string(),
292            value: cx.value.to_string(),
293            display: cx.value.to_string(),
294            data: serde_json::json!({ "slug": status_slug(cx.value) }),
295        }
296    }
297    fn render_default(&self, vm: &CellVm) -> Markup {
298        let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
299        Markup::from_template(&CellStatusTmpl {
300            label: &vm.display,
301            slug,
302        })
303        .unwrap_or_default()
304    }
305}
306
307/// A CSS-safe modifier from a status value (lowercased, non-alphanumerics to `-`).
308fn status_slug(value: &str) -> String {
309    value
310        .trim()
311        .to_lowercase()
312        .chars()
313        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
314        .collect()
315}
316
317/// A list view descriptor: which table, which columns, default ordering, page
318/// size, and (optionally) where per-row edit links point.
319#[derive(Debug, Clone, Serialize)]
320pub struct ListConfig {
321    pub entity: String,
322    /// The screen title, localized at render. Serde stays a plain string.
323    pub title: Text,
324    pub columns: Vec<ListColumn>,
325    pub order_by: String,
326    pub order_dir: SortDir,
327    pub per_page: i64,
328    pub id_field: String,
329    /// When set, rows link to `{edit_base}/{id}/edit`.
330    pub edit_base: Option<String>,
331    /// Whether to offer a "New" link to `{edit_base}/new`. A resource that only
332    /// edits existing records (no create screen) sets this false.
333    pub creatable: bool,
334}
335
336/// Query-string parameters for a list view.
337#[derive(Deserialize)]
338pub struct ListParams {
339    page: Option<i64>,
340}
341
342/// One rendered row: its id (for edit links) and its display cells.
343pub struct RowView {
344    pub id: String,
345    pub cells: Vec<String>,
346}
347
348/// Display-ready rows plus the total row count for the pager.
349pub struct ListPage {
350    pub rows: Vec<RowView>,
351    pub total: i64,
352}
353
354/// Runs the list query for a config, returning display-ready rows and the total.
355/// Built with `sea-query` and dynamic identifiers (`Alias`), and every selected
356/// column is cast to text so a value of any type reads back uniformly as a
357/// string for display, without a Postgres-specific `row_to_json`.
358pub(crate) async fn query(db: &Db, config: &ListConfig, offset: i64) -> anyhow::Result<ListPage> {
359    if !valid_ident(&config.entity)
360        || !valid_ident(&config.order_by)
361        || !valid_ident(&config.id_field)
362        || !config.columns.iter().all(|c| valid_ident(&c.field))
363    {
364        anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
365    }
366
367    let dir = match config.order_dir {
368        SortDir::Asc => Order::Asc,
369        SortDir::Desc => Order::Desc,
370    };
371    // Scope each sea-query builder so it drops before the await that follows: its
372    // identifiers are reference-counted (not `Send`), and a live builder across
373    // the await would make this future non-`Send`.
374    let (sql, values) = {
375        let mut select = Query::select();
376        for column in &config.columns {
377            select.expr_as(
378                Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
379                Alias::new(&column.field),
380            );
381        }
382        select
383            .expr_as(
384                Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
385                Alias::new(ID_ALIAS),
386            )
387            .from(Alias::new(&config.entity))
388            .order_by(Alias::new(&config.order_by), dir)
389            .limit(config.per_page.max(0) as u64)
390            .offset(offset.max(0) as u64);
391        build(db.backend, select)
392    };
393    let raw = bind_values(sqlx::query(&sql), values)
394        .fetch_all(&db.pool)
395        .await?;
396
397    let (csql, cvalues) = {
398        let count = Query::select()
399            .expr(Expr::col(Alias::new(&config.id_field)).count())
400            .from(Alias::new(&config.entity))
401            .to_owned();
402        build(db.backend, count)
403    };
404    let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
405        .fetch_one(&db.pool)
406        .await?
407        .0;
408
409    let rows = raw
410        .iter()
411        .map(|row| RowView {
412            id: get_text(row, ID_ALIAS),
413            cells: config
414                .columns
415                .iter()
416                .map(|c| get_text(row, &c.field))
417                .collect(),
418        })
419        .collect();
420    Ok(ListPage { rows, total })
421}
422
423/// Reads a text-cast column as a display string, treating null or a decode
424/// error as empty.
425fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
426    // `get_text_opt` falls back to a byte read for MySQL, where a cast-to-char of
427    // a `text` column still comes back typed as BLOB.
428    row.get_text_opt(column).ok().flatten().unwrap_or_default()
429}
430
431/// Renders a list view for the given config.
432pub(crate) async fn handle(
433    state: &AdminState,
434    config: &ListConfig,
435    params: ListParams,
436    shell: crate::Shell,
437) -> Response {
438    let page = params.page.unwrap_or(1).max(1);
439    let offset = (page - 1) * config.per_page;
440    match query(&state.db, config, offset).await {
441        Ok(result) => {
442            let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
443            let date_loc = date_locale(shell.locale());
444            let rows: Vec<RowView> = result
445                .rows
446                .into_iter()
447                .map(|row| RowView {
448                    id: row.id,
449                    cells: row
450                        .cells
451                        .iter()
452                        .zip(&config.columns)
453                        .map(|(raw, col)| {
454                            let cx = CellCx {
455                                value: raw,
456                                tz: shell.tz,
457                                locale: date_loc,
458                            };
459                            let scope = OverrideScope {
460                                surface: Surface::Column,
461                                view_key: &col.column_type,
462                                resource: Some(&config.entity),
463                                field: Some(&col.field),
464                            };
465                            match state.column_types.get(&col.column_type) {
466                                Some(ct) => {
467                                    render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
468                                        .into_string()
469                                }
470                                None => String::new(),
471                            }
472                        })
473                        .collect(),
474                })
475                .collect();
476            let keys: Vec<&str> = config
477                .columns
478                .iter()
479                .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
480                .flatten()
481                .collect();
482            let mut shell = shell;
483            shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
484            let shown = rows.len() as i64;
485            // Localize the title and column headers before the shell is moved in.
486            let title = shell.tt(&config.title);
487            let columns = config.columns.iter().map(|c| shell.tt(&c.label)).collect();
488            render(ListTemplate {
489                shell,
490                title,
491                columns,
492                rows,
493                shown,
494                page,
495                total: result.total,
496                total_pages,
497                edit_base: config.edit_base.clone(),
498                creatable: config.creatable,
499            })
500        }
501        Err(_) => render_error(),
502    }
503}
504
505#[derive(Template)]
506#[template(path = "list.html")]
507struct ListTemplate {
508    shell: crate::Shell,
509    title: String,
510    columns: Vec<String>,
511    rows: Vec<RowView>,
512    /// The count on this page (`rows.len()`), precomputed as `i64` for the footer.
513    shown: i64,
514    page: i64,
515    total: i64,
516    total_pages: i64,
517    edit_base: Option<String>,
518    creatable: bool,
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn config() -> ListConfig {
526        ListConfig {
527            entity: "backend_users".to_string(),
528            title: "Users".into(),
529            columns: vec![
530                ListColumn::new("username", "Username"),
531                ListColumn::new("is_superuser", "Superuser"),
532            ],
533            order_by: "created_at".to_string(),
534            order_dir: SortDir::Desc,
535            per_page: 25,
536            id_field: "id".to_string(),
537            edit_base: None,
538            creatable: false,
539        }
540    }
541
542    #[test]
543    fn column_types_format_their_cell_display() {
544        let ist: Tz = "Asia/Kolkata".parse().unwrap();
545        let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
546            ct.view_model(&CellCx {
547                value,
548                tz,
549                locale: chrono::Locale::en_US,
550            })
551            .display
552        };
553        // 10:00 UTC is 15:30 in Asia/Kolkata (UTC+5:30)
554        assert_eq!(
555            display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
556            "13 Aug 2026, 15:30"
557        );
558        assert_eq!(
559            display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
560            "13 Aug 2026"
561        );
562        assert_eq!(
563            display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
564            "15:30"
565        );
566        assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
567        assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
568        assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
569        // An unparseable timestamp falls through unchanged.
570        assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
571    }
572
573    #[test]
574    fn status_pill_renders_a_slugged_markup_span() {
575        let vm = StatusPillColumn.view_model(&CellCx {
576            value: "In Progress",
577            tz: Tz::UTC,
578            locale: chrono::Locale::en_US,
579        });
580        let html = StatusPillColumn.render_default(&vm).into_string();
581        assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
582        assert!(html.contains(">In Progress<"));
583    }
584
585    /// A fresh test database with the auth tables migrated in, on whichever
586    /// backend the run targets. Hold the returned guard for the test's lifetime.
587    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
588        laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
589    }
590
591    #[tokio::test]
592    async fn query_returns_display_rows() {
593        let (db, _guard) = test_db().await;
594        let hash = laterite_auth::password::hash_password("pw").unwrap();
595        laterite_auth::store::create_user(
596            &db,
597            "root",
598            "root@example.test",
599            "Ada",
600            None,
601            &hash,
602            true,
603        )
604        .await
605        .unwrap();
606
607        let result = query(&db, &config(), 0).await.unwrap();
608        assert_eq!(result.total, 1);
609        assert_eq!(result.rows.len(), 1);
610        assert_eq!(result.rows[0].cells[0], "root");
611        // Booleans store as 0/1 integers everywhere, so a cast-to-text superuser
612        // flag reads back as "1"; the display layer maps it to "Yes".
613        assert_eq!(result.rows[0].cells[1], "1");
614        assert!(!result.rows[0].id.is_empty());
615    }
616
617    #[tokio::test]
618    async fn query_rejects_bad_identifiers() {
619        let (db, _guard) = test_db().await;
620        let mut bad = config();
621        bad.entity = "backend_users; drop table backend_users".to_string();
622        assert!(query(&db, &bad, 0).await.is_err());
623    }
624}