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(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
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 exportable: bool,
617 pub toolbar: Vec<ToolbarButton>,
619}
620
621impl ListConfig {
622 pub fn new(
627 entity: impl Into<String>,
628 title: impl Into<Text>,
629 columns: Vec<ListColumn>,
630 ) -> Self {
631 Self {
632 entity: entity.into(),
633 title: title.into(),
634 columns,
635 ..Self::default()
636 }
637 }
638
639 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
641 self.order_by = column.into();
642 self.order_dir = dir;
643 self
644 }
645
646 pub fn per_page(mut self, rows: i64) -> Self {
647 self.per_page = rows;
648 self
649 }
650
651 pub fn id_field(mut self, column: impl Into<String>) -> Self {
653 self.id_field = column.into();
654 self
655 }
656
657 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
659 self.edit_base = Some(base.into());
660 self
661 }
662
663 pub fn creatable(mut self) -> Self {
667 self.creatable = true;
668 self
669 }
670
671 pub fn exportable(mut self) -> Self {
675 self.exportable = true;
676 self
677 }
678
679 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
680 self.filters = filters;
681 self
682 }
683
684 pub fn deletable(mut self) -> Self {
687 self.deletable = true;
688 self
689 }
690
691 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
692 self.toolbar = buttons;
693 self
694 }
695}
696
697impl Default for ListConfig {
698 fn default() -> Self {
705 Self {
706 entity: String::new(),
707 title: Text::new(""),
708 columns: Vec::new(),
709 order_by: "id".to_string(),
710 order_dir: SortDir::Desc,
711 per_page: 25,
712 id_field: "id".to_string(),
713 edit_base: None,
714 creatable: false,
715 filters: Vec::new(),
716 deletable: false,
717 exportable: false,
718 toolbar: Vec::new(),
719 }
720 }
721}
722
723#[derive(Deserialize)]
725pub struct ListParams {
726 page: Option<i64>,
727 sort: Option<String>,
728 dir: Option<String>,
729 q: Option<String>,
730}
731
732pub(crate) fn resolve_sort(
737 config: &ListConfig,
738 sort: Option<&str>,
739 dir: Option<&str>,
740) -> (String, SortDir) {
741 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s)) {
742 Some(field) => field.to_string(),
743 None => config.order_by.clone(),
744 };
745 let dir = match dir {
746 Some("asc") => SortDir::Asc,
747 Some("desc") => SortDir::Desc,
748 _ if by == config.order_by => config.order_dir,
751 _ => SortDir::Asc,
752 };
753 (by, dir)
754}
755
756pub struct RowView {
758 pub id: String,
759 pub cells: Vec<String>,
760}
761
762pub struct ListPage {
764 pub rows: Vec<RowView>,
765 pub total: i64,
766}
767
768pub(crate) struct ListQuery<'a> {
771 pub offset: i64,
772 pub order_by: &'a str,
773 pub order_dir: SortDir,
774 pub q: &'a str,
775 pub filters: &'a [ActiveFilter<'a>],
776 pub per_page: Option<i64>,
779}
780
781pub(crate) struct ActiveFilter<'a> {
785 filter: &'a ListFilter,
786 raw: String,
788 value: sea_query::Value,
789}
790
791pub(crate) fn resolve_filters<'a>(
794 config: &'a ListConfig,
795 params: &HashMap<String, String>,
796) -> Vec<ActiveFilter<'a>> {
797 let mut out = Vec::new();
798 for filter in &config.filters {
799 let Some(raw) = params.get(&filter.param()).map(|v| v.trim()) else {
800 continue;
801 };
802 if raw.is_empty() {
803 continue;
804 }
805 let value = match &filter.kind {
806 FilterKind::Boolean => match raw {
809 "1" => sea_query::Value::Bool(Some(true)),
810 "0" => sea_query::Value::Bool(Some(false)),
811 _ => continue,
812 },
813 FilterKind::Select { options } => {
814 if !options.iter().any(|o| o.value == raw) {
815 continue;
816 }
817 sea_query::Value::String(Some(Box::new(raw.to_string())))
818 }
819 };
820 out.push(ActiveFilter {
821 filter,
822 raw: raw.to_string(),
823 value,
824 });
825 }
826 out
827}
828
829fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
831 if active.is_empty() {
832 return None;
833 }
834 let mut all = sea_query::Condition::all();
835 for f in active {
836 all = all.add(Expr::col(Alias::new(&f.filter.field)).eq(f.value.clone()));
837 }
838 Some(all)
839}
840
841fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
845 let caps = laterite_core::capabilities::CapabilitySet::default();
849 let profile = SearchProfile::new();
850 let mut any = sea_query::Condition::any();
851 let mut matched = false;
852 for column in config.columns.iter().filter(|c| c.is_searchable()) {
853 if let Some(cond) = profile.condition(&caps, &column.field, q) {
854 any = any.add(cond);
855 matched = true;
856 }
857 }
858 matched.then_some(any)
859}
860
861pub(crate) async fn query(
866 db: &Db,
867 config: &ListConfig,
868 req: &ListQuery<'_>,
869) -> anyhow::Result<ListPage> {
870 if !valid_ident(&config.entity)
871 || !valid_ident(&config.order_by)
872 || !valid_ident(req.order_by)
873 || !valid_ident(&config.id_field)
874 || !config.columns.iter().all(|c| valid_ident(&c.field))
875 || !config.filters.iter().all(|f| valid_ident(&f.field))
876 {
877 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
878 }
879
880 let dir = match req.order_dir {
881 SortDir::Asc => Order::Asc,
882 SortDir::Desc => Order::Desc,
883 };
884 let (sql, values) = {
888 let mut select = Query::select();
889 for column in &config.columns {
890 select.expr_as(
891 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
892 Alias::new(&column.field),
893 );
894 }
895 select
896 .expr_as(
897 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
898 Alias::new(ID_ALIAS),
899 )
900 .from(Alias::new(&config.entity))
901 .order_by(Alias::new(req.order_by), dir)
902 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
903 .offset(req.offset.max(0) as u64);
904 if let Some(cond) = search_condition(config, req.q) {
905 select.cond_where(cond);
906 }
907 if let Some(cond) = filter_condition(req.filters) {
908 select.cond_where(cond);
909 }
910 build(db.backend, select)
911 };
912 let raw = bind_values(sqlx::query(&sql), values)
913 .fetch_all(&db.pool)
914 .await?;
915
916 let (csql, cvalues) = {
917 let mut count = Query::select();
918 count
919 .expr(Expr::col(Alias::new(&config.id_field)).count())
920 .from(Alias::new(&config.entity));
921 if let Some(cond) = search_condition(config, req.q) {
923 count.cond_where(cond);
924 }
925 if let Some(cond) = filter_condition(req.filters) {
926 count.cond_where(cond);
927 }
928 build(db.backend, count)
929 };
930 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
931 .fetch_one(&db.pool)
932 .await?
933 .0;
934
935 let rows = raw
936 .iter()
937 .map(|row| RowView {
938 id: get_text(row, ID_ALIAS),
939 cells: config
940 .columns
941 .iter()
942 .map(|c| get_text(row, &c.field))
943 .collect(),
944 })
945 .collect();
946 Ok(ListPage { rows, total })
947}
948
949fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
952 row.get_text_opt(column).ok().flatten().unwrap_or_default()
955}
956
957#[allow(clippy::too_many_arguments)]
959pub(crate) async fn handle(
960 state: &AdminState,
961 config: &ListConfig,
962 path: &str,
963 params: ListParams,
964 raw: &HashMap<String, String>,
965 user: &laterite_auth::AuthenticatedUser,
966 shell: crate::Shell,
967 headers: &axum::http::HeaderMap,
968) -> Response {
969 let stored = stored_columns(state, path, user).await;
973 let declared = config;
974 let narrowed = narrowed(config, stored.as_deref());
975 let config = &narrowed;
976
977 let page = params.page.unwrap_or(1).max(1);
978 let offset = (page - 1) * config.per_page;
979 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
980 let q = params.q.unwrap_or_default();
981 let active = resolve_filters(config, raw);
982 let req = ListQuery {
983 offset,
984 order_by: &order_by,
985 order_dir,
986 q: q.trim(),
987 filters: &active,
988 per_page: None,
989 };
990 let mut carry = String::new();
993 if !q.trim().is_empty() {
994 carry.push_str(&format!("&q={}", urlencode(q.trim())));
995 }
996 for f in &active {
997 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
998 }
999 match query(&state.db, config, &req).await {
1000 Ok(result) => {
1001 let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
1002 let date_loc = date_locale(shell.locale());
1003 let rows: Vec<RowView> = result
1004 .rows
1005 .into_iter()
1006 .map(|row| RowView {
1007 id: row.id,
1008 cells: row
1009 .cells
1010 .iter()
1011 .zip(&config.columns)
1012 .map(|(raw, col)| {
1013 let cx = CellCx {
1014 value: raw,
1015 tz: shell.tz,
1016 locale: date_loc,
1017 };
1018 let scope = OverrideScope {
1019 surface: Surface::Column,
1020 view_key: &col.column_type,
1021 resource: Some(&config.entity),
1022 field: Some(&col.field),
1023 };
1024 match state.column_types.get(&col.column_type) {
1025 Some(ct) => {
1026 render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
1027 .into_string()
1028 }
1029 None => String::new(),
1030 }
1031 })
1032 .collect(),
1033 })
1034 .collect();
1035 let keys: Vec<&str> = config
1036 .columns
1037 .iter()
1038 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1039 .flatten()
1040 .collect();
1041 let mut shell = shell;
1042 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1043 let shown = rows.len() as i64;
1044 let title = shell.tt(&config.title);
1046 let active_dir = match order_dir {
1047 SortDir::Asc => "asc",
1048 SortDir::Desc => "desc",
1049 };
1050 let columns: Vec<ColumnHead> = config
1051 .columns
1052 .iter()
1053 .map(|c| {
1054 let active = c.field == order_by;
1055 ColumnHead {
1056 label: shell.tt(&c.label),
1057 field: c.field.clone(),
1058 next_dir: if active && active_dir == "asc" {
1061 "desc".to_string()
1062 } else {
1063 "asc".to_string()
1064 },
1065 active: if active {
1066 active_dir.to_string()
1067 } else {
1068 String::new()
1069 },
1070 }
1071 })
1072 .collect();
1073 let shell_for_toolbar = shell.clone();
1074 let filter_views = filter_views(config, &active, &shell);
1075 let pickers: Vec<ColumnChoice> = declared
1076 .columns
1077 .iter()
1078 .map(|c| ColumnChoice {
1079 field: c.field.clone(),
1080 label: shell.tt(&c.label),
1081 shown: config.columns.iter().any(|v| v.field == c.field),
1082 })
1083 .collect();
1084 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1086 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1087 let page_view = ListTemplate {
1088 shell,
1089 title,
1090 columns,
1091 rows,
1092 shown,
1093 page,
1094 total: result.total,
1095 total_pages,
1096 edit_base: config.edit_base.clone(),
1097 sort: order_by,
1098 dir: active_dir.to_string(),
1099 q: q.trim().to_string(),
1100 searchable: config.columns.iter().any(|c| c.is_searchable()),
1101 pickers,
1102 toolbar,
1103 export_query,
1104 path: path.to_string(),
1105 filters: filter_views,
1106 deletable: config.deletable,
1107 exportable: config.exportable,
1108 filtered: !active.is_empty() || !q.trim().is_empty(),
1109 carry: carry.clone(),
1110 };
1111 if crate::form::is_htmx(headers) {
1115 render(ListFragment {
1116 shell: page_view.shell,
1117 columns: page_view.columns,
1118 rows: page_view.rows,
1119 shown: page_view.shown,
1120 page: page_view.page,
1121 total: page_view.total,
1122 total_pages: page_view.total_pages,
1123 edit_base: page_view.edit_base,
1124 sort: page_view.sort,
1125 dir: page_view.dir,
1126 carry: page_view.carry,
1127 filtered: page_view.filtered,
1128 deletable: page_view.deletable,
1129 })
1130 } else {
1131 render(page_view)
1132 }
1133 }
1134 Err(_) => render_error(),
1135 }
1136}
1137
1138pub(crate) async fn set_columns(
1145 state: &AdminState,
1146 config: &ListConfig,
1147 path: &str,
1148 user: &laterite_auth::AuthenticatedUser,
1149 session: &crate::session::SessionHandle,
1150 headers: &axum::http::HeaderMap,
1151 pairs: &[(String, String)],
1152) -> Response {
1153 let chosen: Vec<&str> = pairs
1154 .iter()
1155 .filter(|(k, _)| k == "column")
1156 .map(|(_, v)| v.trim())
1157 .filter(|v| config.columns.iter().any(|c| c.field == *v))
1158 .collect();
1159
1160 let key = columns_preference_key(path);
1161 let result = if chosen.is_empty() || chosen.len() == config.columns.len() {
1162 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1163 } else {
1164 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1165 .await
1166 };
1167 match result {
1168 Ok(()) => session.push_flash(
1169 crate::session::FlashLevel::Success,
1170 laterite_core::t!("Columns updated."),
1171 ),
1172 Err(e) => {
1173 tracing::error!(error = %e, "storing the column choice failed");
1174 session.push_flash(
1175 crate::session::FlashLevel::Error,
1176 laterite_core::t!("Could not save your column choice."),
1177 );
1178 }
1179 }
1180 let back = format!("{}{}", state.admin_path, path);
1181 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1182}
1183
1184pub struct ColumnChoice {
1186 pub field: String,
1187 pub label: String,
1188 pub shown: bool,
1189}
1190
1191pub struct ColumnHead {
1194 pub label: String,
1195 pub field: String,
1196 pub next_dir: String,
1197 pub active: String,
1198}
1199
1200#[derive(Template)]
1201#[template(path = "list.html")]
1202struct ListTemplate {
1203 shell: crate::Shell,
1204 title: String,
1205 columns: Vec<ColumnHead>,
1206 rows: Vec<RowView>,
1207 shown: i64,
1209 page: i64,
1210 total: i64,
1211 total_pages: i64,
1212 edit_base: Option<String>,
1213 sort: String,
1215 dir: String,
1216 q: String,
1218 searchable: bool,
1220 deletable: bool,
1222 exportable: bool,
1225 pickers: Vec<ColumnChoice>,
1227 toolbar: Vec<ToolbarView>,
1229 export_query: String,
1232 path: String,
1234 filters: Vec<FilterView>,
1236 filtered: bool,
1238 carry: String,
1240}
1241
1242pub struct FilterView {
1244 pub param: String,
1245 pub label: String,
1246 pub boolean: bool,
1247 pub options: Vec<FilterOptionView>,
1248 pub value: String,
1250}
1251
1252pub struct FilterOptionView {
1253 pub value: String,
1254 pub label: String,
1255}
1256
1257fn filter_views(
1259 config: &ListConfig,
1260 active: &[ActiveFilter<'_>],
1261 shell: &crate::Shell,
1262) -> Vec<FilterView> {
1263 config
1264 .filters
1265 .iter()
1266 .map(|f| {
1267 let value = active
1268 .iter()
1269 .find(|a| a.filter.field == f.field)
1270 .map(|a| a.raw.clone())
1271 .unwrap_or_default();
1272 let (boolean, options) = match &f.kind {
1273 FilterKind::Boolean => (
1274 true,
1275 vec![
1276 FilterOptionView {
1277 value: "1".to_string(),
1278 label: shell.tt(&laterite_core::t!("Yes")),
1279 },
1280 FilterOptionView {
1281 value: "0".to_string(),
1282 label: shell.tt(&laterite_core::t!("No")),
1283 },
1284 ],
1285 ),
1286 FilterKind::Select { options } => (
1287 false,
1288 options
1289 .iter()
1290 .map(|o| FilterOptionView {
1291 value: o.value.clone(),
1292 label: shell.tt(&o.label),
1293 })
1294 .collect(),
1295 ),
1296 };
1297 FilterView {
1298 param: f.param(),
1299 label: shell.tt(&f.label),
1300 boolean,
1301 options,
1302 value,
1303 }
1304 })
1305 .collect()
1306}
1307
1308fn urlencode(value: &str) -> String {
1310 let mut out = String::with_capacity(value.len());
1311 for byte in value.as_bytes() {
1312 match byte {
1313 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1314 out.push(*byte as char)
1315 }
1316 b' ' => out.push('+'),
1317 _ => out.push_str(&format!("%{byte:02X}")),
1318 }
1319 }
1320 out
1321}
1322
1323#[derive(Template)]
1325#[template(path = "_list_table.html")]
1326struct ListFragment {
1327 shell: crate::Shell,
1328 columns: Vec<ColumnHead>,
1329 rows: Vec<RowView>,
1330 shown: i64,
1331 page: i64,
1332 total: i64,
1333 total_pages: i64,
1334 edit_base: Option<String>,
1335 sort: String,
1336 dir: String,
1337 carry: String,
1338 filtered: bool,
1342 deletable: bool,
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347 use super::*;
1348
1349 fn config() -> ListConfig {
1350 ListConfig {
1351 entity: "backend_users".to_string(),
1352 title: "Users".into(),
1353 columns: vec![
1354 ListColumn::new("username", "Username"),
1355 ListColumn::new("is_superuser", "Superuser").yes_no(),
1356 ],
1357 order_by: "created_at".to_string(),
1358 order_dir: SortDir::Desc,
1359 per_page: 25,
1360 id_field: "id".to_string(),
1361 edit_base: None,
1362 creatable: false,
1363 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1364 deletable: true,
1365 ..Default::default()
1366 }
1367 }
1368
1369 #[test]
1370 fn column_types_format_their_cell_display() {
1371 let ist: Tz = "Asia/Kolkata".parse().unwrap();
1372 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1373 ct.view_model(&CellCx {
1374 value,
1375 tz,
1376 locale: chrono::Locale::en_US,
1377 })
1378 .display
1379 };
1380 assert_eq!(
1382 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
1383 "13 Aug 2026, 15:30"
1384 );
1385 assert_eq!(
1386 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
1387 "13 Aug 2026"
1388 );
1389 assert_eq!(
1390 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
1391 "15:30"
1392 );
1393 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
1394 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
1395 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
1396 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
1398 }
1399
1400 #[test]
1401 fn sort_falls_back_to_the_configured_order() {
1402 let c = config();
1403 assert_eq!(
1404 resolve_sort(&c, None, None),
1405 ("created_at".to_string(), SortDir::Desc)
1406 );
1407 }
1408
1409 #[test]
1410 fn a_declared_column_can_be_sorted_either_way() {
1411 let c = config();
1412 assert_eq!(
1413 resolve_sort(&c, Some("username"), Some("asc")),
1414 ("username".to_string(), SortDir::Asc)
1415 );
1416 assert_eq!(
1417 resolve_sort(&c, Some("username"), Some("desc")),
1418 ("username".to_string(), SortDir::Desc)
1419 );
1420 }
1421
1422 #[test]
1425 fn an_undeclared_sort_column_is_ignored() {
1426 let c = config();
1427 for crafted in [
1428 "password_hash",
1429 "id) --",
1430 "username; drop table backend_users",
1431 "",
1432 ] {
1433 assert_eq!(
1434 resolve_sort(&c, Some(crafted), Some("asc")).0,
1435 "created_at",
1436 "{crafted} must not reach the query"
1437 );
1438 }
1439 }
1440
1441 #[test]
1442 fn an_unknown_direction_falls_back() {
1443 let c = config();
1444 assert_eq!(
1446 resolve_sort(&c, Some("username"), Some("sideways")).1,
1447 SortDir::Asc
1448 );
1449 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1451 }
1452
1453 #[test]
1456 fn a_contributed_column_type_renders_its_cell() {
1457 struct Rating;
1458 impl ColumnType for Rating {
1459 fn view_key(&self) -> &'static str {
1460 "acme.rating"
1461 }
1462 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1463 let stars = cx.value.parse::<usize>().unwrap_or(0);
1464 text_cell("acme.rating", cx.value, "*".repeat(stars))
1465 }
1466 fn render_default(&self, vm: &CellVm) -> Markup {
1467 render_text(vm)
1468 }
1469 }
1470
1471 let reg = ColumnTypeReg::new(Arc::new(Rating));
1472 assert_eq!(reg.name(), "acme.rating", "the type names itself");
1473
1474 let mut registry = builtin_column_registry();
1475 registry.insert(reg.name().to_string(), reg.column_type);
1476 let rendered = render_cell(
1477 registry.get("acme.rating").unwrap().as_ref(),
1478 &crate::field::NoOverrides,
1479 &OverrideScope {
1480 surface: Surface::Column,
1481 view_key: "acme.rating",
1482 resource: Some("products"),
1483 field: Some("stars"),
1484 },
1485 &CellCx {
1486 value: "3",
1487 tz: Tz::UTC,
1488 locale: chrono::Locale::en_US,
1489 },
1490 );
1491 assert!(rendered.into_string().contains("***"));
1492 }
1493
1494 #[test]
1497 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
1498 struct Impostor;
1499 impl ColumnType for Impostor {
1500 fn view_key(&self) -> &'static str {
1501 "text"
1502 }
1503 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1504 text_cell("text", cx.value, String::new())
1505 }
1506 fn render_default(&self, vm: &CellVm) -> Markup {
1507 render_text(vm)
1508 }
1509 }
1510 let reg = ColumnTypeReg::new(Arc::new(Impostor));
1511 assert_eq!(reg.name(), "text");
1512 assert!(builtin_column_registry().contains_key(reg.name()));
1514 }
1515
1516 #[test]
1517 fn status_pill_renders_a_slugged_markup_span() {
1518 let vm = StatusPillColumn.view_model(&CellCx {
1519 value: "In Progress",
1520 tz: Tz::UTC,
1521 locale: chrono::Locale::en_US,
1522 });
1523 let html = StatusPillColumn.render_default(&vm).into_string();
1524 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
1525 assert!(html.contains(">In Progress<"));
1526 }
1527
1528 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1531 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
1532 }
1533
1534 #[tokio::test]
1535 async fn query_returns_display_rows() {
1536 let (db, _guard) = test_db().await;
1537 let hash = laterite_auth::password::hash_password("pw").unwrap();
1538 laterite_auth::store::create_user(
1539 &db,
1540 "root",
1541 "root@example.test",
1542 "Ada",
1543 None,
1544 &hash,
1545 true,
1546 )
1547 .await
1548 .unwrap();
1549
1550 let c = config();
1551 let result = query(&db, &c, &plain(&c)).await.unwrap();
1552 assert_eq!(result.total, 1);
1553 assert_eq!(result.rows.len(), 1);
1554 assert_eq!(result.rows[0].cells[0], "root");
1555 assert_eq!(result.rows[0].cells[1], "1");
1558 assert!(!result.rows[0].id.is_empty());
1559 }
1560
1561 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
1562 get_with(params, HashMap::new(), headers).await
1563 }
1564
1565 async fn get_with(
1566 params: ListParams,
1567 raw: HashMap<String, String>,
1568 headers: axum::http::HeaderMap,
1569 ) -> String {
1570 let (db, _guard) = test_db().await;
1571 let state = AdminState::new(
1572 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1573 db,
1574 );
1575 let resp = handle(
1576 &state,
1577 &config(),
1578 "/admin/users",
1579 params,
1580 &raw,
1581 &crate::audit::test_actor(),
1582 crate::Shell::test(),
1583 &headers,
1584 )
1585 .await;
1586 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1587 .await
1588 .unwrap();
1589 String::from_utf8(bytes.to_vec()).unwrap()
1590 }
1591
1592 fn params(sort: Option<&str>) -> ListParams {
1593 ListParams {
1594 page: None,
1595 sort: sort.map(|s| s.to_string()),
1596 dir: Some("asc".to_string()),
1597 q: None,
1598 }
1599 }
1600
1601 fn plain(config: &ListConfig) -> ListQuery<'_> {
1603 ListQuery {
1604 offset: 0,
1605 order_by: &config.order_by,
1606 order_dir: config.order_dir,
1607 q: "",
1608 filters: &[],
1609 per_page: None,
1610 }
1611 }
1612
1613 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
1614 ListQuery {
1615 offset: 0,
1616 order_by: &config.order_by,
1617 order_dir: config.order_dir,
1618 q,
1619 filters: &[],
1620 per_page: None,
1621 }
1622 }
1623
1624 fn amps(html: &str) -> String {
1627 html.replace("&", "&").replace("&", "&")
1628 }
1629
1630 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1632 pairs
1633 .iter()
1634 .map(|(k, v)| (k.to_string(), v.to_string()))
1635 .collect()
1636 }
1637
1638 #[tokio::test]
1639 async fn search_filters_rows_and_the_total() {
1640 let (db, _guard) = test_db().await;
1641 for name in ["ada", "brendan", "clara"] {
1642 let hash = laterite_auth::password::hash_password("x").unwrap();
1643 laterite_auth::store::create_user(
1644 &db,
1645 name,
1646 &format!("{name}@example.test"),
1647 name,
1648 None,
1649 &hash,
1650 false,
1651 )
1652 .await
1653 .unwrap();
1654 }
1655 let c = config();
1656
1657 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
1658 assert_eq!(hit.rows.len(), 1);
1659 assert_eq!(hit.rows[0].cells[0], "brendan");
1660 assert_eq!(hit.total, 1);
1662
1663 assert_eq!(
1665 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
1666 1
1667 );
1668
1669 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
1671 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
1672
1673 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
1675 }
1676
1677 #[tokio::test]
1680 async fn search_skips_a_non_text_column() {
1681 let (db, _guard) = test_db().await;
1682 let hash = laterite_auth::password::hash_password("x").unwrap();
1683 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
1684 .await
1685 .unwrap();
1686 let c = config();
1687 assert!(c.columns[0].is_searchable(), "username is text");
1688 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
1689 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
1691 }
1692
1693 #[test]
1694 fn a_column_can_override_its_searchability() {
1695 assert!(ListColumn::new("code", "Code").is_searchable());
1696 assert!(!ListColumn::new("code", "Code")
1697 .searchable(false)
1698 .is_searchable());
1699 assert!(ListColumn::new("at", "At")
1700 .datetime()
1701 .searchable(true)
1702 .is_searchable());
1703 }
1704
1705 async fn seed_users(db: &Db) {
1706 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
1707 let hash = laterite_auth::password::hash_password("x").unwrap();
1708 laterite_auth::store::create_user(
1709 db,
1710 name,
1711 &format!("{name}@example.test"),
1712 name,
1713 None,
1714 &hash,
1715 su,
1716 )
1717 .await
1718 .unwrap();
1719 }
1720 }
1721
1722 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
1723 let active = resolve_filters(c, params);
1724 let req = ListQuery {
1725 offset: 0,
1726 order_by: &c.order_by,
1727 order_dir: c.order_dir,
1728 q: "",
1729 filters: &active,
1730 per_page: None,
1731 };
1732 query(db, c, &req).await.unwrap().total
1733 }
1734
1735 #[tokio::test]
1736 async fn a_filter_narrows_the_rows_and_the_total() {
1737 let (db, _guard) = test_db().await;
1738 seed_users(&db).await;
1739 let c = config();
1740
1741 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
1742 assert_eq!(
1743 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
1744 1
1745 );
1746 assert_eq!(
1747 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
1748 2
1749 );
1750 }
1751
1752 #[test]
1755 fn an_undeclared_filter_or_value_is_ignored() {
1756 let c = config();
1757 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
1759 for bad in ["yes", "true", "1 or 1=1", "", " "] {
1761 assert!(
1762 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
1763 "{bad} must not reach the query"
1764 );
1765 }
1766
1767 let with_select = ListConfig {
1768 filters: vec![ListFilter::select(
1769 "status",
1770 "Status",
1771 vec![FilterOption::new("live", "Live")],
1772 )],
1773 ..config()
1774 };
1775 assert_eq!(
1776 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
1777 1
1778 );
1779 assert!(
1780 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
1781 "an option the descriptor never offered is dropped"
1782 );
1783 }
1784
1785 #[tokio::test]
1786 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
1787 let mut p = params(Some("username"));
1788 p.q = None;
1789 let html = get_with(
1790 p,
1791 filter_params(&[("f_is_superuser", "1")]),
1792 axum::http::HeaderMap::new(),
1793 )
1794 .await;
1795 let links = amps(&html);
1796 assert!(
1797 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
1798 "a sort link carries the filter"
1799 );
1800 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
1802 assert!(
1803 html.contains(r#"<option value="1" selected>"#),
1804 "the control shows the active value"
1805 );
1806 assert!(html.contains("Clear"), "and a way back to the full list");
1807 assert!(
1808 html.contains(r#"data-filtered="true""#),
1809 "the region says the list is narrowed, which is what reveals Clear"
1810 );
1811 }
1812
1813 #[tokio::test]
1816 async fn the_empty_state_distinguishes_no_records_from_no_match() {
1817 let (db, _guard) = test_db().await;
1818 let state = AdminState::new(
1819 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1820 db,
1821 );
1822 let render = |raw: HashMap<String, String>, q: Option<&str>| {
1823 let q = q.map(String::from);
1824 let state = state.clone();
1825 async move {
1826 let p = ListParams {
1827 page: None,
1828 sort: None,
1829 dir: None,
1830 q,
1831 };
1832 let resp = handle(
1833 &state,
1834 &config(),
1835 "/admin/users",
1836 p,
1837 &raw,
1838 &crate::audit::test_actor(),
1839 crate::Shell::test(),
1840 &axum::http::HeaderMap::new(),
1841 )
1842 .await;
1843 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1844 .await
1845 .unwrap();
1846 String::from_utf8(bytes.to_vec()).unwrap()
1847 }
1848 };
1849
1850 let bare = render(HashMap::new(), None).await;
1852 assert!(bare.contains("No records yet."));
1853
1854 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
1856 assert!(filtered.contains("No records match."));
1857 assert!(!filtered.contains("No records yet."));
1858
1859 let searched = render(HashMap::new(), Some("zzz")).await;
1861 assert!(searched.contains("No records match."));
1862 }
1863
1864 #[test]
1865 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
1866 let c = config();
1867 let kept = visible_columns(&c, Some("is_superuser,username"));
1870 assert_eq!(
1871 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
1872 ["username", "is_superuser"]
1873 );
1874 }
1875
1876 #[test]
1877 fn no_stored_choice_shows_every_column() {
1878 let c = config();
1879 assert_eq!(visible_columns(&c, None).len(), c.columns.len());
1880 }
1881
1882 #[test]
1885 fn a_stale_choice_falls_back_to_every_column() {
1886 let c = config();
1887 assert_eq!(
1888 visible_columns(&c, Some("gone,removed")).len(),
1889 c.columns.len()
1890 );
1891 assert_eq!(visible_columns(&c, Some("")).len(), c.columns.len());
1892 assert_eq!(visible_columns(&c, Some(" , ")).len(), c.columns.len());
1893 assert_eq!(visible_columns(&c, Some("gone,username")).len(), 1);
1895 }
1896
1897 #[test]
1900 fn the_toolbar_hides_what_the_operator_may_not_do() {
1901 let shell = crate::Shell::test();
1902 let cfg = ListConfig {
1903 creatable: false,
1904 toolbar: vec![
1905 ToolbarButton::new("Open", "/reports"),
1906 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
1907 ],
1908 ..config()
1909 };
1910
1911 let root = laterite_auth::AuthenticatedUser {
1913 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1914 ..crate::audit::test_actor()
1915 };
1916 let views = toolbar_views(&cfg, "/admin", &root, &shell);
1917 assert_eq!(
1918 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1919 ["Open", "Audit"]
1920 );
1921
1922 let granted = laterite_auth::AuthenticatedUser {
1924 permissions: laterite_auth::PermissionSet::with_overrides(
1925 false,
1926 ["backend.view_audit".to_string()],
1927 [],
1928 [],
1929 ),
1930 ..crate::audit::test_actor()
1931 };
1932 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
1933
1934 let plain = laterite_auth::AuthenticatedUser {
1936 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
1937 ..crate::audit::test_actor()
1938 };
1939 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
1940 assert_eq!(
1941 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1942 ["Open"]
1943 );
1944 }
1945
1946 #[test]
1949 fn a_toolbar_href_follows_the_admin_mount() {
1950 let shell = crate::Shell::test();
1951 let cfg = ListConfig {
1952 creatable: true,
1953 edit_base: Some("/roles".to_string()),
1954 toolbar: vec![
1955 ToolbarButton::new("Reports", "/reports"),
1956 ToolbarButton::new("Docs", "https://example.test/docs"),
1957 ],
1958 ..config()
1959 };
1960 let root = laterite_auth::AuthenticatedUser {
1961 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1962 ..crate::audit::test_actor()
1963 };
1964 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
1965 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
1966 assert_eq!(views[1].href, "/backoffice/reports");
1967 assert_eq!(views[2].href, "https://example.test/docs");
1969 }
1970
1971 #[tokio::test]
1972 async fn an_htmx_sort_returns_the_list_region_alone() {
1973 let mut headers = axum::http::HeaderMap::new();
1974 headers.insert("hx-request", "true".parse().unwrap());
1975 let html = get(params(Some("username")), headers).await;
1976 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
1977 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
1978 assert!(!html.contains("<body"), "no page chrome");
1981 assert!(html.contains(r#"data-filtered="false""#));
1983 }
1984
1985 #[tokio::test]
1988 async fn the_search_term_survives_a_sort_link() {
1989 let mut p = params(Some("username"));
1990 p.q = Some("bren".to_string());
1991 let html = get(p, axum::http::HeaderMap::new()).await;
1992 assert!(
1993 amps(&html).contains("?sort=username&dir=desc&q=bren"),
1994 "sort and pager links carry the term"
1995 );
1996 assert!(
1997 html.contains(r#"value="bren""#),
1998 "and the box still shows it"
1999 );
2000 }
2001
2002 #[tokio::test]
2003 async fn a_plain_sort_returns_the_whole_page() {
2004 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
2005 assert!(html.contains("<body"), "lists still work without htmx");
2006 assert!(html.contains(r#"id="lat-list""#));
2007 assert!(html.contains(r#"href="?sort=username"#));
2009 }
2010
2011 #[tokio::test]
2012 async fn query_rejects_bad_identifiers() {
2013 let (db, _guard) = test_db().await;
2014 let mut bad = config();
2015 bad.entity = "backend_users; drop table backend_users".to_string();
2016 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2017 }
2018}