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(Clone, Debug)]
742pub(crate) struct ImportLink {
743 pub href: String,
744 pub permission: String,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize)]
751#[serde(deny_unknown_fields)]
752pub struct ToolbarButton {
753 pub label: Text,
755 pub href: String,
758 pub icon: Option<String>,
760 pub permission: Option<String>,
763 pub primary: bool,
765}
766
767impl ToolbarButton {
768 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
769 Self {
770 label: label.into(),
771 href: href.into(),
772 icon: None,
773 permission: None,
774 primary: false,
775 }
776 }
777
778 pub fn icon(mut self, icon: &str) -> Self {
779 self.icon = Some(icon.to_string());
780 self
781 }
782
783 pub fn require(mut self, permission: impl Into<String>) -> Self {
785 self.permission = Some(permission.into());
786 self
787 }
788
789 pub fn primary(mut self) -> Self {
790 self.primary = true;
791 self
792 }
793}
794
795pub struct ToolbarView {
797 pub label: String,
798 pub href: String,
799 pub icon: String,
801 pub primary: bool,
802}
803
804fn toolbar_views(
808 config: &ListConfig,
809 admin_path: &str,
810 user: &laterite_auth::AuthenticatedUser,
811 shell: &crate::Shell,
812) -> Vec<ToolbarView> {
813 let mut out = Vec::new();
814 if config.creatable {
815 if let Some(base) = &config.edit_base {
816 out.push(ToolbarView {
817 label: shell.tt(&laterite_core::t!("New")),
818 href: format!("{base}/new"),
821 icon: String::new(),
822 primary: true,
823 });
824 }
825 }
826 for button in &config.toolbar {
827 if let Some(needed) = &button.permission {
828 if !user.permissions.allows(needed) {
829 continue;
830 }
831 }
832 let href = if button.href.starts_with('/') {
833 format!("{admin_path}{}", button.href)
834 } else {
835 button.href.clone()
836 };
837 out.push(ToolbarView {
838 label: shell.tt(&button.label),
839 href,
840 icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
841 primary: button.primary,
842 });
843 }
844 out
845}
846
847pub(crate) fn columns_preference_key(base_path: &str) -> String {
850 format!("list.columns.{base_path}")
851}
852
853pub(crate) async fn stored_columns(
858 state: &AdminState,
859 path: &str,
860 user: &laterite_auth::AuthenticatedUser,
861) -> Option<String> {
862 match laterite_auth::store::user_preference(
863 &state.db,
864 user.user.id,
865 &columns_preference_key(path),
866 )
867 .await
868 {
869 Ok(stored) => stored,
870 Err(e) => {
871 tracing::warn!(error = %e, "reading the column preference failed");
872 None
873 }
874 }
875}
876
877pub(crate) fn per_page_preference_key(base_path: &str) -> String {
879 format!("list.per_page:{base_path}")
880}
881
882pub(crate) async fn stored_per_page(
886 state: &AdminState,
887 config: &ListConfig,
888 path: &str,
889 user: &laterite_auth::AuthenticatedUser,
890) -> i64 {
891 let stored = match laterite_auth::store::user_preference(
892 &state.db,
893 user.user.id,
894 &per_page_preference_key(path),
895 )
896 .await
897 {
898 Ok(stored) => stored,
899 Err(e) => {
900 tracing::warn!(error = %e, "reading the page-size preference failed");
901 None
902 }
903 };
904 offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
905}
906
907pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
909 size.filter(|n| config.per_page_options.contains(n))
910 .unwrap_or(config.per_page)
911}
912
913pub(crate) fn allowed_columns<'a>(
916 config: &'a ListConfig,
917 user: &laterite_auth::AuthenticatedUser,
918) -> Vec<&'a ListColumn> {
919 config
920 .columns
921 .iter()
922 .filter(|c| {
923 c.permission
924 .as_deref()
925 .is_none_or(|p| user.permissions.allows(p))
926 })
927 .collect()
928}
929
930pub(crate) fn allowed_filters<'a>(
933 config: &'a ListConfig,
934 user: &laterite_auth::AuthenticatedUser,
935) -> Vec<&'a ListFilter> {
936 config
937 .filters
938 .iter()
939 .filter(|f| {
940 f.permission
941 .as_deref()
942 .is_none_or(|p| user.permissions.allows(p))
943 })
944 .collect()
945}
946
947pub(crate) fn narrowed(
950 config: &ListConfig,
951 stored: Option<&str>,
952 user: &laterite_auth::AuthenticatedUser,
953) -> ListConfig {
954 ListConfig {
955 columns: visible_columns(config, stored, user)
956 .into_iter()
957 .cloned()
958 .collect(),
959 filters: allowed_filters(config, user).into_iter().cloned().collect(),
960 ..config.clone()
961 }
962}
963
964pub(crate) fn visible_columns<'a>(
972 config: &'a ListConfig,
973 stored: Option<&str>,
974 user: &laterite_auth::AuthenticatedUser,
975) -> Vec<&'a ListColumn> {
976 let allowed = allowed_columns(config, user);
977 let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
978 let Some(stored) = stored else {
979 return by_default();
980 };
981 let chosen: Vec<&str> = stored
982 .split(',')
983 .map(str::trim)
984 .filter(|s| !s.is_empty())
985 .collect();
986 let kept: Vec<&ListColumn> = allowed
987 .iter()
988 .copied()
989 .filter(|c| chosen.iter().any(|n| *n == c.field))
990 .collect();
991 if kept.is_empty() {
992 return by_default();
993 }
994 kept
995}
996
997pub(crate) fn check(
1001 config: &ListConfig,
1002 column_types: &ColumnRegistry,
1003 permission_codes: &std::collections::HashSet<String>,
1004) -> Result<(), String> {
1005 for col in &config.columns {
1006 if column_types.get(&col.column_type).is_none() {
1007 return Err(format!(
1008 "column `{}` uses unregistered type `{}`",
1009 col.field, col.column_type
1010 ));
1011 }
1012 if let Some(p) = &col.permission {
1015 if !permission_codes.contains(p) {
1016 return Err(format!(
1017 "column `{}` requires unregistered permission `{p}`",
1018 col.field
1019 ));
1020 }
1021 }
1022 }
1023 for filter in &config.filters {
1024 if let Some(p) = &filter.permission {
1025 if !permission_codes.contains(p) {
1026 return Err(format!(
1027 "filter `{}` requires unregistered permission `{p}`",
1028 filter.field
1029 ));
1030 }
1031 }
1032 }
1033 Ok(())
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1038#[serde(deny_unknown_fields, default)]
1039pub struct SearchConfig {
1040 pub enabled: bool,
1042 pub prompt: Option<Text>,
1044 pub on_enter: bool,
1046}
1047
1048impl Default for SearchConfig {
1049 fn default() -> Self {
1050 Self {
1051 enabled: true,
1052 prompt: None,
1053 on_enter: false,
1054 }
1055 }
1056}
1057
1058impl SearchConfig {
1059 pub fn off() -> Self {
1061 Self {
1062 enabled: false,
1063 ..Self::default()
1064 }
1065 }
1066
1067 pub fn prompt(mut self, text: impl Into<Text>) -> Self {
1068 self.prompt = Some(text.into());
1069 self
1070 }
1071
1072 pub fn on_enter(mut self) -> Self {
1073 self.on_enter = true;
1074 self
1075 }
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize)]
1085#[serde(deny_unknown_fields, default)]
1086#[non_exhaustive]
1087pub struct ListConfig {
1088 pub entity: String,
1089 pub title: Text,
1091 #[serde(
1092 deserialize_with = "crate::keyed::deserialize",
1093 serialize_with = "crate::keyed::serialize"
1094 )]
1095 pub columns: Vec<ListColumn>,
1096 pub order_by: String,
1097 pub order_dir: SortDir,
1098 pub per_page: i64,
1099 pub id_field: String,
1100 pub edit_base: Option<String>,
1102 pub creatable: bool,
1105 #[serde(
1107 deserialize_with = "crate::keyed::deserialize",
1108 serialize_with = "crate::keyed::serialize"
1109 )]
1110 pub filters: Vec<ListFilter>,
1111 pub deletable: bool,
1115 pub exportable: bool,
1118 #[serde(skip)]
1122 pub(crate) import: Option<ImportLink>,
1123 pub toolbar: Vec<ToolbarButton>,
1125 pub search: SearchConfig,
1126 pub per_page_options: Vec<i64>,
1129 pub no_records_message: Option<Text>,
1131 pub row_click: RowClick,
1134 pub remember: bool,
1137}
1138
1139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1141#[serde(rename_all = "lowercase")]
1142pub enum RowClick {
1143 #[default]
1145 Open,
1146 None,
1148}
1149
1150impl ListConfig {
1151 pub fn new(
1156 entity: impl Into<String>,
1157 title: impl Into<Text>,
1158 columns: Vec<ListColumn>,
1159 ) -> Self {
1160 Self {
1161 entity: entity.into(),
1162 title: title.into(),
1163 columns,
1164 ..Self::default()
1165 }
1166 }
1167
1168 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
1170 self.order_by = column.into();
1171 self.order_dir = dir;
1172 self
1173 }
1174
1175 pub fn per_page(mut self, rows: i64) -> Self {
1176 self.per_page = rows;
1177 self
1178 }
1179
1180 pub fn id_field(mut self, column: impl Into<String>) -> Self {
1182 self.id_field = column.into();
1183 self
1184 }
1185
1186 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
1188 self.edit_base = Some(base.into());
1189 self
1190 }
1191
1192 pub fn creatable(mut self) -> Self {
1196 self.creatable = true;
1197 self
1198 }
1199
1200 pub fn exportable(mut self) -> Self {
1204 self.exportable = true;
1205 self
1206 }
1207
1208 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
1209 self.filters = filters;
1210 self
1211 }
1212
1213 pub fn deletable(mut self) -> Self {
1216 self.deletable = true;
1217 self
1218 }
1219
1220 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
1221 self.toolbar = buttons;
1222 self
1223 }
1224
1225 pub fn search(mut self, search: SearchConfig) -> Self {
1226 self.search = search;
1227 self
1228 }
1229
1230 pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
1231 self.per_page_options = sizes;
1232 self
1233 }
1234
1235 pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
1236 self.no_records_message = Some(text.into());
1237 self
1238 }
1239
1240 pub fn row_click(mut self, action: RowClick) -> Self {
1242 self.row_click = action;
1243 self
1244 }
1245
1246 pub fn remember(mut self, remember: bool) -> Self {
1249 self.remember = remember;
1250 self
1251 }
1252}
1253
1254impl Default for ListConfig {
1255 fn default() -> Self {
1262 Self {
1263 entity: String::new(),
1264 title: Text::new(""),
1265 columns: Vec::new(),
1266 order_by: "id".to_string(),
1267 order_dir: SortDir::Desc,
1268 per_page: 25,
1269 id_field: "id".to_string(),
1270 edit_base: None,
1271 creatable: false,
1272 filters: Vec::new(),
1273 deletable: false,
1274 exportable: false,
1275 import: None,
1276 toolbar: Vec::new(),
1277 search: SearchConfig::default(),
1278 per_page_options: Vec::new(),
1279 no_records_message: None,
1280 row_click: RowClick::Open,
1281 remember: true,
1282 }
1283 }
1284}
1285
1286#[derive(Deserialize)]
1288pub struct ListParams {
1289 page: Option<i64>,
1290 sort: Option<String>,
1291 dir: Option<String>,
1292 q: Option<String>,
1293}
1294
1295pub(crate) fn resolve_sort(
1300 config: &ListConfig,
1301 sort: Option<&str>,
1302 dir: Option<&str>,
1303) -> (String, SortDir) {
1304 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1305 Some(field) => field.to_string(),
1306 None => config.order_by.clone(),
1307 };
1308 let dir = match dir {
1309 Some("asc") => SortDir::Asc,
1310 Some("desc") => SortDir::Desc,
1311 _ if by == config.order_by => config.order_dir,
1314 _ => SortDir::Asc,
1315 };
1316 (by, dir)
1317}
1318
1319pub struct RowView {
1321 pub id: String,
1322 pub cells: Vec<String>,
1323}
1324
1325pub struct CellView {
1327 pub html: String,
1328 pub class: String,
1329}
1330
1331pub struct RenderedRow {
1333 pub id: String,
1334 pub cells: Vec<CellView>,
1335}
1336
1337pub struct ListPage {
1339 pub rows: Vec<RowView>,
1340 pub total: i64,
1341}
1342
1343pub(crate) struct ListQuery<'a> {
1346 pub offset: i64,
1347 pub order_by: &'a str,
1348 pub order_dir: SortDir,
1349 pub q: &'a str,
1350 pub filters: &'a [ActiveFilter<'a>],
1351 pub per_page: Option<i64>,
1354}
1355
1356pub(crate) struct ActiveFilter<'a> {
1360 filter: &'a ListFilter,
1361 raw: String,
1363 matcher: FilterMatch,
1366}
1367
1368pub(crate) enum FilterMatch {
1370 Equals(sea_query::Value),
1372 Contains(String),
1374 StartsWith(String),
1376}
1377
1378fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1384 match filter.filter_type.as_str() {
1385 "boolean" => match raw {
1388 "1" => Some(FilterMatch::Equals(true.into())),
1389 "0" => Some(FilterMatch::Equals(false.into())),
1390 _ => None,
1391 },
1392 "select" => filter
1393 .choices()
1394 .iter()
1395 .any(|o| o.value == raw)
1396 .then(|| FilterMatch::Equals(raw.into())),
1397 "text" => Some(FilterMatch::Contains(raw.to_string())),
1398 "number" => raw
1399 .parse::<i64>()
1400 .ok()
1401 .map(|n| FilterMatch::Equals(n.into())),
1402 "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1406 _ => None,
1407 }
1408}
1409
1410fn is_iso_date(raw: &str) -> bool {
1412 raw.len() == 10
1413 && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1414 if i == 4 || i == 7 {
1415 *b == b'-'
1416 } else {
1417 b.is_ascii_digit()
1418 }
1419 })
1420}
1421
1422pub(crate) fn resolve_filters<'a>(
1425 config: &'a ListConfig,
1426 params: &HashMap<String, String>,
1427) -> Vec<ActiveFilter<'a>> {
1428 let mut out = Vec::new();
1429 for filter in &config.filters {
1430 let submitted = params.get(&filter.param()).map(|v| v.trim());
1431 let raw = match submitted {
1432 Some(value) => value,
1434 None => filter.default_value.as_deref().unwrap_or_default(),
1436 };
1437 if raw.is_empty() {
1438 continue;
1439 }
1440 let Some(matcher) = filter_match(filter, raw) else {
1441 continue;
1442 };
1443 out.push(ActiveFilter {
1444 filter,
1445 raw: raw.to_string(),
1446 matcher,
1447 });
1448 }
1449 out
1450}
1451
1452fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1454 if active.is_empty() {
1455 return None;
1456 }
1457 let caps = laterite_core::capabilities::CapabilitySet::default();
1458 let mut all = sea_query::Condition::all();
1459 for f in active {
1460 let col = Expr::col(Alias::new(&f.filter.field));
1461 all = match &f.matcher {
1462 FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1463 FilterMatch::Contains(term) => {
1465 match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1466 Some(cond) => all.add(cond),
1467 None => all,
1468 }
1469 }
1470 FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1471 };
1472 }
1473 Some(all)
1474}
1475
1476fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1480 let caps = laterite_core::capabilities::CapabilitySet::default();
1484 let profile = SearchProfile::new();
1485 let mut any = sea_query::Condition::any();
1486 let mut matched = false;
1487 for column in config.columns.iter().filter(|c| c.is_searchable()) {
1488 if let Some(cond) = profile.condition(&caps, &column.field, q) {
1489 any = any.add(cond);
1490 matched = true;
1491 }
1492 }
1493 matched.then_some(any)
1494}
1495
1496pub(crate) async fn query(
1501 db: &Db,
1502 config: &ListConfig,
1503 req: &ListQuery<'_>,
1504) -> anyhow::Result<ListPage> {
1505 if !valid_ident(&config.entity)
1506 || !valid_ident(&config.order_by)
1507 || !valid_ident(req.order_by)
1508 || !valid_ident(&config.id_field)
1509 || !config.columns.iter().all(|c| valid_ident(&c.field))
1510 || !config.filters.iter().all(|f| valid_ident(&f.field))
1511 {
1512 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1513 }
1514
1515 let dir = match req.order_dir {
1516 SortDir::Asc => Order::Asc,
1517 SortDir::Desc => Order::Desc,
1518 };
1519 let (sql, values) = {
1523 let mut select = Query::select();
1524 for column in &config.columns {
1525 select.expr_as(
1526 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1527 Alias::new(&column.field),
1528 );
1529 }
1530 select
1531 .expr_as(
1532 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
1533 Alias::new(ID_ALIAS),
1534 )
1535 .from(Alias::new(&config.entity))
1536 .order_by(Alias::new(req.order_by), dir)
1537 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1538 .offset(req.offset.max(0) as u64);
1539 if let Some(cond) = search_condition(config, req.q) {
1540 select.cond_where(cond);
1541 }
1542 if let Some(cond) = filter_condition(req.filters) {
1543 select.cond_where(cond);
1544 }
1545 build(db.backend, select)
1546 };
1547 let raw = bind_values(sqlx::query(&sql), values)
1548 .fetch_all(&db.pool)
1549 .await?;
1550
1551 let (csql, cvalues) = {
1552 let mut count = Query::select();
1553 count
1554 .expr(Expr::col(Alias::new(&config.id_field)).count())
1555 .from(Alias::new(&config.entity));
1556 if let Some(cond) = search_condition(config, req.q) {
1558 count.cond_where(cond);
1559 }
1560 if let Some(cond) = filter_condition(req.filters) {
1561 count.cond_where(cond);
1562 }
1563 build(db.backend, count)
1564 };
1565 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1566 .fetch_one(&db.pool)
1567 .await?
1568 .0;
1569
1570 let rows = raw
1571 .iter()
1572 .map(|row| RowView {
1573 id: get_text(row, ID_ALIAS),
1574 cells: config
1575 .columns
1576 .iter()
1577 .map(|c| get_text(row, &c.field))
1578 .collect(),
1579 })
1580 .collect();
1581 Ok(ListPage { rows, total })
1582}
1583
1584fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1587 row.get_text_opt(column).ok().flatten().unwrap_or_default()
1590}
1591
1592#[cfg(test)]
1594#[allow(clippy::too_many_arguments)]
1595pub(crate) async fn handle(
1596 state: &AdminState,
1597 config: &ListConfig,
1598 path: &str,
1599 params: ListParams,
1600 raw: &HashMap<String, String>,
1601 user: &laterite_auth::AuthenticatedUser,
1602 shell: crate::Shell,
1603 headers: &axum::http::HeaderMap,
1604) -> Response {
1605 handle_remembering(
1606 state, config, path, params, raw, user, shell, headers, None, None,
1607 )
1608 .await
1609}
1610
1611fn query_to_remember(
1615 config: &ListConfig,
1616 params: &ListParams,
1617 active: &[ActiveFilter<'_>],
1618) -> Option<String> {
1619 let mut parts = Vec::new();
1620 let (by, dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1621 if by != config.order_by || dir != config.order_dir {
1622 let dir = match dir {
1623 SortDir::Asc => "asc",
1624 SortDir::Desc => "desc",
1625 };
1626 parts.push(format!("sort={}&dir={dir}", urlencode(&by)));
1627 }
1628 if let Some(q) = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
1629 parts.push(format!("q={}", urlencode(q)));
1630 }
1631 for f in active {
1632 parts.push(format!("{}={}", f.filter.param(), urlencode(&f.raw)));
1633 }
1634 (!parts.is_empty()).then(|| parts.join("&"))
1635}
1636
1637#[allow(clippy::too_many_arguments)]
1643pub(crate) async fn handle_remembering(
1644 state: &AdminState,
1645 config: &ListConfig,
1646 path: &str,
1647 params: ListParams,
1648 raw: &HashMap<String, String>,
1649 user: &laterite_auth::AuthenticatedUser,
1650 shell: crate::Shell,
1651 headers: &axum::http::HeaderMap,
1652 query_string: Option<&str>,
1653 session: Option<&crate::session::SessionHandle>,
1654) -> Response {
1655 if let Some(session) = session.filter(|_| config.remember) {
1656 match query_string.filter(|q| !q.is_empty()) {
1657 None if !crate::form::is_htmx(headers) => {
1658 if let Some(remembered) = session.remembered_list(path) {
1659 return axum::response::Redirect::to(&format!("{path}?{remembered}"))
1660 .into_response();
1661 }
1662 }
1663 None => {}
1664 Some(_) => {
1665 let active = resolve_filters(config, raw);
1666 let keep = query_to_remember(config, ¶ms, &active);
1667 session.remember_list(path, keep.as_deref());
1668 }
1669 }
1670 }
1671 show(state, config, path, params, raw, user, shell, headers).await
1672}
1673
1674#[allow(clippy::too_many_arguments)]
1676async fn show(
1677 state: &AdminState,
1678 config: &ListConfig,
1679 path: &str,
1680 params: ListParams,
1681 raw: &HashMap<String, String>,
1682 user: &laterite_auth::AuthenticatedUser,
1683 shell: crate::Shell,
1684 headers: &axum::http::HeaderMap,
1685) -> Response {
1686 let stored = stored_columns(state, path, user).await;
1690 let declared = config;
1691 let narrowed = narrowed(config, stored.as_deref(), user);
1692 let config = &narrowed;
1693
1694 let page = params.page.unwrap_or(1).max(1);
1695 let per_page = stored_per_page(state, config, path, user).await;
1696 let offset = (page - 1) * per_page;
1697 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1698 let q = params.q.unwrap_or_default();
1699 let active = resolve_filters(config, raw);
1700 let req = ListQuery {
1701 offset,
1702 order_by: &order_by,
1703 order_dir,
1704 q: q.trim(),
1705 filters: &active,
1706 per_page: Some(per_page),
1707 };
1708 let mut carry = String::new();
1711 if !q.trim().is_empty() {
1712 carry.push_str(&format!("&q={}", urlencode(q.trim())));
1713 }
1714 for f in &active {
1715 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
1716 }
1717 match query(&state.db, config, &req).await {
1718 Ok(result) => {
1719 let total_pages = ((result.total + per_page - 1) / per_page).max(1);
1720 let date_loc = date_locale(shell.locale());
1721 let labels: Vec<Option<HashMap<String, String>>> = config
1723 .columns
1724 .iter()
1725 .map(|c| {
1726 (!c.labels.is_empty()).then(|| {
1727 c.labels
1728 .iter()
1729 .map(|l| (l.value.clone(), shell.tt(&l.label)))
1730 .collect()
1731 })
1732 })
1733 .collect();
1734 let rows: Vec<RenderedRow> = result
1735 .rows
1736 .into_iter()
1737 .map(|row| RenderedRow {
1738 id: row.id,
1739 cells: row
1740 .cells
1741 .iter()
1742 .zip(config.columns.iter().zip(&labels))
1743 .map(|(raw, (col, labels))| {
1744 let cx = CellCx {
1745 value: raw,
1746 tz: shell.tz,
1747 locale: date_loc,
1748 };
1749 let scope = OverrideScope {
1750 surface: Surface::Column,
1751 view_key: &col.column_type,
1752 resource: Some(&config.entity),
1753 field: Some(&col.field),
1754 };
1755 let html = match state.column_types.get(&col.column_type) {
1756 Some(ct) => render_cell(
1757 ct.as_ref(),
1758 state.overrides.as_ref(),
1759 &scope,
1760 &cx,
1761 labels.as_ref(),
1762 )
1763 .into_string(),
1764 None => String::new(),
1766 };
1767 CellView {
1768 html,
1769 class: col.align_class().to_string(),
1770 }
1771 })
1772 .collect(),
1773 })
1774 .collect();
1775 let keys: Vec<&str> = config
1776 .columns
1777 .iter()
1778 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1779 .flatten()
1780 .collect();
1781 let mut shell = shell;
1782 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1783 let shown = rows.len() as i64;
1784 let title = shell.tt(&config.title);
1786 let active_dir = match order_dir {
1787 SortDir::Asc => "asc",
1788 SortDir::Desc => "desc",
1789 };
1790 let columns: Vec<ColumnHead> = config
1791 .columns
1792 .iter()
1793 .map(|c| {
1794 let active = c.field == order_by;
1795 ColumnHead {
1796 label: shell.tt(&c.label),
1797 field: c.field.clone(),
1798 next_dir: if active && active_dir == "asc" {
1801 "desc".to_string()
1802 } else {
1803 "asc".to_string()
1804 },
1805 active: if active {
1806 active_dir.to_string()
1807 } else {
1808 String::new()
1809 },
1810 sortable: c.sortable,
1811 width: c.width.clone(),
1812 align_class: c.align_class().to_string(),
1813 }
1814 })
1815 .collect();
1816 let shell_for_toolbar = shell.clone();
1817 let filter_views = filter_views(config, &active, &shell);
1818 let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
1819 .into_iter()
1820 .map(|c| ColumnChoice {
1821 field: c.field.clone(),
1822 label: shell.tt(&c.label),
1823 shown: config.columns.iter().any(|v| v.field == c.field),
1824 })
1825 .collect();
1826 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1828 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1829 let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
1831 let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
1832 let page_view = ListTemplate {
1833 shell,
1834 title,
1835 columns,
1836 rows,
1837 shown,
1838 page,
1839 total: result.total,
1840 total_pages,
1841 edit_base: config.edit_base.clone(),
1842 row_link: config.row_click == RowClick::Open,
1843 sort: order_by,
1844 dir: active_dir.to_string(),
1845 q: q.trim().to_string(),
1846 searchable: config.search.enabled
1847 && config.columns.iter().any(|c| c.is_searchable()),
1848 search_prompt,
1849 search_on_enter: config.search.on_enter,
1850 no_records_message,
1851 per_page,
1852 per_page_options: config.per_page_options.clone(),
1853 pickers,
1854 toolbar,
1855 export_query,
1856 path: path.to_string(),
1857 filters: filter_views,
1858 deletable: config.deletable,
1859 exportable: config.exportable,
1860 import_href: config
1861 .import
1862 .as_ref()
1863 .filter(|link| user.permissions.allows(&link.permission))
1864 .map(|link| link.href.clone()),
1865 filtered: !active.is_empty() || !q.trim().is_empty(),
1866 carry: carry.clone(),
1867 };
1868 if crate::form::is_htmx(headers) {
1872 render(ListFragment {
1873 shell: page_view.shell,
1874 columns: page_view.columns,
1875 rows: page_view.rows,
1876 shown: page_view.shown,
1877 page: page_view.page,
1878 total: page_view.total,
1879 total_pages: page_view.total_pages,
1880 edit_base: page_view.edit_base,
1881 row_link: page_view.row_link,
1882 sort: page_view.sort,
1883 dir: page_view.dir,
1884 carry: page_view.carry,
1885 filtered: page_view.filtered,
1886 deletable: page_view.deletable,
1887 no_records_message: page_view.no_records_message,
1888 })
1889 } else {
1890 render(page_view)
1891 }
1892 }
1893 Err(_) => render_error(),
1894 }
1895}
1896
1897pub(crate) async fn set_columns(
1904 state: &AdminState,
1905 config: &ListConfig,
1906 path: &str,
1907 user: &laterite_auth::AuthenticatedUser,
1908 session: &crate::session::SessionHandle,
1909 headers: &axum::http::HeaderMap,
1910 pairs: &[(String, String)],
1911) -> Response {
1912 let allowed = allowed_columns(config, user);
1913 let chosen: Vec<&str> = pairs
1914 .iter()
1915 .filter(|(k, _)| k == "column")
1916 .map(|(_, v)| v.trim())
1917 .filter(|v| allowed.iter().any(|c| c.field == *v))
1918 .collect();
1919 let mut by_default: Vec<&str> = allowed
1923 .iter()
1924 .filter(|c| !c.invisible)
1925 .map(|c| c.field.as_str())
1926 .collect();
1927 let mut as_chosen = chosen.clone();
1928 by_default.sort_unstable();
1929 as_chosen.sort_unstable();
1930 as_chosen.dedup();
1931
1932 let size_key = per_page_preference_key(path);
1935 let size = pairs
1936 .iter()
1937 .find(|(k, _)| k == "per_page")
1938 .and_then(|(_, v)| v.trim().parse::<i64>().ok())
1939 .filter(|n| config.per_page_options.contains(n));
1940 let size_result = match size {
1941 Some(n) if n != config.per_page => {
1942 laterite_auth::store::set_user_preference(
1943 &state.db,
1944 user.user.id,
1945 &size_key,
1946 &n.to_string(),
1947 )
1948 .await
1949 }
1950 _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
1951 };
1952
1953 let key = columns_preference_key(path);
1954 let result = if chosen.is_empty() || as_chosen == by_default {
1955 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1956 } else {
1957 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1958 .await
1959 };
1960 match result.and(size_result) {
1961 Ok(()) => session.push_flash(
1962 crate::session::FlashLevel::Success,
1963 laterite_core::t!("List setup saved."),
1964 ),
1965 Err(e) => {
1966 tracing::error!(error = %e, "storing the list setup failed");
1967 session.push_flash(
1968 crate::session::FlashLevel::Error,
1969 laterite_core::t!("Could not save your list setup."),
1970 );
1971 }
1972 }
1973 let back = format!("{}{}", state.admin_path, path);
1974 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1975}
1976
1977pub struct ColumnChoice {
1979 pub field: String,
1980 pub label: String,
1981 pub shown: bool,
1982}
1983
1984pub struct ColumnHead {
1987 pub label: String,
1988 pub field: String,
1989 pub next_dir: String,
1990 pub active: String,
1991 pub sortable: bool,
1992 pub width: Option<String>,
1993 pub align_class: String,
1994}
1995
1996#[derive(Template)]
1997#[template(path = "list.html")]
1998struct ListTemplate {
1999 shell: crate::Shell,
2000 title: String,
2001 columns: Vec<ColumnHead>,
2002 rows: Vec<RenderedRow>,
2003 shown: i64,
2005 page: i64,
2006 total: i64,
2007 total_pages: i64,
2008 edit_base: Option<String>,
2009 row_link: bool,
2011 sort: String,
2013 dir: String,
2014 q: String,
2016 searchable: bool,
2018 search_prompt: Option<String>,
2020 search_on_enter: bool,
2021 no_records_message: Option<String>,
2023 per_page: i64,
2026 per_page_options: Vec<i64>,
2027 deletable: bool,
2029 exportable: bool,
2032 import_href: Option<String>,
2035 pickers: Vec<ColumnChoice>,
2037 toolbar: Vec<ToolbarView>,
2039 export_query: String,
2042 path: String,
2044 filters: Vec<FilterView>,
2046 filtered: bool,
2048 carry: String,
2050}
2051
2052pub struct FilterView {
2054 pub param: String,
2055 pub label: String,
2056 pub input_type: String,
2058 pub options: Vec<FilterOptionView>,
2059 pub value: String,
2061}
2062
2063pub struct FilterOptionView {
2064 pub value: String,
2065 pub label: String,
2066}
2067
2068fn filter_views(
2070 config: &ListConfig,
2071 active: &[ActiveFilter<'_>],
2072 shell: &crate::Shell,
2073) -> Vec<FilterView> {
2074 config
2075 .filters
2076 .iter()
2077 .map(|f| {
2078 let value = active
2079 .iter()
2080 .find(|a| a.filter.field == f.field)
2081 .map(|a| a.raw.clone())
2082 .unwrap_or_default();
2083 let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
2086 "boolean" => vec![
2087 FilterOptionView {
2088 value: "1".to_string(),
2089 label: shell.tt(&laterite_core::t!("Yes")),
2090 },
2091 FilterOptionView {
2092 value: "0".to_string(),
2093 label: shell.tt(&laterite_core::t!("No")),
2094 },
2095 ],
2096 "select" => f
2097 .choices()
2098 .iter()
2099 .map(|o| FilterOptionView {
2100 value: o.value.clone(),
2101 label: shell.tt(&o.label),
2102 })
2103 .collect(),
2104 _ => Vec::new(),
2105 };
2106 let input_type = match f.filter_type.as_str() {
2107 "number" => "number",
2108 "date" => "date",
2109 _ => "text",
2110 };
2111 FilterView {
2112 param: f.param(),
2113 label: shell.tt(&f.label),
2114 input_type: input_type.to_string(),
2115 options,
2116 value,
2117 }
2118 })
2119 .collect()
2120}
2121
2122fn urlencode(value: &str) -> String {
2124 let mut out = String::with_capacity(value.len());
2125 for byte in value.as_bytes() {
2126 match byte {
2127 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2128 out.push(*byte as char)
2129 }
2130 b' ' => out.push('+'),
2131 _ => out.push_str(&format!("%{byte:02X}")),
2132 }
2133 }
2134 out
2135}
2136
2137#[derive(Template)]
2139#[template(path = "_list_table.html")]
2140struct ListFragment {
2141 shell: crate::Shell,
2142 columns: Vec<ColumnHead>,
2143 rows: Vec<RenderedRow>,
2144 shown: i64,
2145 page: i64,
2146 total: i64,
2147 total_pages: i64,
2148 edit_base: Option<String>,
2149 row_link: bool,
2151 sort: String,
2152 dir: String,
2153 carry: String,
2154 filtered: bool,
2158 deletable: bool,
2159 no_records_message: Option<String>,
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164 use super::*;
2165
2166 fn config() -> ListConfig {
2167 ListConfig {
2168 entity: "backend_users".to_string(),
2169 title: "Users".into(),
2170 columns: vec![
2171 ListColumn::new("username", "Username"),
2172 ListColumn::new("is_superuser", "Superuser").yes_no(),
2173 ],
2174 order_by: "created_at".to_string(),
2175 order_dir: SortDir::Desc,
2176 per_page: 25,
2177 id_field: "id".to_string(),
2178 edit_base: None,
2179 creatable: false,
2180 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
2181 deletable: true,
2182 ..Default::default()
2183 }
2184 }
2185
2186 #[test]
2187 fn column_types_format_their_cell_display() {
2188 let ist: Tz = "Asia/Kolkata".parse().unwrap();
2189 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
2190 ct.view_model(&CellCx {
2191 value,
2192 tz,
2193 locale: chrono::Locale::en_US,
2194 })
2195 .display
2196 };
2197 assert_eq!(
2199 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
2200 "13 Aug 2026, 15:30"
2201 );
2202 assert_eq!(
2203 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
2204 "13 Aug 2026"
2205 );
2206 assert_eq!(
2207 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
2208 "15:30"
2209 );
2210 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
2211 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
2212 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
2213 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
2215 }
2216
2217 #[test]
2218 fn sort_falls_back_to_the_configured_order() {
2219 let c = config();
2220 assert_eq!(
2221 resolve_sort(&c, None, None),
2222 ("created_at".to_string(), SortDir::Desc)
2223 );
2224 }
2225
2226 #[test]
2227 fn a_declared_column_can_be_sorted_either_way() {
2228 let c = config();
2229 assert_eq!(
2230 resolve_sort(&c, Some("username"), Some("asc")),
2231 ("username".to_string(), SortDir::Asc)
2232 );
2233 assert_eq!(
2234 resolve_sort(&c, Some("username"), Some("desc")),
2235 ("username".to_string(), SortDir::Desc)
2236 );
2237 }
2238
2239 #[test]
2242 fn an_undeclared_sort_column_is_ignored() {
2243 let c = config();
2244 for crafted in [
2245 "password_hash",
2246 "id) --",
2247 "username; drop table backend_users",
2248 "",
2249 ] {
2250 assert_eq!(
2251 resolve_sort(&c, Some(crafted), Some("asc")).0,
2252 "created_at",
2253 "{crafted} must not reach the query"
2254 );
2255 }
2256 }
2257
2258 #[test]
2259 fn an_unknown_direction_falls_back() {
2260 let c = config();
2261 assert_eq!(
2263 resolve_sort(&c, Some("username"), Some("sideways")).1,
2264 SortDir::Asc
2265 );
2266 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
2268 }
2269
2270 #[test]
2273 fn a_contributed_column_type_renders_its_cell() {
2274 struct Rating;
2275 impl ColumnType for Rating {
2276 fn view_key(&self) -> &'static str {
2277 "acme.rating"
2278 }
2279 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
2280 let stars = cx.value.parse::<usize>().unwrap_or(0);
2281 text_cell("acme.rating", cx.value, "*".repeat(stars))
2282 }
2283 fn render_default(&self, vm: &CellVm) -> Markup {
2284 render_text(vm)
2285 }
2286 }
2287
2288 let reg = ColumnTypeReg::new(Arc::new(Rating));
2289 assert_eq!(reg.name(), "acme.rating", "the type names itself");
2290
2291 let mut registry = builtin_column_registry();
2292 registry.insert(reg.name().to_string(), reg.column_type);
2293 let rendered = render_cell(
2294 registry.get("acme.rating").unwrap().as_ref(),
2295 &crate::field::NoOverrides,
2296 &OverrideScope {
2297 surface: Surface::Column,
2298 view_key: "acme.rating",
2299 resource: Some("products"),
2300 field: Some("stars"),
2301 },
2302 &CellCx {
2303 value: "3",
2304 tz: Tz::UTC,
2305 locale: chrono::Locale::en_US,
2306 },
2307 None,
2308 );
2309 assert!(rendered.into_string().contains("***"));
2310 }
2311
2312 #[test]
2315 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
2316 struct Impostor;
2317 impl ColumnType for Impostor {
2318 fn view_key(&self) -> &'static str {
2319 "text"
2320 }
2321 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
2322 text_cell("text", cx.value, String::new())
2323 }
2324 fn render_default(&self, vm: &CellVm) -> Markup {
2325 render_text(vm)
2326 }
2327 }
2328 let reg = ColumnTypeReg::new(Arc::new(Impostor));
2329 assert_eq!(reg.name(), "text");
2330 assert!(builtin_column_registry().contains_key(reg.name()));
2332 }
2333
2334 #[test]
2335 fn status_pill_renders_a_slugged_markup_span() {
2336 let vm = StatusPillColumn.view_model(&CellCx {
2337 value: "In Progress",
2338 tz: Tz::UTC,
2339 locale: chrono::Locale::en_US,
2340 });
2341 let html = StatusPillColumn.render_default(&vm).into_string();
2342 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
2343 assert!(html.contains(">In Progress<"));
2344 }
2345
2346 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
2349 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
2350 }
2351
2352 #[tokio::test]
2353 async fn query_returns_display_rows() {
2354 let (db, _guard) = test_db().await;
2355 let hash = laterite_auth::password::hash_password("pw").unwrap();
2356 laterite_auth::store::create_user(
2357 &db,
2358 "root",
2359 "root@example.test",
2360 "Ada",
2361 None,
2362 &hash,
2363 true,
2364 )
2365 .await
2366 .unwrap();
2367
2368 let c = config();
2369 let result = query(&db, &c, &plain(&c)).await.unwrap();
2370 assert_eq!(result.total, 1);
2371 assert_eq!(result.rows.len(), 1);
2372 assert_eq!(result.rows[0].cells[0], "root");
2373 assert_eq!(result.rows[0].cells[1], "1");
2376 assert!(!result.rows[0].id.is_empty());
2377 }
2378
2379 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
2380 get_with(params, HashMap::new(), headers).await
2381 }
2382
2383 async fn get_with(
2384 params: ListParams,
2385 raw: HashMap<String, String>,
2386 headers: axum::http::HeaderMap,
2387 ) -> String {
2388 let (db, _guard) = test_db().await;
2389 let state = AdminState::new(
2390 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2391 db,
2392 );
2393 let resp = handle(
2394 &state,
2395 &config(),
2396 "/admin/users",
2397 params,
2398 &raw,
2399 &crate::audit::test_actor(),
2400 crate::Shell::test(),
2401 &headers,
2402 )
2403 .await;
2404 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2405 .await
2406 .unwrap();
2407 String::from_utf8(bytes.to_vec()).unwrap()
2408 }
2409
2410 #[test]
2411 fn a_typed_filter_accepts_only_its_own_shape() {
2412 let mut c = config();
2413 c.filters = vec![
2414 ListFilter::number("views", "Views"),
2415 ListFilter::date("created_at", "Created"),
2416 ListFilter::text("username", "Username"),
2417 ];
2418 let accepted = |param: &str, value: &str| {
2419 !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
2420 };
2421 assert!(accepted("f_views", "42"));
2422 assert!(
2423 !accepted("f_views", "4.2"),
2424 "a number filter takes whole numbers"
2425 );
2426 assert!(!accepted("f_views", "; drop table"), "and nothing else");
2427
2428 assert!(accepted("f_created_at", "2026-09-21"));
2429 assert!(
2430 !accepted("f_created_at", "2026-9-21"),
2431 "a date is ISO or nothing"
2432 );
2433 assert!(
2434 !accepted("f_created_at", "2026-09-21%"),
2435 "so a pattern cannot slip in"
2436 );
2437
2438 assert!(accepted("f_username", "ada"));
2439 }
2440
2441 #[test]
2442 fn a_filter_default_applies_only_until_the_operator_decides() {
2443 let mut c = config();
2444 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
2445 assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
2447 assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
2449 let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
2451 assert_eq!(active.len(), 1);
2452 }
2453
2454 #[test]
2455 fn a_gated_filter_is_narrowed_away_with_its_default() {
2456 let mut c = config();
2457 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
2458 .default_value("1")
2459 .require("acme.secret")];
2460 let plain = laterite_auth::AuthenticatedUser {
2461 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2462 ..crate::audit::test_actor()
2463 };
2464 assert!(allowed_filters(&c, &plain).is_empty());
2465 let narrowed = narrowed(&c, None, &plain);
2467 assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
2468 assert_eq!(allowed_filters(&c, &root()).len(), 1);
2469 }
2470
2471 #[test]
2472 fn a_stored_page_size_counts_only_while_it_is_offered() {
2473 let mut cfg = config();
2474 assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
2475 cfg.per_page_options = vec![25, 50, 100];
2476 assert_eq!(offered_per_page(&cfg, Some(50)), 50);
2477 assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
2478 assert_eq!(offered_per_page(&cfg, None), 25);
2479 }
2480
2481 fn params(sort: Option<&str>) -> ListParams {
2482 ListParams {
2483 page: None,
2484 sort: sort.map(|s| s.to_string()),
2485 dir: Some("asc".to_string()),
2486 q: None,
2487 }
2488 }
2489
2490 fn plain(config: &ListConfig) -> ListQuery<'_> {
2492 ListQuery {
2493 offset: 0,
2494 order_by: &config.order_by,
2495 order_dir: config.order_dir,
2496 q: "",
2497 filters: &[],
2498 per_page: None,
2499 }
2500 }
2501
2502 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
2503 ListQuery {
2504 offset: 0,
2505 order_by: &config.order_by,
2506 order_dir: config.order_dir,
2507 q,
2508 filters: &[],
2509 per_page: None,
2510 }
2511 }
2512
2513 fn amps(html: &str) -> String {
2516 html.replace("&", "&").replace("&", "&")
2517 }
2518
2519 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2521 pairs
2522 .iter()
2523 .map(|(k, v)| (k.to_string(), v.to_string()))
2524 .collect()
2525 }
2526
2527 #[tokio::test]
2528 async fn search_filters_rows_and_the_total() {
2529 let (db, _guard) = test_db().await;
2530 for name in ["ada", "brendan", "clara"] {
2531 let hash = laterite_auth::password::hash_password("x").unwrap();
2532 laterite_auth::store::create_user(
2533 &db,
2534 name,
2535 &format!("{name}@example.test"),
2536 name,
2537 None,
2538 &hash,
2539 false,
2540 )
2541 .await
2542 .unwrap();
2543 }
2544 let c = config();
2545
2546 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
2547 assert_eq!(hit.rows.len(), 1);
2548 assert_eq!(hit.rows[0].cells[0], "brendan");
2549 assert_eq!(hit.total, 1);
2551
2552 assert_eq!(
2554 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
2555 1
2556 );
2557
2558 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
2560 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
2561
2562 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
2564 }
2565
2566 #[tokio::test]
2569 async fn search_skips_a_non_text_column() {
2570 let (db, _guard) = test_db().await;
2571 let hash = laterite_auth::password::hash_password("x").unwrap();
2572 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
2573 .await
2574 .unwrap();
2575 let c = config();
2576 assert!(c.columns[0].is_searchable(), "username is text");
2577 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
2578 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
2580 }
2581
2582 #[test]
2583 fn a_column_can_override_its_searchability() {
2584 assert!(ListColumn::new("code", "Code").is_searchable());
2585 assert!(!ListColumn::new("code", "Code")
2586 .searchable(false)
2587 .is_searchable());
2588 assert!(ListColumn::new("at", "At")
2589 .datetime()
2590 .searchable(true)
2591 .is_searchable());
2592 }
2593
2594 async fn seed_users(db: &Db) {
2595 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
2596 let hash = laterite_auth::password::hash_password("x").unwrap();
2597 laterite_auth::store::create_user(
2598 db,
2599 name,
2600 &format!("{name}@example.test"),
2601 name,
2602 None,
2603 &hash,
2604 su,
2605 )
2606 .await
2607 .unwrap();
2608 }
2609 }
2610
2611 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
2612 let active = resolve_filters(c, params);
2613 let req = ListQuery {
2614 offset: 0,
2615 order_by: &c.order_by,
2616 order_dir: c.order_dir,
2617 q: "",
2618 filters: &active,
2619 per_page: None,
2620 };
2621 query(db, c, &req).await.unwrap().total
2622 }
2623
2624 #[tokio::test]
2625 async fn a_filter_narrows_the_rows_and_the_total() {
2626 let (db, _guard) = test_db().await;
2627 seed_users(&db).await;
2628 let c = config();
2629
2630 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
2631 assert_eq!(
2632 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
2633 1
2634 );
2635 assert_eq!(
2636 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
2637 2
2638 );
2639 }
2640
2641 #[test]
2644 fn an_undeclared_filter_or_value_is_ignored() {
2645 let c = config();
2646 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
2648 for bad in ["yes", "true", "1 or 1=1", "", " "] {
2650 assert!(
2651 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
2652 "{bad} must not reach the query"
2653 );
2654 }
2655
2656 let with_select = ListConfig {
2657 filters: vec![ListFilter::select(
2658 "status",
2659 "Status",
2660 vec![FilterOption::new("live", "Live")],
2661 )],
2662 ..config()
2663 };
2664 assert_eq!(
2665 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
2666 1
2667 );
2668 assert!(
2669 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
2670 "an option the descriptor never offered is dropped"
2671 );
2672 }
2673
2674 #[tokio::test]
2675 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
2676 let mut p = params(Some("username"));
2677 p.q = None;
2678 let html = get_with(
2679 p,
2680 filter_params(&[("f_is_superuser", "1")]),
2681 axum::http::HeaderMap::new(),
2682 )
2683 .await;
2684 let links = amps(&html);
2685 assert!(
2686 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
2687 "a sort link carries the filter"
2688 );
2689 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
2691 assert!(
2692 html.contains(r#"<option value="1" selected>"#),
2693 "the control shows the active value"
2694 );
2695 assert!(html.contains("Clear"), "and a way back to the full list");
2696 assert!(
2697 html.contains(r#"data-filtered="true""#),
2698 "the region says the list is narrowed, which is what reveals Clear"
2699 );
2700 }
2701
2702 #[tokio::test]
2705 async fn the_empty_state_distinguishes_no_records_from_no_match() {
2706 let (db, _guard) = test_db().await;
2707 let state = AdminState::new(
2708 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2709 db,
2710 );
2711 let render = |raw: HashMap<String, String>, q: Option<&str>| {
2712 let q = q.map(String::from);
2713 let state = state.clone();
2714 async move {
2715 let p = ListParams {
2716 page: None,
2717 sort: None,
2718 dir: None,
2719 q,
2720 };
2721 let resp = handle(
2722 &state,
2723 &config(),
2724 "/admin/users",
2725 p,
2726 &raw,
2727 &crate::audit::test_actor(),
2728 crate::Shell::test(),
2729 &axum::http::HeaderMap::new(),
2730 )
2731 .await;
2732 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2733 .await
2734 .unwrap();
2735 String::from_utf8(bytes.to_vec()).unwrap()
2736 }
2737 };
2738
2739 let bare = render(HashMap::new(), None).await;
2741 assert!(bare.contains("No records yet."));
2742
2743 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
2745 assert!(filtered.contains("No records match."));
2746 assert!(!filtered.contains("No records yet."));
2747
2748 let searched = render(HashMap::new(), Some("zzz")).await;
2750 assert!(searched.contains("No records match."));
2751 }
2752
2753 fn root() -> laterite_auth::AuthenticatedUser {
2755 laterite_auth::AuthenticatedUser {
2756 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2757 ..crate::audit::test_actor()
2758 }
2759 }
2760
2761 #[test]
2762 fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
2763 let mut c = config();
2764 c.columns[1].invisible = true;
2765 assert_eq!(visible_columns(&c, None, &root()).len(), 1);
2766 assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
2767 assert_eq!(
2768 visible_columns(&c, Some("is_superuser"), &root())[0].field,
2769 "is_superuser"
2770 );
2771 }
2772
2773 #[test]
2774 fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
2775 let mut c = config();
2776 c.columns[1].permission = Some("acme.secret".into());
2777 let plain = laterite_auth::AuthenticatedUser {
2778 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2779 ..crate::audit::test_actor()
2780 };
2781 assert_eq!(visible_columns(&c, None, &plain).len(), 1);
2782 assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
2784 assert_eq!(visible_columns(&c, None, &root()).len(), 2);
2785 }
2786
2787 #[test]
2788 fn an_unsortable_column_is_not_sorted_by() {
2789 let mut c = config();
2790 c.columns[0].sortable = false;
2791 let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
2792 assert_eq!(by, c.order_by, "falls back to the descriptor's order");
2793 }
2794
2795 #[test]
2796 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
2797 let c = config();
2798 let kept = visible_columns(&c, Some("is_superuser,username"), &root());
2801 assert_eq!(
2802 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
2803 ["username", "is_superuser"]
2804 );
2805 }
2806
2807 #[test]
2808 fn no_stored_choice_shows_every_column() {
2809 let c = config();
2810 assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
2811 }
2812
2813 #[test]
2816 fn a_stale_choice_falls_back_to_every_column() {
2817 let c = config();
2818 assert_eq!(
2819 visible_columns(&c, Some("gone,removed"), &root()).len(),
2820 c.columns.len()
2821 );
2822 assert_eq!(
2823 visible_columns(&c, Some(""), &root()).len(),
2824 c.columns.len()
2825 );
2826 assert_eq!(
2827 visible_columns(&c, Some(" , "), &root()).len(),
2828 c.columns.len()
2829 );
2830 assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
2832 }
2833
2834 #[test]
2837 fn the_toolbar_hides_what_the_operator_may_not_do() {
2838 let shell = crate::Shell::test();
2839 let cfg = ListConfig {
2840 creatable: false,
2841 toolbar: vec![
2842 ToolbarButton::new("Open", "/reports"),
2843 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
2844 ],
2845 ..config()
2846 };
2847
2848 let root = laterite_auth::AuthenticatedUser {
2850 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2851 ..crate::audit::test_actor()
2852 };
2853 let views = toolbar_views(&cfg, "/admin", &root, &shell);
2854 assert_eq!(
2855 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2856 ["Open", "Audit"]
2857 );
2858
2859 let granted = laterite_auth::AuthenticatedUser {
2861 permissions: laterite_auth::PermissionSet::with_overrides(
2862 false,
2863 ["backend.view_audit".to_string()],
2864 [],
2865 [],
2866 ),
2867 ..crate::audit::test_actor()
2868 };
2869 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
2870
2871 let plain = laterite_auth::AuthenticatedUser {
2873 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2874 ..crate::audit::test_actor()
2875 };
2876 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
2877 assert_eq!(
2878 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2879 ["Open"]
2880 );
2881 }
2882
2883 #[test]
2886 fn a_toolbar_href_follows_the_admin_mount() {
2887 let shell = crate::Shell::test();
2888 let cfg = ListConfig {
2889 creatable: true,
2890 edit_base: Some("/backoffice/roles".to_string()),
2892 toolbar: vec![
2893 ToolbarButton::new("Reports", "/reports"),
2894 ToolbarButton::new("Docs", "https://example.test/docs"),
2895 ],
2896 ..config()
2897 };
2898 let root = laterite_auth::AuthenticatedUser {
2899 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2900 ..crate::audit::test_actor()
2901 };
2902 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
2903 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
2904 assert_eq!(views[1].href, "/backoffice/reports");
2905 assert_eq!(views[2].href, "https://example.test/docs");
2907 }
2908
2909 #[tokio::test]
2910 async fn an_htmx_sort_returns_the_list_region_alone() {
2911 let mut headers = axum::http::HeaderMap::new();
2912 headers.insert("hx-request", "true".parse().unwrap());
2913 let html = get(params(Some("username")), headers).await;
2914 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
2915 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
2916 assert!(!html.contains("<body"), "no page chrome");
2919 assert!(html.contains(r#"data-filtered="false""#));
2921 }
2922
2923 #[tokio::test]
2926 async fn the_search_term_survives_a_sort_link() {
2927 let mut p = params(Some("username"));
2928 p.q = Some("bren".to_string());
2929 let html = get(p, axum::http::HeaderMap::new()).await;
2930 assert!(
2931 amps(&html).contains("?sort=username&dir=desc&q=bren"),
2932 "sort and pager links carry the term"
2933 );
2934 assert!(
2935 html.contains(r#"value="bren""#),
2936 "and the box still shows it"
2937 );
2938 }
2939
2940 #[tokio::test]
2941 async fn a_plain_sort_returns_the_whole_page() {
2942 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
2943 assert!(html.contains("<body"), "lists still work without htmx");
2944 assert!(html.contains(r#"id="lat-list""#));
2945 assert!(html.contains(r#"href="?sort=username"#));
2947 }
2948
2949 #[tokio::test]
2950 async fn query_rejects_bad_identifiers() {
2951 let (db, _guard) = test_db().await;
2952 let mut bad = config();
2953 bad.entity = "backend_users; drop table backend_users".to_string();
2954 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2955 }
2956}