use std::collections::HashMap;
use std::sync::Arc;
use askama::Template;
use axum::response::{IntoResponse, 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, DbBackend, Text};
use sea_query::{Alias, Expr, Order, Query, SimpleExpr};
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, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ValueLabel {
pub value: String,
pub label: Text,
}
impl ValueLabel {
pub fn new(value: impl Into<String>, label: impl Into<Text>) -> Self {
Self {
value: value.into(),
label: label.into(),
}
}
}
#[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>,
pub labels: Vec<ValueLabel>,
pub editable: 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,
labels: Vec::new(),
editable: false,
}
}
pub fn labels(mut self, labels: Vec<ValueLabel>) -> Self {
self.labels = labels;
self
}
pub fn editable(mut self) -> Self {
self.editable = true;
self
}
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<'_>,
labels: Option<&HashMap<String, String>>,
) -> Markup {
let mut vm = ct.view_model(cx);
if let Some(label) = labels.and_then(|l| l.get(&vm.value)) {
vm.display = label.clone();
}
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()?,
"labels" => column.labels = map.next_value()?,
"editable" => column.editable = 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("editable", &self.editable)?;
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)?;
if !self.labels.is_empty() {
map.serialize_entry("labels", &self.labels)?;
}
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(Clone, Debug)]
pub(crate) struct ImportLink {
pub href: String,
pub permission: String,
}
#[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> {
if let Some(tree) = &config.tree {
if !valid_ident(&tree.parent_key) {
return Err(format!(
"tree names an invalid parent column `{}`",
tree.parent_key
));
}
}
if let Some(reorder) = &config.reorder {
if !valid_ident(&reorder.column) {
return Err(format!(
"reorder names an invalid column `{}`",
reorder.column
));
}
}
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 ReorderConfig {
pub column: String,
}
impl Default for ReorderConfig {
fn default() -> Self {
Self {
column: "sort_order".to_string(),
}
}
}
fn deserialize_reorder<'de, D>(deserializer: D) -> Result<Option<ReorderConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Key {
Flag(bool),
Config(ReorderConfig),
}
Ok(match Option::<Key>::deserialize(deserializer)? {
Some(Key::Flag(true)) => Some(ReorderConfig::default()),
Some(Key::Flag(false)) | None => None,
Some(Key::Config(config)) => Some(config),
})
}
const REORDER_STEP: i64 = 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct TreeConfig {
pub parent_key: String,
pub source: Option<String>,
}
impl TreeConfig {
pub fn new(parent_key: impl Into<String>) -> Self {
Self {
parent_key: parent_key.into(),
source: None,
}
}
pub fn move_to(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
}
impl Default for TreeConfig {
fn default() -> Self {
Self::new("parent_id")
}
}
fn deserialize_tree<'de, D>(deserializer: D) -> Result<Option<TreeConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Key {
Flag(bool),
Config(TreeConfig),
}
Ok(match Option::<Key>::deserialize(deserializer)? {
Some(Key::Flag(true)) => Some(TreeConfig::default()),
Some(Key::Flag(false)) | None => None,
Some(Key::Config(config)) => Some(config),
})
}
const TREE_CHILDREN_LIMIT: i64 = 1000;
#[derive(Clone)]
pub(crate) struct Editor(pub Arc<crate::form::PreparedForm>);
impl std::fmt::Debug for Editor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Editor")
}
}
#[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,
#[serde(skip)]
pub(crate) import: Option<ImportLink>,
#[serde(skip)]
pub(crate) scope: Option<Scope>,
#[serde(deserialize_with = "deserialize_reorder")]
pub reorder: Option<ReorderConfig>,
#[serde(deserialize_with = "deserialize_tree")]
pub tree: Option<TreeConfig>,
#[serde(skip)]
pub(crate) editor: Option<Editor>,
pub toolbar: Vec<ToolbarButton>,
pub search: SearchConfig,
pub per_page_options: Vec<i64>,
pub no_records_message: Option<Text>,
pub row_click: RowClick,
pub remember: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RowClick {
#[default]
Open,
None,
}
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 tree(mut self, tree: TreeConfig) -> Self {
self.tree = Some(tree);
self
}
pub fn reorder(mut self, column: impl Into<String>) -> Self {
self.reorder = Some(ReorderConfig {
column: column.into(),
});
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
}
pub fn row_click(mut self, action: RowClick) -> Self {
self.row_click = action;
self
}
pub fn remember(mut self, remember: bool) -> Self {
self.remember = remember;
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,
import: None,
scope: None,
reorder: None,
tree: None,
editor: None,
toolbar: Vec::new(),
search: SearchConfig::default(),
per_page_options: Vec::new(),
no_records_message: None,
row_click: RowClick::Open,
remember: true,
}
}
}
#[derive(Deserialize, Default)]
pub struct ListParams {
page: Option<i64>,
sort: Option<String>,
dir: Option<String>,
q: Option<String>,
parent: Option<String>,
depth: Option<i64>,
}
pub(crate) fn resolve_sort(
config: &ListConfig,
sort: Option<&str>,
dir: Option<&str>,
) -> (String, SortDir) {
let (natural, natural_dir) = natural_order(config);
let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
Some(field) => field.to_string(),
None => natural.to_string(),
};
let dir = match dir {
Some("asc") => SortDir::Asc,
Some("desc") => SortDir::Desc,
_ if by == natural => natural_dir,
_ => SortDir::Asc,
};
(by, dir)
}
fn natural_order(config: &ListConfig) -> (&str, SortDir) {
match &config.reorder {
Some(reorder) => (&reorder.column, SortDir::Asc),
None => (&config.order_by, config.order_dir),
}
}
pub struct RowView {
pub id: String,
pub cells: Vec<String>,
pub children: i64,
}
pub struct CellView {
pub html: String,
pub class: String,
}
pub struct RenderedRow {
pub id: String,
pub cells: Vec<CellView>,
pub depth: i64,
pub indent: String,
pub toggle: Option<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 children_of: Option<&'a str>,
}
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)
}
#[derive(Clone, Debug)]
pub(crate) enum Scope {
Column {
column: String,
value: Option<String>,
},
Pivot {
table: String,
parent_key: String,
related_key: String,
id_field: String,
value: String,
},
}
impl Scope {
fn idents_valid(&self) -> bool {
match self {
Scope::Column { column, .. } => valid_ident(column),
Scope::Pivot {
table,
parent_key,
related_key,
id_field,
..
} => {
valid_ident(table)
&& valid_ident(parent_key)
&& valid_ident(related_key)
&& valid_ident(id_field)
}
}
}
fn condition(&self, backend: DbBackend, entity_id: &str) -> SimpleExpr {
let cast = Alias::new(text_cast(backend));
match self {
Scope::Column {
column,
value: Some(value),
} => Expr::col(Alias::new(column))
.cast_as(cast)
.eq(value.clone()),
Scope::Column {
column,
value: None,
} => Expr::col(Alias::new(column)).is_null(),
Scope::Pivot {
table,
parent_key,
related_key,
value,
..
} => Expr::col(Alias::new(entity_id)).in_subquery(
Query::select()
.column(Alias::new(related_key))
.from(Alias::new(table))
.and_where(
Expr::col(Alias::new(parent_key))
.cast_as(cast)
.eq(value.clone()),
)
.to_owned(),
),
}
}
fn row_id(&self, backend: DbBackend, entity: &str, entity_id: &str) -> Option<SimpleExpr> {
match self {
Scope::Column { .. } => None,
Scope::Pivot {
table,
parent_key,
related_key,
id_field,
value,
} => {
let pivot = Alias::new("lat_pivot");
Some(SimpleExpr::SubQuery(
None,
Box::new(
Query::select()
.column((pivot.clone(), Alias::new(id_field)))
.from_as(Alias::new(table), pivot.clone())
.and_where(
Expr::col((pivot.clone(), Alias::new(related_key)))
.equals((Alias::new(entity), Alias::new(entity_id))),
)
.and_where(
Expr::col((pivot, Alias::new(parent_key)))
.cast_as(Alias::new(text_cast(backend)))
.eq(value.clone()),
)
.to_owned()
.into_sub_query_statement(),
),
))
}
}
}
}
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))
|| !config.scope.iter().all(Scope::idents_valid)
{
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),
);
}
let row_id = config
.scope
.as_ref()
.and_then(|scope| scope.row_id(db.backend, &config.entity, &config.id_field))
.unwrap_or_else(|| Expr::col(Alias::new(&config.id_field)).into());
select
.expr_as(
row_id.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);
if let Some(parent_key) = req.children_of {
let child = Alias::new("lat_child");
select.expr_as(
SimpleExpr::SubQuery(
None,
Box::new(
Query::select()
.expr(Expr::col((child.clone(), Alias::new(&config.id_field))).count())
.from_as(Alias::new(&config.entity), child.clone())
.and_where(
Expr::col((child, Alias::new(parent_key))).equals((
Alias::new(&config.entity),
Alias::new(&config.id_field),
)),
)
.to_owned()
.into_sub_query_statement(),
),
)
.cast_as(Alias::new(text_cast(db.backend))),
Alias::new("lat_children"),
);
}
if req.order_by != config.id_field {
select.order_by(Alias::new(&config.id_field), Order::Asc);
}
select
.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);
}
if let Some(scope) = &config.scope {
select.and_where(scope.condition(db.backend, &config.id_field));
}
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);
}
if let Some(scope) = &config.scope {
count.and_where(scope.condition(db.backend, &config.id_field));
}
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(),
children: match req.children_of {
Some(_) => get_text(row, "lat_children").trim().parse().unwrap_or(0),
None => 0,
},
})
.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()
}
#[cfg(test)]
#[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 {
handle_remembering(
state, config, path, params, raw, user, shell, headers, None, None,
)
.await
}
fn query_to_remember(
config: &ListConfig,
params: &ListParams,
active: &[ActiveFilter<'_>],
) -> Option<String> {
let mut parts = Vec::new();
let (by, dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
let (natural, natural_dir) = natural_order(config);
if by != natural || dir != natural_dir {
let dir = match dir {
SortDir::Asc => "asc",
SortDir::Desc => "desc",
};
parts.push(format!("sort={}&dir={dir}", urlencode(&by)));
}
if let Some(q) = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
parts.push(format!("q={}", urlencode(q)));
}
for f in active {
parts.push(format!("{}={}", f.filter.param(), urlencode(&f.raw)));
}
(!parts.is_empty()).then(|| parts.join("&"))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn handle_remembering(
state: &AdminState,
config: &ListConfig,
path: &str,
params: ListParams,
raw: &HashMap<String, String>,
user: &laterite_auth::AuthenticatedUser,
shell: crate::Shell,
headers: &axum::http::HeaderMap,
query_string: Option<&str>,
session: Option<&crate::session::SessionHandle>,
) -> Response {
if let Some(session) = session.filter(|_| config.remember) {
match query_string.filter(|q| !q.is_empty()) {
None if !crate::form::is_htmx(headers) => {
if let Some(remembered) = session.remembered_list(path) {
return axum::response::Redirect::to(&format!("{path}?{remembered}"))
.into_response();
}
}
None => {}
Some(_) => {
let active = resolve_filters(config, raw);
let keep = query_to_remember(config, ¶ms, &active);
session.remember_list(path, keep.as_deref());
}
}
}
show(state, config, path, params, raw, user, shell, headers).await
}
#[allow(clippy::too_many_arguments)]
async fn show(
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 children = params.parent.is_some();
match build_page(state, config, path, params, raw, user, shell).await {
Some(page_view) => {
if crate::form::is_htmx(headers) && children && page_view.tree.is_some() {
render(rows_of(page_view, "lat-list", None))
} else if crate::form::is_htmx(headers) {
render(fragment_of(page_view, "lat-list", None))
} else {
render(page_view)
}
}
None => render_error(),
}
}
async fn build_page(
state: &AdminState,
config: &ListConfig,
path: &str,
params: ListParams,
raw: &HashMap<String, String>,
user: &laterite_auth::AuthenticatedUser,
shell: crate::Shell,
) -> Option<ListTemplate> {
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 (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 (natural, natural_dir) = natural_order(config);
let own_order =
order_by == natural && order_dir == natural_dir && q.trim().is_empty() && active.is_empty();
let reorder = config
.reorder
.as_ref()
.filter(|_| own_order)
.map(|_| ReorderView {
path: format!("{path}/reorder"),
page,
parent: params.parent.clone().unwrap_or_default(),
});
let structure = config.tree.as_ref().filter(|_| own_order);
let scoped;
let config = match structure {
Some(tree) => {
let mut c = config.clone();
c.scope = Some(Scope::Column {
column: tree.parent_key.clone(),
value: params.parent.clone(),
});
scoped = c;
&scoped
}
None => config,
};
let under_parent = structure.is_some() && params.parent.is_some();
let depth = if under_parent {
params.depth.unwrap_or(1).max(1)
} else {
0
};
let per_page = if under_parent {
TREE_CHILDREN_LIMIT
} else {
per_page
};
let offset = (page - 1) * per_page;
let tree = structure.map(|t| TreeView {
move_path: t.source.as_ref().map(|_| path.to_string()),
});
let crumbs = match (structure, ¶ms.parent) {
(Some(t), Some(parent)) => crumbs(&state.db, config, t, parent, path).await,
_ => Vec::new(),
};
let req = ListQuery {
offset,
order_by: &order_by,
order_dir,
q: q.trim(),
filters: &active,
per_page: Some(per_page),
children_of: structure.map(|t| t.parent_key.as_str()),
};
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)));
}
let result = query(&state.db, config, &req).await.ok()?;
{
{
let total_pages = ((result.total + per_page - 1) / per_page).max(1);
let date_loc = date_locale(shell.locale());
let labels: Vec<Option<HashMap<String, String>>> = config
.columns
.iter()
.map(|c| {
(!c.labels.is_empty()).then(|| {
c.labels
.iter()
.map(|l| (l.value.clone(), shell.tt(&l.label)))
.collect()
})
})
.collect();
let rows: Vec<RenderedRow> = result
.rows
.into_iter()
.map(|row| RenderedRow {
depth,
indent: format!("{}px", 16 + depth * 18),
toggle: (structure.is_some() && row.children > 0)
.then(|| format!("{path}?parent={}&depth={}", row.id, depth + 1)),
id: row.id.clone(),
cells: row
.cells
.iter()
.zip(config.columns.iter().zip(&labels))
.map(|(raw, (col, labels))| {
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 (&config.editor, col.editable) {
(Some(editor), true) => cell_markup(
state, &editor.0, path, &row.id, &col.field, raw, &shell, None,
),
_ => match state.column_types.get(&col.column_type) {
Some(ct) => render_cell(
ct.as_ref(),
state.overrides.as_ref(),
&scope,
&cx,
labels.as_ref(),
)
.into_string(),
None => String::new(),
},
};
CellView {
html,
class: col.align_class().to_string(),
}
})
.collect(),
})
.collect();
let editable_assets: Vec<&'static str> = match &config.editor {
Some(editor) => config
.columns
.iter()
.filter(|c| c.editable)
.flat_map(|c| editor.0.field_assets(&c.field))
.collect(),
None => Vec::new(),
};
let keys: Vec<&str> = config
.columns
.iter()
.filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
.flatten()
.chain(editable_assets)
.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(),
row_link: config.row_click == RowClick::Open,
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,
import_href: config
.import
.as_ref()
.filter(|link| user.permissions.allows(&link.permission))
.map(|link| link.href.clone()),
filtered: !active.is_empty() || !q.trim().is_empty(),
carry: carry.clone(),
region: "lat-list".to_string(),
delete_path: None,
reorder,
tree,
crumbs,
};
Some(page_view)
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct RowAction {
pub path: String,
pub label: String,
pub confirm: String,
pub icon: &'static str,
}
#[derive(Clone, Debug)]
pub(crate) struct ReorderView {
pub path: String,
pub page: i64,
pub parent: String,
}
#[derive(Clone, Debug)]
pub(crate) struct TreeView {
pub move_path: Option<String>,
}
pub(crate) struct Crumb {
pub label: String,
pub href: String,
}
async fn crumbs(
db: &Db,
config: &ListConfig,
tree: &TreeConfig,
parent: &str,
path: &str,
) -> Vec<Crumb> {
let mut trail = Vec::new();
let mut current = Some(parent.to_string());
for _ in 0..64 {
let Some(id) = current.take() else { break };
match parent_row(db, config, tree, &id).await {
Ok(Some((label, above))) => {
trail.push(Crumb {
label,
href: format!("{path}?parent={id}"),
});
current = above;
}
_ => break,
}
}
trail.push(Crumb {
label: String::new(),
href: path.to_string(),
});
trail.reverse();
trail
}
async fn parent_row(
db: &Db,
config: &ListConfig,
tree: &TreeConfig,
id: &str,
) -> anyhow::Result<Option<(String, Option<String>)>> {
let label_col = config
.columns
.first()
.map(|c| c.field.as_str())
.unwrap_or(&config.id_field);
let (sql, values) = {
let cast = Alias::new(text_cast(db.backend));
build(
db.backend,
Query::select()
.expr_as(
Expr::col(Alias::new(label_col)).cast_as(cast.clone()),
Alias::new("lat_label"),
)
.expr_as(
Expr::col(Alias::new(&tree.parent_key)).cast_as(cast.clone()),
Alias::new("lat_parent"),
)
.from(Alias::new(&config.entity))
.and_where(
Expr::col(Alias::new(&config.id_field))
.cast_as(cast)
.eq(id.to_string()),
)
.to_owned(),
)
};
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(row.map(|row| {
let above = row.get_text("lat_parent").ok().filter(|p| !p.is_empty());
(get_text(&row, "lat_label"), above)
}))
}
async fn ordered_keys(
db: &Db,
config: &ListConfig,
column: &str,
) -> anyhow::Result<Vec<(String, i64)>> {
let (sql, values) = {
let cast = Alias::new(text_cast(db.backend));
let mut select = Query::select();
select
.expr_as(
Expr::col(Alias::new(&config.id_field)).cast_as(cast.clone()),
Alias::new(ID_ALIAS),
)
.expr_as(
Expr::col(Alias::new(column)).cast_as(cast),
Alias::new("lat_key"),
)
.from(Alias::new(&config.entity))
.order_by(Alias::new(column), Order::Asc)
.order_by(Alias::new(&config.id_field), Order::Asc);
if let Some(scope) = &config.scope {
select.and_where(scope.condition(db.backend, &config.id_field));
}
build(db.backend, select)
};
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
Ok(rows
.iter()
.map(|row| {
let key = get_text(row, "lat_key").trim().parse().unwrap_or(0);
(get_text(row, ID_ALIAS), key)
})
.collect())
}
async fn write_keys(
db: &Db,
config: &ListConfig,
column: &str,
changes: &[(String, i64)],
) -> anyhow::Result<()> {
let mut tx = db.pool.begin().await?;
for (id, key) in changes {
let (sql, values) = build(
db.backend,
Query::update()
.table(Alias::new(&config.entity))
.value(Alias::new(column), *key)
.and_where(
Expr::col(Alias::new(&config.id_field))
.cast_as(Alias::new(text_cast(db.backend)))
.eq(id.clone()),
)
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
enum Move {
Up,
Down,
Before(String),
After(String),
}
fn placement(order: &[(String, i64)], id: &str, target: usize) -> Vec<(String, i64)> {
let mut ids: Vec<&str> = order.iter().map(|(i, _)| i.as_str()).collect();
let from = match ids.iter().position(|i| *i == id) {
Some(from) => from,
None => return Vec::new(),
};
ids.remove(from);
let target = target.min(ids.len());
ids.insert(target, id);
let key_of = |i: &str| {
order
.iter()
.find(|(o, _)| o == i)
.map(|(_, k)| *k)
.unwrap_or(0)
};
let prev = target.checked_sub(1).map(|i| key_of(ids[i]));
let next = ids.get(target + 1).map(|i| key_of(i));
let key = match (prev, next) {
(Some(a), Some(b)) if b - a >= 2 => Some(a + (b - a) / 2),
(Some(a), None) => Some(a + REORDER_STEP),
(None, Some(b)) => Some(b - REORDER_STEP),
(None, None) => Some(key_of(id)),
_ => None,
};
match key {
Some(key) => vec![(id.to_string(), key)],
None => ids
.iter()
.enumerate()
.map(|(i, row)| (row.to_string(), (i as i64 + 1) * REORDER_STEP))
.filter(|(row, key)| key_of(row) != *key)
.collect(),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn reorder_handler(
state: &AdminState,
config: &ListConfig,
path: &str,
region: &str,
row_action: Option<RowAction>,
user: &laterite_auth::AuthenticatedUser,
session: &crate::session::SessionHandle,
shell: crate::Shell,
headers: &axum::http::HeaderMap,
pairs: &[(String, String)],
) -> Response {
let Some(reorder) = &config.reorder else {
return crate::not_found();
};
let field = |name: &str| {
pairs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.trim().to_string())
.filter(|v| !v.is_empty())
};
let page = field("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1)
.max(1);
let parent = field("parent");
let scoped;
let config = match &config.tree {
Some(tree) => {
let mut c = config.clone();
c.scope = Some(Scope::Column {
column: tree.parent_key.clone(),
value: parent.clone(),
});
scoped = c;
&scoped
}
None => config,
};
let fatal = 'apply: {
let (Some(id), Some(mv)) = (
field("id"),
match (field("move").as_deref(), field("before"), field("after")) {
(Some("up"), _, _) => Some(Move::Up),
(Some("down"), _, _) => Some(Move::Down),
(_, Some(before), _) => Some(Move::Before(before)),
(_, _, Some(after)) => Some(Move::After(after)),
_ => None,
},
) else {
break 'apply false;
};
let order = match ordered_keys(&state.db, config, &reorder.column).await {
Ok(order) => order,
Err(e) => {
tracing::error!(entity = %config.entity, error = %e, "reorder read failed");
break 'apply true;
}
};
let position = |needle: &str| order.iter().position(|(i, _)| i == needle);
let Some(from) = position(&id) else {
session.push_flash(
crate::session::FlashLevel::Error,
laterite_core::t!("That record is no longer in the list."),
);
break 'apply false;
};
let target = match mv {
Move::Up => from.saturating_sub(1),
Move::Down => from + 1,
Move::Before(other) => match position(&other) {
Some(p) if p > from => p - 1,
Some(p) => p,
None => from,
},
Move::After(other) => match position(&other) {
Some(p) if p > from => p,
Some(p) => p + 1,
None => from,
},
};
let changes = placement(&order, &id, target);
if changes.is_empty() {
break 'apply false;
}
if let Err(e) = write_keys(&state.db, config, &reorder.column, &changes).await {
tracing::error!(entity = %config.entity, error = %e, "reorder write failed");
break 'apply true;
}
crate::audit::record(
state,
user,
&format!("{}.reorder", config.entity),
Some(&config.entity),
Some(&id),
None,
Some(&format!("position {}", target.min(order.len() - 1) + 1)),
)
.await;
false
};
if fatal {
return render_error();
}
if crate::form::is_htmx(headers) {
let params = ListParams {
page: Some(page),
parent: parent.clone(),
depth: field("depth").and_then(|d| d.parse().ok()),
..Default::default()
};
let Some(view) =
build_page(state, config, path, params, &HashMap::new(), user, shell).await
else {
return render_error();
};
return if parent.is_some() && view.tree.is_some() {
render(rows_of(view, region, row_action))
} else {
render(fragment_of(view, region, row_action))
};
}
let back = match parent {
Some(parent) => format!("{path}?parent={parent}&page={page}"),
None => format!("{path}?page={page}"),
};
axum::response::Redirect::to(&back).into_response()
}
pub(crate) async fn move_form(
state: &AdminState,
config: &ListConfig,
form: &crate::form::PreparedForm,
path: &str,
id: &str,
shell: crate::Shell,
) -> Response {
let Some(tree) = &config.tree else {
return crate::not_found();
};
let current = match parent_row(&state.db, config, tree, id).await {
Ok(Some((_, parent))) => parent.unwrap_or_default(),
Ok(None) => return crate::not_found(),
Err(_) => return render_error(),
};
let mut values = HashMap::new();
values.insert(tree.parent_key.clone(), current);
let action = format!("{path}/{id}/move");
crate::form::page(state, form, &action, &values, &shell)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn move_record(
state: &AdminState,
config: &ListConfig,
form: &crate::form::PreparedForm,
path: &str,
id: &str,
user: &laterite_auth::AuthenticatedUser,
session: &crate::session::SessionHandle,
shell: crate::Shell,
headers: &axum::http::HeaderMap,
data: HashMap<String, String>,
) -> Response {
let Some(tree) = &config.tree else {
return crate::not_found();
};
match parent_row(&state.db, config, tree, id).await {
Ok(Some(_)) => {}
Ok(None) => return crate::not_found(),
Err(_) => return render_error(),
}
let action = format!("{path}/{id}/move");
let chosen = data
.get(&tree.parent_key)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let mut bag = laterite_core::validation::ErrorBag::default();
if let Some(parent) = &chosen {
let mut current = Some(parent.clone());
let mut steps = 0;
while let Some(at) = current.take() {
if at == id {
bag.add(
&tree.parent_key,
laterite_core::t!("A record cannot be moved under itself."),
);
break;
}
match parent_row(&state.db, config, tree, &at).await {
Ok(Some((_, above))) => current = above,
Ok(None) => {
bag.add(
&tree.parent_key,
laterite_core::t!("There is no such record."),
);
break;
}
Err(_) => return render_error(),
}
steps += 1;
if steps > 64 {
break;
}
}
}
let htmx = crate::form::is_htmx(headers);
if !bag.is_empty() {
return crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell);
}
let actor = laterite_core::Actor::from(user);
let outcome = async {
let mut tx = state
.db
.pool
.begin()
.await
.map_err(|e| MoveFailure::Failed(e.to_string()))?;
let mut cx = laterite_core::SaveCx::new(state.db.backend, &mut tx, actor);
let request = crate::persist::MoveTo {
entity: &config.entity,
id_field: &config.id_field,
id,
parent_key: &tree.parent_key,
parent: chosen.as_deref(),
};
form.persister()
.move_to(&mut cx, &request)
.await
.map_err(|e| match e {
crate::persist::SaveError::Invalid(bag) => MoveFailure::Refused(bag),
crate::persist::SaveError::Failed(m) => MoveFailure::Failed(m),
})?;
tx.commit()
.await
.map_err(|e| MoveFailure::Failed(e.to_string()))
}
.await;
match outcome {
Ok(()) => {
crate::audit::record(
state,
user,
&format!("{}.move", config.entity),
Some(&config.entity),
Some(id),
None,
Some(&match &chosen {
Some(parent) => format!("under {parent}"),
None => "top level".to_string(),
}),
)
.await;
session.push_flash(
crate::session::FlashLevel::Success,
laterite_core::t!("Moved."),
);
let back = match chosen {
Some(parent) => format!("{path}?parent={parent}"),
None => path.to_string(),
};
crate::form::saved_response(htmx, &back)
}
Err(MoveFailure::Refused(bag)) => {
crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell)
}
Err(MoveFailure::Failed(m)) => {
tracing::error!(entity = %config.entity, id, error = %m, "move failed");
crate::form::refused(
htmx,
state,
form,
&action,
Some(laterite_core::t!("Could not move. Try again.")),
&data,
&bag,
&shell,
)
}
}
}
enum MoveFailure {
Refused(laterite_core::validation::ErrorBag),
Failed(String),
}
#[allow(clippy::too_many_arguments)]
fn cell_markup(
state: &AdminState,
form: &crate::form::PreparedForm,
path: &str,
id: &str,
field: &str,
value: &str,
shell: &crate::Shell,
error: Option<&str>,
) -> String {
let dom_id = format!("cell-{id}-{field}");
let control = form
.control(state, field, &dom_id, value, shell)
.unwrap_or_default();
let action = format!("{path}/{id}/cell");
let mut html = format!(
r#"<form class="lat-cell{}" method="post" action="{action}" hx-post="{action}" hx-trigger="change, submit" hx-target="this" hx-swap="outerHTML" data-lat-widget="cell" data-lat-enter-scope><input type="hidden" name="_csrf" value="{}"><input type="hidden" name="field" value="{}">{control}<button class="lat-btn lat-btn--ghost lat-btn--sm lat-cell__save" type="submit">{}</button>"#,
if error.is_some() { " is-invalid" } else { "" },
crate::form::escape_attr(&shell.csrf_token),
crate::form::escape_attr(field),
shell.tt(&laterite_core::t!("Save")),
);
if let Some(message) = error {
html.push_str(&format!(
r#"<span class="lat-cell__error">{}</span>"#,
crate::form::escape_attr(message)
));
}
html.push_str("</form>");
html
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn cell_handler(
state: &AdminState,
config: &ListConfig,
path: &str,
id: &str,
user: &laterite_auth::AuthenticatedUser,
session: &crate::session::SessionHandle,
shell: crate::Shell,
headers: &axum::http::HeaderMap,
mut data: HashMap<String, String>,
) -> Response {
let Some(editor) = &config.editor else {
return crate::not_found();
};
let form = &editor.0;
let Some(field) = data.remove("field") else {
return crate::not_found();
};
data.remove("_csrf");
if !config
.columns
.iter()
.any(|c| c.editable && c.field == field)
|| !form.has_field(&field)
{
return crate::not_found();
}
let htmx = crate::form::is_htmx(headers);
let back = path.to_string();
let current = match crate::form::load_values(&state.db, form, id).await {
Ok(Some(values)) => values,
Ok(None) => return crate::not_found(),
Err(_) => return render_error(),
};
let submitted = data.get(&field).cloned().unwrap_or_default();
let mut merged = current.clone();
merged.remove(&field);
for (key, value) in data {
merged.insert(key, value);
}
let merged = crate::form::gathered(form, merged);
let actor = laterite_core::Actor::from(user);
match crate::form::write(&state.db, form, &merged, Some(id), &actor).await {
Ok(_) => {
if htmx {
let stored = crate::form::load_values(&state.db, form, id)
.await
.ok()
.flatten()
.and_then(|values| values.get(&field).cloned())
.unwrap_or(submitted);
let html = cell_markup(state, form, path, id, &field, &stored, &shell, None);
return axum::response::Html(html).into_response();
}
session.push_flash(
crate::session::FlashLevel::Success,
laterite_core::t!("Saved."),
);
crate::form::saved_response(false, &back)
}
Err(crate::persist::SaveError::Invalid(bag)) => {
let message = bag
.messages(&field)
.first()
.map(|m| shell.tt(m))
.or_else(|| {
bag.iter()
.next()
.and_then(|(_, m)| m.first())
.map(|m| shell.tt(m))
})
.unwrap_or_else(|| shell.tt(&laterite_core::t!("Could not save.")));
if htmx {
let html = cell_markup(
state,
form,
path,
id,
&field,
&submitted,
&shell,
Some(&message),
);
return (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
axum::response::Html(html),
)
.into_response();
}
session.push_flash(crate::session::FlashLevel::Error, Text::dynamic(message));
crate::form::saved_response(false, &back)
}
Err(crate::persist::SaveError::Failed(m)) => {
tracing::error!(entity = %config.entity, id, field, error = %m, "cell save failed");
render_error()
}
}
}
fn fragment_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> ListFragment {
ListFragment {
shell: page.shell,
columns: page.columns,
rows: page.rows,
shown: page.shown,
page: page.page,
total: page.total,
total_pages: page.total_pages,
edit_base: page.edit_base,
row_link: page.row_link,
sort: page.sort,
dir: page.dir,
carry: page.carry,
filtered: page.filtered,
deletable: page.deletable,
no_records_message: page.no_records_message,
path: page.path,
region: region.to_string(),
delete_path,
reorder: page.reorder,
tree: page.tree,
}
}
fn rows_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> RowsFragment {
RowsFragment {
shell: page.shell,
rows: page.rows,
page: page.page,
total_pages: page.total_pages,
edit_base: page.edit_base,
row_link: page.row_link,
deletable: page.deletable,
region: region.to_string(),
delete_path,
reorder: page.reorder,
tree: page.tree,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn fragment(
state: &AdminState,
config: &ListConfig,
path: &str,
params: ListParams,
raw: &HashMap<String, String>,
user: &laterite_auth::AuthenticatedUser,
shell: crate::Shell,
region: &str,
delete_path: Option<RowAction>,
) -> Result<crate::html::Markup, ()> {
let page = build_page(state, config, path, params, raw, user, shell)
.await
.ok_or(())?;
crate::html::Markup::from_template(&fragment_of(page, region, delete_path)).map_err(|_| ())
}
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>,
row_link: bool,
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,
import_href: Option<String>,
pickers: Vec<ColumnChoice>,
toolbar: Vec<ToolbarView>,
export_query: String,
path: String,
filters: Vec<FilterView>,
filtered: bool,
carry: String,
region: String,
delete_path: Option<RowAction>,
reorder: Option<ReorderView>,
tree: Option<TreeView>,
crumbs: Vec<Crumb>,
}
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>,
row_link: bool,
sort: String,
dir: String,
carry: String,
filtered: bool,
deletable: bool,
no_records_message: Option<String>,
path: String,
region: String,
delete_path: Option<RowAction>,
reorder: Option<ReorderView>,
tree: Option<TreeView>,
}
#[derive(Template)]
#[template(path = "_list_rows.html")]
struct RowsFragment {
shell: crate::Shell,
rows: Vec<RenderedRow>,
page: i64,
total_pages: i64,
edit_base: Option<String>,
row_link: bool,
deletable: bool,
region: String,
delete_path: Option<RowAction>,
reorder: Option<ReorderView>,
tree: Option<TreeView>,
}
#[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,
},
None,
);
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,
parent: None,
depth: None,
}
}
fn plain(config: &ListConfig) -> ListQuery<'_> {
ListQuery {
offset: 0,
order_by: &config.order_by,
order_dir: config.order_dir,
q: "",
filters: &[],
per_page: None,
children_of: 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,
children_of: 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,
children_of: 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,
parent: None,
depth: None,
};
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#"?sort=username"#) && html.contains(r#"href="/admin/"#),
"{html}"
);
}
#[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());
}
}