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, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortDir {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Align {
Left,
Center,
Right,
}
impl Align {
fn class(self) -> &'static str {
match self {
Align::Left => "lat-col--left",
Align::Center => "lat-col--center",
Align::Right => "lat-col--right",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ListColumn {
pub field: String,
pub label: Text,
pub column_type: String,
pub options: serde_json::Value,
pub sortable: bool,
pub invisible: bool,
pub width: Option<String>,
pub align: Option<Align>,
pub permission: Option<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(),
sortable: true,
invisible: false,
width: None,
align: None,
permission: None,
searchable: None,
options: serde_json::Value::Null,
}
}
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 fn sortable(mut self, sortable: bool) -> Self {
self.sortable = sortable;
self
}
pub fn invisible(mut self) -> Self {
self.invisible = true;
self
}
pub fn width(mut self, width: impl Into<String>) -> Self {
self.width = Some(width.into());
self
}
pub fn align(mut self, align: Align) -> Self {
self.align = Some(align);
self
}
pub fn require(mut self, permission: impl Into<String>) -> Self {
self.permission = Some(permission.into());
self
}
pub(crate) fn align_class(&self) -> &'static str {
self.align.map(Align::class).unwrap_or("")
}
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 option_keys(&self) -> Option<Vec<&'static str>> {
None
}
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()
}
pub(crate) 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 option_keys(&self) -> Option<Vec<&'static str>> {
Some(Vec::new())
}
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 option_keys(&self) -> Option<Vec<&'static str>> {
Some(Vec::new())
}
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 option_keys(&self) -> Option<Vec<&'static str>> {
Some(Vec::new())
}
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 option_keys(&self) -> Option<Vec<&'static str>> {
Some(Vec::new())
}
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 option_keys(&self) -> Option<Vec<&'static str>> {
Some(Vec::new())
}
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 option_keys(&self) -> Option<Vec<&'static str>> {
Some(Vec::new())
}
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()
}
impl Default for ListColumn {
fn default() -> Self {
Self::new("", Text::new(""))
}
}
impl Default for ListFilter {
fn default() -> Self {
Self::of("", Text::new(""), "text")
}
}
impl<'de> Deserialize<'de> for ListColumn {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct Entry;
impl<'de> serde::de::Visitor<'de> for Entry {
type Value = ListColumn;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a column: its own keys and its type's")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> Result<ListColumn, A::Error> {
let mut column = ListColumn::default();
let theirs = crate::keyed::split_entry(map, |key, map| {
match key {
"label" => column.label = map.next_value()?,
"type" => column.column_type = map.next_value()?,
"sortable" => column.sortable = map.next_value()?,
"invisible" => column.invisible = map.next_value()?,
"width" => column.width = map.next_value()?,
"align" => column.align = map.next_value()?,
"permission" => column.permission = map.next_value()?,
"searchable" => column.searchable = map.next_value()?,
_ => return Ok(false),
}
Ok(true)
})?;
column.options = theirs;
Ok(column)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<ListColumn, E> {
Ok(ListColumn::default())
}
}
de.deserialize_map(Entry)
}
}
impl Serialize for ListColumn {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = ser.serialize_map(None)?;
map.serialize_entry("label", &self.label)?;
map.serialize_entry("type", &self.column_type)?;
map.serialize_entry("sortable", &self.sortable)?;
map.serialize_entry("invisible", &self.invisible)?;
map.serialize_entry("width", &self.width)?;
map.serialize_entry("align", &self.align)?;
map.serialize_entry("permission", &self.permission)?;
map.serialize_entry("searchable", &self.searchable)?;
crate::keyed::serialize_theirs(&mut map, &self.options)?;
map.end()
}
}
impl<'de> Deserialize<'de> for ListFilter {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct Entry;
impl<'de> serde::de::Visitor<'de> for Entry {
type Value = ListFilter;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a filter: its own keys and its type's")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> Result<ListFilter, A::Error> {
let mut filter = ListFilter::default();
let theirs = crate::keyed::split_entry(map, |key, map| {
match key {
"label" => filter.label = map.next_value()?,
"type" => filter.filter_type = map.next_value()?,
"default_value" => filter.default_value = map.next_value()?,
"permission" => filter.permission = map.next_value()?,
_ => return Ok(false),
}
Ok(true)
})?;
filter.options = theirs;
Ok(filter)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<ListFilter, E> {
Ok(ListFilter::default())
}
}
de.deserialize_map(Entry)
}
}
impl Serialize for ListFilter {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = ser.serialize_map(None)?;
map.serialize_entry("label", &self.label)?;
map.serialize_entry("type", &self.filter_type)?;
map.serialize_entry("default_value", &self.default_value)?;
map.serialize_entry("permission", &self.permission)?;
crate::keyed::serialize_theirs(&mut map, &self.options)?;
map.end()
}
}
impl crate::keyed::Keyed for ListColumn {
fn key(&self) -> &str {
&self.field
}
fn set_key(&mut self, key: String) {
self.field = key;
}
}
impl crate::keyed::Keyed for ListFilter {
fn key(&self) -> &str {
&self.field
}
fn set_key(&mut self, key: String) {
self.field = key;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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)]
#[non_exhaustive]
pub struct ListFilter {
pub field: String,
pub label: Text,
pub filter_type: String,
pub options: serde_json::Value,
pub default_value: Option<String>,
pub permission: Option<String>,
}
impl ListFilter {
pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
Self {
field: field.to_string(),
label: label.into(),
filter_type: filter_type.to_string(),
options: serde_json::Value::Null,
default_value: None,
permission: None,
}
}
pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
Self::of(field, label, "boolean")
}
pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
let mut filter = Self::of(field, label, "select");
filter.options = serde_json::json!({ "options": options });
filter
}
pub fn text(field: &str, label: impl Into<Text>) -> Self {
Self::of(field, label, "text")
}
pub fn number(field: &str, label: impl Into<Text>) -> Self {
Self::of(field, label, "number")
}
pub fn date(field: &str, label: impl Into<Text>) -> Self {
Self::of(field, label, "date")
}
pub(crate) fn choices(&self) -> Vec<FilterOption> {
self.options
.get("options")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default()
}
pub fn default_value(mut self, value: impl Into<String>) -> Self {
self.default_value = Some(value.into());
self
}
pub fn require(mut self, permission: impl Into<String>) -> Self {
self.permission = Some(permission.into());
self
}
pub(crate) fn param(&self) -> String {
format!("f_{}", self.field)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
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!("{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(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
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 per_page_preference_key(base_path: &str) -> String {
format!("list.per_page:{base_path}")
}
pub(crate) async fn stored_per_page(
state: &AdminState,
config: &ListConfig,
path: &str,
user: &laterite_auth::AuthenticatedUser,
) -> i64 {
let stored = match laterite_auth::store::user_preference(
&state.db,
user.user.id,
&per_page_preference_key(path),
)
.await
{
Ok(stored) => stored,
Err(e) => {
tracing::warn!(error = %e, "reading the page-size preference failed");
None
}
};
offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
}
pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
size.filter(|n| config.per_page_options.contains(n))
.unwrap_or(config.per_page)
}
pub(crate) fn allowed_columns<'a>(
config: &'a ListConfig,
user: &laterite_auth::AuthenticatedUser,
) -> Vec<&'a ListColumn> {
config
.columns
.iter()
.filter(|c| {
c.permission
.as_deref()
.is_none_or(|p| user.permissions.allows(p))
})
.collect()
}
pub(crate) fn allowed_filters<'a>(
config: &'a ListConfig,
user: &laterite_auth::AuthenticatedUser,
) -> Vec<&'a ListFilter> {
config
.filters
.iter()
.filter(|f| {
f.permission
.as_deref()
.is_none_or(|p| user.permissions.allows(p))
})
.collect()
}
pub(crate) fn narrowed(
config: &ListConfig,
stored: Option<&str>,
user: &laterite_auth::AuthenticatedUser,
) -> ListConfig {
ListConfig {
columns: visible_columns(config, stored, user)
.into_iter()
.cloned()
.collect(),
filters: allowed_filters(config, user).into_iter().cloned().collect(),
..config.clone()
}
}
pub(crate) fn visible_columns<'a>(
config: &'a ListConfig,
stored: Option<&str>,
user: &laterite_auth::AuthenticatedUser,
) -> Vec<&'a ListColumn> {
let allowed = allowed_columns(config, user);
let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
let Some(stored) = stored else {
return by_default();
};
let chosen: Vec<&str> = stored
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let kept: Vec<&ListColumn> = allowed
.iter()
.copied()
.filter(|c| chosen.iter().any(|n| *n == c.field))
.collect();
if kept.is_empty() {
return by_default();
}
kept
}
pub(crate) fn check(
config: &ListConfig,
column_types: &ColumnRegistry,
permission_codes: &std::collections::HashSet<String>,
) -> Result<(), String> {
for col in &config.columns {
if column_types.get(&col.column_type).is_none() {
return Err(format!(
"column `{}` uses unregistered type `{}`",
col.field, col.column_type
));
}
if let Some(p) = &col.permission {
if !permission_codes.contains(p) {
return Err(format!(
"column `{}` requires unregistered permission `{p}`",
col.field
));
}
}
}
for filter in &config.filters {
if let Some(p) = &filter.permission {
if !permission_codes.contains(p) {
return Err(format!(
"filter `{}` requires unregistered permission `{p}`",
filter.field
));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct SearchConfig {
pub enabled: bool,
pub prompt: Option<Text>,
pub on_enter: bool,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
enabled: true,
prompt: None,
on_enter: false,
}
}
}
impl SearchConfig {
pub fn off() -> Self {
Self {
enabled: false,
..Self::default()
}
}
pub fn prompt(mut self, text: impl Into<Text>) -> Self {
self.prompt = Some(text.into());
self
}
pub fn on_enter(mut self) -> Self {
self.on_enter = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct ListConfig {
pub entity: String,
pub title: Text,
#[serde(
deserialize_with = "crate::keyed::deserialize",
serialize_with = "crate::keyed::serialize"
)]
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,
#[serde(
deserialize_with = "crate::keyed::deserialize",
serialize_with = "crate::keyed::serialize"
)]
pub filters: Vec<ListFilter>,
pub deletable: bool,
pub exportable: bool,
pub toolbar: Vec<ToolbarButton>,
pub search: SearchConfig,
pub per_page_options: Vec<i64>,
pub no_records_message: Option<Text>,
}
impl ListConfig {
pub fn new(
entity: impl Into<String>,
title: impl Into<Text>,
columns: Vec<ListColumn>,
) -> Self {
Self {
entity: entity.into(),
title: title.into(),
columns,
..Self::default()
}
}
pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
self.order_by = column.into();
self.order_dir = dir;
self
}
pub fn per_page(mut self, rows: i64) -> Self {
self.per_page = rows;
self
}
pub fn id_field(mut self, column: impl Into<String>) -> Self {
self.id_field = column.into();
self
}
pub fn edit_base(mut self, base: impl Into<String>) -> Self {
self.edit_base = Some(base.into());
self
}
pub fn creatable(mut self) -> Self {
self.creatable = true;
self
}
pub fn exportable(mut self) -> Self {
self.exportable = true;
self
}
pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
self.filters = filters;
self
}
pub fn deletable(mut self) -> Self {
self.deletable = true;
self
}
pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
self.toolbar = buttons;
self
}
pub fn search(mut self, search: SearchConfig) -> Self {
self.search = search;
self
}
pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
self.per_page_options = sizes;
self
}
pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
self.no_records_message = Some(text.into());
self
}
}
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,
exportable: false,
toolbar: Vec::new(),
search: SearchConfig::default(),
per_page_options: Vec::new(),
no_records_message: None,
}
}
}
#[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 && c.sortable)) {
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 CellView {
pub html: String,
pub class: String,
}
pub struct RenderedRow {
pub id: String,
pub cells: Vec<CellView>,
}
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,
matcher: FilterMatch,
}
pub(crate) enum FilterMatch {
Equals(sea_query::Value),
Contains(String),
StartsWith(String),
}
fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
match filter.filter_type.as_str() {
"boolean" => match raw {
"1" => Some(FilterMatch::Equals(true.into())),
"0" => Some(FilterMatch::Equals(false.into())),
_ => None,
},
"select" => filter
.choices()
.iter()
.any(|o| o.value == raw)
.then(|| FilterMatch::Equals(raw.into())),
"text" => Some(FilterMatch::Contains(raw.to_string())),
"number" => raw
.parse::<i64>()
.ok()
.map(|n| FilterMatch::Equals(n.into())),
"date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
_ => None,
}
}
fn is_iso_date(raw: &str) -> bool {
raw.len() == 10
&& raw.as_bytes().iter().enumerate().all(|(i, b)| {
if i == 4 || i == 7 {
*b == b'-'
} else {
b.is_ascii_digit()
}
})
}
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 submitted = params.get(&filter.param()).map(|v| v.trim());
let raw = match submitted {
Some(value) => value,
None => filter.default_value.as_deref().unwrap_or_default(),
};
if raw.is_empty() {
continue;
}
let Some(matcher) = filter_match(filter, raw) else {
continue;
};
out.push(ActiveFilter {
filter,
raw: raw.to_string(),
matcher,
});
}
out
}
fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
if active.is_empty() {
return None;
}
let caps = laterite_core::capabilities::CapabilitySet::default();
let mut all = sea_query::Condition::all();
for f in active {
let col = Expr::col(Alias::new(&f.filter.field));
all = match &f.matcher {
FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
FilterMatch::Contains(term) => {
match SearchProfile::new().condition(&caps, &f.filter.field, term) {
Some(cond) => all.add(cond),
None => all,
}
}
FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
};
}
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(), user);
let config = &narrowed;
let page = params.page.unwrap_or(1).max(1);
let per_page = stored_per_page(state, config, path, user).await;
let offset = (page - 1) * 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: Some(per_page),
};
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 + per_page - 1) / per_page).max(1);
let date_loc = date_locale(shell.locale());
let rows: Vec<RenderedRow> = result
.rows
.into_iter()
.map(|row| RenderedRow {
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),
};
let html = 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(),
};
CellView {
html,
class: col.align_class().to_string(),
}
})
.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, &state.asset_urls);
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()
},
sortable: c.sortable,
width: c.width.clone(),
align_class: c.align_class().to_string(),
}
})
.collect();
let shell_for_toolbar = shell.clone();
let filter_views = filter_views(config, &active, &shell);
let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
.into_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 search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
let page_view = ListTemplate {
shell,
title,
columns,
rows,
shown,
page,
total: result.total,
total_pages,
edit_base: config.edit_base.clone(),
sort: order_by,
dir: active_dir.to_string(),
q: q.trim().to_string(),
searchable: config.search.enabled
&& config.columns.iter().any(|c| c.is_searchable()),
search_prompt,
search_on_enter: config.search.on_enter,
no_records_message,
per_page,
per_page_options: config.per_page_options.clone(),
pickers,
toolbar,
export_query,
path: path.to_string(),
filters: filter_views,
deletable: config.deletable,
exportable: config.exportable,
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,
deletable: page_view.deletable,
no_records_message: page_view.no_records_message,
})
} 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 allowed = allowed_columns(config, user);
let chosen: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "column")
.map(|(_, v)| v.trim())
.filter(|v| allowed.iter().any(|c| c.field == *v))
.collect();
let mut by_default: Vec<&str> = allowed
.iter()
.filter(|c| !c.invisible)
.map(|c| c.field.as_str())
.collect();
let mut as_chosen = chosen.clone();
by_default.sort_unstable();
as_chosen.sort_unstable();
as_chosen.dedup();
let size_key = per_page_preference_key(path);
let size = pairs
.iter()
.find(|(k, _)| k == "per_page")
.and_then(|(_, v)| v.trim().parse::<i64>().ok())
.filter(|n| config.per_page_options.contains(n));
let size_result = match size {
Some(n) if n != config.per_page => {
laterite_auth::store::set_user_preference(
&state.db,
user.user.id,
&size_key,
&n.to_string(),
)
.await
}
_ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
};
let key = columns_preference_key(path);
let result = if chosen.is_empty() || as_chosen == by_default {
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.and(size_result) {
Ok(()) => session.push_flash(
crate::session::FlashLevel::Success,
laterite_core::t!("List setup saved."),
),
Err(e) => {
tracing::error!(error = %e, "storing the list setup failed");
session.push_flash(
crate::session::FlashLevel::Error,
laterite_core::t!("Could not save your list setup."),
);
}
}
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,
pub sortable: bool,
pub width: Option<String>,
pub align_class: String,
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate {
shell: crate::Shell,
title: String,
columns: Vec<ColumnHead>,
rows: Vec<RenderedRow>,
shown: i64,
page: i64,
total: i64,
total_pages: i64,
edit_base: Option<String>,
sort: String,
dir: String,
q: String,
searchable: bool,
search_prompt: Option<String>,
search_on_enter: bool,
no_records_message: Option<String>,
per_page: i64,
per_page_options: Vec<i64>,
deletable: bool,
exportable: 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 input_type: String,
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 options: Vec<FilterOptionView> = match f.filter_type.as_str() {
"boolean" => 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")),
},
],
"select" => f
.choices()
.iter()
.map(|o| FilterOptionView {
value: o.value.clone(),
label: shell.tt(&o.label),
})
.collect(),
_ => Vec::new(),
};
let input_type = match f.filter_type.as_str() {
"number" => "number",
"date" => "date",
_ => "text",
};
FilterView {
param: f.param(),
label: shell.tt(&f.label),
input_type: input_type.to_string(),
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<RenderedRow>,
shown: i64,
page: i64,
total: i64,
total_pages: i64,
edit_base: Option<String>,
sort: String,
dir: String,
carry: String,
filtered: bool,
deletable: bool,
no_records_message: Option<String>,
}
#[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()
}
#[test]
fn a_typed_filter_accepts_only_its_own_shape() {
let mut c = config();
c.filters = vec![
ListFilter::number("views", "Views"),
ListFilter::date("created_at", "Created"),
ListFilter::text("username", "Username"),
];
let accepted = |param: &str, value: &str| {
!resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
};
assert!(accepted("f_views", "42"));
assert!(
!accepted("f_views", "4.2"),
"a number filter takes whole numbers"
);
assert!(!accepted("f_views", "; drop table"), "and nothing else");
assert!(accepted("f_created_at", "2026-09-21"));
assert!(
!accepted("f_created_at", "2026-9-21"),
"a date is ISO or nothing"
);
assert!(
!accepted("f_created_at", "2026-09-21%"),
"so a pattern cannot slip in"
);
assert!(accepted("f_username", "ada"));
}
#[test]
fn a_filter_default_applies_only_until_the_operator_decides() {
let mut c = config();
c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
assert_eq!(active.len(), 1);
}
#[test]
fn a_gated_filter_is_narrowed_away_with_its_default() {
let mut c = config();
c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
.default_value("1")
.require("acme.secret")];
let plain = laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
..crate::audit::test_actor()
};
assert!(allowed_filters(&c, &plain).is_empty());
let narrowed = narrowed(&c, None, &plain);
assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
assert_eq!(allowed_filters(&c, &root()).len(), 1);
}
#[test]
fn a_stored_page_size_counts_only_while_it_is_offered() {
let mut cfg = config();
assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
cfg.per_page_options = vec![25, 50, 100];
assert_eq!(offered_per_page(&cfg, Some(50)), 50);
assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
assert_eq!(offered_per_page(&cfg, None), 25);
}
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."));
}
fn root() -> laterite_auth::AuthenticatedUser {
laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
..crate::audit::test_actor()
}
}
#[test]
fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
let mut c = config();
c.columns[1].invisible = true;
assert_eq!(visible_columns(&c, None, &root()).len(), 1);
assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
assert_eq!(
visible_columns(&c, Some("is_superuser"), &root())[0].field,
"is_superuser"
);
}
#[test]
fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
let mut c = config();
c.columns[1].permission = Some("acme.secret".into());
let plain = laterite_auth::AuthenticatedUser {
permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
..crate::audit::test_actor()
};
assert_eq!(visible_columns(&c, None, &plain).len(), 1);
assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
assert_eq!(visible_columns(&c, None, &root()).len(), 2);
}
#[test]
fn an_unsortable_column_is_not_sorted_by() {
let mut c = config();
c.columns[0].sortable = false;
let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
assert_eq!(by, c.order_by, "falls back to the descriptor's order");
}
#[test]
fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
let c = config();
let kept = visible_columns(&c, Some("is_superuser,username"), &root());
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, &root()).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"), &root()).len(),
c.columns.len()
);
assert_eq!(
visible_columns(&c, Some(""), &root()).len(),
c.columns.len()
);
assert_eq!(
visible_columns(&c, Some(" , "), &root()).len(),
c.columns.len()
);
assert_eq!(visible_columns(&c, Some("gone,username"), &root()).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("/backoffice/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());
}
}