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