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, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum Align {
43 Left,
44 Center,
45 Right,
46}
47
48impl Align {
49 fn class(self) -> &'static str {
50 match self {
51 Align::Left => "lat-col--left",
52 Align::Center => "lat-col--center",
53 Align::Right => "lat-col--right",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct ListColumn {
60 pub field: String,
61 pub label: Text,
63 #[serde(rename = "type")]
65 pub column_type: String,
66 pub sortable: bool,
68 pub invisible: bool,
70 pub width: Option<String>,
72 pub align: Option<Align>,
73 pub permission: Option<String>,
76 pub searchable: Option<bool>,
80}
81
82impl ListColumn {
83 pub fn new(field: &str, label: impl Into<Text>) -> Self {
84 Self {
85 field: field.to_string(),
86 label: label.into(),
87 column_type: "text".to_string(),
88 sortable: true,
89 invisible: false,
90 width: None,
91 align: None,
92 permission: None,
93 searchable: None,
94 }
95 }
96
97 fn of(mut self, column_type: &str) -> Self {
98 self.column_type = column_type.to_string();
99 self
100 }
101
102 pub fn datetime(self) -> Self {
104 self.of("datetime")
105 }
106 pub fn date(self) -> Self {
108 self.of("date")
109 }
110 pub fn time(self) -> Self {
112 self.of("time")
113 }
114 pub fn yes_no(self) -> Self {
116 self.of("boolean")
117 }
118 pub fn pill(self) -> Self {
120 self.of("status_pill")
121 }
122
123 pub fn searchable(mut self, searchable: bool) -> Self {
125 self.searchable = Some(searchable);
126 self
127 }
128
129 pub fn sortable(mut self, sortable: bool) -> Self {
130 self.sortable = sortable;
131 self
132 }
133
134 pub fn invisible(mut self) -> Self {
136 self.invisible = true;
137 self
138 }
139
140 pub fn width(mut self, width: impl Into<String>) -> Self {
141 self.width = Some(width.into());
142 self
143 }
144
145 pub fn align(mut self, align: Align) -> Self {
146 self.align = Some(align);
147 self
148 }
149
150 pub fn require(mut self, permission: impl Into<String>) -> Self {
152 self.permission = Some(permission.into());
153 self
154 }
155
156 pub(crate) fn align_class(&self) -> &'static str {
157 self.align.map(Align::class).unwrap_or("")
158 }
159
160 pub(crate) fn is_searchable(&self) -> bool {
162 self.searchable.unwrap_or(self.column_type == "text")
163 }
164}
165
166pub struct CellCx<'a> {
169 pub value: &'a str,
170 pub tz: Tz,
171 pub locale: chrono::Locale,
172}
173
174pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
180 chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
181}
182
183#[derive(Serialize)]
186pub struct CellVm {
187 pub view_key: String,
188 pub value: String,
189 pub display: String,
190 pub data: serde_json::Value,
191}
192
193pub trait ColumnType: Send + Sync + 'static {
196 fn view_key(&self) -> &'static str;
197 fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
198 fn render_default(&self, vm: &CellVm) -> Markup;
199 fn assets(&self) -> Vec<&'static str> {
202 Vec::new()
203 }
204}
205
206pub struct ColumnTypeReg {
213 pub column_type: Arc<dyn ColumnType>,
214}
215
216impl ColumnTypeReg {
217 pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
218 Self { column_type }
219 }
220
221 pub fn name(&self) -> &'static str {
223 self.column_type.view_key()
224 }
225}
226
227pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
229
230pub(crate) fn render_cell(
232 ct: &dyn ColumnType,
233 resolver: &dyn OverrideResolver,
234 scope: &OverrideScope<'_>,
235 cx: &CellCx<'_>,
236) -> Markup {
237 let vm = ct.view_model(cx);
238 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
239 Some(Ok(html)) => Markup::from_override(html),
240 Some(Err(_)) | None => ct.render_default(&vm),
241 }
242}
243
244pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
245 vec![
246 Arc::new(TextColumn),
247 Arc::new(DateTimeColumn),
248 Arc::new(DateColumn),
249 Arc::new(TimeColumn),
250 Arc::new(BoolColumn),
251 Arc::new(StatusPillColumn),
252 ]
253}
254
255pub(crate) fn builtin_column_registry() -> ColumnRegistry {
257 builtin_column_types()
258 .into_iter()
259 .map(|c| (c.view_key().to_string(), c))
260 .collect()
261}
262
263#[derive(Template)]
264#[template(path = "cells/text.html")]
265struct CellTextTmpl<'a> {
266 display: &'a str,
267}
268
269fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
271 CellVm {
272 view_key: view_key.to_string(),
273 value: value.to_string(),
274 display,
275 data: serde_json::Value::Null,
276 }
277}
278
279fn render_text(vm: &CellVm) -> Markup {
280 Markup::from_template(&CellTextTmpl {
281 display: &vm.display,
282 })
283 .unwrap_or_default()
284}
285
286pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
289 match DateTime::parse_from_rfc3339(raw) {
290 Ok(dt) => dt
291 .with_timezone(&tz)
292 .format_localized(pattern, locale)
293 .to_string(),
294 Err(_) => raw.to_string(),
295 }
296}
297
298struct TextColumn;
299impl ColumnType for TextColumn {
300 fn view_key(&self) -> &'static str {
301 "text"
302 }
303 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
304 text_cell("text", cx.value, cx.value.to_string())
305 }
306 fn render_default(&self, vm: &CellVm) -> Markup {
307 render_text(vm)
308 }
309}
310
311struct DateTimeColumn;
312impl ColumnType for DateTimeColumn {
313 fn view_key(&self) -> &'static str {
314 "datetime"
315 }
316 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
317 text_cell(
318 "datetime",
319 cx.value,
320 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
321 )
322 }
323 fn render_default(&self, vm: &CellVm) -> Markup {
324 render_text(vm)
325 }
326}
327
328struct DateColumn;
329impl ColumnType for DateColumn {
330 fn view_key(&self) -> &'static str {
331 "date"
332 }
333 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
334 text_cell(
335 "date",
336 cx.value,
337 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
338 )
339 }
340 fn render_default(&self, vm: &CellVm) -> Markup {
341 render_text(vm)
342 }
343}
344
345struct TimeColumn;
346impl ColumnType for TimeColumn {
347 fn view_key(&self) -> &'static str {
348 "time"
349 }
350 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
351 text_cell(
352 "time",
353 cx.value,
354 format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
355 )
356 }
357 fn render_default(&self, vm: &CellVm) -> Markup {
358 render_text(vm)
359 }
360}
361
362struct BoolColumn;
363impl ColumnType for BoolColumn {
364 fn view_key(&self) -> &'static str {
365 "boolean"
366 }
367 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
368 let display = match cx.value {
369 "1" | "true" => "Yes",
370 "0" | "false" | "" => "No",
371 other => other,
372 };
373 text_cell("boolean", cx.value, display.to_string())
374 }
375 fn render_default(&self, vm: &CellVm) -> Markup {
376 render_text(vm)
377 }
378}
379
380#[derive(Template)]
381#[template(path = "cells/status.html")]
382struct CellStatusTmpl<'a> {
383 label: &'a str,
384 slug: &'a str,
385}
386
387struct StatusPillColumn;
389impl ColumnType for StatusPillColumn {
390 fn view_key(&self) -> &'static str {
391 "status_pill"
392 }
393 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
394 CellVm {
395 view_key: "status_pill".to_string(),
396 value: cx.value.to_string(),
397 display: cx.value.to_string(),
398 data: serde_json::json!({ "slug": status_slug(cx.value) }),
399 }
400 }
401 fn render_default(&self, vm: &CellVm) -> Markup {
402 let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
403 Markup::from_template(&CellStatusTmpl {
404 label: &vm.display,
405 slug,
406 })
407 .unwrap_or_default()
408 }
409}
410
411fn status_slug(value: &str) -> String {
413 value
414 .trim()
415 .to_lowercase()
416 .chars()
417 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
418 .collect()
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct FilterOption {
424 pub value: String,
425 pub label: Text,
427}
428
429impl FilterOption {
430 pub fn new(value: &str, label: impl Into<Text>) -> Self {
431 Self {
432 value: value.to_string(),
433 label: label.into(),
434 }
435 }
436}
437
438#[derive(Debug, Clone, Serialize)]
440pub struct ListFilter {
441 pub field: String,
442 pub label: Text,
444 #[serde(rename = "type")]
446 pub filter_type: String,
447 #[serde(default)]
449 pub options: serde_json::Value,
450 pub default_value: Option<String>,
453 pub permission: Option<String>,
456}
457
458impl ListFilter {
459 pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
461 Self {
462 field: field.to_string(),
463 label: label.into(),
464 filter_type: filter_type.to_string(),
465 options: serde_json::Value::Null,
466 default_value: None,
467 permission: None,
468 }
469 }
470
471 pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
473 Self::of(field, label, "boolean")
474 }
475
476 pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
479 let mut filter = Self::of(field, label, "select");
480 filter.options = serde_json::json!({ "options": options });
481 filter
482 }
483
484 pub fn text(field: &str, label: impl Into<Text>) -> Self {
486 Self::of(field, label, "text")
487 }
488
489 pub fn number(field: &str, label: impl Into<Text>) -> Self {
491 Self::of(field, label, "number")
492 }
493
494 pub fn date(field: &str, label: impl Into<Text>) -> Self {
497 Self::of(field, label, "date")
498 }
499
500 pub(crate) fn choices(&self) -> Vec<FilterOption> {
502 self.options
503 .get("options")
504 .and_then(|v| serde_json::from_value(v.clone()).ok())
505 .unwrap_or_default()
506 }
507
508 pub fn default_value(mut self, value: impl Into<String>) -> Self {
510 self.default_value = Some(value.into());
511 self
512 }
513
514 pub fn require(mut self, permission: impl Into<String>) -> Self {
516 self.permission = Some(permission.into());
517 self
518 }
519
520 pub(crate) fn param(&self) -> String {
521 format!("f_{}", self.field)
522 }
523}
524
525#[derive(Debug, Clone, Serialize)]
531pub struct ToolbarButton {
532 pub label: Text,
534 pub href: String,
537 pub icon: Option<String>,
539 pub permission: Option<String>,
542 pub primary: bool,
544}
545
546impl ToolbarButton {
547 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
548 Self {
549 label: label.into(),
550 href: href.into(),
551 icon: None,
552 permission: None,
553 primary: false,
554 }
555 }
556
557 pub fn icon(mut self, icon: &str) -> Self {
558 self.icon = Some(icon.to_string());
559 self
560 }
561
562 pub fn require(mut self, permission: impl Into<String>) -> Self {
564 self.permission = Some(permission.into());
565 self
566 }
567
568 pub fn primary(mut self) -> Self {
569 self.primary = true;
570 self
571 }
572}
573
574pub struct ToolbarView {
576 pub label: String,
577 pub href: String,
578 pub icon: String,
580 pub primary: bool,
581}
582
583fn toolbar_views(
587 config: &ListConfig,
588 admin_path: &str,
589 user: &laterite_auth::AuthenticatedUser,
590 shell: &crate::Shell,
591) -> Vec<ToolbarView> {
592 let mut out = Vec::new();
593 if config.creatable {
594 if let Some(base) = &config.edit_base {
595 out.push(ToolbarView {
596 label: shell.tt(&laterite_core::t!("New")),
597 href: format!("{base}/new"),
600 icon: String::new(),
601 primary: true,
602 });
603 }
604 }
605 for button in &config.toolbar {
606 if let Some(needed) = &button.permission {
607 if !user.permissions.allows(needed) {
608 continue;
609 }
610 }
611 let href = if button.href.starts_with('/') {
612 format!("{admin_path}{}", button.href)
613 } else {
614 button.href.clone()
615 };
616 out.push(ToolbarView {
617 label: shell.tt(&button.label),
618 href,
619 icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
620 primary: button.primary,
621 });
622 }
623 out
624}
625
626pub(crate) fn columns_preference_key(base_path: &str) -> String {
629 format!("list.columns.{base_path}")
630}
631
632pub(crate) async fn stored_columns(
637 state: &AdminState,
638 path: &str,
639 user: &laterite_auth::AuthenticatedUser,
640) -> Option<String> {
641 match laterite_auth::store::user_preference(
642 &state.db,
643 user.user.id,
644 &columns_preference_key(path),
645 )
646 .await
647 {
648 Ok(stored) => stored,
649 Err(e) => {
650 tracing::warn!(error = %e, "reading the column preference failed");
651 None
652 }
653 }
654}
655
656pub(crate) fn per_page_preference_key(base_path: &str) -> String {
658 format!("list.per_page:{base_path}")
659}
660
661pub(crate) async fn stored_per_page(
665 state: &AdminState,
666 config: &ListConfig,
667 path: &str,
668 user: &laterite_auth::AuthenticatedUser,
669) -> i64 {
670 let stored = match laterite_auth::store::user_preference(
671 &state.db,
672 user.user.id,
673 &per_page_preference_key(path),
674 )
675 .await
676 {
677 Ok(stored) => stored,
678 Err(e) => {
679 tracing::warn!(error = %e, "reading the page-size preference failed");
680 None
681 }
682 };
683 offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
684}
685
686pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
688 size.filter(|n| config.per_page_options.contains(n))
689 .unwrap_or(config.per_page)
690}
691
692pub(crate) fn allowed_columns<'a>(
695 config: &'a ListConfig,
696 user: &laterite_auth::AuthenticatedUser,
697) -> Vec<&'a ListColumn> {
698 config
699 .columns
700 .iter()
701 .filter(|c| {
702 c.permission
703 .as_deref()
704 .is_none_or(|p| user.permissions.allows(p))
705 })
706 .collect()
707}
708
709pub(crate) fn allowed_filters<'a>(
712 config: &'a ListConfig,
713 user: &laterite_auth::AuthenticatedUser,
714) -> Vec<&'a ListFilter> {
715 config
716 .filters
717 .iter()
718 .filter(|f| {
719 f.permission
720 .as_deref()
721 .is_none_or(|p| user.permissions.allows(p))
722 })
723 .collect()
724}
725
726pub(crate) fn narrowed(
729 config: &ListConfig,
730 stored: Option<&str>,
731 user: &laterite_auth::AuthenticatedUser,
732) -> ListConfig {
733 ListConfig {
734 columns: visible_columns(config, stored, user)
735 .into_iter()
736 .cloned()
737 .collect(),
738 filters: allowed_filters(config, user).into_iter().cloned().collect(),
739 ..config.clone()
740 }
741}
742
743pub(crate) fn visible_columns<'a>(
751 config: &'a ListConfig,
752 stored: Option<&str>,
753 user: &laterite_auth::AuthenticatedUser,
754) -> Vec<&'a ListColumn> {
755 let allowed = allowed_columns(config, user);
756 let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
757 let Some(stored) = stored else {
758 return by_default();
759 };
760 let chosen: Vec<&str> = stored
761 .split(',')
762 .map(str::trim)
763 .filter(|s| !s.is_empty())
764 .collect();
765 let kept: Vec<&ListColumn> = allowed
766 .iter()
767 .copied()
768 .filter(|c| chosen.iter().any(|n| *n == c.field))
769 .collect();
770 if kept.is_empty() {
771 return by_default();
772 }
773 kept
774}
775
776pub(crate) fn check(
780 config: &ListConfig,
781 column_types: &ColumnRegistry,
782 permission_codes: &std::collections::HashSet<String>,
783) -> Result<(), String> {
784 for col in &config.columns {
785 if column_types.get(&col.column_type).is_none() {
786 return Err(format!(
787 "column `{}` uses unregistered type `{}`",
788 col.field, col.column_type
789 ));
790 }
791 if let Some(p) = &col.permission {
794 if !permission_codes.contains(p) {
795 return Err(format!(
796 "column `{}` requires unregistered permission `{p}`",
797 col.field
798 ));
799 }
800 }
801 }
802 for filter in &config.filters {
803 if let Some(p) = &filter.permission {
804 if !permission_codes.contains(p) {
805 return Err(format!(
806 "filter `{}` requires unregistered permission `{p}`",
807 filter.field
808 ));
809 }
810 }
811 }
812 Ok(())
813}
814
815#[derive(Debug, Clone, Serialize)]
817pub struct SearchConfig {
818 pub enabled: bool,
820 pub prompt: Option<Text>,
822 pub on_enter: bool,
824}
825
826impl Default for SearchConfig {
827 fn default() -> Self {
828 Self {
829 enabled: true,
830 prompt: None,
831 on_enter: false,
832 }
833 }
834}
835
836impl SearchConfig {
837 pub fn off() -> Self {
839 Self {
840 enabled: false,
841 ..Self::default()
842 }
843 }
844
845 pub fn prompt(mut self, text: impl Into<Text>) -> Self {
846 self.prompt = Some(text.into());
847 self
848 }
849
850 pub fn on_enter(mut self) -> Self {
851 self.on_enter = true;
852 self
853 }
854}
855
856#[derive(Debug, Clone, Serialize)]
863#[non_exhaustive]
864pub struct ListConfig {
865 pub entity: String,
866 pub title: Text,
868 pub columns: Vec<ListColumn>,
869 pub order_by: String,
870 pub order_dir: SortDir,
871 pub per_page: i64,
872 pub id_field: String,
873 pub edit_base: Option<String>,
875 pub creatable: bool,
878 pub filters: Vec<ListFilter>,
880 pub deletable: bool,
884 pub exportable: bool,
887 pub toolbar: Vec<ToolbarButton>,
889 pub search: SearchConfig,
890 pub per_page_options: Vec<i64>,
893 pub no_records_message: Option<Text>,
895}
896
897impl ListConfig {
898 pub fn new(
903 entity: impl Into<String>,
904 title: impl Into<Text>,
905 columns: Vec<ListColumn>,
906 ) -> Self {
907 Self {
908 entity: entity.into(),
909 title: title.into(),
910 columns,
911 ..Self::default()
912 }
913 }
914
915 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
917 self.order_by = column.into();
918 self.order_dir = dir;
919 self
920 }
921
922 pub fn per_page(mut self, rows: i64) -> Self {
923 self.per_page = rows;
924 self
925 }
926
927 pub fn id_field(mut self, column: impl Into<String>) -> Self {
929 self.id_field = column.into();
930 self
931 }
932
933 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
935 self.edit_base = Some(base.into());
936 self
937 }
938
939 pub fn creatable(mut self) -> Self {
943 self.creatable = true;
944 self
945 }
946
947 pub fn exportable(mut self) -> Self {
951 self.exportable = true;
952 self
953 }
954
955 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
956 self.filters = filters;
957 self
958 }
959
960 pub fn deletable(mut self) -> Self {
963 self.deletable = true;
964 self
965 }
966
967 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
968 self.toolbar = buttons;
969 self
970 }
971
972 pub fn search(mut self, search: SearchConfig) -> Self {
973 self.search = search;
974 self
975 }
976
977 pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
978 self.per_page_options = sizes;
979 self
980 }
981
982 pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
983 self.no_records_message = Some(text.into());
984 self
985 }
986}
987
988impl Default for ListConfig {
989 fn default() -> Self {
996 Self {
997 entity: String::new(),
998 title: Text::new(""),
999 columns: Vec::new(),
1000 order_by: "id".to_string(),
1001 order_dir: SortDir::Desc,
1002 per_page: 25,
1003 id_field: "id".to_string(),
1004 edit_base: None,
1005 creatable: false,
1006 filters: Vec::new(),
1007 deletable: false,
1008 exportable: false,
1009 toolbar: Vec::new(),
1010 search: SearchConfig::default(),
1011 per_page_options: Vec::new(),
1012 no_records_message: None,
1013 }
1014 }
1015}
1016
1017#[derive(Deserialize)]
1019pub struct ListParams {
1020 page: Option<i64>,
1021 sort: Option<String>,
1022 dir: Option<String>,
1023 q: Option<String>,
1024}
1025
1026pub(crate) fn resolve_sort(
1031 config: &ListConfig,
1032 sort: Option<&str>,
1033 dir: Option<&str>,
1034) -> (String, SortDir) {
1035 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1036 Some(field) => field.to_string(),
1037 None => config.order_by.clone(),
1038 };
1039 let dir = match dir {
1040 Some("asc") => SortDir::Asc,
1041 Some("desc") => SortDir::Desc,
1042 _ if by == config.order_by => config.order_dir,
1045 _ => SortDir::Asc,
1046 };
1047 (by, dir)
1048}
1049
1050pub struct RowView {
1052 pub id: String,
1053 pub cells: Vec<String>,
1054}
1055
1056pub struct CellView {
1058 pub html: String,
1059 pub class: String,
1060}
1061
1062pub struct RenderedRow {
1064 pub id: String,
1065 pub cells: Vec<CellView>,
1066}
1067
1068pub struct ListPage {
1070 pub rows: Vec<RowView>,
1071 pub total: i64,
1072}
1073
1074pub(crate) struct ListQuery<'a> {
1077 pub offset: i64,
1078 pub order_by: &'a str,
1079 pub order_dir: SortDir,
1080 pub q: &'a str,
1081 pub filters: &'a [ActiveFilter<'a>],
1082 pub per_page: Option<i64>,
1085}
1086
1087pub(crate) struct ActiveFilter<'a> {
1091 filter: &'a ListFilter,
1092 raw: String,
1094 matcher: FilterMatch,
1097}
1098
1099pub(crate) enum FilterMatch {
1101 Equals(sea_query::Value),
1103 Contains(String),
1105 StartsWith(String),
1107}
1108
1109fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1115 match filter.filter_type.as_str() {
1116 "boolean" => match raw {
1119 "1" => Some(FilterMatch::Equals(true.into())),
1120 "0" => Some(FilterMatch::Equals(false.into())),
1121 _ => None,
1122 },
1123 "select" => filter
1124 .choices()
1125 .iter()
1126 .any(|o| o.value == raw)
1127 .then(|| FilterMatch::Equals(raw.into())),
1128 "text" => Some(FilterMatch::Contains(raw.to_string())),
1129 "number" => raw
1130 .parse::<i64>()
1131 .ok()
1132 .map(|n| FilterMatch::Equals(n.into())),
1133 "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1137 _ => None,
1138 }
1139}
1140
1141fn is_iso_date(raw: &str) -> bool {
1143 raw.len() == 10
1144 && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1145 if i == 4 || i == 7 {
1146 *b == b'-'
1147 } else {
1148 b.is_ascii_digit()
1149 }
1150 })
1151}
1152
1153pub(crate) fn resolve_filters<'a>(
1156 config: &'a ListConfig,
1157 params: &HashMap<String, String>,
1158) -> Vec<ActiveFilter<'a>> {
1159 let mut out = Vec::new();
1160 for filter in &config.filters {
1161 let submitted = params.get(&filter.param()).map(|v| v.trim());
1162 let raw = match submitted {
1163 Some(value) => value,
1165 None => filter.default_value.as_deref().unwrap_or_default(),
1167 };
1168 if raw.is_empty() {
1169 continue;
1170 }
1171 let Some(matcher) = filter_match(filter, raw) else {
1172 continue;
1173 };
1174 out.push(ActiveFilter {
1175 filter,
1176 raw: raw.to_string(),
1177 matcher,
1178 });
1179 }
1180 out
1181}
1182
1183fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1185 if active.is_empty() {
1186 return None;
1187 }
1188 let caps = laterite_core::capabilities::CapabilitySet::default();
1189 let mut all = sea_query::Condition::all();
1190 for f in active {
1191 let col = Expr::col(Alias::new(&f.filter.field));
1192 all = match &f.matcher {
1193 FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1194 FilterMatch::Contains(term) => {
1196 match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1197 Some(cond) => all.add(cond),
1198 None => all,
1199 }
1200 }
1201 FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1202 };
1203 }
1204 Some(all)
1205}
1206
1207fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1211 let caps = laterite_core::capabilities::CapabilitySet::default();
1215 let profile = SearchProfile::new();
1216 let mut any = sea_query::Condition::any();
1217 let mut matched = false;
1218 for column in config.columns.iter().filter(|c| c.is_searchable()) {
1219 if let Some(cond) = profile.condition(&caps, &column.field, q) {
1220 any = any.add(cond);
1221 matched = true;
1222 }
1223 }
1224 matched.then_some(any)
1225}
1226
1227pub(crate) async fn query(
1232 db: &Db,
1233 config: &ListConfig,
1234 req: &ListQuery<'_>,
1235) -> anyhow::Result<ListPage> {
1236 if !valid_ident(&config.entity)
1237 || !valid_ident(&config.order_by)
1238 || !valid_ident(req.order_by)
1239 || !valid_ident(&config.id_field)
1240 || !config.columns.iter().all(|c| valid_ident(&c.field))
1241 || !config.filters.iter().all(|f| valid_ident(&f.field))
1242 {
1243 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1244 }
1245
1246 let dir = match req.order_dir {
1247 SortDir::Asc => Order::Asc,
1248 SortDir::Desc => Order::Desc,
1249 };
1250 let (sql, values) = {
1254 let mut select = Query::select();
1255 for column in &config.columns {
1256 select.expr_as(
1257 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1258 Alias::new(&column.field),
1259 );
1260 }
1261 select
1262 .expr_as(
1263 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
1264 Alias::new(ID_ALIAS),
1265 )
1266 .from(Alias::new(&config.entity))
1267 .order_by(Alias::new(req.order_by), dir)
1268 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1269 .offset(req.offset.max(0) as u64);
1270 if let Some(cond) = search_condition(config, req.q) {
1271 select.cond_where(cond);
1272 }
1273 if let Some(cond) = filter_condition(req.filters) {
1274 select.cond_where(cond);
1275 }
1276 build(db.backend, select)
1277 };
1278 let raw = bind_values(sqlx::query(&sql), values)
1279 .fetch_all(&db.pool)
1280 .await?;
1281
1282 let (csql, cvalues) = {
1283 let mut count = Query::select();
1284 count
1285 .expr(Expr::col(Alias::new(&config.id_field)).count())
1286 .from(Alias::new(&config.entity));
1287 if let Some(cond) = search_condition(config, req.q) {
1289 count.cond_where(cond);
1290 }
1291 if let Some(cond) = filter_condition(req.filters) {
1292 count.cond_where(cond);
1293 }
1294 build(db.backend, count)
1295 };
1296 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1297 .fetch_one(&db.pool)
1298 .await?
1299 .0;
1300
1301 let rows = raw
1302 .iter()
1303 .map(|row| RowView {
1304 id: get_text(row, ID_ALIAS),
1305 cells: config
1306 .columns
1307 .iter()
1308 .map(|c| get_text(row, &c.field))
1309 .collect(),
1310 })
1311 .collect();
1312 Ok(ListPage { rows, total })
1313}
1314
1315fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1318 row.get_text_opt(column).ok().flatten().unwrap_or_default()
1321}
1322
1323#[allow(clippy::too_many_arguments)]
1325pub(crate) async fn handle(
1326 state: &AdminState,
1327 config: &ListConfig,
1328 path: &str,
1329 params: ListParams,
1330 raw: &HashMap<String, String>,
1331 user: &laterite_auth::AuthenticatedUser,
1332 shell: crate::Shell,
1333 headers: &axum::http::HeaderMap,
1334) -> Response {
1335 let stored = stored_columns(state, path, user).await;
1339 let declared = config;
1340 let narrowed = narrowed(config, stored.as_deref(), user);
1341 let config = &narrowed;
1342
1343 let page = params.page.unwrap_or(1).max(1);
1344 let per_page = stored_per_page(state, config, path, user).await;
1345 let offset = (page - 1) * per_page;
1346 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1347 let q = params.q.unwrap_or_default();
1348 let active = resolve_filters(config, raw);
1349 let req = ListQuery {
1350 offset,
1351 order_by: &order_by,
1352 order_dir,
1353 q: q.trim(),
1354 filters: &active,
1355 per_page: Some(per_page),
1356 };
1357 let mut carry = String::new();
1360 if !q.trim().is_empty() {
1361 carry.push_str(&format!("&q={}", urlencode(q.trim())));
1362 }
1363 for f in &active {
1364 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
1365 }
1366 match query(&state.db, config, &req).await {
1367 Ok(result) => {
1368 let total_pages = ((result.total + per_page - 1) / per_page).max(1);
1369 let date_loc = date_locale(shell.locale());
1370 let rows: Vec<RenderedRow> = result
1371 .rows
1372 .into_iter()
1373 .map(|row| RenderedRow {
1374 id: row.id,
1375 cells: row
1376 .cells
1377 .iter()
1378 .zip(&config.columns)
1379 .map(|(raw, col)| {
1380 let cx = CellCx {
1381 value: raw,
1382 tz: shell.tz,
1383 locale: date_loc,
1384 };
1385 let scope = OverrideScope {
1386 surface: Surface::Column,
1387 view_key: &col.column_type,
1388 resource: Some(&config.entity),
1389 field: Some(&col.field),
1390 };
1391 let html = match state.column_types.get(&col.column_type) {
1392 Some(ct) => {
1393 render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
1394 .into_string()
1395 }
1396 None => String::new(),
1398 };
1399 CellView {
1400 html,
1401 class: col.align_class().to_string(),
1402 }
1403 })
1404 .collect(),
1405 })
1406 .collect();
1407 let keys: Vec<&str> = config
1408 .columns
1409 .iter()
1410 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1411 .flatten()
1412 .collect();
1413 let mut shell = shell;
1414 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1415 let shown = rows.len() as i64;
1416 let title = shell.tt(&config.title);
1418 let active_dir = match order_dir {
1419 SortDir::Asc => "asc",
1420 SortDir::Desc => "desc",
1421 };
1422 let columns: Vec<ColumnHead> = config
1423 .columns
1424 .iter()
1425 .map(|c| {
1426 let active = c.field == order_by;
1427 ColumnHead {
1428 label: shell.tt(&c.label),
1429 field: c.field.clone(),
1430 next_dir: if active && active_dir == "asc" {
1433 "desc".to_string()
1434 } else {
1435 "asc".to_string()
1436 },
1437 active: if active {
1438 active_dir.to_string()
1439 } else {
1440 String::new()
1441 },
1442 sortable: c.sortable,
1443 width: c.width.clone(),
1444 align_class: c.align_class().to_string(),
1445 }
1446 })
1447 .collect();
1448 let shell_for_toolbar = shell.clone();
1449 let filter_views = filter_views(config, &active, &shell);
1450 let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
1451 .into_iter()
1452 .map(|c| ColumnChoice {
1453 field: c.field.clone(),
1454 label: shell.tt(&c.label),
1455 shown: config.columns.iter().any(|v| v.field == c.field),
1456 })
1457 .collect();
1458 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1460 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1461 let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
1463 let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
1464 let page_view = ListTemplate {
1465 shell,
1466 title,
1467 columns,
1468 rows,
1469 shown,
1470 page,
1471 total: result.total,
1472 total_pages,
1473 edit_base: config.edit_base.clone(),
1474 sort: order_by,
1475 dir: active_dir.to_string(),
1476 q: q.trim().to_string(),
1477 searchable: config.search.enabled
1478 && config.columns.iter().any(|c| c.is_searchable()),
1479 search_prompt,
1480 search_on_enter: config.search.on_enter,
1481 no_records_message,
1482 per_page,
1483 per_page_options: config.per_page_options.clone(),
1484 pickers,
1485 toolbar,
1486 export_query,
1487 path: path.to_string(),
1488 filters: filter_views,
1489 deletable: config.deletable,
1490 exportable: config.exportable,
1491 filtered: !active.is_empty() || !q.trim().is_empty(),
1492 carry: carry.clone(),
1493 };
1494 if crate::form::is_htmx(headers) {
1498 render(ListFragment {
1499 shell: page_view.shell,
1500 columns: page_view.columns,
1501 rows: page_view.rows,
1502 shown: page_view.shown,
1503 page: page_view.page,
1504 total: page_view.total,
1505 total_pages: page_view.total_pages,
1506 edit_base: page_view.edit_base,
1507 sort: page_view.sort,
1508 dir: page_view.dir,
1509 carry: page_view.carry,
1510 filtered: page_view.filtered,
1511 deletable: page_view.deletable,
1512 no_records_message: page_view.no_records_message,
1513 })
1514 } else {
1515 render(page_view)
1516 }
1517 }
1518 Err(_) => render_error(),
1519 }
1520}
1521
1522pub(crate) async fn set_columns(
1529 state: &AdminState,
1530 config: &ListConfig,
1531 path: &str,
1532 user: &laterite_auth::AuthenticatedUser,
1533 session: &crate::session::SessionHandle,
1534 headers: &axum::http::HeaderMap,
1535 pairs: &[(String, String)],
1536) -> Response {
1537 let allowed = allowed_columns(config, user);
1538 let chosen: Vec<&str> = pairs
1539 .iter()
1540 .filter(|(k, _)| k == "column")
1541 .map(|(_, v)| v.trim())
1542 .filter(|v| allowed.iter().any(|c| c.field == *v))
1543 .collect();
1544 let mut by_default: Vec<&str> = allowed
1548 .iter()
1549 .filter(|c| !c.invisible)
1550 .map(|c| c.field.as_str())
1551 .collect();
1552 let mut as_chosen = chosen.clone();
1553 by_default.sort_unstable();
1554 as_chosen.sort_unstable();
1555 as_chosen.dedup();
1556
1557 let size_key = per_page_preference_key(path);
1560 let size = pairs
1561 .iter()
1562 .find(|(k, _)| k == "per_page")
1563 .and_then(|(_, v)| v.trim().parse::<i64>().ok())
1564 .filter(|n| config.per_page_options.contains(n));
1565 let size_result = match size {
1566 Some(n) if n != config.per_page => {
1567 laterite_auth::store::set_user_preference(
1568 &state.db,
1569 user.user.id,
1570 &size_key,
1571 &n.to_string(),
1572 )
1573 .await
1574 }
1575 _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
1576 };
1577
1578 let key = columns_preference_key(path);
1579 let result = if chosen.is_empty() || as_chosen == by_default {
1580 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1581 } else {
1582 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1583 .await
1584 };
1585 match result.and(size_result) {
1586 Ok(()) => session.push_flash(
1587 crate::session::FlashLevel::Success,
1588 laterite_core::t!("List setup saved."),
1589 ),
1590 Err(e) => {
1591 tracing::error!(error = %e, "storing the list setup failed");
1592 session.push_flash(
1593 crate::session::FlashLevel::Error,
1594 laterite_core::t!("Could not save your list setup."),
1595 );
1596 }
1597 }
1598 let back = format!("{}{}", state.admin_path, path);
1599 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1600}
1601
1602pub struct ColumnChoice {
1604 pub field: String,
1605 pub label: String,
1606 pub shown: bool,
1607}
1608
1609pub struct ColumnHead {
1612 pub label: String,
1613 pub field: String,
1614 pub next_dir: String,
1615 pub active: String,
1616 pub sortable: bool,
1617 pub width: Option<String>,
1618 pub align_class: String,
1619}
1620
1621#[derive(Template)]
1622#[template(path = "list.html")]
1623struct ListTemplate {
1624 shell: crate::Shell,
1625 title: String,
1626 columns: Vec<ColumnHead>,
1627 rows: Vec<RenderedRow>,
1628 shown: i64,
1630 page: i64,
1631 total: i64,
1632 total_pages: i64,
1633 edit_base: Option<String>,
1634 sort: String,
1636 dir: String,
1637 q: String,
1639 searchable: bool,
1641 search_prompt: Option<String>,
1643 search_on_enter: bool,
1644 no_records_message: Option<String>,
1646 per_page: i64,
1649 per_page_options: Vec<i64>,
1650 deletable: bool,
1652 exportable: bool,
1655 pickers: Vec<ColumnChoice>,
1657 toolbar: Vec<ToolbarView>,
1659 export_query: String,
1662 path: String,
1664 filters: Vec<FilterView>,
1666 filtered: bool,
1668 carry: String,
1670}
1671
1672pub struct FilterView {
1674 pub param: String,
1675 pub label: String,
1676 pub input_type: String,
1678 pub options: Vec<FilterOptionView>,
1679 pub value: String,
1681}
1682
1683pub struct FilterOptionView {
1684 pub value: String,
1685 pub label: String,
1686}
1687
1688fn filter_views(
1690 config: &ListConfig,
1691 active: &[ActiveFilter<'_>],
1692 shell: &crate::Shell,
1693) -> Vec<FilterView> {
1694 config
1695 .filters
1696 .iter()
1697 .map(|f| {
1698 let value = active
1699 .iter()
1700 .find(|a| a.filter.field == f.field)
1701 .map(|a| a.raw.clone())
1702 .unwrap_or_default();
1703 let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
1706 "boolean" => vec![
1707 FilterOptionView {
1708 value: "1".to_string(),
1709 label: shell.tt(&laterite_core::t!("Yes")),
1710 },
1711 FilterOptionView {
1712 value: "0".to_string(),
1713 label: shell.tt(&laterite_core::t!("No")),
1714 },
1715 ],
1716 "select" => f
1717 .choices()
1718 .iter()
1719 .map(|o| FilterOptionView {
1720 value: o.value.clone(),
1721 label: shell.tt(&o.label),
1722 })
1723 .collect(),
1724 _ => Vec::new(),
1725 };
1726 let input_type = match f.filter_type.as_str() {
1727 "number" => "number",
1728 "date" => "date",
1729 _ => "text",
1730 };
1731 FilterView {
1732 param: f.param(),
1733 label: shell.tt(&f.label),
1734 input_type: input_type.to_string(),
1735 options,
1736 value,
1737 }
1738 })
1739 .collect()
1740}
1741
1742fn urlencode(value: &str) -> String {
1744 let mut out = String::with_capacity(value.len());
1745 for byte in value.as_bytes() {
1746 match byte {
1747 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1748 out.push(*byte as char)
1749 }
1750 b' ' => out.push('+'),
1751 _ => out.push_str(&format!("%{byte:02X}")),
1752 }
1753 }
1754 out
1755}
1756
1757#[derive(Template)]
1759#[template(path = "_list_table.html")]
1760struct ListFragment {
1761 shell: crate::Shell,
1762 columns: Vec<ColumnHead>,
1763 rows: Vec<RenderedRow>,
1764 shown: i64,
1765 page: i64,
1766 total: i64,
1767 total_pages: i64,
1768 edit_base: Option<String>,
1769 sort: String,
1770 dir: String,
1771 carry: String,
1772 filtered: bool,
1776 deletable: bool,
1777 no_records_message: Option<String>,
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782 use super::*;
1783
1784 fn config() -> ListConfig {
1785 ListConfig {
1786 entity: "backend_users".to_string(),
1787 title: "Users".into(),
1788 columns: vec![
1789 ListColumn::new("username", "Username"),
1790 ListColumn::new("is_superuser", "Superuser").yes_no(),
1791 ],
1792 order_by: "created_at".to_string(),
1793 order_dir: SortDir::Desc,
1794 per_page: 25,
1795 id_field: "id".to_string(),
1796 edit_base: None,
1797 creatable: false,
1798 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1799 deletable: true,
1800 ..Default::default()
1801 }
1802 }
1803
1804 #[test]
1805 fn column_types_format_their_cell_display() {
1806 let ist: Tz = "Asia/Kolkata".parse().unwrap();
1807 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1808 ct.view_model(&CellCx {
1809 value,
1810 tz,
1811 locale: chrono::Locale::en_US,
1812 })
1813 .display
1814 };
1815 assert_eq!(
1817 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
1818 "13 Aug 2026, 15:30"
1819 );
1820 assert_eq!(
1821 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
1822 "13 Aug 2026"
1823 );
1824 assert_eq!(
1825 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
1826 "15:30"
1827 );
1828 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
1829 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
1830 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
1831 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
1833 }
1834
1835 #[test]
1836 fn sort_falls_back_to_the_configured_order() {
1837 let c = config();
1838 assert_eq!(
1839 resolve_sort(&c, None, None),
1840 ("created_at".to_string(), SortDir::Desc)
1841 );
1842 }
1843
1844 #[test]
1845 fn a_declared_column_can_be_sorted_either_way() {
1846 let c = config();
1847 assert_eq!(
1848 resolve_sort(&c, Some("username"), Some("asc")),
1849 ("username".to_string(), SortDir::Asc)
1850 );
1851 assert_eq!(
1852 resolve_sort(&c, Some("username"), Some("desc")),
1853 ("username".to_string(), SortDir::Desc)
1854 );
1855 }
1856
1857 #[test]
1860 fn an_undeclared_sort_column_is_ignored() {
1861 let c = config();
1862 for crafted in [
1863 "password_hash",
1864 "id) --",
1865 "username; drop table backend_users",
1866 "",
1867 ] {
1868 assert_eq!(
1869 resolve_sort(&c, Some(crafted), Some("asc")).0,
1870 "created_at",
1871 "{crafted} must not reach the query"
1872 );
1873 }
1874 }
1875
1876 #[test]
1877 fn an_unknown_direction_falls_back() {
1878 let c = config();
1879 assert_eq!(
1881 resolve_sort(&c, Some("username"), Some("sideways")).1,
1882 SortDir::Asc
1883 );
1884 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1886 }
1887
1888 #[test]
1891 fn a_contributed_column_type_renders_its_cell() {
1892 struct Rating;
1893 impl ColumnType for Rating {
1894 fn view_key(&self) -> &'static str {
1895 "acme.rating"
1896 }
1897 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1898 let stars = cx.value.parse::<usize>().unwrap_or(0);
1899 text_cell("acme.rating", cx.value, "*".repeat(stars))
1900 }
1901 fn render_default(&self, vm: &CellVm) -> Markup {
1902 render_text(vm)
1903 }
1904 }
1905
1906 let reg = ColumnTypeReg::new(Arc::new(Rating));
1907 assert_eq!(reg.name(), "acme.rating", "the type names itself");
1908
1909 let mut registry = builtin_column_registry();
1910 registry.insert(reg.name().to_string(), reg.column_type);
1911 let rendered = render_cell(
1912 registry.get("acme.rating").unwrap().as_ref(),
1913 &crate::field::NoOverrides,
1914 &OverrideScope {
1915 surface: Surface::Column,
1916 view_key: "acme.rating",
1917 resource: Some("products"),
1918 field: Some("stars"),
1919 },
1920 &CellCx {
1921 value: "3",
1922 tz: Tz::UTC,
1923 locale: chrono::Locale::en_US,
1924 },
1925 );
1926 assert!(rendered.into_string().contains("***"));
1927 }
1928
1929 #[test]
1932 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
1933 struct Impostor;
1934 impl ColumnType for Impostor {
1935 fn view_key(&self) -> &'static str {
1936 "text"
1937 }
1938 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1939 text_cell("text", cx.value, String::new())
1940 }
1941 fn render_default(&self, vm: &CellVm) -> Markup {
1942 render_text(vm)
1943 }
1944 }
1945 let reg = ColumnTypeReg::new(Arc::new(Impostor));
1946 assert_eq!(reg.name(), "text");
1947 assert!(builtin_column_registry().contains_key(reg.name()));
1949 }
1950
1951 #[test]
1952 fn status_pill_renders_a_slugged_markup_span() {
1953 let vm = StatusPillColumn.view_model(&CellCx {
1954 value: "In Progress",
1955 tz: Tz::UTC,
1956 locale: chrono::Locale::en_US,
1957 });
1958 let html = StatusPillColumn.render_default(&vm).into_string();
1959 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
1960 assert!(html.contains(">In Progress<"));
1961 }
1962
1963 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1966 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
1967 }
1968
1969 #[tokio::test]
1970 async fn query_returns_display_rows() {
1971 let (db, _guard) = test_db().await;
1972 let hash = laterite_auth::password::hash_password("pw").unwrap();
1973 laterite_auth::store::create_user(
1974 &db,
1975 "root",
1976 "root@example.test",
1977 "Ada",
1978 None,
1979 &hash,
1980 true,
1981 )
1982 .await
1983 .unwrap();
1984
1985 let c = config();
1986 let result = query(&db, &c, &plain(&c)).await.unwrap();
1987 assert_eq!(result.total, 1);
1988 assert_eq!(result.rows.len(), 1);
1989 assert_eq!(result.rows[0].cells[0], "root");
1990 assert_eq!(result.rows[0].cells[1], "1");
1993 assert!(!result.rows[0].id.is_empty());
1994 }
1995
1996 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
1997 get_with(params, HashMap::new(), headers).await
1998 }
1999
2000 async fn get_with(
2001 params: ListParams,
2002 raw: HashMap<String, String>,
2003 headers: axum::http::HeaderMap,
2004 ) -> String {
2005 let (db, _guard) = test_db().await;
2006 let state = AdminState::new(
2007 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2008 db,
2009 );
2010 let resp = handle(
2011 &state,
2012 &config(),
2013 "/admin/users",
2014 params,
2015 &raw,
2016 &crate::audit::test_actor(),
2017 crate::Shell::test(),
2018 &headers,
2019 )
2020 .await;
2021 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2022 .await
2023 .unwrap();
2024 String::from_utf8(bytes.to_vec()).unwrap()
2025 }
2026
2027 #[test]
2028 fn a_typed_filter_accepts_only_its_own_shape() {
2029 let mut c = config();
2030 c.filters = vec![
2031 ListFilter::number("views", "Views"),
2032 ListFilter::date("created_at", "Created"),
2033 ListFilter::text("username", "Username"),
2034 ];
2035 let accepted = |param: &str, value: &str| {
2036 !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
2037 };
2038 assert!(accepted("f_views", "42"));
2039 assert!(
2040 !accepted("f_views", "4.2"),
2041 "a number filter takes whole numbers"
2042 );
2043 assert!(!accepted("f_views", "; drop table"), "and nothing else");
2044
2045 assert!(accepted("f_created_at", "2026-09-21"));
2046 assert!(
2047 !accepted("f_created_at", "2026-9-21"),
2048 "a date is ISO or nothing"
2049 );
2050 assert!(
2051 !accepted("f_created_at", "2026-09-21%"),
2052 "so a pattern cannot slip in"
2053 );
2054
2055 assert!(accepted("f_username", "ada"));
2056 }
2057
2058 #[test]
2059 fn a_filter_default_applies_only_until_the_operator_decides() {
2060 let mut c = config();
2061 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
2062 assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
2064 assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
2066 let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
2068 assert_eq!(active.len(), 1);
2069 }
2070
2071 #[test]
2072 fn a_gated_filter_is_narrowed_away_with_its_default() {
2073 let mut c = config();
2074 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
2075 .default_value("1")
2076 .require("acme.secret")];
2077 let plain = laterite_auth::AuthenticatedUser {
2078 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2079 ..crate::audit::test_actor()
2080 };
2081 assert!(allowed_filters(&c, &plain).is_empty());
2082 let narrowed = narrowed(&c, None, &plain);
2084 assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
2085 assert_eq!(allowed_filters(&c, &root()).len(), 1);
2086 }
2087
2088 #[test]
2089 fn a_stored_page_size_counts_only_while_it_is_offered() {
2090 let mut cfg = config();
2091 assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
2092 cfg.per_page_options = vec![25, 50, 100];
2093 assert_eq!(offered_per_page(&cfg, Some(50)), 50);
2094 assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
2095 assert_eq!(offered_per_page(&cfg, None), 25);
2096 }
2097
2098 fn params(sort: Option<&str>) -> ListParams {
2099 ListParams {
2100 page: None,
2101 sort: sort.map(|s| s.to_string()),
2102 dir: Some("asc".to_string()),
2103 q: None,
2104 }
2105 }
2106
2107 fn plain(config: &ListConfig) -> ListQuery<'_> {
2109 ListQuery {
2110 offset: 0,
2111 order_by: &config.order_by,
2112 order_dir: config.order_dir,
2113 q: "",
2114 filters: &[],
2115 per_page: None,
2116 }
2117 }
2118
2119 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
2120 ListQuery {
2121 offset: 0,
2122 order_by: &config.order_by,
2123 order_dir: config.order_dir,
2124 q,
2125 filters: &[],
2126 per_page: None,
2127 }
2128 }
2129
2130 fn amps(html: &str) -> String {
2133 html.replace("&", "&").replace("&", "&")
2134 }
2135
2136 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2138 pairs
2139 .iter()
2140 .map(|(k, v)| (k.to_string(), v.to_string()))
2141 .collect()
2142 }
2143
2144 #[tokio::test]
2145 async fn search_filters_rows_and_the_total() {
2146 let (db, _guard) = test_db().await;
2147 for name in ["ada", "brendan", "clara"] {
2148 let hash = laterite_auth::password::hash_password("x").unwrap();
2149 laterite_auth::store::create_user(
2150 &db,
2151 name,
2152 &format!("{name}@example.test"),
2153 name,
2154 None,
2155 &hash,
2156 false,
2157 )
2158 .await
2159 .unwrap();
2160 }
2161 let c = config();
2162
2163 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
2164 assert_eq!(hit.rows.len(), 1);
2165 assert_eq!(hit.rows[0].cells[0], "brendan");
2166 assert_eq!(hit.total, 1);
2168
2169 assert_eq!(
2171 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
2172 1
2173 );
2174
2175 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
2177 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
2178
2179 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
2181 }
2182
2183 #[tokio::test]
2186 async fn search_skips_a_non_text_column() {
2187 let (db, _guard) = test_db().await;
2188 let hash = laterite_auth::password::hash_password("x").unwrap();
2189 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
2190 .await
2191 .unwrap();
2192 let c = config();
2193 assert!(c.columns[0].is_searchable(), "username is text");
2194 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
2195 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
2197 }
2198
2199 #[test]
2200 fn a_column_can_override_its_searchability() {
2201 assert!(ListColumn::new("code", "Code").is_searchable());
2202 assert!(!ListColumn::new("code", "Code")
2203 .searchable(false)
2204 .is_searchable());
2205 assert!(ListColumn::new("at", "At")
2206 .datetime()
2207 .searchable(true)
2208 .is_searchable());
2209 }
2210
2211 async fn seed_users(db: &Db) {
2212 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
2213 let hash = laterite_auth::password::hash_password("x").unwrap();
2214 laterite_auth::store::create_user(
2215 db,
2216 name,
2217 &format!("{name}@example.test"),
2218 name,
2219 None,
2220 &hash,
2221 su,
2222 )
2223 .await
2224 .unwrap();
2225 }
2226 }
2227
2228 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
2229 let active = resolve_filters(c, params);
2230 let req = ListQuery {
2231 offset: 0,
2232 order_by: &c.order_by,
2233 order_dir: c.order_dir,
2234 q: "",
2235 filters: &active,
2236 per_page: None,
2237 };
2238 query(db, c, &req).await.unwrap().total
2239 }
2240
2241 #[tokio::test]
2242 async fn a_filter_narrows_the_rows_and_the_total() {
2243 let (db, _guard) = test_db().await;
2244 seed_users(&db).await;
2245 let c = config();
2246
2247 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
2248 assert_eq!(
2249 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
2250 1
2251 );
2252 assert_eq!(
2253 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
2254 2
2255 );
2256 }
2257
2258 #[test]
2261 fn an_undeclared_filter_or_value_is_ignored() {
2262 let c = config();
2263 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
2265 for bad in ["yes", "true", "1 or 1=1", "", " "] {
2267 assert!(
2268 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
2269 "{bad} must not reach the query"
2270 );
2271 }
2272
2273 let with_select = ListConfig {
2274 filters: vec![ListFilter::select(
2275 "status",
2276 "Status",
2277 vec![FilterOption::new("live", "Live")],
2278 )],
2279 ..config()
2280 };
2281 assert_eq!(
2282 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
2283 1
2284 );
2285 assert!(
2286 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
2287 "an option the descriptor never offered is dropped"
2288 );
2289 }
2290
2291 #[tokio::test]
2292 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
2293 let mut p = params(Some("username"));
2294 p.q = None;
2295 let html = get_with(
2296 p,
2297 filter_params(&[("f_is_superuser", "1")]),
2298 axum::http::HeaderMap::new(),
2299 )
2300 .await;
2301 let links = amps(&html);
2302 assert!(
2303 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
2304 "a sort link carries the filter"
2305 );
2306 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
2308 assert!(
2309 html.contains(r#"<option value="1" selected>"#),
2310 "the control shows the active value"
2311 );
2312 assert!(html.contains("Clear"), "and a way back to the full list");
2313 assert!(
2314 html.contains(r#"data-filtered="true""#),
2315 "the region says the list is narrowed, which is what reveals Clear"
2316 );
2317 }
2318
2319 #[tokio::test]
2322 async fn the_empty_state_distinguishes_no_records_from_no_match() {
2323 let (db, _guard) = test_db().await;
2324 let state = AdminState::new(
2325 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2326 db,
2327 );
2328 let render = |raw: HashMap<String, String>, q: Option<&str>| {
2329 let q = q.map(String::from);
2330 let state = state.clone();
2331 async move {
2332 let p = ListParams {
2333 page: None,
2334 sort: None,
2335 dir: None,
2336 q,
2337 };
2338 let resp = handle(
2339 &state,
2340 &config(),
2341 "/admin/users",
2342 p,
2343 &raw,
2344 &crate::audit::test_actor(),
2345 crate::Shell::test(),
2346 &axum::http::HeaderMap::new(),
2347 )
2348 .await;
2349 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2350 .await
2351 .unwrap();
2352 String::from_utf8(bytes.to_vec()).unwrap()
2353 }
2354 };
2355
2356 let bare = render(HashMap::new(), None).await;
2358 assert!(bare.contains("No records yet."));
2359
2360 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
2362 assert!(filtered.contains("No records match."));
2363 assert!(!filtered.contains("No records yet."));
2364
2365 let searched = render(HashMap::new(), Some("zzz")).await;
2367 assert!(searched.contains("No records match."));
2368 }
2369
2370 fn root() -> laterite_auth::AuthenticatedUser {
2372 laterite_auth::AuthenticatedUser {
2373 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2374 ..crate::audit::test_actor()
2375 }
2376 }
2377
2378 #[test]
2379 fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
2380 let mut c = config();
2381 c.columns[1].invisible = true;
2382 assert_eq!(visible_columns(&c, None, &root()).len(), 1);
2383 assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
2384 assert_eq!(
2385 visible_columns(&c, Some("is_superuser"), &root())[0].field,
2386 "is_superuser"
2387 );
2388 }
2389
2390 #[test]
2391 fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
2392 let mut c = config();
2393 c.columns[1].permission = Some("acme.secret".into());
2394 let plain = laterite_auth::AuthenticatedUser {
2395 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2396 ..crate::audit::test_actor()
2397 };
2398 assert_eq!(visible_columns(&c, None, &plain).len(), 1);
2399 assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
2401 assert_eq!(visible_columns(&c, None, &root()).len(), 2);
2402 }
2403
2404 #[test]
2405 fn an_unsortable_column_is_not_sorted_by() {
2406 let mut c = config();
2407 c.columns[0].sortable = false;
2408 let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
2409 assert_eq!(by, c.order_by, "falls back to the descriptor's order");
2410 }
2411
2412 #[test]
2413 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
2414 let c = config();
2415 let kept = visible_columns(&c, Some("is_superuser,username"), &root());
2418 assert_eq!(
2419 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
2420 ["username", "is_superuser"]
2421 );
2422 }
2423
2424 #[test]
2425 fn no_stored_choice_shows_every_column() {
2426 let c = config();
2427 assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
2428 }
2429
2430 #[test]
2433 fn a_stale_choice_falls_back_to_every_column() {
2434 let c = config();
2435 assert_eq!(
2436 visible_columns(&c, Some("gone,removed"), &root()).len(),
2437 c.columns.len()
2438 );
2439 assert_eq!(
2440 visible_columns(&c, Some(""), &root()).len(),
2441 c.columns.len()
2442 );
2443 assert_eq!(
2444 visible_columns(&c, Some(" , "), &root()).len(),
2445 c.columns.len()
2446 );
2447 assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
2449 }
2450
2451 #[test]
2454 fn the_toolbar_hides_what_the_operator_may_not_do() {
2455 let shell = crate::Shell::test();
2456 let cfg = ListConfig {
2457 creatable: false,
2458 toolbar: vec![
2459 ToolbarButton::new("Open", "/reports"),
2460 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
2461 ],
2462 ..config()
2463 };
2464
2465 let root = laterite_auth::AuthenticatedUser {
2467 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2468 ..crate::audit::test_actor()
2469 };
2470 let views = toolbar_views(&cfg, "/admin", &root, &shell);
2471 assert_eq!(
2472 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2473 ["Open", "Audit"]
2474 );
2475
2476 let granted = laterite_auth::AuthenticatedUser {
2478 permissions: laterite_auth::PermissionSet::with_overrides(
2479 false,
2480 ["backend.view_audit".to_string()],
2481 [],
2482 [],
2483 ),
2484 ..crate::audit::test_actor()
2485 };
2486 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
2487
2488 let plain = laterite_auth::AuthenticatedUser {
2490 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2491 ..crate::audit::test_actor()
2492 };
2493 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
2494 assert_eq!(
2495 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2496 ["Open"]
2497 );
2498 }
2499
2500 #[test]
2503 fn a_toolbar_href_follows_the_admin_mount() {
2504 let shell = crate::Shell::test();
2505 let cfg = ListConfig {
2506 creatable: true,
2507 edit_base: Some("/backoffice/roles".to_string()),
2509 toolbar: vec![
2510 ToolbarButton::new("Reports", "/reports"),
2511 ToolbarButton::new("Docs", "https://example.test/docs"),
2512 ],
2513 ..config()
2514 };
2515 let root = laterite_auth::AuthenticatedUser {
2516 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2517 ..crate::audit::test_actor()
2518 };
2519 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
2520 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
2521 assert_eq!(views[1].href, "/backoffice/reports");
2522 assert_eq!(views[2].href, "https://example.test/docs");
2524 }
2525
2526 #[tokio::test]
2527 async fn an_htmx_sort_returns_the_list_region_alone() {
2528 let mut headers = axum::http::HeaderMap::new();
2529 headers.insert("hx-request", "true".parse().unwrap());
2530 let html = get(params(Some("username")), headers).await;
2531 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
2532 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
2533 assert!(!html.contains("<body"), "no page chrome");
2536 assert!(html.contains(r#"data-filtered="false""#));
2538 }
2539
2540 #[tokio::test]
2543 async fn the_search_term_survives_a_sort_link() {
2544 let mut p = params(Some("username"));
2545 p.q = Some("bren".to_string());
2546 let html = get(p, axum::http::HeaderMap::new()).await;
2547 assert!(
2548 amps(&html).contains("?sort=username&dir=desc&q=bren"),
2549 "sort and pager links carry the term"
2550 );
2551 assert!(
2552 html.contains(r#"value="bren""#),
2553 "and the box still shows it"
2554 );
2555 }
2556
2557 #[tokio::test]
2558 async fn a_plain_sort_returns_the_whole_page() {
2559 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
2560 assert!(html.contains("<body"), "lists still work without htmx");
2561 assert!(html.contains(r#"id="lat-list""#));
2562 assert!(html.contains(r#"href="?sort=username"#));
2564 }
2565
2566 #[tokio::test]
2567 async fn query_rejects_bad_identifiers() {
2568 let (db, _guard) = test_db().await;
2569 let mut bad = config();
2570 bad.entity = "backend_users; drop table backend_users".to_string();
2571 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2572 }
2573}