1use 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#[derive(Debug, Clone, Serialize)]
39pub struct ListColumn {
40 pub field: String,
41 pub label: Text,
43 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 pub fn datetime(self) -> Self {
63 self.of("datetime")
64 }
65 pub fn date(self) -> Self {
67 self.of("date")
68 }
69 pub fn time(self) -> Self {
71 self.of("time")
72 }
73 pub fn yes_no(self) -> Self {
75 self.of("boolean")
76 }
77 pub fn pill(self) -> Self {
79 self.of("status_pill")
80 }
81}
82
83pub struct CellCx<'a> {
86 pub value: &'a str,
87 pub tz: Tz,
88 pub locale: chrono::Locale,
89}
90
91pub(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#[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
110pub 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 fn assets(&self) -> Vec<&'static str> {
119 Vec::new()
120 }
121}
122
123pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
125
126pub(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
151pub(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
165fn 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
182fn 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
283struct 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
307fn 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#[derive(Debug, Clone, Serialize)]
320pub struct ListConfig {
321 pub entity: String,
322 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 pub edit_base: Option<String>,
331 pub creatable: bool,
334}
335
336#[derive(Deserialize)]
338pub struct ListParams {
339 page: Option<i64>,
340}
341
342pub struct RowView {
344 pub id: String,
345 pub cells: Vec<String>,
346}
347
348pub struct ListPage {
350 pub rows: Vec<RowView>,
351 pub total: i64,
352}
353
354pub(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 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
423fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
426 row.get_text_opt(column).ok().flatten().unwrap_or_default()
429}
430
431pub(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 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 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 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 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 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 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}