1use std::collections::HashMap;
12use std::sync::Arc;
13
14use askama::Template;
15use axum::response::Response;
16use chrono::DateTime;
17use chrono_tz::Tz;
18use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
19use laterite_core::search::SearchProfile;
20use laterite_core::{AnyRowExt, Db, Text};
21use sea_query::{Alias, Expr, Order, Query};
22use serde::{Deserialize, Serialize};
23
24use crate::field::{OverrideResolver, OverrideScope, Surface};
25use crate::html::Markup;
26use crate::sql::valid_ident;
27use crate::{render, render_error, AdminState};
28
29const ID_ALIAS: &str = "_lat_id";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32pub enum SortDir {
33 Asc,
34 Desc,
35}
36
37#[derive(Debug, Clone, Serialize)]
40pub struct ListColumn {
41 pub field: String,
42 pub label: Text,
44 pub column_type: String,
46 pub searchable: Option<bool>,
50}
51
52impl ListColumn {
53 pub fn new(field: &str, label: impl Into<Text>) -> Self {
54 Self {
55 field: field.to_string(),
56 label: label.into(),
57 column_type: "text".to_string(),
58 searchable: None,
59 }
60 }
61
62 fn of(mut self, column_type: &str) -> Self {
63 self.column_type = column_type.to_string();
64 self
65 }
66
67 pub fn datetime(self) -> Self {
69 self.of("datetime")
70 }
71 pub fn date(self) -> Self {
73 self.of("date")
74 }
75 pub fn time(self) -> Self {
77 self.of("time")
78 }
79 pub fn yes_no(self) -> Self {
81 self.of("boolean")
82 }
83 pub fn pill(self) -> Self {
85 self.of("status_pill")
86 }
87
88 pub fn searchable(mut self, searchable: bool) -> Self {
90 self.searchable = Some(searchable);
91 self
92 }
93
94 pub(crate) fn is_searchable(&self) -> bool {
96 self.searchable.unwrap_or(self.column_type == "text")
97 }
98}
99
100pub struct CellCx<'a> {
103 pub value: &'a str,
104 pub tz: Tz,
105 pub locale: chrono::Locale,
106}
107
108pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
114 chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
115}
116
117#[derive(Serialize)]
120pub struct CellVm {
121 pub view_key: String,
122 pub value: String,
123 pub display: String,
124 pub data: serde_json::Value,
125}
126
127pub trait ColumnType: Send + Sync + 'static {
130 fn view_key(&self) -> &'static str;
131 fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
132 fn render_default(&self, vm: &CellVm) -> Markup;
133 fn assets(&self) -> Vec<&'static str> {
136 Vec::new()
137 }
138}
139
140pub struct ColumnTypeReg {
147 pub column_type: Arc<dyn ColumnType>,
148}
149
150impl ColumnTypeReg {
151 pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
152 Self { column_type }
153 }
154
155 pub fn name(&self) -> &'static str {
157 self.column_type.view_key()
158 }
159}
160
161pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
163
164pub(crate) fn render_cell(
166 ct: &dyn ColumnType,
167 resolver: &dyn OverrideResolver,
168 scope: &OverrideScope<'_>,
169 cx: &CellCx<'_>,
170) -> Markup {
171 let vm = ct.view_model(cx);
172 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
173 Some(Ok(html)) => Markup::from_override(html),
174 Some(Err(_)) | None => ct.render_default(&vm),
175 }
176}
177
178pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
179 vec![
180 Arc::new(TextColumn),
181 Arc::new(DateTimeColumn),
182 Arc::new(DateColumn),
183 Arc::new(TimeColumn),
184 Arc::new(BoolColumn),
185 Arc::new(StatusPillColumn),
186 ]
187}
188
189pub(crate) fn builtin_column_registry() -> ColumnRegistry {
191 builtin_column_types()
192 .into_iter()
193 .map(|c| (c.view_key().to_string(), c))
194 .collect()
195}
196
197#[derive(Template)]
198#[template(path = "cells/text.html")]
199struct CellTextTmpl<'a> {
200 display: &'a str,
201}
202
203fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
205 CellVm {
206 view_key: view_key.to_string(),
207 value: value.to_string(),
208 display,
209 data: serde_json::Value::Null,
210 }
211}
212
213fn render_text(vm: &CellVm) -> Markup {
214 Markup::from_template(&CellTextTmpl {
215 display: &vm.display,
216 })
217 .unwrap_or_default()
218}
219
220pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
223 match DateTime::parse_from_rfc3339(raw) {
224 Ok(dt) => dt
225 .with_timezone(&tz)
226 .format_localized(pattern, locale)
227 .to_string(),
228 Err(_) => raw.to_string(),
229 }
230}
231
232struct TextColumn;
233impl ColumnType for TextColumn {
234 fn view_key(&self) -> &'static str {
235 "text"
236 }
237 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
238 text_cell("text", cx.value, cx.value.to_string())
239 }
240 fn render_default(&self, vm: &CellVm) -> Markup {
241 render_text(vm)
242 }
243}
244
245struct DateTimeColumn;
246impl ColumnType for DateTimeColumn {
247 fn view_key(&self) -> &'static str {
248 "datetime"
249 }
250 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
251 text_cell(
252 "datetime",
253 cx.value,
254 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
255 )
256 }
257 fn render_default(&self, vm: &CellVm) -> Markup {
258 render_text(vm)
259 }
260}
261
262struct DateColumn;
263impl ColumnType for DateColumn {
264 fn view_key(&self) -> &'static str {
265 "date"
266 }
267 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
268 text_cell(
269 "date",
270 cx.value,
271 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
272 )
273 }
274 fn render_default(&self, vm: &CellVm) -> Markup {
275 render_text(vm)
276 }
277}
278
279struct TimeColumn;
280impl ColumnType for TimeColumn {
281 fn view_key(&self) -> &'static str {
282 "time"
283 }
284 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
285 text_cell(
286 "time",
287 cx.value,
288 format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
289 )
290 }
291 fn render_default(&self, vm: &CellVm) -> Markup {
292 render_text(vm)
293 }
294}
295
296struct BoolColumn;
297impl ColumnType for BoolColumn {
298 fn view_key(&self) -> &'static str {
299 "boolean"
300 }
301 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
302 let display = match cx.value {
303 "1" | "true" => "Yes",
304 "0" | "false" | "" => "No",
305 other => other,
306 };
307 text_cell("boolean", cx.value, display.to_string())
308 }
309 fn render_default(&self, vm: &CellVm) -> Markup {
310 render_text(vm)
311 }
312}
313
314#[derive(Template)]
315#[template(path = "cells/status.html")]
316struct CellStatusTmpl<'a> {
317 label: &'a str,
318 slug: &'a str,
319}
320
321struct StatusPillColumn;
323impl ColumnType for StatusPillColumn {
324 fn view_key(&self) -> &'static str {
325 "status_pill"
326 }
327 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
328 CellVm {
329 view_key: "status_pill".to_string(),
330 value: cx.value.to_string(),
331 display: cx.value.to_string(),
332 data: serde_json::json!({ "slug": status_slug(cx.value) }),
333 }
334 }
335 fn render_default(&self, vm: &CellVm) -> Markup {
336 let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
337 Markup::from_template(&CellStatusTmpl {
338 label: &vm.display,
339 slug,
340 })
341 .unwrap_or_default()
342 }
343}
344
345fn status_slug(value: &str) -> String {
347 value
348 .trim()
349 .to_lowercase()
350 .chars()
351 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
352 .collect()
353}
354
355#[derive(Debug, Clone, Serialize)]
357pub struct FilterOption {
358 pub value: String,
359 pub label: Text,
361}
362
363impl FilterOption {
364 pub fn new(value: &str, label: impl Into<Text>) -> Self {
365 Self {
366 value: value.to_string(),
367 label: label.into(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize)]
374pub enum FilterKind {
375 Boolean,
378 Select { options: Vec<FilterOption> },
380}
381
382#[derive(Debug, Clone, Serialize)]
384pub struct ListFilter {
385 pub field: String,
386 pub label: Text,
388 pub kind: FilterKind,
389}
390
391impl ListFilter {
392 pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
394 Self {
395 field: field.to_string(),
396 label: label.into(),
397 kind: FilterKind::Boolean,
398 }
399 }
400
401 pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
403 Self {
404 field: field.to_string(),
405 label: label.into(),
406 kind: FilterKind::Select { options },
407 }
408 }
409
410 pub(crate) fn param(&self) -> String {
413 format!("f_{}", self.field)
414 }
415}
416
417#[derive(Debug, Clone, Serialize)]
423pub struct ToolbarButton {
424 pub label: Text,
426 pub href: String,
429 pub icon: Option<String>,
431 pub permission: Option<String>,
434 pub primary: bool,
436}
437
438impl ToolbarButton {
439 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
440 Self {
441 label: label.into(),
442 href: href.into(),
443 icon: None,
444 permission: None,
445 primary: false,
446 }
447 }
448
449 pub fn icon(mut self, icon: &str) -> Self {
450 self.icon = Some(icon.to_string());
451 self
452 }
453
454 pub fn require(mut self, permission: impl Into<String>) -> Self {
456 self.permission = Some(permission.into());
457 self
458 }
459
460 pub fn primary(mut self) -> Self {
461 self.primary = true;
462 self
463 }
464}
465
466pub struct ToolbarView {
468 pub label: String,
469 pub href: String,
470 pub icon: String,
472 pub primary: bool,
473}
474
475fn toolbar_views(
479 config: &ListConfig,
480 admin_path: &str,
481 user: &laterite_auth::AuthenticatedUser,
482 shell: &crate::Shell,
483) -> Vec<ToolbarView> {
484 let mut out = Vec::new();
485 if config.creatable {
486 if let Some(base) = &config.edit_base {
487 out.push(ToolbarView {
488 label: shell.tt(&laterite_core::t!("New")),
489 href: format!("{admin_path}{base}/new"),
490 icon: String::new(),
491 primary: true,
492 });
493 }
494 }
495 for button in &config.toolbar {
496 if let Some(needed) = &button.permission {
497 if !user.permissions.allows(needed) {
498 continue;
499 }
500 }
501 let href = if button.href.starts_with('/') {
502 format!("{admin_path}{}", button.href)
503 } else {
504 button.href.clone()
505 };
506 out.push(ToolbarView {
507 label: shell.tt(&button.label),
508 href,
509 icon: crate::icons::svg(button.icon.as_deref()).to_string(),
510 primary: button.primary,
511 });
512 }
513 out
514}
515
516pub(crate) fn columns_preference_key(base_path: &str) -> String {
519 format!("list.columns.{base_path}")
520}
521
522pub(crate) async fn stored_columns(
527 state: &AdminState,
528 path: &str,
529 user: &laterite_auth::AuthenticatedUser,
530) -> Option<String> {
531 match laterite_auth::store::user_preference(
532 &state.db,
533 user.user.id,
534 &columns_preference_key(path),
535 )
536 .await
537 {
538 Ok(stored) => stored,
539 Err(e) => {
540 tracing::warn!(error = %e, "reading the column preference failed");
541 None
542 }
543 }
544}
545
546pub(crate) fn narrowed(config: &ListConfig, stored: Option<&str>) -> ListConfig {
548 ListConfig {
549 columns: visible_columns(config, stored)
550 .into_iter()
551 .cloned()
552 .collect(),
553 ..config.clone()
554 }
555}
556
557pub(crate) fn visible_columns<'a>(
564 config: &'a ListConfig,
565 stored: Option<&str>,
566) -> Vec<&'a ListColumn> {
567 let Some(stored) = stored else {
568 return config.columns.iter().collect();
569 };
570 let chosen: Vec<&str> = stored
571 .split(',')
572 .map(str::trim)
573 .filter(|s| !s.is_empty())
574 .collect();
575 let kept: Vec<&ListColumn> = config
576 .columns
577 .iter()
578 .filter(|c| chosen.iter().any(|n| *n == c.field))
579 .collect();
580 if kept.is_empty() {
581 return config.columns.iter().collect();
582 }
583 kept
584}
585
586#[derive(Debug, Clone, Serialize)]
593#[non_exhaustive]
594pub struct ListConfig {
595 pub entity: String,
596 pub title: Text,
598 pub columns: Vec<ListColumn>,
599 pub order_by: String,
600 pub order_dir: SortDir,
601 pub per_page: i64,
602 pub id_field: String,
603 pub edit_base: Option<String>,
605 pub creatable: bool,
608 pub filters: Vec<ListFilter>,
610 pub deletable: bool,
614 pub toolbar: Vec<ToolbarButton>,
616}
617
618impl ListConfig {
619 pub fn new(
624 entity: impl Into<String>,
625 title: impl Into<Text>,
626 columns: Vec<ListColumn>,
627 ) -> Self {
628 Self {
629 entity: entity.into(),
630 title: title.into(),
631 columns,
632 ..Self::default()
633 }
634 }
635
636 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
638 self.order_by = column.into();
639 self.order_dir = dir;
640 self
641 }
642
643 pub fn per_page(mut self, rows: i64) -> Self {
644 self.per_page = rows;
645 self
646 }
647
648 pub fn id_field(mut self, column: impl Into<String>) -> Self {
650 self.id_field = column.into();
651 self
652 }
653
654 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
656 self.edit_base = Some(base.into());
657 self
658 }
659
660 pub fn creatable(mut self) -> Self {
664 self.creatable = true;
665 self
666 }
667
668 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
669 self.filters = filters;
670 self
671 }
672
673 pub fn deletable(mut self) -> Self {
676 self.deletable = true;
677 self
678 }
679
680 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
681 self.toolbar = buttons;
682 self
683 }
684}
685
686impl Default for ListConfig {
687 fn default() -> Self {
694 Self {
695 entity: String::new(),
696 title: Text::new(""),
697 columns: Vec::new(),
698 order_by: "id".to_string(),
699 order_dir: SortDir::Desc,
700 per_page: 25,
701 id_field: "id".to_string(),
702 edit_base: None,
703 creatable: false,
704 filters: Vec::new(),
705 deletable: false,
706 toolbar: Vec::new(),
707 }
708 }
709}
710
711#[derive(Deserialize)]
713pub struct ListParams {
714 page: Option<i64>,
715 sort: Option<String>,
716 dir: Option<String>,
717 q: Option<String>,
718}
719
720pub(crate) fn resolve_sort(
725 config: &ListConfig,
726 sort: Option<&str>,
727 dir: Option<&str>,
728) -> (String, SortDir) {
729 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s)) {
730 Some(field) => field.to_string(),
731 None => config.order_by.clone(),
732 };
733 let dir = match dir {
734 Some("asc") => SortDir::Asc,
735 Some("desc") => SortDir::Desc,
736 _ if by == config.order_by => config.order_dir,
739 _ => SortDir::Asc,
740 };
741 (by, dir)
742}
743
744pub struct RowView {
746 pub id: String,
747 pub cells: Vec<String>,
748}
749
750pub struct ListPage {
752 pub rows: Vec<RowView>,
753 pub total: i64,
754}
755
756pub(crate) struct ListQuery<'a> {
759 pub offset: i64,
760 pub order_by: &'a str,
761 pub order_dir: SortDir,
762 pub q: &'a str,
763 pub filters: &'a [ActiveFilter<'a>],
764 pub per_page: Option<i64>,
767}
768
769pub(crate) struct ActiveFilter<'a> {
773 filter: &'a ListFilter,
774 raw: String,
776 value: sea_query::Value,
777}
778
779pub(crate) fn resolve_filters<'a>(
782 config: &'a ListConfig,
783 params: &HashMap<String, String>,
784) -> Vec<ActiveFilter<'a>> {
785 let mut out = Vec::new();
786 for filter in &config.filters {
787 let Some(raw) = params.get(&filter.param()).map(|v| v.trim()) else {
788 continue;
789 };
790 if raw.is_empty() {
791 continue;
792 }
793 let value = match &filter.kind {
794 FilterKind::Boolean => match raw {
797 "1" => sea_query::Value::Bool(Some(true)),
798 "0" => sea_query::Value::Bool(Some(false)),
799 _ => continue,
800 },
801 FilterKind::Select { options } => {
802 if !options.iter().any(|o| o.value == raw) {
803 continue;
804 }
805 sea_query::Value::String(Some(Box::new(raw.to_string())))
806 }
807 };
808 out.push(ActiveFilter {
809 filter,
810 raw: raw.to_string(),
811 value,
812 });
813 }
814 out
815}
816
817fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
819 if active.is_empty() {
820 return None;
821 }
822 let mut all = sea_query::Condition::all();
823 for f in active {
824 all = all.add(Expr::col(Alias::new(&f.filter.field)).eq(f.value.clone()));
825 }
826 Some(all)
827}
828
829fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
833 let caps = laterite_core::capabilities::CapabilitySet::default();
837 let profile = SearchProfile::new();
838 let mut any = sea_query::Condition::any();
839 let mut matched = false;
840 for column in config.columns.iter().filter(|c| c.is_searchable()) {
841 if let Some(cond) = profile.condition(&caps, &column.field, q) {
842 any = any.add(cond);
843 matched = true;
844 }
845 }
846 matched.then_some(any)
847}
848
849pub(crate) async fn query(
854 db: &Db,
855 config: &ListConfig,
856 req: &ListQuery<'_>,
857) -> anyhow::Result<ListPage> {
858 if !valid_ident(&config.entity)
859 || !valid_ident(&config.order_by)
860 || !valid_ident(req.order_by)
861 || !valid_ident(&config.id_field)
862 || !config.columns.iter().all(|c| valid_ident(&c.field))
863 || !config.filters.iter().all(|f| valid_ident(&f.field))
864 {
865 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
866 }
867
868 let dir = match req.order_dir {
869 SortDir::Asc => Order::Asc,
870 SortDir::Desc => Order::Desc,
871 };
872 let (sql, values) = {
876 let mut select = Query::select();
877 for column in &config.columns {
878 select.expr_as(
879 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
880 Alias::new(&column.field),
881 );
882 }
883 select
884 .expr_as(
885 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
886 Alias::new(ID_ALIAS),
887 )
888 .from(Alias::new(&config.entity))
889 .order_by(Alias::new(req.order_by), dir)
890 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
891 .offset(req.offset.max(0) as u64);
892 if let Some(cond) = search_condition(config, req.q) {
893 select.cond_where(cond);
894 }
895 if let Some(cond) = filter_condition(req.filters) {
896 select.cond_where(cond);
897 }
898 build(db.backend, select)
899 };
900 let raw = bind_values(sqlx::query(&sql), values)
901 .fetch_all(&db.pool)
902 .await?;
903
904 let (csql, cvalues) = {
905 let mut count = Query::select();
906 count
907 .expr(Expr::col(Alias::new(&config.id_field)).count())
908 .from(Alias::new(&config.entity));
909 if let Some(cond) = search_condition(config, req.q) {
911 count.cond_where(cond);
912 }
913 if let Some(cond) = filter_condition(req.filters) {
914 count.cond_where(cond);
915 }
916 build(db.backend, count)
917 };
918 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
919 .fetch_one(&db.pool)
920 .await?
921 .0;
922
923 let rows = raw
924 .iter()
925 .map(|row| RowView {
926 id: get_text(row, ID_ALIAS),
927 cells: config
928 .columns
929 .iter()
930 .map(|c| get_text(row, &c.field))
931 .collect(),
932 })
933 .collect();
934 Ok(ListPage { rows, total })
935}
936
937fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
940 row.get_text_opt(column).ok().flatten().unwrap_or_default()
943}
944
945#[allow(clippy::too_many_arguments)]
947pub(crate) async fn handle(
948 state: &AdminState,
949 config: &ListConfig,
950 path: &str,
951 params: ListParams,
952 raw: &HashMap<String, String>,
953 user: &laterite_auth::AuthenticatedUser,
954 shell: crate::Shell,
955 headers: &axum::http::HeaderMap,
956) -> Response {
957 let stored = stored_columns(state, path, user).await;
961 let declared = config;
962 let narrowed = narrowed(config, stored.as_deref());
963 let config = &narrowed;
964
965 let page = params.page.unwrap_or(1).max(1);
966 let offset = (page - 1) * config.per_page;
967 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
968 let q = params.q.unwrap_or_default();
969 let active = resolve_filters(config, raw);
970 let req = ListQuery {
971 offset,
972 order_by: &order_by,
973 order_dir,
974 q: q.trim(),
975 filters: &active,
976 per_page: None,
977 };
978 let mut carry = String::new();
981 if !q.trim().is_empty() {
982 carry.push_str(&format!("&q={}", urlencode(q.trim())));
983 }
984 for f in &active {
985 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
986 }
987 match query(&state.db, config, &req).await {
988 Ok(result) => {
989 let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
990 let date_loc = date_locale(shell.locale());
991 let rows: Vec<RowView> = result
992 .rows
993 .into_iter()
994 .map(|row| RowView {
995 id: row.id,
996 cells: row
997 .cells
998 .iter()
999 .zip(&config.columns)
1000 .map(|(raw, col)| {
1001 let cx = CellCx {
1002 value: raw,
1003 tz: shell.tz,
1004 locale: date_loc,
1005 };
1006 let scope = OverrideScope {
1007 surface: Surface::Column,
1008 view_key: &col.column_type,
1009 resource: Some(&config.entity),
1010 field: Some(&col.field),
1011 };
1012 match state.column_types.get(&col.column_type) {
1013 Some(ct) => {
1014 render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
1015 .into_string()
1016 }
1017 None => String::new(),
1018 }
1019 })
1020 .collect(),
1021 })
1022 .collect();
1023 let keys: Vec<&str> = config
1024 .columns
1025 .iter()
1026 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1027 .flatten()
1028 .collect();
1029 let mut shell = shell;
1030 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1031 let shown = rows.len() as i64;
1032 let title = shell.tt(&config.title);
1034 let active_dir = match order_dir {
1035 SortDir::Asc => "asc",
1036 SortDir::Desc => "desc",
1037 };
1038 let columns: Vec<ColumnHead> = config
1039 .columns
1040 .iter()
1041 .map(|c| {
1042 let active = c.field == order_by;
1043 ColumnHead {
1044 label: shell.tt(&c.label),
1045 field: c.field.clone(),
1046 next_dir: if active && active_dir == "asc" {
1049 "desc".to_string()
1050 } else {
1051 "asc".to_string()
1052 },
1053 active: if active {
1054 active_dir.to_string()
1055 } else {
1056 String::new()
1057 },
1058 }
1059 })
1060 .collect();
1061 let shell_for_toolbar = shell.clone();
1062 let filter_views = filter_views(config, &active, &shell);
1063 let pickers: Vec<ColumnChoice> = declared
1064 .columns
1065 .iter()
1066 .map(|c| ColumnChoice {
1067 field: c.field.clone(),
1068 label: shell.tt(&c.label),
1069 shown: config.columns.iter().any(|v| v.field == c.field),
1070 })
1071 .collect();
1072 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1074 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1075 let page_view = ListTemplate {
1076 shell,
1077 title,
1078 columns,
1079 rows,
1080 shown,
1081 page,
1082 total: result.total,
1083 total_pages,
1084 edit_base: config.edit_base.clone(),
1085 creatable: config.creatable,
1086 sort: order_by,
1087 dir: active_dir.to_string(),
1088 q: q.trim().to_string(),
1089 searchable: config.columns.iter().any(|c| c.is_searchable()),
1090 pickers,
1091 toolbar,
1092 export_query,
1093 path: path.to_string(),
1094 filters: filter_views,
1095 deletable: config.deletable,
1096 filtered: !active.is_empty() || !q.trim().is_empty(),
1097 carry: carry.clone(),
1098 };
1099 if crate::form::is_htmx(headers) {
1103 render(ListFragment {
1104 shell: page_view.shell,
1105 columns: page_view.columns,
1106 rows: page_view.rows,
1107 shown: page_view.shown,
1108 page: page_view.page,
1109 total: page_view.total,
1110 total_pages: page_view.total_pages,
1111 edit_base: page_view.edit_base,
1112 sort: page_view.sort,
1113 dir: page_view.dir,
1114 carry: page_view.carry,
1115 filtered: page_view.filtered,
1116 creatable: page_view.creatable,
1117 deletable: page_view.deletable,
1118 })
1119 } else {
1120 render(page_view)
1121 }
1122 }
1123 Err(_) => render_error(),
1124 }
1125}
1126
1127pub(crate) async fn set_columns(
1134 state: &AdminState,
1135 config: &ListConfig,
1136 path: &str,
1137 user: &laterite_auth::AuthenticatedUser,
1138 session: &crate::session::SessionHandle,
1139 headers: &axum::http::HeaderMap,
1140 pairs: &[(String, String)],
1141) -> Response {
1142 let chosen: Vec<&str> = pairs
1143 .iter()
1144 .filter(|(k, _)| k == "column")
1145 .map(|(_, v)| v.trim())
1146 .filter(|v| config.columns.iter().any(|c| c.field == *v))
1147 .collect();
1148
1149 let key = columns_preference_key(path);
1150 let result = if chosen.is_empty() || chosen.len() == config.columns.len() {
1151 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1152 } else {
1153 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1154 .await
1155 };
1156 match result {
1157 Ok(()) => session.push_flash(
1158 crate::session::FlashLevel::Success,
1159 laterite_core::t!("Columns updated."),
1160 ),
1161 Err(e) => {
1162 tracing::error!(error = %e, "storing the column choice failed");
1163 session.push_flash(
1164 crate::session::FlashLevel::Error,
1165 laterite_core::t!("Could not save your column choice."),
1166 );
1167 }
1168 }
1169 let back = format!("{}{}", state.admin_path, path);
1170 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1171}
1172
1173pub struct ColumnChoice {
1175 pub field: String,
1176 pub label: String,
1177 pub shown: bool,
1178}
1179
1180pub struct ColumnHead {
1183 pub label: String,
1184 pub field: String,
1185 pub next_dir: String,
1186 pub active: String,
1187}
1188
1189#[derive(Template)]
1190#[template(path = "list.html")]
1191struct ListTemplate {
1192 shell: crate::Shell,
1193 title: String,
1194 columns: Vec<ColumnHead>,
1195 rows: Vec<RowView>,
1196 shown: i64,
1198 page: i64,
1199 total: i64,
1200 total_pages: i64,
1201 edit_base: Option<String>,
1202 creatable: bool,
1203 sort: String,
1205 dir: String,
1206 q: String,
1208 searchable: bool,
1210 deletable: bool,
1212 pickers: Vec<ColumnChoice>,
1214 toolbar: Vec<ToolbarView>,
1216 export_query: String,
1219 path: String,
1221 filters: Vec<FilterView>,
1223 filtered: bool,
1225 carry: String,
1227}
1228
1229pub struct FilterView {
1231 pub param: String,
1232 pub label: String,
1233 pub boolean: bool,
1234 pub options: Vec<FilterOptionView>,
1235 pub value: String,
1237}
1238
1239pub struct FilterOptionView {
1240 pub value: String,
1241 pub label: String,
1242}
1243
1244fn filter_views(
1246 config: &ListConfig,
1247 active: &[ActiveFilter<'_>],
1248 shell: &crate::Shell,
1249) -> Vec<FilterView> {
1250 config
1251 .filters
1252 .iter()
1253 .map(|f| {
1254 let value = active
1255 .iter()
1256 .find(|a| a.filter.field == f.field)
1257 .map(|a| a.raw.clone())
1258 .unwrap_or_default();
1259 let (boolean, options) = match &f.kind {
1260 FilterKind::Boolean => (
1261 true,
1262 vec![
1263 FilterOptionView {
1264 value: "1".to_string(),
1265 label: shell.tt(&laterite_core::t!("Yes")),
1266 },
1267 FilterOptionView {
1268 value: "0".to_string(),
1269 label: shell.tt(&laterite_core::t!("No")),
1270 },
1271 ],
1272 ),
1273 FilterKind::Select { options } => (
1274 false,
1275 options
1276 .iter()
1277 .map(|o| FilterOptionView {
1278 value: o.value.clone(),
1279 label: shell.tt(&o.label),
1280 })
1281 .collect(),
1282 ),
1283 };
1284 FilterView {
1285 param: f.param(),
1286 label: shell.tt(&f.label),
1287 boolean,
1288 options,
1289 value,
1290 }
1291 })
1292 .collect()
1293}
1294
1295fn urlencode(value: &str) -> String {
1297 let mut out = String::with_capacity(value.len());
1298 for byte in value.as_bytes() {
1299 match byte {
1300 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1301 out.push(*byte as char)
1302 }
1303 b' ' => out.push('+'),
1304 _ => out.push_str(&format!("%{byte:02X}")),
1305 }
1306 }
1307 out
1308}
1309
1310#[derive(Template)]
1312#[template(path = "_list_table.html")]
1313struct ListFragment {
1314 shell: crate::Shell,
1315 columns: Vec<ColumnHead>,
1316 rows: Vec<RowView>,
1317 shown: i64,
1318 page: i64,
1319 total: i64,
1320 total_pages: i64,
1321 edit_base: Option<String>,
1322 sort: String,
1323 dir: String,
1324 carry: String,
1325 filtered: bool,
1329 creatable: bool,
1330 deletable: bool,
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 use super::*;
1336
1337 fn config() -> ListConfig {
1338 ListConfig {
1339 entity: "backend_users".to_string(),
1340 title: "Users".into(),
1341 columns: vec![
1342 ListColumn::new("username", "Username"),
1343 ListColumn::new("is_superuser", "Superuser").yes_no(),
1344 ],
1345 order_by: "created_at".to_string(),
1346 order_dir: SortDir::Desc,
1347 per_page: 25,
1348 id_field: "id".to_string(),
1349 edit_base: None,
1350 creatable: false,
1351 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1352 deletable: true,
1353 ..Default::default()
1354 }
1355 }
1356
1357 #[test]
1358 fn column_types_format_their_cell_display() {
1359 let ist: Tz = "Asia/Kolkata".parse().unwrap();
1360 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1361 ct.view_model(&CellCx {
1362 value,
1363 tz,
1364 locale: chrono::Locale::en_US,
1365 })
1366 .display
1367 };
1368 assert_eq!(
1370 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
1371 "13 Aug 2026, 15:30"
1372 );
1373 assert_eq!(
1374 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
1375 "13 Aug 2026"
1376 );
1377 assert_eq!(
1378 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
1379 "15:30"
1380 );
1381 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
1382 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
1383 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
1384 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
1386 }
1387
1388 #[test]
1389 fn sort_falls_back_to_the_configured_order() {
1390 let c = config();
1391 assert_eq!(
1392 resolve_sort(&c, None, None),
1393 ("created_at".to_string(), SortDir::Desc)
1394 );
1395 }
1396
1397 #[test]
1398 fn a_declared_column_can_be_sorted_either_way() {
1399 let c = config();
1400 assert_eq!(
1401 resolve_sort(&c, Some("username"), Some("asc")),
1402 ("username".to_string(), SortDir::Asc)
1403 );
1404 assert_eq!(
1405 resolve_sort(&c, Some("username"), Some("desc")),
1406 ("username".to_string(), SortDir::Desc)
1407 );
1408 }
1409
1410 #[test]
1413 fn an_undeclared_sort_column_is_ignored() {
1414 let c = config();
1415 for crafted in [
1416 "password_hash",
1417 "id) --",
1418 "username; drop table backend_users",
1419 "",
1420 ] {
1421 assert_eq!(
1422 resolve_sort(&c, Some(crafted), Some("asc")).0,
1423 "created_at",
1424 "{crafted} must not reach the query"
1425 );
1426 }
1427 }
1428
1429 #[test]
1430 fn an_unknown_direction_falls_back() {
1431 let c = config();
1432 assert_eq!(
1434 resolve_sort(&c, Some("username"), Some("sideways")).1,
1435 SortDir::Asc
1436 );
1437 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1439 }
1440
1441 #[test]
1444 fn a_contributed_column_type_renders_its_cell() {
1445 struct Rating;
1446 impl ColumnType for Rating {
1447 fn view_key(&self) -> &'static str {
1448 "acme.rating"
1449 }
1450 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1451 let stars = cx.value.parse::<usize>().unwrap_or(0);
1452 text_cell("acme.rating", cx.value, "*".repeat(stars))
1453 }
1454 fn render_default(&self, vm: &CellVm) -> Markup {
1455 render_text(vm)
1456 }
1457 }
1458
1459 let reg = ColumnTypeReg::new(Arc::new(Rating));
1460 assert_eq!(reg.name(), "acme.rating", "the type names itself");
1461
1462 let mut registry = builtin_column_registry();
1463 registry.insert(reg.name().to_string(), reg.column_type);
1464 let rendered = render_cell(
1465 registry.get("acme.rating").unwrap().as_ref(),
1466 &crate::field::NoOverrides,
1467 &OverrideScope {
1468 surface: Surface::Column,
1469 view_key: "acme.rating",
1470 resource: Some("products"),
1471 field: Some("stars"),
1472 },
1473 &CellCx {
1474 value: "3",
1475 tz: Tz::UTC,
1476 locale: chrono::Locale::en_US,
1477 },
1478 );
1479 assert!(rendered.into_string().contains("***"));
1480 }
1481
1482 #[test]
1485 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
1486 struct Impostor;
1487 impl ColumnType for Impostor {
1488 fn view_key(&self) -> &'static str {
1489 "text"
1490 }
1491 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1492 text_cell("text", cx.value, String::new())
1493 }
1494 fn render_default(&self, vm: &CellVm) -> Markup {
1495 render_text(vm)
1496 }
1497 }
1498 let reg = ColumnTypeReg::new(Arc::new(Impostor));
1499 assert_eq!(reg.name(), "text");
1500 assert!(builtin_column_registry().contains_key(reg.name()));
1502 }
1503
1504 #[test]
1505 fn status_pill_renders_a_slugged_markup_span() {
1506 let vm = StatusPillColumn.view_model(&CellCx {
1507 value: "In Progress",
1508 tz: Tz::UTC,
1509 locale: chrono::Locale::en_US,
1510 });
1511 let html = StatusPillColumn.render_default(&vm).into_string();
1512 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
1513 assert!(html.contains(">In Progress<"));
1514 }
1515
1516 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1519 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
1520 }
1521
1522 #[tokio::test]
1523 async fn query_returns_display_rows() {
1524 let (db, _guard) = test_db().await;
1525 let hash = laterite_auth::password::hash_password("pw").unwrap();
1526 laterite_auth::store::create_user(
1527 &db,
1528 "root",
1529 "root@example.test",
1530 "Ada",
1531 None,
1532 &hash,
1533 true,
1534 )
1535 .await
1536 .unwrap();
1537
1538 let c = config();
1539 let result = query(&db, &c, &plain(&c)).await.unwrap();
1540 assert_eq!(result.total, 1);
1541 assert_eq!(result.rows.len(), 1);
1542 assert_eq!(result.rows[0].cells[0], "root");
1543 assert_eq!(result.rows[0].cells[1], "1");
1546 assert!(!result.rows[0].id.is_empty());
1547 }
1548
1549 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
1550 get_with(params, HashMap::new(), headers).await
1551 }
1552
1553 async fn get_with(
1554 params: ListParams,
1555 raw: HashMap<String, String>,
1556 headers: axum::http::HeaderMap,
1557 ) -> String {
1558 let (db, _guard) = test_db().await;
1559 let state = AdminState::new(
1560 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1561 db,
1562 );
1563 let resp = handle(
1564 &state,
1565 &config(),
1566 "/admin/users",
1567 params,
1568 &raw,
1569 &crate::audit::test_actor(),
1570 crate::Shell::test(),
1571 &headers,
1572 )
1573 .await;
1574 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1575 .await
1576 .unwrap();
1577 String::from_utf8(bytes.to_vec()).unwrap()
1578 }
1579
1580 fn params(sort: Option<&str>) -> ListParams {
1581 ListParams {
1582 page: None,
1583 sort: sort.map(|s| s.to_string()),
1584 dir: Some("asc".to_string()),
1585 q: None,
1586 }
1587 }
1588
1589 fn plain(config: &ListConfig) -> ListQuery<'_> {
1591 ListQuery {
1592 offset: 0,
1593 order_by: &config.order_by,
1594 order_dir: config.order_dir,
1595 q: "",
1596 filters: &[],
1597 per_page: None,
1598 }
1599 }
1600
1601 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
1602 ListQuery {
1603 offset: 0,
1604 order_by: &config.order_by,
1605 order_dir: config.order_dir,
1606 q,
1607 filters: &[],
1608 per_page: None,
1609 }
1610 }
1611
1612 fn amps(html: &str) -> String {
1615 html.replace("&", "&").replace("&", "&")
1616 }
1617
1618 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1620 pairs
1621 .iter()
1622 .map(|(k, v)| (k.to_string(), v.to_string()))
1623 .collect()
1624 }
1625
1626 #[tokio::test]
1627 async fn search_filters_rows_and_the_total() {
1628 let (db, _guard) = test_db().await;
1629 for name in ["ada", "brendan", "clara"] {
1630 let hash = laterite_auth::password::hash_password("x").unwrap();
1631 laterite_auth::store::create_user(
1632 &db,
1633 name,
1634 &format!("{name}@example.test"),
1635 name,
1636 None,
1637 &hash,
1638 false,
1639 )
1640 .await
1641 .unwrap();
1642 }
1643 let c = config();
1644
1645 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
1646 assert_eq!(hit.rows.len(), 1);
1647 assert_eq!(hit.rows[0].cells[0], "brendan");
1648 assert_eq!(hit.total, 1);
1650
1651 assert_eq!(
1653 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
1654 1
1655 );
1656
1657 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
1659 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
1660
1661 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
1663 }
1664
1665 #[tokio::test]
1668 async fn search_skips_a_non_text_column() {
1669 let (db, _guard) = test_db().await;
1670 let hash = laterite_auth::password::hash_password("x").unwrap();
1671 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
1672 .await
1673 .unwrap();
1674 let c = config();
1675 assert!(c.columns[0].is_searchable(), "username is text");
1676 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
1677 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
1679 }
1680
1681 #[test]
1682 fn a_column_can_override_its_searchability() {
1683 assert!(ListColumn::new("code", "Code").is_searchable());
1684 assert!(!ListColumn::new("code", "Code")
1685 .searchable(false)
1686 .is_searchable());
1687 assert!(ListColumn::new("at", "At")
1688 .datetime()
1689 .searchable(true)
1690 .is_searchable());
1691 }
1692
1693 async fn seed_users(db: &Db) {
1694 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
1695 let hash = laterite_auth::password::hash_password("x").unwrap();
1696 laterite_auth::store::create_user(
1697 db,
1698 name,
1699 &format!("{name}@example.test"),
1700 name,
1701 None,
1702 &hash,
1703 su,
1704 )
1705 .await
1706 .unwrap();
1707 }
1708 }
1709
1710 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
1711 let active = resolve_filters(c, params);
1712 let req = ListQuery {
1713 offset: 0,
1714 order_by: &c.order_by,
1715 order_dir: c.order_dir,
1716 q: "",
1717 filters: &active,
1718 per_page: None,
1719 };
1720 query(db, c, &req).await.unwrap().total
1721 }
1722
1723 #[tokio::test]
1724 async fn a_filter_narrows_the_rows_and_the_total() {
1725 let (db, _guard) = test_db().await;
1726 seed_users(&db).await;
1727 let c = config();
1728
1729 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
1730 assert_eq!(
1731 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
1732 1
1733 );
1734 assert_eq!(
1735 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
1736 2
1737 );
1738 }
1739
1740 #[test]
1743 fn an_undeclared_filter_or_value_is_ignored() {
1744 let c = config();
1745 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
1747 for bad in ["yes", "true", "1 or 1=1", "", " "] {
1749 assert!(
1750 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
1751 "{bad} must not reach the query"
1752 );
1753 }
1754
1755 let with_select = ListConfig {
1756 filters: vec![ListFilter::select(
1757 "status",
1758 "Status",
1759 vec![FilterOption::new("live", "Live")],
1760 )],
1761 ..config()
1762 };
1763 assert_eq!(
1764 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
1765 1
1766 );
1767 assert!(
1768 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
1769 "an option the descriptor never offered is dropped"
1770 );
1771 }
1772
1773 #[tokio::test]
1774 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
1775 let mut p = params(Some("username"));
1776 p.q = None;
1777 let html = get_with(
1778 p,
1779 filter_params(&[("f_is_superuser", "1")]),
1780 axum::http::HeaderMap::new(),
1781 )
1782 .await;
1783 let links = amps(&html);
1784 assert!(
1785 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
1786 "a sort link carries the filter"
1787 );
1788 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
1790 assert!(
1791 html.contains(r#"<option value="1" selected>"#),
1792 "the control shows the active value"
1793 );
1794 assert!(html.contains("Clear"), "and a way back to the full list");
1795 assert!(
1796 html.contains(r#"data-filtered="true""#),
1797 "the region says the list is narrowed, which is what reveals Clear"
1798 );
1799 }
1800
1801 #[tokio::test]
1804 async fn the_empty_state_distinguishes_no_records_from_no_match() {
1805 let (db, _guard) = test_db().await;
1806 let state = AdminState::new(
1807 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1808 db,
1809 );
1810 let render = |raw: HashMap<String, String>, q: Option<&str>| {
1811 let q = q.map(String::from);
1812 let state = state.clone();
1813 async move {
1814 let p = ListParams {
1815 page: None,
1816 sort: None,
1817 dir: None,
1818 q,
1819 };
1820 let resp = handle(
1821 &state,
1822 &config(),
1823 "/admin/users",
1824 p,
1825 &raw,
1826 &crate::audit::test_actor(),
1827 crate::Shell::test(),
1828 &axum::http::HeaderMap::new(),
1829 )
1830 .await;
1831 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1832 .await
1833 .unwrap();
1834 String::from_utf8(bytes.to_vec()).unwrap()
1835 }
1836 };
1837
1838 let bare = render(HashMap::new(), None).await;
1840 assert!(bare.contains("No records yet."));
1841
1842 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
1844 assert!(filtered.contains("No records match."));
1845 assert!(!filtered.contains("No records yet."));
1846
1847 let searched = render(HashMap::new(), Some("zzz")).await;
1849 assert!(searched.contains("No records match."));
1850 }
1851
1852 #[test]
1853 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
1854 let c = config();
1855 let kept = visible_columns(&c, Some("is_superuser,username"));
1858 assert_eq!(
1859 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
1860 ["username", "is_superuser"]
1861 );
1862 }
1863
1864 #[test]
1865 fn no_stored_choice_shows_every_column() {
1866 let c = config();
1867 assert_eq!(visible_columns(&c, None).len(), c.columns.len());
1868 }
1869
1870 #[test]
1873 fn a_stale_choice_falls_back_to_every_column() {
1874 let c = config();
1875 assert_eq!(
1876 visible_columns(&c, Some("gone,removed")).len(),
1877 c.columns.len()
1878 );
1879 assert_eq!(visible_columns(&c, Some("")).len(), c.columns.len());
1880 assert_eq!(visible_columns(&c, Some(" , ")).len(), c.columns.len());
1881 assert_eq!(visible_columns(&c, Some("gone,username")).len(), 1);
1883 }
1884
1885 #[test]
1888 fn the_toolbar_hides_what_the_operator_may_not_do() {
1889 let shell = crate::Shell::test();
1890 let cfg = ListConfig {
1891 creatable: false,
1892 toolbar: vec![
1893 ToolbarButton::new("Open", "/reports"),
1894 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
1895 ],
1896 ..config()
1897 };
1898
1899 let root = laterite_auth::AuthenticatedUser {
1901 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1902 ..crate::audit::test_actor()
1903 };
1904 let views = toolbar_views(&cfg, "/admin", &root, &shell);
1905 assert_eq!(
1906 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1907 ["Open", "Audit"]
1908 );
1909
1910 let granted = laterite_auth::AuthenticatedUser {
1912 permissions: laterite_auth::PermissionSet::with_overrides(
1913 false,
1914 ["backend.view_audit".to_string()],
1915 [],
1916 [],
1917 ),
1918 ..crate::audit::test_actor()
1919 };
1920 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
1921
1922 let plain = laterite_auth::AuthenticatedUser {
1924 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
1925 ..crate::audit::test_actor()
1926 };
1927 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
1928 assert_eq!(
1929 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1930 ["Open"]
1931 );
1932 }
1933
1934 #[test]
1937 fn a_toolbar_href_follows_the_admin_mount() {
1938 let shell = crate::Shell::test();
1939 let cfg = ListConfig {
1940 creatable: true,
1941 edit_base: Some("/roles".to_string()),
1942 toolbar: vec![
1943 ToolbarButton::new("Reports", "/reports"),
1944 ToolbarButton::new("Docs", "https://example.test/docs"),
1945 ],
1946 ..config()
1947 };
1948 let root = laterite_auth::AuthenticatedUser {
1949 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1950 ..crate::audit::test_actor()
1951 };
1952 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
1953 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
1954 assert_eq!(views[1].href, "/backoffice/reports");
1955 assert_eq!(views[2].href, "https://example.test/docs");
1957 }
1958
1959 #[tokio::test]
1960 async fn an_htmx_sort_returns_the_list_region_alone() {
1961 let mut headers = axum::http::HeaderMap::new();
1962 headers.insert("hx-request", "true".parse().unwrap());
1963 let html = get(params(Some("username")), headers).await;
1964 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
1965 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
1966 assert!(!html.contains("<body"), "no page chrome");
1969 assert!(html.contains(r#"data-filtered="false""#));
1971 }
1972
1973 #[tokio::test]
1976 async fn the_search_term_survives_a_sort_link() {
1977 let mut p = params(Some("username"));
1978 p.q = Some("bren".to_string());
1979 let html = get(p, axum::http::HeaderMap::new()).await;
1980 assert!(
1981 amps(&html).contains("?sort=username&dir=desc&q=bren"),
1982 "sort and pager links carry the term"
1983 );
1984 assert!(
1985 html.contains(r#"value="bren""#),
1986 "and the box still shows it"
1987 );
1988 }
1989
1990 #[tokio::test]
1991 async fn a_plain_sort_returns_the_whole_page() {
1992 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
1993 assert!(html.contains("<body"), "lists still work without htmx");
1994 assert!(html.contains(r#"id="lat-list""#));
1995 assert!(html.contains(r#"href="?sort=username"#));
1997 }
1998
1999 #[tokio::test]
2000 async fn query_rejects_bad_identifiers() {
2001 let (db, _guard) = test_db().await;
2002 let mut bad = config();
2003 bad.entity = "backend_users; drop table backend_users".to_string();
2004 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2005 }
2006}