use std::collections::HashMap;
use std::sync::Arc;
use askama::Template;
use axum::response::Response;
use chrono::DateTime;
use chrono_tz::Tz;
use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
use laterite_core::{AnyRowExt, Db, Text};
use sea_query::{Alias, Expr, Order, Query};
use serde::{Deserialize, Serialize};
use crate::field::{OverrideResolver, OverrideScope, Surface};
use crate::html::Markup;
use crate::sql::valid_ident;
use crate::{render, render_error, AdminState};
const ID_ALIAS: &str = "_lat_id";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum SortDir {
Asc,
Desc,
}
#[derive(Debug, Clone, Serialize)]
pub struct ListColumn {
pub field: String,
pub label: Text,
pub column_type: String,
}
impl ListColumn {
pub fn new(field: &str, label: impl Into<Text>) -> Self {
Self {
field: field.to_string(),
label: label.into(),
column_type: "text".to_string(),
}
}
fn of(mut self, column_type: &str) -> Self {
self.column_type = column_type.to_string();
self
}
pub fn datetime(self) -> Self {
self.of("datetime")
}
pub fn date(self) -> Self {
self.of("date")
}
pub fn time(self) -> Self {
self.of("time")
}
pub fn yes_no(self) -> Self {
self.of("boolean")
}
pub fn pill(self) -> Self {
self.of("status_pill")
}
}
pub struct CellCx<'a> {
pub value: &'a str,
pub tz: Tz,
pub locale: chrono::Locale,
}
pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
}
#[derive(Serialize)]
pub struct CellVm {
pub view_key: String,
pub value: String,
pub display: String,
pub data: serde_json::Value,
}
pub trait ColumnType: Send + Sync + 'static {
fn view_key(&self) -> &'static str;
fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
fn render_default(&self, vm: &CellVm) -> Markup;
fn assets(&self) -> Vec<&'static str> {
Vec::new()
}
}
pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
pub(crate) fn render_cell(
ct: &dyn ColumnType,
resolver: &dyn OverrideResolver,
scope: &OverrideScope<'_>,
cx: &CellCx<'_>,
) -> Markup {
let vm = ct.view_model(cx);
match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
Some(Ok(html)) => Markup::from_override(html),
Some(Err(_)) | None => ct.render_default(&vm),
}
}
pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
vec![
Arc::new(TextColumn),
Arc::new(DateTimeColumn),
Arc::new(DateColumn),
Arc::new(TimeColumn),
Arc::new(BoolColumn),
Arc::new(StatusPillColumn),
]
}
pub(crate) fn builtin_column_registry() -> ColumnRegistry {
builtin_column_types()
.into_iter()
.map(|c| (c.view_key().to_string(), c))
.collect()
}
#[derive(Template)]
#[template(path = "cells/text.html")]
struct CellTextTmpl<'a> {
display: &'a str,
}
fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
CellVm {
view_key: view_key.to_string(),
value: value.to_string(),
display,
data: serde_json::Value::Null,
}
}
fn render_text(vm: &CellVm) -> Markup {
Markup::from_template(&CellTextTmpl {
display: &vm.display,
})
.unwrap_or_default()
}
fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
match DateTime::parse_from_rfc3339(raw) {
Ok(dt) => dt
.with_timezone(&tz)
.format_localized(pattern, locale)
.to_string(),
Err(_) => raw.to_string(),
}
}
struct TextColumn;
impl ColumnType for TextColumn {
fn view_key(&self) -> &'static str {
"text"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
text_cell("text", cx.value, cx.value.to_string())
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
struct DateTimeColumn;
impl ColumnType for DateTimeColumn {
fn view_key(&self) -> &'static str {
"datetime"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
text_cell(
"datetime",
cx.value,
format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
)
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
struct DateColumn;
impl ColumnType for DateColumn {
fn view_key(&self) -> &'static str {
"date"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
text_cell(
"date",
cx.value,
format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
)
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
struct TimeColumn;
impl ColumnType for TimeColumn {
fn view_key(&self) -> &'static str {
"time"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
text_cell(
"time",
cx.value,
format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
)
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
struct BoolColumn;
impl ColumnType for BoolColumn {
fn view_key(&self) -> &'static str {
"boolean"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
let display = match cx.value {
"1" | "true" => "Yes",
"0" | "false" | "" => "No",
other => other,
};
text_cell("boolean", cx.value, display.to_string())
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
#[derive(Template)]
#[template(path = "cells/status.html")]
struct CellStatusTmpl<'a> {
label: &'a str,
slug: &'a str,
}
struct StatusPillColumn;
impl ColumnType for StatusPillColumn {
fn view_key(&self) -> &'static str {
"status_pill"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
CellVm {
view_key: "status_pill".to_string(),
value: cx.value.to_string(),
display: cx.value.to_string(),
data: serde_json::json!({ "slug": status_slug(cx.value) }),
}
}
fn render_default(&self, vm: &CellVm) -> Markup {
let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
Markup::from_template(&CellStatusTmpl {
label: &vm.display,
slug,
})
.unwrap_or_default()
}
}
fn status_slug(value: &str) -> String {
value
.trim()
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct ListConfig {
pub entity: String,
pub title: Text,
pub columns: Vec<ListColumn>,
pub order_by: String,
pub order_dir: SortDir,
pub per_page: i64,
pub id_field: String,
pub edit_base: Option<String>,
pub creatable: bool,
}
#[derive(Deserialize)]
pub struct ListParams {
page: Option<i64>,
}
pub struct RowView {
pub id: String,
pub cells: Vec<String>,
}
pub struct ListPage {
pub rows: Vec<RowView>,
pub total: i64,
}
pub(crate) async fn query(db: &Db, config: &ListConfig, offset: i64) -> anyhow::Result<ListPage> {
if !valid_ident(&config.entity)
|| !valid_ident(&config.order_by)
|| !valid_ident(&config.id_field)
|| !config.columns.iter().all(|c| valid_ident(&c.field))
{
anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
}
let dir = match config.order_dir {
SortDir::Asc => Order::Asc,
SortDir::Desc => Order::Desc,
};
let (sql, values) = {
let mut select = Query::select();
for column in &config.columns {
select.expr_as(
Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
Alias::new(&column.field),
);
}
select
.expr_as(
Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
Alias::new(ID_ALIAS),
)
.from(Alias::new(&config.entity))
.order_by(Alias::new(&config.order_by), dir)
.limit(config.per_page.max(0) as u64)
.offset(offset.max(0) as u64);
build(db.backend, select)
};
let raw = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
let (csql, cvalues) = {
let count = Query::select()
.expr(Expr::col(Alias::new(&config.id_field)).count())
.from(Alias::new(&config.entity))
.to_owned();
build(db.backend, count)
};
let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
.fetch_one(&db.pool)
.await?
.0;
let rows = raw
.iter()
.map(|row| RowView {
id: get_text(row, ID_ALIAS),
cells: config
.columns
.iter()
.map(|c| get_text(row, &c.field))
.collect(),
})
.collect();
Ok(ListPage { rows, total })
}
fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
row.get_text_opt(column).ok().flatten().unwrap_or_default()
}
pub(crate) async fn handle(
state: &AdminState,
config: &ListConfig,
params: ListParams,
shell: crate::Shell,
) -> Response {
let page = params.page.unwrap_or(1).max(1);
let offset = (page - 1) * config.per_page;
match query(&state.db, config, offset).await {
Ok(result) => {
let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
let date_loc = date_locale(shell.locale());
let rows: Vec<RowView> = result
.rows
.into_iter()
.map(|row| RowView {
id: row.id,
cells: row
.cells
.iter()
.zip(&config.columns)
.map(|(raw, col)| {
let cx = CellCx {
value: raw,
tz: shell.tz,
locale: date_loc,
};
let scope = OverrideScope {
surface: Surface::Column,
view_key: &col.column_type,
resource: Some(&config.entity),
field: Some(&col.field),
};
match state.column_types.get(&col.column_type) {
Some(ct) => {
render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
.into_string()
}
None => String::new(),
}
})
.collect(),
})
.collect();
let keys: Vec<&str> = config
.columns
.iter()
.filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
.flatten()
.collect();
let mut shell = shell;
shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
let shown = rows.len() as i64;
let title = shell.tt(&config.title);
let columns = config.columns.iter().map(|c| shell.tt(&c.label)).collect();
render(ListTemplate {
shell,
title,
columns,
rows,
shown,
page,
total: result.total,
total_pages,
edit_base: config.edit_base.clone(),
creatable: config.creatable,
})
}
Err(_) => render_error(),
}
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate {
shell: crate::Shell,
title: String,
columns: Vec<String>,
rows: Vec<RowView>,
shown: i64,
page: i64,
total: i64,
total_pages: i64,
edit_base: Option<String>,
creatable: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> ListConfig {
ListConfig {
entity: "backend_users".to_string(),
title: "Users".into(),
columns: vec![
ListColumn::new("username", "Username"),
ListColumn::new("is_superuser", "Superuser"),
],
order_by: "created_at".to_string(),
order_dir: SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: None,
creatable: false,
}
}
#[test]
fn column_types_format_their_cell_display() {
let ist: Tz = "Asia/Kolkata".parse().unwrap();
let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
ct.view_model(&CellCx {
value,
tz,
locale: chrono::Locale::en_US,
})
.display
};
assert_eq!(
display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
"13 Aug 2026, 15:30"
);
assert_eq!(
display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
"13 Aug 2026"
);
assert_eq!(
display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
"15:30"
);
assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
}
#[test]
fn status_pill_renders_a_slugged_markup_span() {
let vm = StatusPillColumn.view_model(&CellCx {
value: "In Progress",
tz: Tz::UTC,
locale: chrono::Locale::en_US,
});
let html = StatusPillColumn.render_default(&vm).into_string();
assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
assert!(html.contains(">In Progress<"));
}
async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
}
#[tokio::test]
async fn query_returns_display_rows() {
let (db, _guard) = test_db().await;
let hash = laterite_auth::password::hash_password("pw").unwrap();
laterite_auth::store::create_user(
&db,
"root",
"root@example.test",
"Ada",
None,
&hash,
true,
)
.await
.unwrap();
let result = query(&db, &config(), 0).await.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0].cells[0], "root");
assert_eq!(result.rows[0].cells[1], "1");
assert!(!result.rows[0].id.is_empty());
}
#[tokio::test]
async fn query_rejects_bad_identifiers() {
let (db, _guard) = test_db().await;
let mut bad = config();
bad.entity = "backend_users; drop table backend_users".to_string();
assert!(query(&db, &bad, 0).await.is_err());
}
}