1use std::collections::HashMap;
12use std::sync::Arc;
13
14use askama::Template;
15use axum::response::{IntoResponse, 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)]
61#[serde(deny_unknown_fields)]
62pub struct ValueLabel {
63 pub value: String,
64 pub label: Text,
66}
67
68impl ValueLabel {
69 pub fn new(value: impl Into<String>, label: impl Into<Text>) -> Self {
70 Self {
71 value: value.into(),
72 label: label.into(),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78#[non_exhaustive]
79pub struct ListColumn {
80 pub field: String,
82 pub label: Text,
84 pub column_type: String,
86 pub options: serde_json::Value,
89 pub sortable: bool,
91 pub invisible: bool,
93 pub width: Option<String>,
95 pub align: Option<Align>,
96 pub permission: Option<String>,
99 pub searchable: Option<bool>,
103 pub labels: Vec<ValueLabel>,
106}
107
108impl ListColumn {
109 pub fn new(field: &str, label: impl Into<Text>) -> Self {
110 Self {
111 field: field.to_string(),
112 label: label.into(),
113 column_type: "text".to_string(),
114 sortable: true,
115 invisible: false,
116 width: None,
117 align: None,
118 permission: None,
119 searchable: None,
120 options: serde_json::Value::Null,
121 labels: Vec::new(),
122 }
123 }
124
125 pub fn labels(mut self, labels: Vec<ValueLabel>) -> Self {
127 self.labels = labels;
128 self
129 }
130
131 fn of(mut self, column_type: &str) -> Self {
132 self.column_type = column_type.to_string();
133 self
134 }
135
136 pub fn datetime(self) -> Self {
138 self.of("datetime")
139 }
140 pub fn date(self) -> Self {
142 self.of("date")
143 }
144 pub fn time(self) -> Self {
146 self.of("time")
147 }
148 pub fn yes_no(self) -> Self {
150 self.of("boolean")
151 }
152 pub fn pill(self) -> Self {
154 self.of("status_pill")
155 }
156
157 pub fn searchable(mut self, searchable: bool) -> Self {
159 self.searchable = Some(searchable);
160 self
161 }
162
163 pub fn sortable(mut self, sortable: bool) -> Self {
164 self.sortable = sortable;
165 self
166 }
167
168 pub fn invisible(mut self) -> Self {
170 self.invisible = true;
171 self
172 }
173
174 pub fn width(mut self, width: impl Into<String>) -> Self {
175 self.width = Some(width.into());
176 self
177 }
178
179 pub fn align(mut self, align: Align) -> Self {
180 self.align = Some(align);
181 self
182 }
183
184 pub fn require(mut self, permission: impl Into<String>) -> Self {
186 self.permission = Some(permission.into());
187 self
188 }
189
190 pub(crate) fn align_class(&self) -> &'static str {
191 self.align.map(Align::class).unwrap_or("")
192 }
193
194 pub(crate) fn is_searchable(&self) -> bool {
196 self.searchable.unwrap_or(self.column_type == "text")
197 }
198}
199
200pub struct CellCx<'a> {
203 pub value: &'a str,
204 pub tz: Tz,
205 pub locale: chrono::Locale,
206}
207
208pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
214 chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
215}
216
217#[derive(Serialize)]
220pub struct CellVm {
221 pub view_key: String,
222 pub value: String,
223 pub display: String,
224 pub data: serde_json::Value,
225}
226
227pub trait ColumnType: Send + Sync + 'static {
230 fn view_key(&self) -> &'static str;
231 fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
232 fn render_default(&self, vm: &CellVm) -> Markup;
233 fn option_keys(&self) -> Option<Vec<&'static str>> {
237 None
238 }
239 fn assets(&self) -> Vec<&'static str> {
242 Vec::new()
243 }
244}
245
246pub struct ColumnTypeReg {
253 pub column_type: Arc<dyn ColumnType>,
254}
255
256impl ColumnTypeReg {
257 pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
258 Self { column_type }
259 }
260
261 pub fn name(&self) -> &'static str {
263 self.column_type.view_key()
264 }
265}
266
267pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
269
270pub(crate) fn render_cell(
275 ct: &dyn ColumnType,
276 resolver: &dyn OverrideResolver,
277 scope: &OverrideScope<'_>,
278 cx: &CellCx<'_>,
279 labels: Option<&HashMap<String, String>>,
280) -> Markup {
281 let mut vm = ct.view_model(cx);
282 if let Some(label) = labels.and_then(|l| l.get(&vm.value)) {
283 vm.display = label.clone();
284 }
285 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
286 Some(Ok(html)) => Markup::from_override(html),
287 Some(Err(_)) | None => ct.render_default(&vm),
288 }
289}
290
291pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
292 vec![
293 Arc::new(TextColumn),
294 Arc::new(DateTimeColumn),
295 Arc::new(DateColumn),
296 Arc::new(TimeColumn),
297 Arc::new(BoolColumn),
298 Arc::new(StatusPillColumn),
299 ]
300}
301
302pub(crate) fn builtin_column_registry() -> ColumnRegistry {
304 builtin_column_types()
305 .into_iter()
306 .map(|c| (c.view_key().to_string(), c))
307 .collect()
308}
309
310#[derive(Template)]
311#[template(path = "cells/text.html")]
312struct CellTextTmpl<'a> {
313 display: &'a str,
314}
315
316fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
318 CellVm {
319 view_key: view_key.to_string(),
320 value: value.to_string(),
321 display,
322 data: serde_json::Value::Null,
323 }
324}
325
326fn render_text(vm: &CellVm) -> Markup {
327 Markup::from_template(&CellTextTmpl {
328 display: &vm.display,
329 })
330 .unwrap_or_default()
331}
332
333pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
336 match DateTime::parse_from_rfc3339(raw) {
337 Ok(dt) => dt
338 .with_timezone(&tz)
339 .format_localized(pattern, locale)
340 .to_string(),
341 Err(_) => raw.to_string(),
342 }
343}
344
345struct TextColumn;
346impl ColumnType for TextColumn {
347 fn option_keys(&self) -> Option<Vec<&'static str>> {
348 Some(Vec::new())
349 }
350 fn view_key(&self) -> &'static str {
351 "text"
352 }
353 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
354 text_cell("text", cx.value, cx.value.to_string())
355 }
356 fn render_default(&self, vm: &CellVm) -> Markup {
357 render_text(vm)
358 }
359}
360
361struct DateTimeColumn;
362impl ColumnType for DateTimeColumn {
363 fn option_keys(&self) -> Option<Vec<&'static str>> {
364 Some(Vec::new())
365 }
366 fn view_key(&self) -> &'static str {
367 "datetime"
368 }
369 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
370 text_cell(
371 "datetime",
372 cx.value,
373 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
374 )
375 }
376 fn render_default(&self, vm: &CellVm) -> Markup {
377 render_text(vm)
378 }
379}
380
381struct DateColumn;
382impl ColumnType for DateColumn {
383 fn option_keys(&self) -> Option<Vec<&'static str>> {
384 Some(Vec::new())
385 }
386 fn view_key(&self) -> &'static str {
387 "date"
388 }
389 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
390 text_cell(
391 "date",
392 cx.value,
393 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
394 )
395 }
396 fn render_default(&self, vm: &CellVm) -> Markup {
397 render_text(vm)
398 }
399}
400
401struct TimeColumn;
402impl ColumnType for TimeColumn {
403 fn option_keys(&self) -> Option<Vec<&'static str>> {
404 Some(Vec::new())
405 }
406 fn view_key(&self) -> &'static str {
407 "time"
408 }
409 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
410 text_cell(
411 "time",
412 cx.value,
413 format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
414 )
415 }
416 fn render_default(&self, vm: &CellVm) -> Markup {
417 render_text(vm)
418 }
419}
420
421struct BoolColumn;
422impl ColumnType for BoolColumn {
423 fn option_keys(&self) -> Option<Vec<&'static str>> {
424 Some(Vec::new())
425 }
426 fn view_key(&self) -> &'static str {
427 "boolean"
428 }
429 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
430 let display = match cx.value {
431 "1" | "true" => "Yes",
432 "0" | "false" | "" => "No",
433 other => other,
434 };
435 text_cell("boolean", cx.value, display.to_string())
436 }
437 fn render_default(&self, vm: &CellVm) -> Markup {
438 render_text(vm)
439 }
440}
441
442#[derive(Template)]
443#[template(path = "cells/status.html")]
444struct CellStatusTmpl<'a> {
445 label: &'a str,
446 slug: &'a str,
447}
448
449struct StatusPillColumn;
451impl ColumnType for StatusPillColumn {
452 fn option_keys(&self) -> Option<Vec<&'static str>> {
453 Some(Vec::new())
454 }
455 fn view_key(&self) -> &'static str {
456 "status_pill"
457 }
458 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
459 CellVm {
460 view_key: "status_pill".to_string(),
461 value: cx.value.to_string(),
462 display: cx.value.to_string(),
463 data: serde_json::json!({ "slug": status_slug(cx.value) }),
464 }
465 }
466 fn render_default(&self, vm: &CellVm) -> Markup {
467 let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
468 Markup::from_template(&CellStatusTmpl {
469 label: &vm.display,
470 slug,
471 })
472 .unwrap_or_default()
473 }
474}
475
476fn status_slug(value: &str) -> String {
478 value
479 .trim()
480 .to_lowercase()
481 .chars()
482 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
483 .collect()
484}
485
486impl Default for ListColumn {
487 fn default() -> Self {
490 Self::new("", Text::new(""))
491 }
492}
493
494impl Default for ListFilter {
495 fn default() -> Self {
497 Self::of("", Text::new(""), "text")
498 }
499}
500
501impl<'de> Deserialize<'de> for ListColumn {
502 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
503 struct Entry;
504 impl<'de> serde::de::Visitor<'de> for Entry {
505 type Value = ListColumn;
506
507 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
508 f.write_str("a column: its own keys and its type's")
509 }
510
511 fn visit_map<A: serde::de::MapAccess<'de>>(
512 self,
513 map: A,
514 ) -> Result<ListColumn, A::Error> {
515 let mut column = ListColumn::default();
516 let theirs = crate::keyed::split_entry(map, |key, map| {
517 match key {
518 "label" => column.label = map.next_value()?,
519 "type" => column.column_type = map.next_value()?,
520 "sortable" => column.sortable = map.next_value()?,
521 "invisible" => column.invisible = map.next_value()?,
522 "width" => column.width = map.next_value()?,
523 "align" => column.align = map.next_value()?,
524 "permission" => column.permission = map.next_value()?,
525 "searchable" => column.searchable = map.next_value()?,
526 "labels" => column.labels = map.next_value()?,
527 _ => return Ok(false),
528 }
529 Ok(true)
530 })?;
531 column.options = theirs;
532 Ok(column)
533 }
534
535 fn visit_unit<E: serde::de::Error>(self) -> Result<ListColumn, E> {
537 Ok(ListColumn::default())
538 }
539 }
540 de.deserialize_map(Entry)
541 }
542}
543
544impl Serialize for ListColumn {
545 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
546 use serde::ser::SerializeMap;
547 let mut map = ser.serialize_map(None)?;
548 map.serialize_entry("label", &self.label)?;
549 map.serialize_entry("type", &self.column_type)?;
550 map.serialize_entry("sortable", &self.sortable)?;
551 map.serialize_entry("invisible", &self.invisible)?;
552 map.serialize_entry("width", &self.width)?;
553 map.serialize_entry("align", &self.align)?;
554 map.serialize_entry("permission", &self.permission)?;
555 map.serialize_entry("searchable", &self.searchable)?;
556 if !self.labels.is_empty() {
557 map.serialize_entry("labels", &self.labels)?;
558 }
559 crate::keyed::serialize_theirs(&mut map, &self.options)?;
560 map.end()
561 }
562}
563
564impl<'de> Deserialize<'de> for ListFilter {
565 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
566 struct Entry;
567 impl<'de> serde::de::Visitor<'de> for Entry {
568 type Value = ListFilter;
569
570 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
571 f.write_str("a filter: its own keys and its type's")
572 }
573
574 fn visit_map<A: serde::de::MapAccess<'de>>(
575 self,
576 map: A,
577 ) -> Result<ListFilter, A::Error> {
578 let mut filter = ListFilter::default();
579 let theirs = crate::keyed::split_entry(map, |key, map| {
580 match key {
581 "label" => filter.label = map.next_value()?,
582 "type" => filter.filter_type = map.next_value()?,
583 "default_value" => filter.default_value = map.next_value()?,
584 "permission" => filter.permission = map.next_value()?,
585 _ => return Ok(false),
586 }
587 Ok(true)
588 })?;
589 filter.options = theirs;
590 Ok(filter)
591 }
592
593 fn visit_unit<E: serde::de::Error>(self) -> Result<ListFilter, E> {
595 Ok(ListFilter::default())
596 }
597 }
598 de.deserialize_map(Entry)
599 }
600}
601
602impl Serialize for ListFilter {
603 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
604 use serde::ser::SerializeMap;
605 let mut map = ser.serialize_map(None)?;
606 map.serialize_entry("label", &self.label)?;
607 map.serialize_entry("type", &self.filter_type)?;
608 map.serialize_entry("default_value", &self.default_value)?;
609 map.serialize_entry("permission", &self.permission)?;
610 crate::keyed::serialize_theirs(&mut map, &self.options)?;
611 map.end()
612 }
613}
614
615impl crate::keyed::Keyed for ListColumn {
616 fn key(&self) -> &str {
617 &self.field
618 }
619 fn set_key(&mut self, key: String) {
620 self.field = key;
621 }
622}
623
624impl crate::keyed::Keyed for ListFilter {
625 fn key(&self) -> &str {
626 &self.field
627 }
628 fn set_key(&mut self, key: String) {
629 self.field = key;
630 }
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct FilterOption {
636 pub value: String,
637 pub label: Text,
639}
640
641impl FilterOption {
642 pub fn new(value: &str, label: impl Into<Text>) -> Self {
643 Self {
644 value: value.to_string(),
645 label: label.into(),
646 }
647 }
648}
649
650#[derive(Debug, Clone)]
652#[non_exhaustive]
653pub struct ListFilter {
654 pub field: String,
656 pub label: Text,
658 pub filter_type: String,
660 pub options: serde_json::Value,
663 pub default_value: Option<String>,
666 pub permission: Option<String>,
669}
670
671impl ListFilter {
672 pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
674 Self {
675 field: field.to_string(),
676 label: label.into(),
677 filter_type: filter_type.to_string(),
678 options: serde_json::Value::Null,
679 default_value: None,
680 permission: None,
681 }
682 }
683
684 pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
686 Self::of(field, label, "boolean")
687 }
688
689 pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
692 let mut filter = Self::of(field, label, "select");
693 filter.options = serde_json::json!({ "options": options });
694 filter
695 }
696
697 pub fn text(field: &str, label: impl Into<Text>) -> Self {
699 Self::of(field, label, "text")
700 }
701
702 pub fn number(field: &str, label: impl Into<Text>) -> Self {
704 Self::of(field, label, "number")
705 }
706
707 pub fn date(field: &str, label: impl Into<Text>) -> Self {
710 Self::of(field, label, "date")
711 }
712
713 pub(crate) fn choices(&self) -> Vec<FilterOption> {
715 self.options
716 .get("options")
717 .and_then(|v| serde_json::from_value(v.clone()).ok())
718 .unwrap_or_default()
719 }
720
721 pub fn default_value(mut self, value: impl Into<String>) -> Self {
723 self.default_value = Some(value.into());
724 self
725 }
726
727 pub fn require(mut self, permission: impl Into<String>) -> Self {
729 self.permission = Some(permission.into());
730 self
731 }
732
733 pub(crate) fn param(&self) -> String {
734 format!("f_{}", self.field)
735 }
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
744#[serde(deny_unknown_fields)]
745pub struct ToolbarButton {
746 pub label: Text,
748 pub href: String,
751 pub icon: Option<String>,
753 pub permission: Option<String>,
756 pub primary: bool,
758}
759
760impl ToolbarButton {
761 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
762 Self {
763 label: label.into(),
764 href: href.into(),
765 icon: None,
766 permission: None,
767 primary: false,
768 }
769 }
770
771 pub fn icon(mut self, icon: &str) -> Self {
772 self.icon = Some(icon.to_string());
773 self
774 }
775
776 pub fn require(mut self, permission: impl Into<String>) -> Self {
778 self.permission = Some(permission.into());
779 self
780 }
781
782 pub fn primary(mut self) -> Self {
783 self.primary = true;
784 self
785 }
786}
787
788pub struct ToolbarView {
790 pub label: String,
791 pub href: String,
792 pub icon: String,
794 pub primary: bool,
795}
796
797fn toolbar_views(
801 config: &ListConfig,
802 admin_path: &str,
803 user: &laterite_auth::AuthenticatedUser,
804 shell: &crate::Shell,
805) -> Vec<ToolbarView> {
806 let mut out = Vec::new();
807 if config.creatable {
808 if let Some(base) = &config.edit_base {
809 out.push(ToolbarView {
810 label: shell.tt(&laterite_core::t!("New")),
811 href: format!("{base}/new"),
814 icon: String::new(),
815 primary: true,
816 });
817 }
818 }
819 for button in &config.toolbar {
820 if let Some(needed) = &button.permission {
821 if !user.permissions.allows(needed) {
822 continue;
823 }
824 }
825 let href = if button.href.starts_with('/') {
826 format!("{admin_path}{}", button.href)
827 } else {
828 button.href.clone()
829 };
830 out.push(ToolbarView {
831 label: shell.tt(&button.label),
832 href,
833 icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
834 primary: button.primary,
835 });
836 }
837 out
838}
839
840pub(crate) fn columns_preference_key(base_path: &str) -> String {
843 format!("list.columns.{base_path}")
844}
845
846pub(crate) async fn stored_columns(
851 state: &AdminState,
852 path: &str,
853 user: &laterite_auth::AuthenticatedUser,
854) -> Option<String> {
855 match laterite_auth::store::user_preference(
856 &state.db,
857 user.user.id,
858 &columns_preference_key(path),
859 )
860 .await
861 {
862 Ok(stored) => stored,
863 Err(e) => {
864 tracing::warn!(error = %e, "reading the column preference failed");
865 None
866 }
867 }
868}
869
870pub(crate) fn per_page_preference_key(base_path: &str) -> String {
872 format!("list.per_page:{base_path}")
873}
874
875pub(crate) async fn stored_per_page(
879 state: &AdminState,
880 config: &ListConfig,
881 path: &str,
882 user: &laterite_auth::AuthenticatedUser,
883) -> i64 {
884 let stored = match laterite_auth::store::user_preference(
885 &state.db,
886 user.user.id,
887 &per_page_preference_key(path),
888 )
889 .await
890 {
891 Ok(stored) => stored,
892 Err(e) => {
893 tracing::warn!(error = %e, "reading the page-size preference failed");
894 None
895 }
896 };
897 offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
898}
899
900pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
902 size.filter(|n| config.per_page_options.contains(n))
903 .unwrap_or(config.per_page)
904}
905
906pub(crate) fn allowed_columns<'a>(
909 config: &'a ListConfig,
910 user: &laterite_auth::AuthenticatedUser,
911) -> Vec<&'a ListColumn> {
912 config
913 .columns
914 .iter()
915 .filter(|c| {
916 c.permission
917 .as_deref()
918 .is_none_or(|p| user.permissions.allows(p))
919 })
920 .collect()
921}
922
923pub(crate) fn allowed_filters<'a>(
926 config: &'a ListConfig,
927 user: &laterite_auth::AuthenticatedUser,
928) -> Vec<&'a ListFilter> {
929 config
930 .filters
931 .iter()
932 .filter(|f| {
933 f.permission
934 .as_deref()
935 .is_none_or(|p| user.permissions.allows(p))
936 })
937 .collect()
938}
939
940pub(crate) fn narrowed(
943 config: &ListConfig,
944 stored: Option<&str>,
945 user: &laterite_auth::AuthenticatedUser,
946) -> ListConfig {
947 ListConfig {
948 columns: visible_columns(config, stored, user)
949 .into_iter()
950 .cloned()
951 .collect(),
952 filters: allowed_filters(config, user).into_iter().cloned().collect(),
953 ..config.clone()
954 }
955}
956
957pub(crate) fn visible_columns<'a>(
965 config: &'a ListConfig,
966 stored: Option<&str>,
967 user: &laterite_auth::AuthenticatedUser,
968) -> Vec<&'a ListColumn> {
969 let allowed = allowed_columns(config, user);
970 let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
971 let Some(stored) = stored else {
972 return by_default();
973 };
974 let chosen: Vec<&str> = stored
975 .split(',')
976 .map(str::trim)
977 .filter(|s| !s.is_empty())
978 .collect();
979 let kept: Vec<&ListColumn> = allowed
980 .iter()
981 .copied()
982 .filter(|c| chosen.iter().any(|n| *n == c.field))
983 .collect();
984 if kept.is_empty() {
985 return by_default();
986 }
987 kept
988}
989
990pub(crate) fn check(
994 config: &ListConfig,
995 column_types: &ColumnRegistry,
996 permission_codes: &std::collections::HashSet<String>,
997) -> Result<(), String> {
998 for col in &config.columns {
999 if column_types.get(&col.column_type).is_none() {
1000 return Err(format!(
1001 "column `{}` uses unregistered type `{}`",
1002 col.field, col.column_type
1003 ));
1004 }
1005 if let Some(p) = &col.permission {
1008 if !permission_codes.contains(p) {
1009 return Err(format!(
1010 "column `{}` requires unregistered permission `{p}`",
1011 col.field
1012 ));
1013 }
1014 }
1015 }
1016 for filter in &config.filters {
1017 if let Some(p) = &filter.permission {
1018 if !permission_codes.contains(p) {
1019 return Err(format!(
1020 "filter `{}` requires unregistered permission `{p}`",
1021 filter.field
1022 ));
1023 }
1024 }
1025 }
1026 Ok(())
1027}
1028
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1031#[serde(deny_unknown_fields, default)]
1032pub struct SearchConfig {
1033 pub enabled: bool,
1035 pub prompt: Option<Text>,
1037 pub on_enter: bool,
1039}
1040
1041impl Default for SearchConfig {
1042 fn default() -> Self {
1043 Self {
1044 enabled: true,
1045 prompt: None,
1046 on_enter: false,
1047 }
1048 }
1049}
1050
1051impl SearchConfig {
1052 pub fn off() -> Self {
1054 Self {
1055 enabled: false,
1056 ..Self::default()
1057 }
1058 }
1059
1060 pub fn prompt(mut self, text: impl Into<Text>) -> Self {
1061 self.prompt = Some(text.into());
1062 self
1063 }
1064
1065 pub fn on_enter(mut self) -> Self {
1066 self.on_enter = true;
1067 self
1068 }
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize)]
1078#[serde(deny_unknown_fields, default)]
1079#[non_exhaustive]
1080pub struct ListConfig {
1081 pub entity: String,
1082 pub title: Text,
1084 #[serde(
1085 deserialize_with = "crate::keyed::deserialize",
1086 serialize_with = "crate::keyed::serialize"
1087 )]
1088 pub columns: Vec<ListColumn>,
1089 pub order_by: String,
1090 pub order_dir: SortDir,
1091 pub per_page: i64,
1092 pub id_field: String,
1093 pub edit_base: Option<String>,
1095 pub creatable: bool,
1098 #[serde(
1100 deserialize_with = "crate::keyed::deserialize",
1101 serialize_with = "crate::keyed::serialize"
1102 )]
1103 pub filters: Vec<ListFilter>,
1104 pub deletable: bool,
1108 pub exportable: bool,
1111 pub toolbar: Vec<ToolbarButton>,
1113 pub search: SearchConfig,
1114 pub per_page_options: Vec<i64>,
1117 pub no_records_message: Option<Text>,
1119 pub row_click: RowClick,
1122 pub remember: bool,
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1129#[serde(rename_all = "lowercase")]
1130pub enum RowClick {
1131 #[default]
1133 Open,
1134 None,
1136}
1137
1138impl ListConfig {
1139 pub fn new(
1144 entity: impl Into<String>,
1145 title: impl Into<Text>,
1146 columns: Vec<ListColumn>,
1147 ) -> Self {
1148 Self {
1149 entity: entity.into(),
1150 title: title.into(),
1151 columns,
1152 ..Self::default()
1153 }
1154 }
1155
1156 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
1158 self.order_by = column.into();
1159 self.order_dir = dir;
1160 self
1161 }
1162
1163 pub fn per_page(mut self, rows: i64) -> Self {
1164 self.per_page = rows;
1165 self
1166 }
1167
1168 pub fn id_field(mut self, column: impl Into<String>) -> Self {
1170 self.id_field = column.into();
1171 self
1172 }
1173
1174 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
1176 self.edit_base = Some(base.into());
1177 self
1178 }
1179
1180 pub fn creatable(mut self) -> Self {
1184 self.creatable = true;
1185 self
1186 }
1187
1188 pub fn exportable(mut self) -> Self {
1192 self.exportable = true;
1193 self
1194 }
1195
1196 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
1197 self.filters = filters;
1198 self
1199 }
1200
1201 pub fn deletable(mut self) -> Self {
1204 self.deletable = true;
1205 self
1206 }
1207
1208 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
1209 self.toolbar = buttons;
1210 self
1211 }
1212
1213 pub fn search(mut self, search: SearchConfig) -> Self {
1214 self.search = search;
1215 self
1216 }
1217
1218 pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
1219 self.per_page_options = sizes;
1220 self
1221 }
1222
1223 pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
1224 self.no_records_message = Some(text.into());
1225 self
1226 }
1227
1228 pub fn row_click(mut self, action: RowClick) -> Self {
1230 self.row_click = action;
1231 self
1232 }
1233
1234 pub fn remember(mut self, remember: bool) -> Self {
1237 self.remember = remember;
1238 self
1239 }
1240}
1241
1242impl Default for ListConfig {
1243 fn default() -> Self {
1250 Self {
1251 entity: String::new(),
1252 title: Text::new(""),
1253 columns: Vec::new(),
1254 order_by: "id".to_string(),
1255 order_dir: SortDir::Desc,
1256 per_page: 25,
1257 id_field: "id".to_string(),
1258 edit_base: None,
1259 creatable: false,
1260 filters: Vec::new(),
1261 deletable: false,
1262 exportable: false,
1263 toolbar: Vec::new(),
1264 search: SearchConfig::default(),
1265 per_page_options: Vec::new(),
1266 no_records_message: None,
1267 row_click: RowClick::Open,
1268 remember: true,
1269 }
1270 }
1271}
1272
1273#[derive(Deserialize)]
1275pub struct ListParams {
1276 page: Option<i64>,
1277 sort: Option<String>,
1278 dir: Option<String>,
1279 q: Option<String>,
1280}
1281
1282pub(crate) fn resolve_sort(
1287 config: &ListConfig,
1288 sort: Option<&str>,
1289 dir: Option<&str>,
1290) -> (String, SortDir) {
1291 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1292 Some(field) => field.to_string(),
1293 None => config.order_by.clone(),
1294 };
1295 let dir = match dir {
1296 Some("asc") => SortDir::Asc,
1297 Some("desc") => SortDir::Desc,
1298 _ if by == config.order_by => config.order_dir,
1301 _ => SortDir::Asc,
1302 };
1303 (by, dir)
1304}
1305
1306pub struct RowView {
1308 pub id: String,
1309 pub cells: Vec<String>,
1310}
1311
1312pub struct CellView {
1314 pub html: String,
1315 pub class: String,
1316}
1317
1318pub struct RenderedRow {
1320 pub id: String,
1321 pub cells: Vec<CellView>,
1322}
1323
1324pub struct ListPage {
1326 pub rows: Vec<RowView>,
1327 pub total: i64,
1328}
1329
1330pub(crate) struct ListQuery<'a> {
1333 pub offset: i64,
1334 pub order_by: &'a str,
1335 pub order_dir: SortDir,
1336 pub q: &'a str,
1337 pub filters: &'a [ActiveFilter<'a>],
1338 pub per_page: Option<i64>,
1341}
1342
1343pub(crate) struct ActiveFilter<'a> {
1347 filter: &'a ListFilter,
1348 raw: String,
1350 matcher: FilterMatch,
1353}
1354
1355pub(crate) enum FilterMatch {
1357 Equals(sea_query::Value),
1359 Contains(String),
1361 StartsWith(String),
1363}
1364
1365fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1371 match filter.filter_type.as_str() {
1372 "boolean" => match raw {
1375 "1" => Some(FilterMatch::Equals(true.into())),
1376 "0" => Some(FilterMatch::Equals(false.into())),
1377 _ => None,
1378 },
1379 "select" => filter
1380 .choices()
1381 .iter()
1382 .any(|o| o.value == raw)
1383 .then(|| FilterMatch::Equals(raw.into())),
1384 "text" => Some(FilterMatch::Contains(raw.to_string())),
1385 "number" => raw
1386 .parse::<i64>()
1387 .ok()
1388 .map(|n| FilterMatch::Equals(n.into())),
1389 "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1393 _ => None,
1394 }
1395}
1396
1397fn is_iso_date(raw: &str) -> bool {
1399 raw.len() == 10
1400 && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1401 if i == 4 || i == 7 {
1402 *b == b'-'
1403 } else {
1404 b.is_ascii_digit()
1405 }
1406 })
1407}
1408
1409pub(crate) fn resolve_filters<'a>(
1412 config: &'a ListConfig,
1413 params: &HashMap<String, String>,
1414) -> Vec<ActiveFilter<'a>> {
1415 let mut out = Vec::new();
1416 for filter in &config.filters {
1417 let submitted = params.get(&filter.param()).map(|v| v.trim());
1418 let raw = match submitted {
1419 Some(value) => value,
1421 None => filter.default_value.as_deref().unwrap_or_default(),
1423 };
1424 if raw.is_empty() {
1425 continue;
1426 }
1427 let Some(matcher) = filter_match(filter, raw) else {
1428 continue;
1429 };
1430 out.push(ActiveFilter {
1431 filter,
1432 raw: raw.to_string(),
1433 matcher,
1434 });
1435 }
1436 out
1437}
1438
1439fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1441 if active.is_empty() {
1442 return None;
1443 }
1444 let caps = laterite_core::capabilities::CapabilitySet::default();
1445 let mut all = sea_query::Condition::all();
1446 for f in active {
1447 let col = Expr::col(Alias::new(&f.filter.field));
1448 all = match &f.matcher {
1449 FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1450 FilterMatch::Contains(term) => {
1452 match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1453 Some(cond) => all.add(cond),
1454 None => all,
1455 }
1456 }
1457 FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1458 };
1459 }
1460 Some(all)
1461}
1462
1463fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1467 let caps = laterite_core::capabilities::CapabilitySet::default();
1471 let profile = SearchProfile::new();
1472 let mut any = sea_query::Condition::any();
1473 let mut matched = false;
1474 for column in config.columns.iter().filter(|c| c.is_searchable()) {
1475 if let Some(cond) = profile.condition(&caps, &column.field, q) {
1476 any = any.add(cond);
1477 matched = true;
1478 }
1479 }
1480 matched.then_some(any)
1481}
1482
1483pub(crate) async fn query(
1488 db: &Db,
1489 config: &ListConfig,
1490 req: &ListQuery<'_>,
1491) -> anyhow::Result<ListPage> {
1492 if !valid_ident(&config.entity)
1493 || !valid_ident(&config.order_by)
1494 || !valid_ident(req.order_by)
1495 || !valid_ident(&config.id_field)
1496 || !config.columns.iter().all(|c| valid_ident(&c.field))
1497 || !config.filters.iter().all(|f| valid_ident(&f.field))
1498 {
1499 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1500 }
1501
1502 let dir = match req.order_dir {
1503 SortDir::Asc => Order::Asc,
1504 SortDir::Desc => Order::Desc,
1505 };
1506 let (sql, values) = {
1510 let mut select = Query::select();
1511 for column in &config.columns {
1512 select.expr_as(
1513 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1514 Alias::new(&column.field),
1515 );
1516 }
1517 select
1518 .expr_as(
1519 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
1520 Alias::new(ID_ALIAS),
1521 )
1522 .from(Alias::new(&config.entity))
1523 .order_by(Alias::new(req.order_by), dir)
1524 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1525 .offset(req.offset.max(0) as u64);
1526 if let Some(cond) = search_condition(config, req.q) {
1527 select.cond_where(cond);
1528 }
1529 if let Some(cond) = filter_condition(req.filters) {
1530 select.cond_where(cond);
1531 }
1532 build(db.backend, select)
1533 };
1534 let raw = bind_values(sqlx::query(&sql), values)
1535 .fetch_all(&db.pool)
1536 .await?;
1537
1538 let (csql, cvalues) = {
1539 let mut count = Query::select();
1540 count
1541 .expr(Expr::col(Alias::new(&config.id_field)).count())
1542 .from(Alias::new(&config.entity));
1543 if let Some(cond) = search_condition(config, req.q) {
1545 count.cond_where(cond);
1546 }
1547 if let Some(cond) = filter_condition(req.filters) {
1548 count.cond_where(cond);
1549 }
1550 build(db.backend, count)
1551 };
1552 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1553 .fetch_one(&db.pool)
1554 .await?
1555 .0;
1556
1557 let rows = raw
1558 .iter()
1559 .map(|row| RowView {
1560 id: get_text(row, ID_ALIAS),
1561 cells: config
1562 .columns
1563 .iter()
1564 .map(|c| get_text(row, &c.field))
1565 .collect(),
1566 })
1567 .collect();
1568 Ok(ListPage { rows, total })
1569}
1570
1571fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1574 row.get_text_opt(column).ok().flatten().unwrap_or_default()
1577}
1578
1579#[cfg(test)]
1581#[allow(clippy::too_many_arguments)]
1582pub(crate) async fn handle(
1583 state: &AdminState,
1584 config: &ListConfig,
1585 path: &str,
1586 params: ListParams,
1587 raw: &HashMap<String, String>,
1588 user: &laterite_auth::AuthenticatedUser,
1589 shell: crate::Shell,
1590 headers: &axum::http::HeaderMap,
1591) -> Response {
1592 handle_remembering(
1593 state, config, path, params, raw, user, shell, headers, None, None,
1594 )
1595 .await
1596}
1597
1598fn query_to_remember(
1602 config: &ListConfig,
1603 params: &ListParams,
1604 active: &[ActiveFilter<'_>],
1605) -> Option<String> {
1606 let mut parts = Vec::new();
1607 let (by, dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1608 if by != config.order_by || dir != config.order_dir {
1609 let dir = match dir {
1610 SortDir::Asc => "asc",
1611 SortDir::Desc => "desc",
1612 };
1613 parts.push(format!("sort={}&dir={dir}", urlencode(&by)));
1614 }
1615 if let Some(q) = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
1616 parts.push(format!("q={}", urlencode(q)));
1617 }
1618 for f in active {
1619 parts.push(format!("{}={}", f.filter.param(), urlencode(&f.raw)));
1620 }
1621 (!parts.is_empty()).then(|| parts.join("&"))
1622}
1623
1624#[allow(clippy::too_many_arguments)]
1630pub(crate) async fn handle_remembering(
1631 state: &AdminState,
1632 config: &ListConfig,
1633 path: &str,
1634 params: ListParams,
1635 raw: &HashMap<String, String>,
1636 user: &laterite_auth::AuthenticatedUser,
1637 shell: crate::Shell,
1638 headers: &axum::http::HeaderMap,
1639 query_string: Option<&str>,
1640 session: Option<&crate::session::SessionHandle>,
1641) -> Response {
1642 if let Some(session) = session.filter(|_| config.remember) {
1643 match query_string.filter(|q| !q.is_empty()) {
1644 None if !crate::form::is_htmx(headers) => {
1645 if let Some(remembered) = session.remembered_list(path) {
1646 return axum::response::Redirect::to(&format!("{path}?{remembered}"))
1647 .into_response();
1648 }
1649 }
1650 None => {}
1651 Some(_) => {
1652 let active = resolve_filters(config, raw);
1653 let keep = query_to_remember(config, ¶ms, &active);
1654 session.remember_list(path, keep.as_deref());
1655 }
1656 }
1657 }
1658 show(state, config, path, params, raw, user, shell, headers).await
1659}
1660
1661#[allow(clippy::too_many_arguments)]
1663async fn show(
1664 state: &AdminState,
1665 config: &ListConfig,
1666 path: &str,
1667 params: ListParams,
1668 raw: &HashMap<String, String>,
1669 user: &laterite_auth::AuthenticatedUser,
1670 shell: crate::Shell,
1671 headers: &axum::http::HeaderMap,
1672) -> Response {
1673 let stored = stored_columns(state, path, user).await;
1677 let declared = config;
1678 let narrowed = narrowed(config, stored.as_deref(), user);
1679 let config = &narrowed;
1680
1681 let page = params.page.unwrap_or(1).max(1);
1682 let per_page = stored_per_page(state, config, path, user).await;
1683 let offset = (page - 1) * per_page;
1684 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1685 let q = params.q.unwrap_or_default();
1686 let active = resolve_filters(config, raw);
1687 let req = ListQuery {
1688 offset,
1689 order_by: &order_by,
1690 order_dir,
1691 q: q.trim(),
1692 filters: &active,
1693 per_page: Some(per_page),
1694 };
1695 let mut carry = String::new();
1698 if !q.trim().is_empty() {
1699 carry.push_str(&format!("&q={}", urlencode(q.trim())));
1700 }
1701 for f in &active {
1702 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
1703 }
1704 match query(&state.db, config, &req).await {
1705 Ok(result) => {
1706 let total_pages = ((result.total + per_page - 1) / per_page).max(1);
1707 let date_loc = date_locale(shell.locale());
1708 let labels: Vec<Option<HashMap<String, String>>> = config
1710 .columns
1711 .iter()
1712 .map(|c| {
1713 (!c.labels.is_empty()).then(|| {
1714 c.labels
1715 .iter()
1716 .map(|l| (l.value.clone(), shell.tt(&l.label)))
1717 .collect()
1718 })
1719 })
1720 .collect();
1721 let rows: Vec<RenderedRow> = result
1722 .rows
1723 .into_iter()
1724 .map(|row| RenderedRow {
1725 id: row.id,
1726 cells: row
1727 .cells
1728 .iter()
1729 .zip(config.columns.iter().zip(&labels))
1730 .map(|(raw, (col, labels))| {
1731 let cx = CellCx {
1732 value: raw,
1733 tz: shell.tz,
1734 locale: date_loc,
1735 };
1736 let scope = OverrideScope {
1737 surface: Surface::Column,
1738 view_key: &col.column_type,
1739 resource: Some(&config.entity),
1740 field: Some(&col.field),
1741 };
1742 let html = match state.column_types.get(&col.column_type) {
1743 Some(ct) => render_cell(
1744 ct.as_ref(),
1745 state.overrides.as_ref(),
1746 &scope,
1747 &cx,
1748 labels.as_ref(),
1749 )
1750 .into_string(),
1751 None => String::new(),
1753 };
1754 CellView {
1755 html,
1756 class: col.align_class().to_string(),
1757 }
1758 })
1759 .collect(),
1760 })
1761 .collect();
1762 let keys: Vec<&str> = config
1763 .columns
1764 .iter()
1765 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1766 .flatten()
1767 .collect();
1768 let mut shell = shell;
1769 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1770 let shown = rows.len() as i64;
1771 let title = shell.tt(&config.title);
1773 let active_dir = match order_dir {
1774 SortDir::Asc => "asc",
1775 SortDir::Desc => "desc",
1776 };
1777 let columns: Vec<ColumnHead> = config
1778 .columns
1779 .iter()
1780 .map(|c| {
1781 let active = c.field == order_by;
1782 ColumnHead {
1783 label: shell.tt(&c.label),
1784 field: c.field.clone(),
1785 next_dir: if active && active_dir == "asc" {
1788 "desc".to_string()
1789 } else {
1790 "asc".to_string()
1791 },
1792 active: if active {
1793 active_dir.to_string()
1794 } else {
1795 String::new()
1796 },
1797 sortable: c.sortable,
1798 width: c.width.clone(),
1799 align_class: c.align_class().to_string(),
1800 }
1801 })
1802 .collect();
1803 let shell_for_toolbar = shell.clone();
1804 let filter_views = filter_views(config, &active, &shell);
1805 let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
1806 .into_iter()
1807 .map(|c| ColumnChoice {
1808 field: c.field.clone(),
1809 label: shell.tt(&c.label),
1810 shown: config.columns.iter().any(|v| v.field == c.field),
1811 })
1812 .collect();
1813 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1815 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1816 let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
1818 let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
1819 let page_view = ListTemplate {
1820 shell,
1821 title,
1822 columns,
1823 rows,
1824 shown,
1825 page,
1826 total: result.total,
1827 total_pages,
1828 edit_base: config.edit_base.clone(),
1829 row_link: config.row_click == RowClick::Open,
1830 sort: order_by,
1831 dir: active_dir.to_string(),
1832 q: q.trim().to_string(),
1833 searchable: config.search.enabled
1834 && config.columns.iter().any(|c| c.is_searchable()),
1835 search_prompt,
1836 search_on_enter: config.search.on_enter,
1837 no_records_message,
1838 per_page,
1839 per_page_options: config.per_page_options.clone(),
1840 pickers,
1841 toolbar,
1842 export_query,
1843 path: path.to_string(),
1844 filters: filter_views,
1845 deletable: config.deletable,
1846 exportable: config.exportable,
1847 filtered: !active.is_empty() || !q.trim().is_empty(),
1848 carry: carry.clone(),
1849 };
1850 if crate::form::is_htmx(headers) {
1854 render(ListFragment {
1855 shell: page_view.shell,
1856 columns: page_view.columns,
1857 rows: page_view.rows,
1858 shown: page_view.shown,
1859 page: page_view.page,
1860 total: page_view.total,
1861 total_pages: page_view.total_pages,
1862 edit_base: page_view.edit_base,
1863 row_link: page_view.row_link,
1864 sort: page_view.sort,
1865 dir: page_view.dir,
1866 carry: page_view.carry,
1867 filtered: page_view.filtered,
1868 deletable: page_view.deletable,
1869 no_records_message: page_view.no_records_message,
1870 })
1871 } else {
1872 render(page_view)
1873 }
1874 }
1875 Err(_) => render_error(),
1876 }
1877}
1878
1879pub(crate) async fn set_columns(
1886 state: &AdminState,
1887 config: &ListConfig,
1888 path: &str,
1889 user: &laterite_auth::AuthenticatedUser,
1890 session: &crate::session::SessionHandle,
1891 headers: &axum::http::HeaderMap,
1892 pairs: &[(String, String)],
1893) -> Response {
1894 let allowed = allowed_columns(config, user);
1895 let chosen: Vec<&str> = pairs
1896 .iter()
1897 .filter(|(k, _)| k == "column")
1898 .map(|(_, v)| v.trim())
1899 .filter(|v| allowed.iter().any(|c| c.field == *v))
1900 .collect();
1901 let mut by_default: Vec<&str> = allowed
1905 .iter()
1906 .filter(|c| !c.invisible)
1907 .map(|c| c.field.as_str())
1908 .collect();
1909 let mut as_chosen = chosen.clone();
1910 by_default.sort_unstable();
1911 as_chosen.sort_unstable();
1912 as_chosen.dedup();
1913
1914 let size_key = per_page_preference_key(path);
1917 let size = pairs
1918 .iter()
1919 .find(|(k, _)| k == "per_page")
1920 .and_then(|(_, v)| v.trim().parse::<i64>().ok())
1921 .filter(|n| config.per_page_options.contains(n));
1922 let size_result = match size {
1923 Some(n) if n != config.per_page => {
1924 laterite_auth::store::set_user_preference(
1925 &state.db,
1926 user.user.id,
1927 &size_key,
1928 &n.to_string(),
1929 )
1930 .await
1931 }
1932 _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
1933 };
1934
1935 let key = columns_preference_key(path);
1936 let result = if chosen.is_empty() || as_chosen == by_default {
1937 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1938 } else {
1939 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1940 .await
1941 };
1942 match result.and(size_result) {
1943 Ok(()) => session.push_flash(
1944 crate::session::FlashLevel::Success,
1945 laterite_core::t!("List setup saved."),
1946 ),
1947 Err(e) => {
1948 tracing::error!(error = %e, "storing the list setup failed");
1949 session.push_flash(
1950 crate::session::FlashLevel::Error,
1951 laterite_core::t!("Could not save your list setup."),
1952 );
1953 }
1954 }
1955 let back = format!("{}{}", state.admin_path, path);
1956 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1957}
1958
1959pub struct ColumnChoice {
1961 pub field: String,
1962 pub label: String,
1963 pub shown: bool,
1964}
1965
1966pub struct ColumnHead {
1969 pub label: String,
1970 pub field: String,
1971 pub next_dir: String,
1972 pub active: String,
1973 pub sortable: bool,
1974 pub width: Option<String>,
1975 pub align_class: String,
1976}
1977
1978#[derive(Template)]
1979#[template(path = "list.html")]
1980struct ListTemplate {
1981 shell: crate::Shell,
1982 title: String,
1983 columns: Vec<ColumnHead>,
1984 rows: Vec<RenderedRow>,
1985 shown: i64,
1987 page: i64,
1988 total: i64,
1989 total_pages: i64,
1990 edit_base: Option<String>,
1991 row_link: bool,
1993 sort: String,
1995 dir: String,
1996 q: String,
1998 searchable: bool,
2000 search_prompt: Option<String>,
2002 search_on_enter: bool,
2003 no_records_message: Option<String>,
2005 per_page: i64,
2008 per_page_options: Vec<i64>,
2009 deletable: bool,
2011 exportable: bool,
2014 pickers: Vec<ColumnChoice>,
2016 toolbar: Vec<ToolbarView>,
2018 export_query: String,
2021 path: String,
2023 filters: Vec<FilterView>,
2025 filtered: bool,
2027 carry: String,
2029}
2030
2031pub struct FilterView {
2033 pub param: String,
2034 pub label: String,
2035 pub input_type: String,
2037 pub options: Vec<FilterOptionView>,
2038 pub value: String,
2040}
2041
2042pub struct FilterOptionView {
2043 pub value: String,
2044 pub label: String,
2045}
2046
2047fn filter_views(
2049 config: &ListConfig,
2050 active: &[ActiveFilter<'_>],
2051 shell: &crate::Shell,
2052) -> Vec<FilterView> {
2053 config
2054 .filters
2055 .iter()
2056 .map(|f| {
2057 let value = active
2058 .iter()
2059 .find(|a| a.filter.field == f.field)
2060 .map(|a| a.raw.clone())
2061 .unwrap_or_default();
2062 let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
2065 "boolean" => vec![
2066 FilterOptionView {
2067 value: "1".to_string(),
2068 label: shell.tt(&laterite_core::t!("Yes")),
2069 },
2070 FilterOptionView {
2071 value: "0".to_string(),
2072 label: shell.tt(&laterite_core::t!("No")),
2073 },
2074 ],
2075 "select" => f
2076 .choices()
2077 .iter()
2078 .map(|o| FilterOptionView {
2079 value: o.value.clone(),
2080 label: shell.tt(&o.label),
2081 })
2082 .collect(),
2083 _ => Vec::new(),
2084 };
2085 let input_type = match f.filter_type.as_str() {
2086 "number" => "number",
2087 "date" => "date",
2088 _ => "text",
2089 };
2090 FilterView {
2091 param: f.param(),
2092 label: shell.tt(&f.label),
2093 input_type: input_type.to_string(),
2094 options,
2095 value,
2096 }
2097 })
2098 .collect()
2099}
2100
2101fn urlencode(value: &str) -> String {
2103 let mut out = String::with_capacity(value.len());
2104 for byte in value.as_bytes() {
2105 match byte {
2106 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2107 out.push(*byte as char)
2108 }
2109 b' ' => out.push('+'),
2110 _ => out.push_str(&format!("%{byte:02X}")),
2111 }
2112 }
2113 out
2114}
2115
2116#[derive(Template)]
2118#[template(path = "_list_table.html")]
2119struct ListFragment {
2120 shell: crate::Shell,
2121 columns: Vec<ColumnHead>,
2122 rows: Vec<RenderedRow>,
2123 shown: i64,
2124 page: i64,
2125 total: i64,
2126 total_pages: i64,
2127 edit_base: Option<String>,
2128 row_link: bool,
2130 sort: String,
2131 dir: String,
2132 carry: String,
2133 filtered: bool,
2137 deletable: bool,
2138 no_records_message: Option<String>,
2139}
2140
2141#[cfg(test)]
2142mod tests {
2143 use super::*;
2144
2145 fn config() -> ListConfig {
2146 ListConfig {
2147 entity: "backend_users".to_string(),
2148 title: "Users".into(),
2149 columns: vec![
2150 ListColumn::new("username", "Username"),
2151 ListColumn::new("is_superuser", "Superuser").yes_no(),
2152 ],
2153 order_by: "created_at".to_string(),
2154 order_dir: SortDir::Desc,
2155 per_page: 25,
2156 id_field: "id".to_string(),
2157 edit_base: None,
2158 creatable: false,
2159 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
2160 deletable: true,
2161 ..Default::default()
2162 }
2163 }
2164
2165 #[test]
2166 fn column_types_format_their_cell_display() {
2167 let ist: Tz = "Asia/Kolkata".parse().unwrap();
2168 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
2169 ct.view_model(&CellCx {
2170 value,
2171 tz,
2172 locale: chrono::Locale::en_US,
2173 })
2174 .display
2175 };
2176 assert_eq!(
2178 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
2179 "13 Aug 2026, 15:30"
2180 );
2181 assert_eq!(
2182 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
2183 "13 Aug 2026"
2184 );
2185 assert_eq!(
2186 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
2187 "15:30"
2188 );
2189 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
2190 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
2191 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
2192 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
2194 }
2195
2196 #[test]
2197 fn sort_falls_back_to_the_configured_order() {
2198 let c = config();
2199 assert_eq!(
2200 resolve_sort(&c, None, None),
2201 ("created_at".to_string(), SortDir::Desc)
2202 );
2203 }
2204
2205 #[test]
2206 fn a_declared_column_can_be_sorted_either_way() {
2207 let c = config();
2208 assert_eq!(
2209 resolve_sort(&c, Some("username"), Some("asc")),
2210 ("username".to_string(), SortDir::Asc)
2211 );
2212 assert_eq!(
2213 resolve_sort(&c, Some("username"), Some("desc")),
2214 ("username".to_string(), SortDir::Desc)
2215 );
2216 }
2217
2218 #[test]
2221 fn an_undeclared_sort_column_is_ignored() {
2222 let c = config();
2223 for crafted in [
2224 "password_hash",
2225 "id) --",
2226 "username; drop table backend_users",
2227 "",
2228 ] {
2229 assert_eq!(
2230 resolve_sort(&c, Some(crafted), Some("asc")).0,
2231 "created_at",
2232 "{crafted} must not reach the query"
2233 );
2234 }
2235 }
2236
2237 #[test]
2238 fn an_unknown_direction_falls_back() {
2239 let c = config();
2240 assert_eq!(
2242 resolve_sort(&c, Some("username"), Some("sideways")).1,
2243 SortDir::Asc
2244 );
2245 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
2247 }
2248
2249 #[test]
2252 fn a_contributed_column_type_renders_its_cell() {
2253 struct Rating;
2254 impl ColumnType for Rating {
2255 fn view_key(&self) -> &'static str {
2256 "acme.rating"
2257 }
2258 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
2259 let stars = cx.value.parse::<usize>().unwrap_or(0);
2260 text_cell("acme.rating", cx.value, "*".repeat(stars))
2261 }
2262 fn render_default(&self, vm: &CellVm) -> Markup {
2263 render_text(vm)
2264 }
2265 }
2266
2267 let reg = ColumnTypeReg::new(Arc::new(Rating));
2268 assert_eq!(reg.name(), "acme.rating", "the type names itself");
2269
2270 let mut registry = builtin_column_registry();
2271 registry.insert(reg.name().to_string(), reg.column_type);
2272 let rendered = render_cell(
2273 registry.get("acme.rating").unwrap().as_ref(),
2274 &crate::field::NoOverrides,
2275 &OverrideScope {
2276 surface: Surface::Column,
2277 view_key: "acme.rating",
2278 resource: Some("products"),
2279 field: Some("stars"),
2280 },
2281 &CellCx {
2282 value: "3",
2283 tz: Tz::UTC,
2284 locale: chrono::Locale::en_US,
2285 },
2286 None,
2287 );
2288 assert!(rendered.into_string().contains("***"));
2289 }
2290
2291 #[test]
2294 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
2295 struct Impostor;
2296 impl ColumnType for Impostor {
2297 fn view_key(&self) -> &'static str {
2298 "text"
2299 }
2300 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
2301 text_cell("text", cx.value, String::new())
2302 }
2303 fn render_default(&self, vm: &CellVm) -> Markup {
2304 render_text(vm)
2305 }
2306 }
2307 let reg = ColumnTypeReg::new(Arc::new(Impostor));
2308 assert_eq!(reg.name(), "text");
2309 assert!(builtin_column_registry().contains_key(reg.name()));
2311 }
2312
2313 #[test]
2314 fn status_pill_renders_a_slugged_markup_span() {
2315 let vm = StatusPillColumn.view_model(&CellCx {
2316 value: "In Progress",
2317 tz: Tz::UTC,
2318 locale: chrono::Locale::en_US,
2319 });
2320 let html = StatusPillColumn.render_default(&vm).into_string();
2321 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
2322 assert!(html.contains(">In Progress<"));
2323 }
2324
2325 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
2328 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
2329 }
2330
2331 #[tokio::test]
2332 async fn query_returns_display_rows() {
2333 let (db, _guard) = test_db().await;
2334 let hash = laterite_auth::password::hash_password("pw").unwrap();
2335 laterite_auth::store::create_user(
2336 &db,
2337 "root",
2338 "root@example.test",
2339 "Ada",
2340 None,
2341 &hash,
2342 true,
2343 )
2344 .await
2345 .unwrap();
2346
2347 let c = config();
2348 let result = query(&db, &c, &plain(&c)).await.unwrap();
2349 assert_eq!(result.total, 1);
2350 assert_eq!(result.rows.len(), 1);
2351 assert_eq!(result.rows[0].cells[0], "root");
2352 assert_eq!(result.rows[0].cells[1], "1");
2355 assert!(!result.rows[0].id.is_empty());
2356 }
2357
2358 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
2359 get_with(params, HashMap::new(), headers).await
2360 }
2361
2362 async fn get_with(
2363 params: ListParams,
2364 raw: HashMap<String, String>,
2365 headers: axum::http::HeaderMap,
2366 ) -> String {
2367 let (db, _guard) = test_db().await;
2368 let state = AdminState::new(
2369 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2370 db,
2371 );
2372 let resp = handle(
2373 &state,
2374 &config(),
2375 "/admin/users",
2376 params,
2377 &raw,
2378 &crate::audit::test_actor(),
2379 crate::Shell::test(),
2380 &headers,
2381 )
2382 .await;
2383 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2384 .await
2385 .unwrap();
2386 String::from_utf8(bytes.to_vec()).unwrap()
2387 }
2388
2389 #[test]
2390 fn a_typed_filter_accepts_only_its_own_shape() {
2391 let mut c = config();
2392 c.filters = vec![
2393 ListFilter::number("views", "Views"),
2394 ListFilter::date("created_at", "Created"),
2395 ListFilter::text("username", "Username"),
2396 ];
2397 let accepted = |param: &str, value: &str| {
2398 !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
2399 };
2400 assert!(accepted("f_views", "42"));
2401 assert!(
2402 !accepted("f_views", "4.2"),
2403 "a number filter takes whole numbers"
2404 );
2405 assert!(!accepted("f_views", "; drop table"), "and nothing else");
2406
2407 assert!(accepted("f_created_at", "2026-09-21"));
2408 assert!(
2409 !accepted("f_created_at", "2026-9-21"),
2410 "a date is ISO or nothing"
2411 );
2412 assert!(
2413 !accepted("f_created_at", "2026-09-21%"),
2414 "so a pattern cannot slip in"
2415 );
2416
2417 assert!(accepted("f_username", "ada"));
2418 }
2419
2420 #[test]
2421 fn a_filter_default_applies_only_until_the_operator_decides() {
2422 let mut c = config();
2423 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
2424 assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
2426 assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
2428 let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
2430 assert_eq!(active.len(), 1);
2431 }
2432
2433 #[test]
2434 fn a_gated_filter_is_narrowed_away_with_its_default() {
2435 let mut c = config();
2436 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
2437 .default_value("1")
2438 .require("acme.secret")];
2439 let plain = laterite_auth::AuthenticatedUser {
2440 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2441 ..crate::audit::test_actor()
2442 };
2443 assert!(allowed_filters(&c, &plain).is_empty());
2444 let narrowed = narrowed(&c, None, &plain);
2446 assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
2447 assert_eq!(allowed_filters(&c, &root()).len(), 1);
2448 }
2449
2450 #[test]
2451 fn a_stored_page_size_counts_only_while_it_is_offered() {
2452 let mut cfg = config();
2453 assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
2454 cfg.per_page_options = vec![25, 50, 100];
2455 assert_eq!(offered_per_page(&cfg, Some(50)), 50);
2456 assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
2457 assert_eq!(offered_per_page(&cfg, None), 25);
2458 }
2459
2460 fn params(sort: Option<&str>) -> ListParams {
2461 ListParams {
2462 page: None,
2463 sort: sort.map(|s| s.to_string()),
2464 dir: Some("asc".to_string()),
2465 q: None,
2466 }
2467 }
2468
2469 fn plain(config: &ListConfig) -> ListQuery<'_> {
2471 ListQuery {
2472 offset: 0,
2473 order_by: &config.order_by,
2474 order_dir: config.order_dir,
2475 q: "",
2476 filters: &[],
2477 per_page: None,
2478 }
2479 }
2480
2481 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
2482 ListQuery {
2483 offset: 0,
2484 order_by: &config.order_by,
2485 order_dir: config.order_dir,
2486 q,
2487 filters: &[],
2488 per_page: None,
2489 }
2490 }
2491
2492 fn amps(html: &str) -> String {
2495 html.replace("&", "&").replace("&", "&")
2496 }
2497
2498 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2500 pairs
2501 .iter()
2502 .map(|(k, v)| (k.to_string(), v.to_string()))
2503 .collect()
2504 }
2505
2506 #[tokio::test]
2507 async fn search_filters_rows_and_the_total() {
2508 let (db, _guard) = test_db().await;
2509 for name in ["ada", "brendan", "clara"] {
2510 let hash = laterite_auth::password::hash_password("x").unwrap();
2511 laterite_auth::store::create_user(
2512 &db,
2513 name,
2514 &format!("{name}@example.test"),
2515 name,
2516 None,
2517 &hash,
2518 false,
2519 )
2520 .await
2521 .unwrap();
2522 }
2523 let c = config();
2524
2525 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
2526 assert_eq!(hit.rows.len(), 1);
2527 assert_eq!(hit.rows[0].cells[0], "brendan");
2528 assert_eq!(hit.total, 1);
2530
2531 assert_eq!(
2533 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
2534 1
2535 );
2536
2537 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
2539 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
2540
2541 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
2543 }
2544
2545 #[tokio::test]
2548 async fn search_skips_a_non_text_column() {
2549 let (db, _guard) = test_db().await;
2550 let hash = laterite_auth::password::hash_password("x").unwrap();
2551 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
2552 .await
2553 .unwrap();
2554 let c = config();
2555 assert!(c.columns[0].is_searchable(), "username is text");
2556 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
2557 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
2559 }
2560
2561 #[test]
2562 fn a_column_can_override_its_searchability() {
2563 assert!(ListColumn::new("code", "Code").is_searchable());
2564 assert!(!ListColumn::new("code", "Code")
2565 .searchable(false)
2566 .is_searchable());
2567 assert!(ListColumn::new("at", "At")
2568 .datetime()
2569 .searchable(true)
2570 .is_searchable());
2571 }
2572
2573 async fn seed_users(db: &Db) {
2574 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
2575 let hash = laterite_auth::password::hash_password("x").unwrap();
2576 laterite_auth::store::create_user(
2577 db,
2578 name,
2579 &format!("{name}@example.test"),
2580 name,
2581 None,
2582 &hash,
2583 su,
2584 )
2585 .await
2586 .unwrap();
2587 }
2588 }
2589
2590 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
2591 let active = resolve_filters(c, params);
2592 let req = ListQuery {
2593 offset: 0,
2594 order_by: &c.order_by,
2595 order_dir: c.order_dir,
2596 q: "",
2597 filters: &active,
2598 per_page: None,
2599 };
2600 query(db, c, &req).await.unwrap().total
2601 }
2602
2603 #[tokio::test]
2604 async fn a_filter_narrows_the_rows_and_the_total() {
2605 let (db, _guard) = test_db().await;
2606 seed_users(&db).await;
2607 let c = config();
2608
2609 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
2610 assert_eq!(
2611 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
2612 1
2613 );
2614 assert_eq!(
2615 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
2616 2
2617 );
2618 }
2619
2620 #[test]
2623 fn an_undeclared_filter_or_value_is_ignored() {
2624 let c = config();
2625 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
2627 for bad in ["yes", "true", "1 or 1=1", "", " "] {
2629 assert!(
2630 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
2631 "{bad} must not reach the query"
2632 );
2633 }
2634
2635 let with_select = ListConfig {
2636 filters: vec![ListFilter::select(
2637 "status",
2638 "Status",
2639 vec![FilterOption::new("live", "Live")],
2640 )],
2641 ..config()
2642 };
2643 assert_eq!(
2644 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
2645 1
2646 );
2647 assert!(
2648 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
2649 "an option the descriptor never offered is dropped"
2650 );
2651 }
2652
2653 #[tokio::test]
2654 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
2655 let mut p = params(Some("username"));
2656 p.q = None;
2657 let html = get_with(
2658 p,
2659 filter_params(&[("f_is_superuser", "1")]),
2660 axum::http::HeaderMap::new(),
2661 )
2662 .await;
2663 let links = amps(&html);
2664 assert!(
2665 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
2666 "a sort link carries the filter"
2667 );
2668 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
2670 assert!(
2671 html.contains(r#"<option value="1" selected>"#),
2672 "the control shows the active value"
2673 );
2674 assert!(html.contains("Clear"), "and a way back to the full list");
2675 assert!(
2676 html.contains(r#"data-filtered="true""#),
2677 "the region says the list is narrowed, which is what reveals Clear"
2678 );
2679 }
2680
2681 #[tokio::test]
2684 async fn the_empty_state_distinguishes_no_records_from_no_match() {
2685 let (db, _guard) = test_db().await;
2686 let state = AdminState::new(
2687 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2688 db,
2689 );
2690 let render = |raw: HashMap<String, String>, q: Option<&str>| {
2691 let q = q.map(String::from);
2692 let state = state.clone();
2693 async move {
2694 let p = ListParams {
2695 page: None,
2696 sort: None,
2697 dir: None,
2698 q,
2699 };
2700 let resp = handle(
2701 &state,
2702 &config(),
2703 "/admin/users",
2704 p,
2705 &raw,
2706 &crate::audit::test_actor(),
2707 crate::Shell::test(),
2708 &axum::http::HeaderMap::new(),
2709 )
2710 .await;
2711 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2712 .await
2713 .unwrap();
2714 String::from_utf8(bytes.to_vec()).unwrap()
2715 }
2716 };
2717
2718 let bare = render(HashMap::new(), None).await;
2720 assert!(bare.contains("No records yet."));
2721
2722 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
2724 assert!(filtered.contains("No records match."));
2725 assert!(!filtered.contains("No records yet."));
2726
2727 let searched = render(HashMap::new(), Some("zzz")).await;
2729 assert!(searched.contains("No records match."));
2730 }
2731
2732 fn root() -> laterite_auth::AuthenticatedUser {
2734 laterite_auth::AuthenticatedUser {
2735 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2736 ..crate::audit::test_actor()
2737 }
2738 }
2739
2740 #[test]
2741 fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
2742 let mut c = config();
2743 c.columns[1].invisible = true;
2744 assert_eq!(visible_columns(&c, None, &root()).len(), 1);
2745 assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
2746 assert_eq!(
2747 visible_columns(&c, Some("is_superuser"), &root())[0].field,
2748 "is_superuser"
2749 );
2750 }
2751
2752 #[test]
2753 fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
2754 let mut c = config();
2755 c.columns[1].permission = Some("acme.secret".into());
2756 let plain = laterite_auth::AuthenticatedUser {
2757 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2758 ..crate::audit::test_actor()
2759 };
2760 assert_eq!(visible_columns(&c, None, &plain).len(), 1);
2761 assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
2763 assert_eq!(visible_columns(&c, None, &root()).len(), 2);
2764 }
2765
2766 #[test]
2767 fn an_unsortable_column_is_not_sorted_by() {
2768 let mut c = config();
2769 c.columns[0].sortable = false;
2770 let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
2771 assert_eq!(by, c.order_by, "falls back to the descriptor's order");
2772 }
2773
2774 #[test]
2775 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
2776 let c = config();
2777 let kept = visible_columns(&c, Some("is_superuser,username"), &root());
2780 assert_eq!(
2781 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
2782 ["username", "is_superuser"]
2783 );
2784 }
2785
2786 #[test]
2787 fn no_stored_choice_shows_every_column() {
2788 let c = config();
2789 assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
2790 }
2791
2792 #[test]
2795 fn a_stale_choice_falls_back_to_every_column() {
2796 let c = config();
2797 assert_eq!(
2798 visible_columns(&c, Some("gone,removed"), &root()).len(),
2799 c.columns.len()
2800 );
2801 assert_eq!(
2802 visible_columns(&c, Some(""), &root()).len(),
2803 c.columns.len()
2804 );
2805 assert_eq!(
2806 visible_columns(&c, Some(" , "), &root()).len(),
2807 c.columns.len()
2808 );
2809 assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
2811 }
2812
2813 #[test]
2816 fn the_toolbar_hides_what_the_operator_may_not_do() {
2817 let shell = crate::Shell::test();
2818 let cfg = ListConfig {
2819 creatable: false,
2820 toolbar: vec![
2821 ToolbarButton::new("Open", "/reports"),
2822 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
2823 ],
2824 ..config()
2825 };
2826
2827 let root = laterite_auth::AuthenticatedUser {
2829 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2830 ..crate::audit::test_actor()
2831 };
2832 let views = toolbar_views(&cfg, "/admin", &root, &shell);
2833 assert_eq!(
2834 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2835 ["Open", "Audit"]
2836 );
2837
2838 let granted = laterite_auth::AuthenticatedUser {
2840 permissions: laterite_auth::PermissionSet::with_overrides(
2841 false,
2842 ["backend.view_audit".to_string()],
2843 [],
2844 [],
2845 ),
2846 ..crate::audit::test_actor()
2847 };
2848 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
2849
2850 let plain = laterite_auth::AuthenticatedUser {
2852 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2853 ..crate::audit::test_actor()
2854 };
2855 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
2856 assert_eq!(
2857 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2858 ["Open"]
2859 );
2860 }
2861
2862 #[test]
2865 fn a_toolbar_href_follows_the_admin_mount() {
2866 let shell = crate::Shell::test();
2867 let cfg = ListConfig {
2868 creatable: true,
2869 edit_base: Some("/backoffice/roles".to_string()),
2871 toolbar: vec![
2872 ToolbarButton::new("Reports", "/reports"),
2873 ToolbarButton::new("Docs", "https://example.test/docs"),
2874 ],
2875 ..config()
2876 };
2877 let root = laterite_auth::AuthenticatedUser {
2878 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2879 ..crate::audit::test_actor()
2880 };
2881 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
2882 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
2883 assert_eq!(views[1].href, "/backoffice/reports");
2884 assert_eq!(views[2].href, "https://example.test/docs");
2886 }
2887
2888 #[tokio::test]
2889 async fn an_htmx_sort_returns_the_list_region_alone() {
2890 let mut headers = axum::http::HeaderMap::new();
2891 headers.insert("hx-request", "true".parse().unwrap());
2892 let html = get(params(Some("username")), headers).await;
2893 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
2894 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
2895 assert!(!html.contains("<body"), "no page chrome");
2898 assert!(html.contains(r#"data-filtered="false""#));
2900 }
2901
2902 #[tokio::test]
2905 async fn the_search_term_survives_a_sort_link() {
2906 let mut p = params(Some("username"));
2907 p.q = Some("bren".to_string());
2908 let html = get(p, axum::http::HeaderMap::new()).await;
2909 assert!(
2910 amps(&html).contains("?sort=username&dir=desc&q=bren"),
2911 "sort and pager links carry the term"
2912 );
2913 assert!(
2914 html.contains(r#"value="bren""#),
2915 "and the box still shows it"
2916 );
2917 }
2918
2919 #[tokio::test]
2920 async fn a_plain_sort_returns_the_whole_page() {
2921 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
2922 assert!(html.contains("<body"), "lists still work without htmx");
2923 assert!(html.contains(r#"id="lat-list""#));
2924 assert!(html.contains(r#"href="?sort=username"#));
2926 }
2927
2928 #[tokio::test]
2929 async fn query_rejects_bad_identifiers() {
2930 let (db, _guard) = test_db().await;
2931 let mut bad = config();
2932 bad.entity = "backend_users; drop table backend_users".to_string();
2933 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2934 }
2935}