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::search::SearchProfile;
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,
pub searchable: Option<bool>,
}
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(),
searchable: None,
}
}
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 fn searchable(mut self, searchable: bool) -> Self {
self.searchable = Some(searchable);
self
}
pub(crate) fn is_searchable(&self) -> bool {
self.searchable.unwrap_or(self.column_type == "text")
}
}
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 struct ColumnTypeReg {
pub column_type: Arc<dyn ColumnType>,
}
impl ColumnTypeReg {
pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
Self { column_type }
}
pub fn name(&self) -> &'static str {
self.column_type.view_key()
}
}
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 FilterOption {
pub value: String,
pub label: Text,
}
impl FilterOption {
pub fn new(value: &str, label: impl Into<Text>) -> Self {
Self {
value: value.to_string(),
label: label.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum FilterKind {
Boolean,
Select { options: Vec<FilterOption> },
}
#[derive(Debug, Clone, Serialize)]
pub struct ListFilter {
pub field: String,
pub label: Text,
pub kind: FilterKind,
}
impl ListFilter {
pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
Self {
field: field.to_string(),
label: label.into(),
kind: FilterKind::Boolean,
}
}
pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
Self {
field: field.to_string(),
label: label.into(),
kind: FilterKind::Select { options },
}
}
pub(crate) fn param(&self) -> String {
format!("f_{}", self.field)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolbarButton {
pub label: Text,
pub href: String,
pub icon: Option<String>,
pub permission: Option<String>,
pub primary: bool,
}
impl ToolbarButton {
pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
Self {
label: label.into(),
href: href.into(),
icon: None,
permission: None,
primary: false,
}
}
pub fn icon(mut self, icon: &str) -> Self {
self.icon = Some(icon.to_string());
self
}
pub fn require(mut self, permission: impl Into<String>) -> Self {
self.permission = Some(permission.into());
self
}
pub fn primary(mut self) -> Self {
self.primary = true;
self
}
}
pub struct ToolbarView {
pub label: String,
pub href: String,
pub icon: String,
pub primary: bool,
}
fn toolbar_views(
config: &ListConfig,
admin_path: &str,
user: &laterite_auth::AuthenticatedUser,
shell: &crate::Shell,
) -> Vec<ToolbarView> {
let mut out = Vec::new();
if config.creatable {
if let Some(base) = &config.edit_base {
out.push(ToolbarView {
label: shell.tt(&laterite_core::t!("New")),
href: format!("{admin_path}{base}/new"),
icon: String::new(),
primary: true,
});
}
}
for button in &config.toolbar {
if let Some(needed) = &button.permission {
if !user.permissions.allows(needed) {
continue;
}
}
let href = if button.href.starts_with('/') {
format!("{admin_path}{}", button.href)
} else {
button.href.clone()
};
out.push(ToolbarView {
label: shell.tt(&button.label),
href,
icon: crate::icons::svg(button.icon.as_deref()).to_string(),
primary: button.primary,
});
}
out
}
pub(crate) fn columns_preference_key(base_path: &str) -> String {
format!("list.columns.{base_path}")
}
pub(crate) async fn stored_columns(
state: &AdminState,
path: &str,
user: &laterite_auth::AuthenticatedUser,
) -> Option<String> {
match laterite_auth::store::user_preference(
&state.db,
user.user.id,
&columns_preference_key(path),
)
.await
{
Ok(stored) => stored,
Err(e) => {
tracing::warn!(error = %e, "reading the column preference failed");
None
}
}
}
pub(crate) fn narrowed(config: &ListConfig, stored: Option<&str>) -> ListConfig {
ListConfig {
columns: visible_columns(config, stored)
.into_iter()
.cloned()
.collect(),
..config.clone()
}
}
pub(crate) fn visible_columns<'a>(
config: &'a ListConfig,
stored: Option<&str>,
) -> Vec<&'a ListColumn> {
let Some(stored) = stored else {
return config.columns.iter().collect();
};
let chosen: Vec<&str> = stored
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let kept: Vec<&ListColumn> = config
.columns
.iter()
.filter(|c| chosen.iter().any(|n| *n == c.field))
.collect();
if kept.is_empty() {
return config.columns.iter().collect();
}
kept
}
#[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,
pub filters: Vec<ListFilter>,
pub deletable: bool,
pub toolbar: Vec<ToolbarButton>,
}
impl Default for ListConfig {
fn default() -> Self {
Self {
entity: String::new(),
title: Text::new(""),
columns: Vec::new(),
order_by: "id".to_string(),
order_dir: SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: None,
creatable: false,
filters: Vec::new(),
deletable: false,
toolbar: Vec::new(),
}
}
}
#[derive(Deserialize)]
pub struct ListParams {
page: Option<i64>,
sort: Option<String>,
dir: Option<String>,
q: Option<String>,
}
pub(crate) fn resolve_sort(
config: &ListConfig,
sort: Option<&str>,
dir: Option<&str>,
) -> (String, SortDir) {
let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s)) {
Some(field) => field.to_string(),
None => config.order_by.clone(),
};
let dir = match dir {
Some("asc") => SortDir::Asc,
Some("desc") => SortDir::Desc,
_ if by == config.order_by => config.order_dir,
_ => SortDir::Asc,
};
(by, dir)
}
pub struct RowView {
pub id: String,
pub cells: Vec<String>,
}
pub struct ListPage {
pub rows: Vec<RowView>,
pub total: i64,
}
pub(crate) struct ListQuery<'a> {
pub offset: i64,
pub order_by: &'a str,
pub order_dir: SortDir,
pub q: &'a str,
pub filters: &'a [ActiveFilter<'a>],
pub per_page: Option<i64>,
}
pub(crate) struct ActiveFilter<'a> {
filter: &'a ListFilter,
raw: String,
value: sea_query::Value,
}
pub(crate) fn resolve_filters<'a>(
config: &'a ListConfig,
params: &HashMap<String, String>,
) -> Vec<ActiveFilter<'a>> {
let mut out = Vec::new();
for filter in &config.filters {
let Some(raw) = params.get(&filter.param()).map(|v| v.trim()) else {
continue;
};
if raw.is_empty() {
continue;
}
let value = match &filter.kind {
FilterKind::Boolean => match raw {
"1" => sea_query::Value::Bool(Some(true)),
"0" => sea_query::Value::Bool(Some(false)),
_ => continue,
},
FilterKind::Select { options } => {
if !options.iter().any(|o| o.value == raw) {
continue;
}
sea_query::Value::String(Some(Box::new(raw.to_string())))
}
};
out.push(ActiveFilter {
filter,
raw: raw.to_string(),
value,
});
}
out
}
fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
if active.is_empty() {
return None;
}
let mut all = sea_query::Condition::all();
for f in active {
all = all.add(Expr::col(Alias::new(&f.filter.field)).eq(f.value.clone()));
}
Some(all)
}
fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
let caps = laterite_core::capabilities::CapabilitySet::default();
let profile = SearchProfile::new();
let mut any = sea_query::Condition::any();
let mut matched = false;
for column in config.columns.iter().filter(|c| c.is_searchable()) {
if let Some(cond) = profile.condition(&caps, &column.field, q) {
any = any.add(cond);
matched = true;
}
}
matched.then_some(any)
}
pub(crate) async fn query(
db: &Db,
config: &ListConfig,
req: &ListQuery<'_>,
) -> anyhow::Result<ListPage> {
if !valid_ident(&config.entity)
|| !valid_ident(&config.order_by)
|| !valid_ident(req.order_by)
|| !valid_ident(&config.id_field)
|| !config.columns.iter().all(|c| valid_ident(&c.field))
|| !config.filters.iter().all(|f| valid_ident(&f.field))
{
anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
}
let dir = match req.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(req.order_by), dir)
.limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
.offset(req.offset.max(0) as u64);
if let Some(cond) = search_condition(config, req.q) {
select.cond_where(cond);
}
if let Some(cond) = filter_condition(req.filters) {
select.cond_where(cond);
}
build(db.backend, select)
};
let raw = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
let (csql, cvalues) = {
let mut count = Query::select();
count
.expr(Expr::col(Alias::new(&config.id_field)).count())
.from(Alias::new(&config.entity));
if let Some(cond) = search_condition(config, req.q) {
count.cond_where(cond);
}
if let Some(cond) = filter_condition(req.filters) {
count.cond_where(cond);
}
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()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn handle(
state: &AdminState,
config: &ListConfig,
path: &str,
params: ListParams,
raw: &HashMap<String, String>,
user: &laterite_auth::AuthenticatedUser,
shell: crate::Shell,
headers: &axum::http::HeaderMap,
) -> Response {
let stored = stored_columns(state, path, user).await;
let declared = config;
let narrowed = narrowed(config, stored.as_deref());
let config = &narrowed;
let page = params.page.unwrap_or(1).max(1);
let offset = (page - 1) * config.per_page;
let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
let q = params.q.unwrap_or_default();
let active = resolve_filters(config, raw);
let req = ListQuery {
offset,
order_by: &order_by,
order_dir,
q: q.trim(),
filters: &active,
per_page: None,
};
let mut carry = String::new();
if !q.trim().is_empty() {
carry.push_str(&format!("&q={}", urlencode(q.trim())));
}
for f in &active {
carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
}
match query(&state.db, config, &req).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 active_dir = match order_dir {
SortDir::Asc => "asc",
SortDir::Desc => "desc",
};
let columns: Vec<ColumnHead> = config
.columns
.iter()
.map(|c| {
let active = c.field == order_by;
ColumnHead {
label: shell.tt(&c.label),
field: c.field.clone(),
next_dir: if active && active_dir == "asc" {
"desc".to_string()
} else {
"asc".to_string()
},
active: if active {
active_dir.to_string()
} else {
String::new()
},
}
})
.collect();
let shell_for_toolbar = shell.clone();
let filter_views = filter_views(config, &active, &shell);
let pickers: Vec<ColumnChoice> = declared
.columns
.iter()
.map(|c| ColumnChoice {
field: c.field.clone(),
label: shell.tt(&c.label),
shown: config.columns.iter().any(|v| v.field == c.field),
})
.collect();
let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
let page_view = ListTemplate {
shell,
title,
columns,
rows,
shown,
page,
total: result.total,
total_pages,
edit_base: config.edit_base.clone(),
creatable: config.creatable,
sort: order_by,
dir: active_dir.to_string(),
q: q.trim().to_string(),
searchable: config.columns.iter().any(|c| c.is_searchable()),
pickers,
toolbar,
export_query,
path: path.to_string(),
filters: filter_views,
deletable: config.deletable,
filtered: !active.is_empty() || !q.trim().is_empty(),
carry: carry.clone(),
};
if crate::form::is_htmx(headers) {
render(ListFragment {
shell: page_view.shell,
columns: page_view.columns,
rows: page_view.rows,
shown: page_view.shown,
page: page_view.page,
total: page_view.total,
total_pages: page_view.total_pages,
edit_base: page_view.edit_base,
sort: page_view.sort,
dir: page_view.dir,
carry: page_view.carry,
filtered: page_view.filtered,
creatable: page_view.creatable,
deletable: page_view.deletable,
})
} else {
render(page_view)
}
}
Err(_) => render_error(),
}
}
pub(crate) async fn set_columns(
state: &AdminState,
config: &ListConfig,
path: &str,
user: &laterite_auth::AuthenticatedUser,
session: &crate::session::SessionHandle,
headers: &axum::http::HeaderMap,
pairs: &[(String, String)],
) -> Response {
let chosen: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "column")
.map(|(_, v)| v.trim())
.filter(|v| config.columns.iter().any(|c| c.field == *v))
.collect();
let key = columns_preference_key(path);
let result = if chosen.is_empty() || chosen.len() == config.columns.len() {
laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
} else {
laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
.await
};
match result {
Ok(()) => session.push_flash(
crate::session::FlashLevel::Success,
laterite_core::t!("Columns updated."),
),
Err(e) => {
tracing::error!(error = %e, "storing the column choice failed");
session.push_flash(
crate::session::FlashLevel::Error,
laterite_core::t!("Could not save your column choice."),
);
}
}
let back = format!("{}{}", state.admin_path, path);
crate::form::saved_response(crate::form::is_htmx(headers), &back)
}
pub struct ColumnChoice {
pub field: String,
pub label: String,
pub shown: bool,
}
pub struct ColumnHead {
pub label: String,
pub field: String,
pub next_dir: String,
pub active: String,
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate {
shell: crate::Shell,
title: String,
columns: Vec<ColumnHead>,
rows: Vec<RowView>,
shown: i64,
page: i64,
total: i64,
total_pages: i64,
edit_base: Option<String>,
creatable: bool,
sort: String,
dir: String,
q: String,
searchable: bool,
deletable: bool,
pickers: Vec<ColumnChoice>,
toolbar: Vec<ToolbarView>,
export_query: String,
path: String,
filters: Vec<FilterView>,
filtered: bool,
carry: String,
}
pub struct FilterView {
pub param: String,
pub label: String,
pub boolean: bool,
pub options: Vec<FilterOptionView>,
pub value: String,
}
pub struct FilterOptionView {
pub value: String,
pub label: String,
}
fn filter_views(
config: &ListConfig,
active: &[ActiveFilter<'_>],
shell: &crate::Shell,
) -> Vec<FilterView> {
config
.filters
.iter()
.map(|f| {
let value = active
.iter()
.find(|a| a.filter.field == f.field)
.map(|a| a.raw.clone())
.unwrap_or_default();
let (boolean, options) = match &f.kind {
FilterKind::Boolean => (
true,
vec![
FilterOptionView {
value: "1".to_string(),
label: shell.tt(&laterite_core::t!("Yes")),
},
FilterOptionView {
value: "0".to_string(),
label: shell.tt(&laterite_core::t!("No")),
},
],
),
FilterKind::Select { options } => (
false,
options
.iter()
.map(|o| FilterOptionView {
value: o.value.clone(),
label: shell.tt(&o.label),
})
.collect(),
),
};
FilterView {
param: f.param(),
label: shell.tt(&f.label),
boolean,
options,
value,
}
})
.collect()
}
fn urlencode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[derive(Template)]
#[template(path = "_list_table.html")]
struct ListFragment {
shell: crate::Shell,
columns: Vec<ColumnHead>,
rows: Vec<RowView>,
shown: i64,
page: i64,
total: i64,
total_pages: i64,
edit_base: Option<String>,
sort: String,
dir: String,
carry: String,
filtered: bool,
creatable: bool,
deletable: 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").yes_no(),
],
order_by: "created_at".to_string(),
order_dir: SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: None,
creatable: false,
filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
deletable: true,
..Default::default()
}
}
#[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 sort_falls_back_to_the_configured_order() {
let c = config();
assert_eq!(
resolve_sort(&c, None, None),
("created_at".to_string(), SortDir::Desc)
);
}
#[test]
fn a_declared_column_can_be_sorted_either_way() {
let c = config();
assert_eq!(
resolve_sort(&c, Some("username"), Some("asc")),
("username".to_string(), SortDir::Asc)
);
assert_eq!(
resolve_sort(&c, Some("username"), Some("desc")),
("username".to_string(), SortDir::Desc)
);
}
#[test]
fn an_undeclared_sort_column_is_ignored() {
let c = config();
for crafted in [
"password_hash",
"id) --",
"username; drop table backend_users",
"",
] {
assert_eq!(
resolve_sort(&c, Some(crafted), Some("asc")).0,
"created_at",
"{crafted} must not reach the query"
);
}
}
#[test]
fn an_unknown_direction_falls_back() {
let c = config();
assert_eq!(
resolve_sort(&c, Some("username"), Some("sideways")).1,
SortDir::Asc
);
assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
}
#[test]
fn a_contributed_column_type_renders_its_cell() {
struct Rating;
impl ColumnType for Rating {
fn view_key(&self) -> &'static str {
"acme.rating"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
let stars = cx.value.parse::<usize>().unwrap_or(0);
text_cell("acme.rating", cx.value, "*".repeat(stars))
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
let reg = ColumnTypeReg::new(Arc::new(Rating));
assert_eq!(reg.name(), "acme.rating", "the type names itself");
let mut registry = builtin_column_registry();
registry.insert(reg.name().to_string(), reg.column_type);
let rendered = render_cell(
registry.get("acme.rating").unwrap().as_ref(),
&crate::field::NoOverrides,
&OverrideScope {
surface: Surface::Column,
view_key: "acme.rating",
resource: Some("products"),
field: Some("stars"),
},
&CellCx {
value: "3",
tz: Tz::UTC,
locale: chrono::Locale::en_US,
},
);
assert!(rendered.into_string().contains("***"));
}
#[test]
fn a_contributed_key_is_the_types_own_and_collides_visibly() {
struct Impostor;
impl ColumnType for Impostor {
fn view_key(&self) -> &'static str {
"text"
}
fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
text_cell("text", cx.value, String::new())
}
fn render_default(&self, vm: &CellVm) -> Markup {
render_text(vm)
}
}
let reg = ColumnTypeReg::new(Arc::new(Impostor));
assert_eq!(reg.name(), "text");
assert!(builtin_column_registry().contains_key(reg.name()));
}
#[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 c = config();
let result = query(&db, &c, &plain(&c)).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());
}
async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
get_with(params, HashMap::new(), headers).await
}
async fn get_with(
params: ListParams,
raw: HashMap<String, String>,
headers: axum::http::HeaderMap,
) -> String {
let (db, _guard) = test_db().await;
let state = AdminState::new(
laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db,
);
let resp = handle(
&state,
&config(),
"/admin/users",
params,
&raw,
&crate::audit::test_actor(),
crate::Shell::test(),
&headers,
)
.await;
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn params(sort: Option<&str>) -> ListParams {
ListParams {
page: None,
sort: sort.map(|s| s.to_string()),
dir: Some("asc".to_string()),
q: None,
}
}
fn plain(config: &ListConfig) -> ListQuery<'_> {
ListQuery {
offset: 0,
order_by: &config.order_by,
order_dir: config.order_dir,
q: "",
filters: &[],
per_page: None,
}
}
fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
ListQuery {
offset: 0,
order_by: &config.order_by,
order_dir: config.order_dir,
q,
filters: &[],
per_page: None,
}
}
fn amps(html: &str) -> String {
html.replace("&", "&").replace("&", "&")
}
fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[tokio::test]
async fn search_filters_rows_and_the_total() {
let (db, _guard) = test_db().await;
for name in ["ada", "brendan", "clara"] {
let hash = laterite_auth::password::hash_password("x").unwrap();
laterite_auth::store::create_user(
&db,
name,
&format!("{name}@example.test"),
name,
None,
&hash,
false,
)
.await
.unwrap();
}
let c = config();
let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
assert_eq!(hit.rows.len(), 1);
assert_eq!(hit.rows[0].cells[0], "brendan");
assert_eq!(hit.total, 1);
assert_eq!(
query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
1
);
assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
}
#[tokio::test]
async fn search_skips_a_non_text_column() {
let (db, _guard) = test_db().await;
let hash = laterite_auth::password::hash_password("x").unwrap();
laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
.await
.unwrap();
let c = config();
assert!(c.columns[0].is_searchable(), "username is text");
assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
}
#[test]
fn a_column_can_override_its_searchability() {
assert!(ListColumn::new("code", "Code").is_searchable());
assert!(!ListColumn::new("code", "Code")
.searchable(false)
.is_searchable());
assert!(ListColumn::new("at", "At")
.datetime()
.searchable(true)
.is_searchable());
}
async fn seed_users(db: &Db) {
for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
let hash = laterite_auth::password::hash_password("x").unwrap();
laterite_auth::store::create_user(
db,
name,
&format!("{name}@example.test"),
name,
None,
&hash,
su,
)
.await
.unwrap();
}
}
async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
let active = resolve_filters(c, params);
let req = ListQuery {
offset: 0,
order_by: &c.order_by,
order_dir: c.order_dir,
q: "",
filters: &active,
per_page: None,
};
query(db, c, &req).await.unwrap().total
}
#[tokio::test]
async fn a_filter_narrows_the_rows_and_the_total() {
let (db, _guard) = test_db().await;
seed_users(&db).await;
let c = config();
assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
assert_eq!(
total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
1
);
assert_eq!(
total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
2
);
}
#[test]
fn an_undeclared_filter_or_value_is_ignored() {
let c = config();
assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
for bad in ["yes", "true", "1 or 1=1", "", " "] {
assert!(
resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
"{bad} must not reach the query"
);
}
let with_select = ListConfig {
filters: vec![ListFilter::select(
"status",
"Status",
vec![FilterOption::new("live", "Live")],
)],
..config()
};
assert_eq!(
resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
1
);
assert!(
resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
"an option the descriptor never offered is dropped"
);
}
#[tokio::test]
async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
let mut p = params(Some("username"));
p.q = None;
let html = get_with(
p,
filter_params(&[("f_is_superuser", "1")]),
axum::http::HeaderMap::new(),
)
.await;
let links = amps(&html);
assert!(
links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
"a sort link carries the filter"
);
assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
assert!(
html.contains(r#"<option value="1" selected>"#),
"the control shows the active value"
);
assert!(html.contains("Clear"), "and a way back to the full list");
assert!(
html.contains(r#"data-filtered="true""#),
"the region says the list is narrowed, which is what reveals Clear"
);
}
#[tokio::test]
async fn the_empty_state_distinguishes_no_records_from_no_match() {
let (db, _guard) = test_db().await;
let state = AdminState::new(
laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db,
);
let render = |raw: HashMap<String, String>, q: Option<&str>| {
let q = q.map(String::from);
let state = state.clone();
async move {
let p = ListParams {
page: None,
sort: None,
dir: None,
q,
};
let resp = handle(
&state,
&config(),
"/admin/users",
p,
&raw,
&crate::audit::test_actor(),
crate::Shell::test(),
&axum::http::HeaderMap::new(),
)
.await;
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
};
let bare = render(HashMap::new(), None).await;
assert!(bare.contains("No records yet."));
let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
assert!(filtered.contains("No records match."));
assert!(!filtered.contains("No records yet."));
let searched = render(HashMap::new(), Some("zzz")).await;
assert!(searched.contains("No records match."));
}
#[test]
fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
let c = config();
let kept = visible_columns(&c, Some("is_superuser,username"));
assert_eq!(
kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
["username", "is_superuser"]
);
}
#[test]
fn no_stored_choice_shows_every_column() {
let c = config();
assert_eq!(visible_columns(&c, None).len(), c.columns.len());
}
#[test]
fn a_stale_choice_falls_back_to_every_column() {
let c = config();
assert_eq!(
visible_columns(&c, Some("gone,removed")).len(),
c.columns.len()
);
assert_eq!(visible_columns(&c, Some("")).len(), c.columns.len());
assert_eq!(visible_columns(&c, Some(" , ")).len(), c.columns.len());
assert_eq!(visible_columns(&c, Some("gone,username")).len(), 1);
}
#[test]
fn the_toolbar_hides_what_the_operator_may_not_do() {
let shell = crate::Shell::test();
let cfg = ListConfig {
creatable: false,
toolbar: vec![
ToolbarButton::new("Open", "/reports"),
ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
],
..config()
};
let root = laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
..crate::audit::test_actor()
};
let views = toolbar_views(&cfg, "/admin", &root, &shell);
assert_eq!(
views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
["Open", "Audit"]
);
let granted = laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(
false,
["backend.view_audit".to_string()],
[],
[],
),
..crate::audit::test_actor()
};
assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
let plain = laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
..crate::audit::test_actor()
};
let views = toolbar_views(&cfg, "/admin", &plain, &shell);
assert_eq!(
views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
["Open"]
);
}
#[test]
fn a_toolbar_href_follows_the_admin_mount() {
let shell = crate::Shell::test();
let cfg = ListConfig {
creatable: true,
edit_base: Some("/roles".to_string()),
toolbar: vec![
ToolbarButton::new("Reports", "/reports"),
ToolbarButton::new("Docs", "https://example.test/docs"),
],
..config()
};
let root = laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
..crate::audit::test_actor()
};
let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
assert_eq!(views[1].href, "/backoffice/reports");
assert_eq!(views[2].href, "https://example.test/docs");
}
#[tokio::test]
async fn an_htmx_sort_returns_the_list_region_alone() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("hx-request", "true".parse().unwrap());
let html = get(params(Some("username")), headers).await;
assert!(html.contains(r#"id="lat-list""#), "the region comes back");
assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
assert!(!html.contains("<body"), "no page chrome");
assert!(html.contains(r#"data-filtered="false""#));
}
#[tokio::test]
async fn the_search_term_survives_a_sort_link() {
let mut p = params(Some("username"));
p.q = Some("bren".to_string());
let html = get(p, axum::http::HeaderMap::new()).await;
assert!(
amps(&html).contains("?sort=username&dir=desc&q=bren"),
"sort and pager links carry the term"
);
assert!(
html.contains(r#"value="bren""#),
"and the box still shows it"
);
}
#[tokio::test]
async fn a_plain_sort_returns_the_whole_page() {
let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
assert!(html.contains("<body"), "lists still work without htmx");
assert!(html.contains(r#"id="lat-list""#));
assert!(html.contains(r#"href="?sort=username"#));
}
#[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, &plain(&bad)).await.is_err());
}
}