1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ColumnKind {
34 #[default]
36 Text,
37 DateTime,
39 Date,
41 Time,
43 Bool,
45}
46
47#[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 pub fn datetime(mut self) -> Self {
67 self.kind = ColumnKind::DateTime;
68 self
69 }
70
71 pub fn date(mut self) -> Self {
73 self.kind = ColumnKind::Date;
74 self
75 }
76
77 pub fn time(mut self) -> Self {
79 self.kind = ColumnKind::Time;
80 self
81 }
82
83 pub fn yes_no(mut self) -> Self {
85 self.kind = ColumnKind::Bool;
86 self
87 }
88}
89
90#[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 pub edit_base: Option<String>,
103 pub creatable: bool,
106}
107
108#[derive(Deserialize)]
110pub struct ListParams {
111 page: Option<i64>,
112}
113
114pub struct RowView {
116 pub id: String,
117 pub cells: Vec<String>,
118}
119
120pub struct ListPage {
122 pub rows: Vec<RowView>,
123 pub total: i64,
124}
125
126pub(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 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
195fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
198 row.get_text_opt(column).ok().flatten().unwrap_or_default()
201}
202
203fn 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
231pub(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 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 assert_eq!(format_cell("n/a", ColumnKind::DateTime, Tz::UTC), "n/a");
328 }
329
330 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 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}