1use std::collections::HashMap;
12use std::sync::Arc;
13
14use askama::Template;
15use axum::response::Response;
16use chrono::DateTime;
17use chrono_tz::Tz;
18use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
19use laterite_core::search::SearchProfile;
20use laterite_core::{AnyRowExt, Db, Text};
21use sea_query::{Alias, Expr, Order, Query};
22use serde::{Deserialize, Serialize};
23
24use crate::field::{OverrideResolver, OverrideScope, Surface};
25use crate::html::Markup;
26use crate::sql::valid_ident;
27use crate::{render, render_error, AdminState};
28
29const ID_ALIAS: &str = "_lat_id";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum SortDir {
34 Asc,
35 Desc,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Align {
44 Left,
45 Center,
46 Right,
47}
48
49impl Align {
50 fn class(self) -> &'static str {
51 match self {
52 Align::Left => "lat-col--left",
53 Align::Center => "lat-col--center",
54 Align::Right => "lat-col--right",
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
60#[non_exhaustive]
61pub struct ListColumn {
62 pub field: String,
64 pub label: Text,
66 pub column_type: String,
68 pub options: serde_json::Value,
71 pub sortable: bool,
73 pub invisible: bool,
75 pub width: Option<String>,
77 pub align: Option<Align>,
78 pub permission: Option<String>,
81 pub searchable: Option<bool>,
85}
86
87impl ListColumn {
88 pub fn new(field: &str, label: impl Into<Text>) -> Self {
89 Self {
90 field: field.to_string(),
91 label: label.into(),
92 column_type: "text".to_string(),
93 sortable: true,
94 invisible: false,
95 width: None,
96 align: None,
97 permission: None,
98 searchable: None,
99 options: serde_json::Value::Null,
100 }
101 }
102
103 fn of(mut self, column_type: &str) -> Self {
104 self.column_type = column_type.to_string();
105 self
106 }
107
108 pub fn datetime(self) -> Self {
110 self.of("datetime")
111 }
112 pub fn date(self) -> Self {
114 self.of("date")
115 }
116 pub fn time(self) -> Self {
118 self.of("time")
119 }
120 pub fn yes_no(self) -> Self {
122 self.of("boolean")
123 }
124 pub fn pill(self) -> Self {
126 self.of("status_pill")
127 }
128
129 pub fn searchable(mut self, searchable: bool) -> Self {
131 self.searchable = Some(searchable);
132 self
133 }
134
135 pub fn sortable(mut self, sortable: bool) -> Self {
136 self.sortable = sortable;
137 self
138 }
139
140 pub fn invisible(mut self) -> Self {
142 self.invisible = true;
143 self
144 }
145
146 pub fn width(mut self, width: impl Into<String>) -> Self {
147 self.width = Some(width.into());
148 self
149 }
150
151 pub fn align(mut self, align: Align) -> Self {
152 self.align = Some(align);
153 self
154 }
155
156 pub fn require(mut self, permission: impl Into<String>) -> Self {
158 self.permission = Some(permission.into());
159 self
160 }
161
162 pub(crate) fn align_class(&self) -> &'static str {
163 self.align.map(Align::class).unwrap_or("")
164 }
165
166 pub(crate) fn is_searchable(&self) -> bool {
168 self.searchable.unwrap_or(self.column_type == "text")
169 }
170}
171
172pub struct CellCx<'a> {
175 pub value: &'a str,
176 pub tz: Tz,
177 pub locale: chrono::Locale,
178}
179
180pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
186 chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
187}
188
189#[derive(Serialize)]
192pub struct CellVm {
193 pub view_key: String,
194 pub value: String,
195 pub display: String,
196 pub data: serde_json::Value,
197}
198
199pub trait ColumnType: Send + Sync + 'static {
202 fn view_key(&self) -> &'static str;
203 fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
204 fn render_default(&self, vm: &CellVm) -> Markup;
205 fn option_keys(&self) -> Option<Vec<&'static str>> {
209 None
210 }
211 fn assets(&self) -> Vec<&'static str> {
214 Vec::new()
215 }
216}
217
218pub struct ColumnTypeReg {
225 pub column_type: Arc<dyn ColumnType>,
226}
227
228impl ColumnTypeReg {
229 pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
230 Self { column_type }
231 }
232
233 pub fn name(&self) -> &'static str {
235 self.column_type.view_key()
236 }
237}
238
239pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
241
242pub(crate) fn render_cell(
244 ct: &dyn ColumnType,
245 resolver: &dyn OverrideResolver,
246 scope: &OverrideScope<'_>,
247 cx: &CellCx<'_>,
248) -> Markup {
249 let vm = ct.view_model(cx);
250 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
251 Some(Ok(html)) => Markup::from_override(html),
252 Some(Err(_)) | None => ct.render_default(&vm),
253 }
254}
255
256pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
257 vec![
258 Arc::new(TextColumn),
259 Arc::new(DateTimeColumn),
260 Arc::new(DateColumn),
261 Arc::new(TimeColumn),
262 Arc::new(BoolColumn),
263 Arc::new(StatusPillColumn),
264 ]
265}
266
267pub(crate) fn builtin_column_registry() -> ColumnRegistry {
269 builtin_column_types()
270 .into_iter()
271 .map(|c| (c.view_key().to_string(), c))
272 .collect()
273}
274
275#[derive(Template)]
276#[template(path = "cells/text.html")]
277struct CellTextTmpl<'a> {
278 display: &'a str,
279}
280
281fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
283 CellVm {
284 view_key: view_key.to_string(),
285 value: value.to_string(),
286 display,
287 data: serde_json::Value::Null,
288 }
289}
290
291fn render_text(vm: &CellVm) -> Markup {
292 Markup::from_template(&CellTextTmpl {
293 display: &vm.display,
294 })
295 .unwrap_or_default()
296}
297
298pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
301 match DateTime::parse_from_rfc3339(raw) {
302 Ok(dt) => dt
303 .with_timezone(&tz)
304 .format_localized(pattern, locale)
305 .to_string(),
306 Err(_) => raw.to_string(),
307 }
308}
309
310struct TextColumn;
311impl ColumnType for TextColumn {
312 fn option_keys(&self) -> Option<Vec<&'static str>> {
313 Some(Vec::new())
314 }
315 fn view_key(&self) -> &'static str {
316 "text"
317 }
318 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
319 text_cell("text", cx.value, cx.value.to_string())
320 }
321 fn render_default(&self, vm: &CellVm) -> Markup {
322 render_text(vm)
323 }
324}
325
326struct DateTimeColumn;
327impl ColumnType for DateTimeColumn {
328 fn option_keys(&self) -> Option<Vec<&'static str>> {
329 Some(Vec::new())
330 }
331 fn view_key(&self) -> &'static str {
332 "datetime"
333 }
334 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
335 text_cell(
336 "datetime",
337 cx.value,
338 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
339 )
340 }
341 fn render_default(&self, vm: &CellVm) -> Markup {
342 render_text(vm)
343 }
344}
345
346struct DateColumn;
347impl ColumnType for DateColumn {
348 fn option_keys(&self) -> Option<Vec<&'static str>> {
349 Some(Vec::new())
350 }
351 fn view_key(&self) -> &'static str {
352 "date"
353 }
354 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
355 text_cell(
356 "date",
357 cx.value,
358 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
359 )
360 }
361 fn render_default(&self, vm: &CellVm) -> Markup {
362 render_text(vm)
363 }
364}
365
366struct TimeColumn;
367impl ColumnType for TimeColumn {
368 fn option_keys(&self) -> Option<Vec<&'static str>> {
369 Some(Vec::new())
370 }
371 fn view_key(&self) -> &'static str {
372 "time"
373 }
374 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
375 text_cell(
376 "time",
377 cx.value,
378 format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
379 )
380 }
381 fn render_default(&self, vm: &CellVm) -> Markup {
382 render_text(vm)
383 }
384}
385
386struct BoolColumn;
387impl ColumnType for BoolColumn {
388 fn option_keys(&self) -> Option<Vec<&'static str>> {
389 Some(Vec::new())
390 }
391 fn view_key(&self) -> &'static str {
392 "boolean"
393 }
394 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
395 let display = match cx.value {
396 "1" | "true" => "Yes",
397 "0" | "false" | "" => "No",
398 other => other,
399 };
400 text_cell("boolean", cx.value, display.to_string())
401 }
402 fn render_default(&self, vm: &CellVm) -> Markup {
403 render_text(vm)
404 }
405}
406
407#[derive(Template)]
408#[template(path = "cells/status.html")]
409struct CellStatusTmpl<'a> {
410 label: &'a str,
411 slug: &'a str,
412}
413
414struct StatusPillColumn;
416impl ColumnType for StatusPillColumn {
417 fn option_keys(&self) -> Option<Vec<&'static str>> {
418 Some(Vec::new())
419 }
420 fn view_key(&self) -> &'static str {
421 "status_pill"
422 }
423 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
424 CellVm {
425 view_key: "status_pill".to_string(),
426 value: cx.value.to_string(),
427 display: cx.value.to_string(),
428 data: serde_json::json!({ "slug": status_slug(cx.value) }),
429 }
430 }
431 fn render_default(&self, vm: &CellVm) -> Markup {
432 let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
433 Markup::from_template(&CellStatusTmpl {
434 label: &vm.display,
435 slug,
436 })
437 .unwrap_or_default()
438 }
439}
440
441fn status_slug(value: &str) -> String {
443 value
444 .trim()
445 .to_lowercase()
446 .chars()
447 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
448 .collect()
449}
450
451impl Default for ListColumn {
452 fn default() -> Self {
455 Self::new("", Text::new(""))
456 }
457}
458
459impl Default for ListFilter {
460 fn default() -> Self {
462 Self::of("", Text::new(""), "text")
463 }
464}
465
466impl<'de> Deserialize<'de> for ListColumn {
467 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
468 struct Entry;
469 impl<'de> serde::de::Visitor<'de> for Entry {
470 type Value = ListColumn;
471
472 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
473 f.write_str("a column: its own keys and its type's")
474 }
475
476 fn visit_map<A: serde::de::MapAccess<'de>>(
477 self,
478 map: A,
479 ) -> Result<ListColumn, A::Error> {
480 let mut column = ListColumn::default();
481 let theirs = crate::keyed::split_entry(map, |key, map| {
482 match key {
483 "label" => column.label = map.next_value()?,
484 "type" => column.column_type = map.next_value()?,
485 "sortable" => column.sortable = map.next_value()?,
486 "invisible" => column.invisible = map.next_value()?,
487 "width" => column.width = map.next_value()?,
488 "align" => column.align = map.next_value()?,
489 "permission" => column.permission = map.next_value()?,
490 "searchable" => column.searchable = map.next_value()?,
491 _ => return Ok(false),
492 }
493 Ok(true)
494 })?;
495 column.options = theirs;
496 Ok(column)
497 }
498
499 fn visit_unit<E: serde::de::Error>(self) -> Result<ListColumn, E> {
501 Ok(ListColumn::default())
502 }
503 }
504 de.deserialize_map(Entry)
505 }
506}
507
508impl Serialize for ListColumn {
509 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
510 use serde::ser::SerializeMap;
511 let mut map = ser.serialize_map(None)?;
512 map.serialize_entry("label", &self.label)?;
513 map.serialize_entry("type", &self.column_type)?;
514 map.serialize_entry("sortable", &self.sortable)?;
515 map.serialize_entry("invisible", &self.invisible)?;
516 map.serialize_entry("width", &self.width)?;
517 map.serialize_entry("align", &self.align)?;
518 map.serialize_entry("permission", &self.permission)?;
519 map.serialize_entry("searchable", &self.searchable)?;
520 crate::keyed::serialize_theirs(&mut map, &self.options)?;
521 map.end()
522 }
523}
524
525impl<'de> Deserialize<'de> for ListFilter {
526 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
527 struct Entry;
528 impl<'de> serde::de::Visitor<'de> for Entry {
529 type Value = ListFilter;
530
531 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
532 f.write_str("a filter: its own keys and its type's")
533 }
534
535 fn visit_map<A: serde::de::MapAccess<'de>>(
536 self,
537 map: A,
538 ) -> Result<ListFilter, A::Error> {
539 let mut filter = ListFilter::default();
540 let theirs = crate::keyed::split_entry(map, |key, map| {
541 match key {
542 "label" => filter.label = map.next_value()?,
543 "type" => filter.filter_type = map.next_value()?,
544 "default_value" => filter.default_value = map.next_value()?,
545 "permission" => filter.permission = map.next_value()?,
546 _ => return Ok(false),
547 }
548 Ok(true)
549 })?;
550 filter.options = theirs;
551 Ok(filter)
552 }
553
554 fn visit_unit<E: serde::de::Error>(self) -> Result<ListFilter, E> {
556 Ok(ListFilter::default())
557 }
558 }
559 de.deserialize_map(Entry)
560 }
561}
562
563impl Serialize for ListFilter {
564 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
565 use serde::ser::SerializeMap;
566 let mut map = ser.serialize_map(None)?;
567 map.serialize_entry("label", &self.label)?;
568 map.serialize_entry("type", &self.filter_type)?;
569 map.serialize_entry("default_value", &self.default_value)?;
570 map.serialize_entry("permission", &self.permission)?;
571 crate::keyed::serialize_theirs(&mut map, &self.options)?;
572 map.end()
573 }
574}
575
576impl crate::keyed::Keyed for ListColumn {
577 fn key(&self) -> &str {
578 &self.field
579 }
580 fn set_key(&mut self, key: String) {
581 self.field = key;
582 }
583}
584
585impl crate::keyed::Keyed for ListFilter {
586 fn key(&self) -> &str {
587 &self.field
588 }
589 fn set_key(&mut self, key: String) {
590 self.field = key;
591 }
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct FilterOption {
597 pub value: String,
598 pub label: Text,
600}
601
602impl FilterOption {
603 pub fn new(value: &str, label: impl Into<Text>) -> Self {
604 Self {
605 value: value.to_string(),
606 label: label.into(),
607 }
608 }
609}
610
611#[derive(Debug, Clone)]
613#[non_exhaustive]
614pub struct ListFilter {
615 pub field: String,
617 pub label: Text,
619 pub filter_type: String,
621 pub options: serde_json::Value,
624 pub default_value: Option<String>,
627 pub permission: Option<String>,
630}
631
632impl ListFilter {
633 pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
635 Self {
636 field: field.to_string(),
637 label: label.into(),
638 filter_type: filter_type.to_string(),
639 options: serde_json::Value::Null,
640 default_value: None,
641 permission: None,
642 }
643 }
644
645 pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
647 Self::of(field, label, "boolean")
648 }
649
650 pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
653 let mut filter = Self::of(field, label, "select");
654 filter.options = serde_json::json!({ "options": options });
655 filter
656 }
657
658 pub fn text(field: &str, label: impl Into<Text>) -> Self {
660 Self::of(field, label, "text")
661 }
662
663 pub fn number(field: &str, label: impl Into<Text>) -> Self {
665 Self::of(field, label, "number")
666 }
667
668 pub fn date(field: &str, label: impl Into<Text>) -> Self {
671 Self::of(field, label, "date")
672 }
673
674 pub(crate) fn choices(&self) -> Vec<FilterOption> {
676 self.options
677 .get("options")
678 .and_then(|v| serde_json::from_value(v.clone()).ok())
679 .unwrap_or_default()
680 }
681
682 pub fn default_value(mut self, value: impl Into<String>) -> Self {
684 self.default_value = Some(value.into());
685 self
686 }
687
688 pub fn require(mut self, permission: impl Into<String>) -> Self {
690 self.permission = Some(permission.into());
691 self
692 }
693
694 pub(crate) fn param(&self) -> String {
695 format!("f_{}", self.field)
696 }
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize)]
705#[serde(deny_unknown_fields)]
706pub struct ToolbarButton {
707 pub label: Text,
709 pub href: String,
712 pub icon: Option<String>,
714 pub permission: Option<String>,
717 pub primary: bool,
719}
720
721impl ToolbarButton {
722 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
723 Self {
724 label: label.into(),
725 href: href.into(),
726 icon: None,
727 permission: None,
728 primary: false,
729 }
730 }
731
732 pub fn icon(mut self, icon: &str) -> Self {
733 self.icon = Some(icon.to_string());
734 self
735 }
736
737 pub fn require(mut self, permission: impl Into<String>) -> Self {
739 self.permission = Some(permission.into());
740 self
741 }
742
743 pub fn primary(mut self) -> Self {
744 self.primary = true;
745 self
746 }
747}
748
749pub struct ToolbarView {
751 pub label: String,
752 pub href: String,
753 pub icon: String,
755 pub primary: bool,
756}
757
758fn toolbar_views(
762 config: &ListConfig,
763 admin_path: &str,
764 user: &laterite_auth::AuthenticatedUser,
765 shell: &crate::Shell,
766) -> Vec<ToolbarView> {
767 let mut out = Vec::new();
768 if config.creatable {
769 if let Some(base) = &config.edit_base {
770 out.push(ToolbarView {
771 label: shell.tt(&laterite_core::t!("New")),
772 href: format!("{base}/new"),
775 icon: String::new(),
776 primary: true,
777 });
778 }
779 }
780 for button in &config.toolbar {
781 if let Some(needed) = &button.permission {
782 if !user.permissions.allows(needed) {
783 continue;
784 }
785 }
786 let href = if button.href.starts_with('/') {
787 format!("{admin_path}{}", button.href)
788 } else {
789 button.href.clone()
790 };
791 out.push(ToolbarView {
792 label: shell.tt(&button.label),
793 href,
794 icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
795 primary: button.primary,
796 });
797 }
798 out
799}
800
801pub(crate) fn columns_preference_key(base_path: &str) -> String {
804 format!("list.columns.{base_path}")
805}
806
807pub(crate) async fn stored_columns(
812 state: &AdminState,
813 path: &str,
814 user: &laterite_auth::AuthenticatedUser,
815) -> Option<String> {
816 match laterite_auth::store::user_preference(
817 &state.db,
818 user.user.id,
819 &columns_preference_key(path),
820 )
821 .await
822 {
823 Ok(stored) => stored,
824 Err(e) => {
825 tracing::warn!(error = %e, "reading the column preference failed");
826 None
827 }
828 }
829}
830
831pub(crate) fn per_page_preference_key(base_path: &str) -> String {
833 format!("list.per_page:{base_path}")
834}
835
836pub(crate) async fn stored_per_page(
840 state: &AdminState,
841 config: &ListConfig,
842 path: &str,
843 user: &laterite_auth::AuthenticatedUser,
844) -> i64 {
845 let stored = match laterite_auth::store::user_preference(
846 &state.db,
847 user.user.id,
848 &per_page_preference_key(path),
849 )
850 .await
851 {
852 Ok(stored) => stored,
853 Err(e) => {
854 tracing::warn!(error = %e, "reading the page-size preference failed");
855 None
856 }
857 };
858 offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
859}
860
861pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
863 size.filter(|n| config.per_page_options.contains(n))
864 .unwrap_or(config.per_page)
865}
866
867pub(crate) fn allowed_columns<'a>(
870 config: &'a ListConfig,
871 user: &laterite_auth::AuthenticatedUser,
872) -> Vec<&'a ListColumn> {
873 config
874 .columns
875 .iter()
876 .filter(|c| {
877 c.permission
878 .as_deref()
879 .is_none_or(|p| user.permissions.allows(p))
880 })
881 .collect()
882}
883
884pub(crate) fn allowed_filters<'a>(
887 config: &'a ListConfig,
888 user: &laterite_auth::AuthenticatedUser,
889) -> Vec<&'a ListFilter> {
890 config
891 .filters
892 .iter()
893 .filter(|f| {
894 f.permission
895 .as_deref()
896 .is_none_or(|p| user.permissions.allows(p))
897 })
898 .collect()
899}
900
901pub(crate) fn narrowed(
904 config: &ListConfig,
905 stored: Option<&str>,
906 user: &laterite_auth::AuthenticatedUser,
907) -> ListConfig {
908 ListConfig {
909 columns: visible_columns(config, stored, user)
910 .into_iter()
911 .cloned()
912 .collect(),
913 filters: allowed_filters(config, user).into_iter().cloned().collect(),
914 ..config.clone()
915 }
916}
917
918pub(crate) fn visible_columns<'a>(
926 config: &'a ListConfig,
927 stored: Option<&str>,
928 user: &laterite_auth::AuthenticatedUser,
929) -> Vec<&'a ListColumn> {
930 let allowed = allowed_columns(config, user);
931 let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
932 let Some(stored) = stored else {
933 return by_default();
934 };
935 let chosen: Vec<&str> = stored
936 .split(',')
937 .map(str::trim)
938 .filter(|s| !s.is_empty())
939 .collect();
940 let kept: Vec<&ListColumn> = allowed
941 .iter()
942 .copied()
943 .filter(|c| chosen.iter().any(|n| *n == c.field))
944 .collect();
945 if kept.is_empty() {
946 return by_default();
947 }
948 kept
949}
950
951pub(crate) fn check(
955 config: &ListConfig,
956 column_types: &ColumnRegistry,
957 permission_codes: &std::collections::HashSet<String>,
958) -> Result<(), String> {
959 for col in &config.columns {
960 if column_types.get(&col.column_type).is_none() {
961 return Err(format!(
962 "column `{}` uses unregistered type `{}`",
963 col.field, col.column_type
964 ));
965 }
966 if let Some(p) = &col.permission {
969 if !permission_codes.contains(p) {
970 return Err(format!(
971 "column `{}` requires unregistered permission `{p}`",
972 col.field
973 ));
974 }
975 }
976 }
977 for filter in &config.filters {
978 if let Some(p) = &filter.permission {
979 if !permission_codes.contains(p) {
980 return Err(format!(
981 "filter `{}` requires unregistered permission `{p}`",
982 filter.field
983 ));
984 }
985 }
986 }
987 Ok(())
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize)]
992#[serde(deny_unknown_fields, default)]
993pub struct SearchConfig {
994 pub enabled: bool,
996 pub prompt: Option<Text>,
998 pub on_enter: bool,
1000}
1001
1002impl Default for SearchConfig {
1003 fn default() -> Self {
1004 Self {
1005 enabled: true,
1006 prompt: None,
1007 on_enter: false,
1008 }
1009 }
1010}
1011
1012impl SearchConfig {
1013 pub fn off() -> Self {
1015 Self {
1016 enabled: false,
1017 ..Self::default()
1018 }
1019 }
1020
1021 pub fn prompt(mut self, text: impl Into<Text>) -> Self {
1022 self.prompt = Some(text.into());
1023 self
1024 }
1025
1026 pub fn on_enter(mut self) -> Self {
1027 self.on_enter = true;
1028 self
1029 }
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1039#[serde(deny_unknown_fields, default)]
1040#[non_exhaustive]
1041pub struct ListConfig {
1042 pub entity: String,
1043 pub title: Text,
1045 #[serde(
1046 deserialize_with = "crate::keyed::deserialize",
1047 serialize_with = "crate::keyed::serialize"
1048 )]
1049 pub columns: Vec<ListColumn>,
1050 pub order_by: String,
1051 pub order_dir: SortDir,
1052 pub per_page: i64,
1053 pub id_field: String,
1054 pub edit_base: Option<String>,
1056 pub creatable: bool,
1059 #[serde(
1061 deserialize_with = "crate::keyed::deserialize",
1062 serialize_with = "crate::keyed::serialize"
1063 )]
1064 pub filters: Vec<ListFilter>,
1065 pub deletable: bool,
1069 pub exportable: bool,
1072 pub toolbar: Vec<ToolbarButton>,
1074 pub search: SearchConfig,
1075 pub per_page_options: Vec<i64>,
1078 pub no_records_message: Option<Text>,
1080}
1081
1082impl ListConfig {
1083 pub fn new(
1088 entity: impl Into<String>,
1089 title: impl Into<Text>,
1090 columns: Vec<ListColumn>,
1091 ) -> Self {
1092 Self {
1093 entity: entity.into(),
1094 title: title.into(),
1095 columns,
1096 ..Self::default()
1097 }
1098 }
1099
1100 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
1102 self.order_by = column.into();
1103 self.order_dir = dir;
1104 self
1105 }
1106
1107 pub fn per_page(mut self, rows: i64) -> Self {
1108 self.per_page = rows;
1109 self
1110 }
1111
1112 pub fn id_field(mut self, column: impl Into<String>) -> Self {
1114 self.id_field = column.into();
1115 self
1116 }
1117
1118 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
1120 self.edit_base = Some(base.into());
1121 self
1122 }
1123
1124 pub fn creatable(mut self) -> Self {
1128 self.creatable = true;
1129 self
1130 }
1131
1132 pub fn exportable(mut self) -> Self {
1136 self.exportable = true;
1137 self
1138 }
1139
1140 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
1141 self.filters = filters;
1142 self
1143 }
1144
1145 pub fn deletable(mut self) -> Self {
1148 self.deletable = true;
1149 self
1150 }
1151
1152 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
1153 self.toolbar = buttons;
1154 self
1155 }
1156
1157 pub fn search(mut self, search: SearchConfig) -> Self {
1158 self.search = search;
1159 self
1160 }
1161
1162 pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
1163 self.per_page_options = sizes;
1164 self
1165 }
1166
1167 pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
1168 self.no_records_message = Some(text.into());
1169 self
1170 }
1171}
1172
1173impl Default for ListConfig {
1174 fn default() -> Self {
1181 Self {
1182 entity: String::new(),
1183 title: Text::new(""),
1184 columns: Vec::new(),
1185 order_by: "id".to_string(),
1186 order_dir: SortDir::Desc,
1187 per_page: 25,
1188 id_field: "id".to_string(),
1189 edit_base: None,
1190 creatable: false,
1191 filters: Vec::new(),
1192 deletable: false,
1193 exportable: false,
1194 toolbar: Vec::new(),
1195 search: SearchConfig::default(),
1196 per_page_options: Vec::new(),
1197 no_records_message: None,
1198 }
1199 }
1200}
1201
1202#[derive(Deserialize)]
1204pub struct ListParams {
1205 page: Option<i64>,
1206 sort: Option<String>,
1207 dir: Option<String>,
1208 q: Option<String>,
1209}
1210
1211pub(crate) fn resolve_sort(
1216 config: &ListConfig,
1217 sort: Option<&str>,
1218 dir: Option<&str>,
1219) -> (String, SortDir) {
1220 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1221 Some(field) => field.to_string(),
1222 None => config.order_by.clone(),
1223 };
1224 let dir = match dir {
1225 Some("asc") => SortDir::Asc,
1226 Some("desc") => SortDir::Desc,
1227 _ if by == config.order_by => config.order_dir,
1230 _ => SortDir::Asc,
1231 };
1232 (by, dir)
1233}
1234
1235pub struct RowView {
1237 pub id: String,
1238 pub cells: Vec<String>,
1239}
1240
1241pub struct CellView {
1243 pub html: String,
1244 pub class: String,
1245}
1246
1247pub struct RenderedRow {
1249 pub id: String,
1250 pub cells: Vec<CellView>,
1251}
1252
1253pub struct ListPage {
1255 pub rows: Vec<RowView>,
1256 pub total: i64,
1257}
1258
1259pub(crate) struct ListQuery<'a> {
1262 pub offset: i64,
1263 pub order_by: &'a str,
1264 pub order_dir: SortDir,
1265 pub q: &'a str,
1266 pub filters: &'a [ActiveFilter<'a>],
1267 pub per_page: Option<i64>,
1270}
1271
1272pub(crate) struct ActiveFilter<'a> {
1276 filter: &'a ListFilter,
1277 raw: String,
1279 matcher: FilterMatch,
1282}
1283
1284pub(crate) enum FilterMatch {
1286 Equals(sea_query::Value),
1288 Contains(String),
1290 StartsWith(String),
1292}
1293
1294fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1300 match filter.filter_type.as_str() {
1301 "boolean" => match raw {
1304 "1" => Some(FilterMatch::Equals(true.into())),
1305 "0" => Some(FilterMatch::Equals(false.into())),
1306 _ => None,
1307 },
1308 "select" => filter
1309 .choices()
1310 .iter()
1311 .any(|o| o.value == raw)
1312 .then(|| FilterMatch::Equals(raw.into())),
1313 "text" => Some(FilterMatch::Contains(raw.to_string())),
1314 "number" => raw
1315 .parse::<i64>()
1316 .ok()
1317 .map(|n| FilterMatch::Equals(n.into())),
1318 "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1322 _ => None,
1323 }
1324}
1325
1326fn is_iso_date(raw: &str) -> bool {
1328 raw.len() == 10
1329 && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1330 if i == 4 || i == 7 {
1331 *b == b'-'
1332 } else {
1333 b.is_ascii_digit()
1334 }
1335 })
1336}
1337
1338pub(crate) fn resolve_filters<'a>(
1341 config: &'a ListConfig,
1342 params: &HashMap<String, String>,
1343) -> Vec<ActiveFilter<'a>> {
1344 let mut out = Vec::new();
1345 for filter in &config.filters {
1346 let submitted = params.get(&filter.param()).map(|v| v.trim());
1347 let raw = match submitted {
1348 Some(value) => value,
1350 None => filter.default_value.as_deref().unwrap_or_default(),
1352 };
1353 if raw.is_empty() {
1354 continue;
1355 }
1356 let Some(matcher) = filter_match(filter, raw) else {
1357 continue;
1358 };
1359 out.push(ActiveFilter {
1360 filter,
1361 raw: raw.to_string(),
1362 matcher,
1363 });
1364 }
1365 out
1366}
1367
1368fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1370 if active.is_empty() {
1371 return None;
1372 }
1373 let caps = laterite_core::capabilities::CapabilitySet::default();
1374 let mut all = sea_query::Condition::all();
1375 for f in active {
1376 let col = Expr::col(Alias::new(&f.filter.field));
1377 all = match &f.matcher {
1378 FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1379 FilterMatch::Contains(term) => {
1381 match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1382 Some(cond) => all.add(cond),
1383 None => all,
1384 }
1385 }
1386 FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1387 };
1388 }
1389 Some(all)
1390}
1391
1392fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1396 let caps = laterite_core::capabilities::CapabilitySet::default();
1400 let profile = SearchProfile::new();
1401 let mut any = sea_query::Condition::any();
1402 let mut matched = false;
1403 for column in config.columns.iter().filter(|c| c.is_searchable()) {
1404 if let Some(cond) = profile.condition(&caps, &column.field, q) {
1405 any = any.add(cond);
1406 matched = true;
1407 }
1408 }
1409 matched.then_some(any)
1410}
1411
1412pub(crate) async fn query(
1417 db: &Db,
1418 config: &ListConfig,
1419 req: &ListQuery<'_>,
1420) -> anyhow::Result<ListPage> {
1421 if !valid_ident(&config.entity)
1422 || !valid_ident(&config.order_by)
1423 || !valid_ident(req.order_by)
1424 || !valid_ident(&config.id_field)
1425 || !config.columns.iter().all(|c| valid_ident(&c.field))
1426 || !config.filters.iter().all(|f| valid_ident(&f.field))
1427 {
1428 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1429 }
1430
1431 let dir = match req.order_dir {
1432 SortDir::Asc => Order::Asc,
1433 SortDir::Desc => Order::Desc,
1434 };
1435 let (sql, values) = {
1439 let mut select = Query::select();
1440 for column in &config.columns {
1441 select.expr_as(
1442 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1443 Alias::new(&column.field),
1444 );
1445 }
1446 select
1447 .expr_as(
1448 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
1449 Alias::new(ID_ALIAS),
1450 )
1451 .from(Alias::new(&config.entity))
1452 .order_by(Alias::new(req.order_by), dir)
1453 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1454 .offset(req.offset.max(0) as u64);
1455 if let Some(cond) = search_condition(config, req.q) {
1456 select.cond_where(cond);
1457 }
1458 if let Some(cond) = filter_condition(req.filters) {
1459 select.cond_where(cond);
1460 }
1461 build(db.backend, select)
1462 };
1463 let raw = bind_values(sqlx::query(&sql), values)
1464 .fetch_all(&db.pool)
1465 .await?;
1466
1467 let (csql, cvalues) = {
1468 let mut count = Query::select();
1469 count
1470 .expr(Expr::col(Alias::new(&config.id_field)).count())
1471 .from(Alias::new(&config.entity));
1472 if let Some(cond) = search_condition(config, req.q) {
1474 count.cond_where(cond);
1475 }
1476 if let Some(cond) = filter_condition(req.filters) {
1477 count.cond_where(cond);
1478 }
1479 build(db.backend, count)
1480 };
1481 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1482 .fetch_one(&db.pool)
1483 .await?
1484 .0;
1485
1486 let rows = raw
1487 .iter()
1488 .map(|row| RowView {
1489 id: get_text(row, ID_ALIAS),
1490 cells: config
1491 .columns
1492 .iter()
1493 .map(|c| get_text(row, &c.field))
1494 .collect(),
1495 })
1496 .collect();
1497 Ok(ListPage { rows, total })
1498}
1499
1500fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1503 row.get_text_opt(column).ok().flatten().unwrap_or_default()
1506}
1507
1508#[allow(clippy::too_many_arguments)]
1510pub(crate) async fn handle(
1511 state: &AdminState,
1512 config: &ListConfig,
1513 path: &str,
1514 params: ListParams,
1515 raw: &HashMap<String, String>,
1516 user: &laterite_auth::AuthenticatedUser,
1517 shell: crate::Shell,
1518 headers: &axum::http::HeaderMap,
1519) -> Response {
1520 let stored = stored_columns(state, path, user).await;
1524 let declared = config;
1525 let narrowed = narrowed(config, stored.as_deref(), user);
1526 let config = &narrowed;
1527
1528 let page = params.page.unwrap_or(1).max(1);
1529 let per_page = stored_per_page(state, config, path, user).await;
1530 let offset = (page - 1) * per_page;
1531 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1532 let q = params.q.unwrap_or_default();
1533 let active = resolve_filters(config, raw);
1534 let req = ListQuery {
1535 offset,
1536 order_by: &order_by,
1537 order_dir,
1538 q: q.trim(),
1539 filters: &active,
1540 per_page: Some(per_page),
1541 };
1542 let mut carry = String::new();
1545 if !q.trim().is_empty() {
1546 carry.push_str(&format!("&q={}", urlencode(q.trim())));
1547 }
1548 for f in &active {
1549 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
1550 }
1551 match query(&state.db, config, &req).await {
1552 Ok(result) => {
1553 let total_pages = ((result.total + per_page - 1) / per_page).max(1);
1554 let date_loc = date_locale(shell.locale());
1555 let rows: Vec<RenderedRow> = result
1556 .rows
1557 .into_iter()
1558 .map(|row| RenderedRow {
1559 id: row.id,
1560 cells: row
1561 .cells
1562 .iter()
1563 .zip(&config.columns)
1564 .map(|(raw, col)| {
1565 let cx = CellCx {
1566 value: raw,
1567 tz: shell.tz,
1568 locale: date_loc,
1569 };
1570 let scope = OverrideScope {
1571 surface: Surface::Column,
1572 view_key: &col.column_type,
1573 resource: Some(&config.entity),
1574 field: Some(&col.field),
1575 };
1576 let html = match state.column_types.get(&col.column_type) {
1577 Some(ct) => {
1578 render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
1579 .into_string()
1580 }
1581 None => String::new(),
1583 };
1584 CellView {
1585 html,
1586 class: col.align_class().to_string(),
1587 }
1588 })
1589 .collect(),
1590 })
1591 .collect();
1592 let keys: Vec<&str> = config
1593 .columns
1594 .iter()
1595 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1596 .flatten()
1597 .collect();
1598 let mut shell = shell;
1599 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1600 let shown = rows.len() as i64;
1601 let title = shell.tt(&config.title);
1603 let active_dir = match order_dir {
1604 SortDir::Asc => "asc",
1605 SortDir::Desc => "desc",
1606 };
1607 let columns: Vec<ColumnHead> = config
1608 .columns
1609 .iter()
1610 .map(|c| {
1611 let active = c.field == order_by;
1612 ColumnHead {
1613 label: shell.tt(&c.label),
1614 field: c.field.clone(),
1615 next_dir: if active && active_dir == "asc" {
1618 "desc".to_string()
1619 } else {
1620 "asc".to_string()
1621 },
1622 active: if active {
1623 active_dir.to_string()
1624 } else {
1625 String::new()
1626 },
1627 sortable: c.sortable,
1628 width: c.width.clone(),
1629 align_class: c.align_class().to_string(),
1630 }
1631 })
1632 .collect();
1633 let shell_for_toolbar = shell.clone();
1634 let filter_views = filter_views(config, &active, &shell);
1635 let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
1636 .into_iter()
1637 .map(|c| ColumnChoice {
1638 field: c.field.clone(),
1639 label: shell.tt(&c.label),
1640 shown: config.columns.iter().any(|v| v.field == c.field),
1641 })
1642 .collect();
1643 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1645 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1646 let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
1648 let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
1649 let page_view = ListTemplate {
1650 shell,
1651 title,
1652 columns,
1653 rows,
1654 shown,
1655 page,
1656 total: result.total,
1657 total_pages,
1658 edit_base: config.edit_base.clone(),
1659 sort: order_by,
1660 dir: active_dir.to_string(),
1661 q: q.trim().to_string(),
1662 searchable: config.search.enabled
1663 && config.columns.iter().any(|c| c.is_searchable()),
1664 search_prompt,
1665 search_on_enter: config.search.on_enter,
1666 no_records_message,
1667 per_page,
1668 per_page_options: config.per_page_options.clone(),
1669 pickers,
1670 toolbar,
1671 export_query,
1672 path: path.to_string(),
1673 filters: filter_views,
1674 deletable: config.deletable,
1675 exportable: config.exportable,
1676 filtered: !active.is_empty() || !q.trim().is_empty(),
1677 carry: carry.clone(),
1678 };
1679 if crate::form::is_htmx(headers) {
1683 render(ListFragment {
1684 shell: page_view.shell,
1685 columns: page_view.columns,
1686 rows: page_view.rows,
1687 shown: page_view.shown,
1688 page: page_view.page,
1689 total: page_view.total,
1690 total_pages: page_view.total_pages,
1691 edit_base: page_view.edit_base,
1692 sort: page_view.sort,
1693 dir: page_view.dir,
1694 carry: page_view.carry,
1695 filtered: page_view.filtered,
1696 deletable: page_view.deletable,
1697 no_records_message: page_view.no_records_message,
1698 })
1699 } else {
1700 render(page_view)
1701 }
1702 }
1703 Err(_) => render_error(),
1704 }
1705}
1706
1707pub(crate) async fn set_columns(
1714 state: &AdminState,
1715 config: &ListConfig,
1716 path: &str,
1717 user: &laterite_auth::AuthenticatedUser,
1718 session: &crate::session::SessionHandle,
1719 headers: &axum::http::HeaderMap,
1720 pairs: &[(String, String)],
1721) -> Response {
1722 let allowed = allowed_columns(config, user);
1723 let chosen: Vec<&str> = pairs
1724 .iter()
1725 .filter(|(k, _)| k == "column")
1726 .map(|(_, v)| v.trim())
1727 .filter(|v| allowed.iter().any(|c| c.field == *v))
1728 .collect();
1729 let mut by_default: Vec<&str> = allowed
1733 .iter()
1734 .filter(|c| !c.invisible)
1735 .map(|c| c.field.as_str())
1736 .collect();
1737 let mut as_chosen = chosen.clone();
1738 by_default.sort_unstable();
1739 as_chosen.sort_unstable();
1740 as_chosen.dedup();
1741
1742 let size_key = per_page_preference_key(path);
1745 let size = pairs
1746 .iter()
1747 .find(|(k, _)| k == "per_page")
1748 .and_then(|(_, v)| v.trim().parse::<i64>().ok())
1749 .filter(|n| config.per_page_options.contains(n));
1750 let size_result = match size {
1751 Some(n) if n != config.per_page => {
1752 laterite_auth::store::set_user_preference(
1753 &state.db,
1754 user.user.id,
1755 &size_key,
1756 &n.to_string(),
1757 )
1758 .await
1759 }
1760 _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
1761 };
1762
1763 let key = columns_preference_key(path);
1764 let result = if chosen.is_empty() || as_chosen == by_default {
1765 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1766 } else {
1767 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1768 .await
1769 };
1770 match result.and(size_result) {
1771 Ok(()) => session.push_flash(
1772 crate::session::FlashLevel::Success,
1773 laterite_core::t!("List setup saved."),
1774 ),
1775 Err(e) => {
1776 tracing::error!(error = %e, "storing the list setup failed");
1777 session.push_flash(
1778 crate::session::FlashLevel::Error,
1779 laterite_core::t!("Could not save your list setup."),
1780 );
1781 }
1782 }
1783 let back = format!("{}{}", state.admin_path, path);
1784 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1785}
1786
1787pub struct ColumnChoice {
1789 pub field: String,
1790 pub label: String,
1791 pub shown: bool,
1792}
1793
1794pub struct ColumnHead {
1797 pub label: String,
1798 pub field: String,
1799 pub next_dir: String,
1800 pub active: String,
1801 pub sortable: bool,
1802 pub width: Option<String>,
1803 pub align_class: String,
1804}
1805
1806#[derive(Template)]
1807#[template(path = "list.html")]
1808struct ListTemplate {
1809 shell: crate::Shell,
1810 title: String,
1811 columns: Vec<ColumnHead>,
1812 rows: Vec<RenderedRow>,
1813 shown: i64,
1815 page: i64,
1816 total: i64,
1817 total_pages: i64,
1818 edit_base: Option<String>,
1819 sort: String,
1821 dir: String,
1822 q: String,
1824 searchable: bool,
1826 search_prompt: Option<String>,
1828 search_on_enter: bool,
1829 no_records_message: Option<String>,
1831 per_page: i64,
1834 per_page_options: Vec<i64>,
1835 deletable: bool,
1837 exportable: bool,
1840 pickers: Vec<ColumnChoice>,
1842 toolbar: Vec<ToolbarView>,
1844 export_query: String,
1847 path: String,
1849 filters: Vec<FilterView>,
1851 filtered: bool,
1853 carry: String,
1855}
1856
1857pub struct FilterView {
1859 pub param: String,
1860 pub label: String,
1861 pub input_type: String,
1863 pub options: Vec<FilterOptionView>,
1864 pub value: String,
1866}
1867
1868pub struct FilterOptionView {
1869 pub value: String,
1870 pub label: String,
1871}
1872
1873fn filter_views(
1875 config: &ListConfig,
1876 active: &[ActiveFilter<'_>],
1877 shell: &crate::Shell,
1878) -> Vec<FilterView> {
1879 config
1880 .filters
1881 .iter()
1882 .map(|f| {
1883 let value = active
1884 .iter()
1885 .find(|a| a.filter.field == f.field)
1886 .map(|a| a.raw.clone())
1887 .unwrap_or_default();
1888 let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
1891 "boolean" => vec![
1892 FilterOptionView {
1893 value: "1".to_string(),
1894 label: shell.tt(&laterite_core::t!("Yes")),
1895 },
1896 FilterOptionView {
1897 value: "0".to_string(),
1898 label: shell.tt(&laterite_core::t!("No")),
1899 },
1900 ],
1901 "select" => f
1902 .choices()
1903 .iter()
1904 .map(|o| FilterOptionView {
1905 value: o.value.clone(),
1906 label: shell.tt(&o.label),
1907 })
1908 .collect(),
1909 _ => Vec::new(),
1910 };
1911 let input_type = match f.filter_type.as_str() {
1912 "number" => "number",
1913 "date" => "date",
1914 _ => "text",
1915 };
1916 FilterView {
1917 param: f.param(),
1918 label: shell.tt(&f.label),
1919 input_type: input_type.to_string(),
1920 options,
1921 value,
1922 }
1923 })
1924 .collect()
1925}
1926
1927fn urlencode(value: &str) -> String {
1929 let mut out = String::with_capacity(value.len());
1930 for byte in value.as_bytes() {
1931 match byte {
1932 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1933 out.push(*byte as char)
1934 }
1935 b' ' => out.push('+'),
1936 _ => out.push_str(&format!("%{byte:02X}")),
1937 }
1938 }
1939 out
1940}
1941
1942#[derive(Template)]
1944#[template(path = "_list_table.html")]
1945struct ListFragment {
1946 shell: crate::Shell,
1947 columns: Vec<ColumnHead>,
1948 rows: Vec<RenderedRow>,
1949 shown: i64,
1950 page: i64,
1951 total: i64,
1952 total_pages: i64,
1953 edit_base: Option<String>,
1954 sort: String,
1955 dir: String,
1956 carry: String,
1957 filtered: bool,
1961 deletable: bool,
1962 no_records_message: Option<String>,
1963}
1964
1965#[cfg(test)]
1966mod tests {
1967 use super::*;
1968
1969 fn config() -> ListConfig {
1970 ListConfig {
1971 entity: "backend_users".to_string(),
1972 title: "Users".into(),
1973 columns: vec![
1974 ListColumn::new("username", "Username"),
1975 ListColumn::new("is_superuser", "Superuser").yes_no(),
1976 ],
1977 order_by: "created_at".to_string(),
1978 order_dir: SortDir::Desc,
1979 per_page: 25,
1980 id_field: "id".to_string(),
1981 edit_base: None,
1982 creatable: false,
1983 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1984 deletable: true,
1985 ..Default::default()
1986 }
1987 }
1988
1989 #[test]
1990 fn column_types_format_their_cell_display() {
1991 let ist: Tz = "Asia/Kolkata".parse().unwrap();
1992 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1993 ct.view_model(&CellCx {
1994 value,
1995 tz,
1996 locale: chrono::Locale::en_US,
1997 })
1998 .display
1999 };
2000 assert_eq!(
2002 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
2003 "13 Aug 2026, 15:30"
2004 );
2005 assert_eq!(
2006 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
2007 "13 Aug 2026"
2008 );
2009 assert_eq!(
2010 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
2011 "15:30"
2012 );
2013 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
2014 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
2015 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
2016 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
2018 }
2019
2020 #[test]
2021 fn sort_falls_back_to_the_configured_order() {
2022 let c = config();
2023 assert_eq!(
2024 resolve_sort(&c, None, None),
2025 ("created_at".to_string(), SortDir::Desc)
2026 );
2027 }
2028
2029 #[test]
2030 fn a_declared_column_can_be_sorted_either_way() {
2031 let c = config();
2032 assert_eq!(
2033 resolve_sort(&c, Some("username"), Some("asc")),
2034 ("username".to_string(), SortDir::Asc)
2035 );
2036 assert_eq!(
2037 resolve_sort(&c, Some("username"), Some("desc")),
2038 ("username".to_string(), SortDir::Desc)
2039 );
2040 }
2041
2042 #[test]
2045 fn an_undeclared_sort_column_is_ignored() {
2046 let c = config();
2047 for crafted in [
2048 "password_hash",
2049 "id) --",
2050 "username; drop table backend_users",
2051 "",
2052 ] {
2053 assert_eq!(
2054 resolve_sort(&c, Some(crafted), Some("asc")).0,
2055 "created_at",
2056 "{crafted} must not reach the query"
2057 );
2058 }
2059 }
2060
2061 #[test]
2062 fn an_unknown_direction_falls_back() {
2063 let c = config();
2064 assert_eq!(
2066 resolve_sort(&c, Some("username"), Some("sideways")).1,
2067 SortDir::Asc
2068 );
2069 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
2071 }
2072
2073 #[test]
2076 fn a_contributed_column_type_renders_its_cell() {
2077 struct Rating;
2078 impl ColumnType for Rating {
2079 fn view_key(&self) -> &'static str {
2080 "acme.rating"
2081 }
2082 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
2083 let stars = cx.value.parse::<usize>().unwrap_or(0);
2084 text_cell("acme.rating", cx.value, "*".repeat(stars))
2085 }
2086 fn render_default(&self, vm: &CellVm) -> Markup {
2087 render_text(vm)
2088 }
2089 }
2090
2091 let reg = ColumnTypeReg::new(Arc::new(Rating));
2092 assert_eq!(reg.name(), "acme.rating", "the type names itself");
2093
2094 let mut registry = builtin_column_registry();
2095 registry.insert(reg.name().to_string(), reg.column_type);
2096 let rendered = render_cell(
2097 registry.get("acme.rating").unwrap().as_ref(),
2098 &crate::field::NoOverrides,
2099 &OverrideScope {
2100 surface: Surface::Column,
2101 view_key: "acme.rating",
2102 resource: Some("products"),
2103 field: Some("stars"),
2104 },
2105 &CellCx {
2106 value: "3",
2107 tz: Tz::UTC,
2108 locale: chrono::Locale::en_US,
2109 },
2110 );
2111 assert!(rendered.into_string().contains("***"));
2112 }
2113
2114 #[test]
2117 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
2118 struct Impostor;
2119 impl ColumnType for Impostor {
2120 fn view_key(&self) -> &'static str {
2121 "text"
2122 }
2123 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
2124 text_cell("text", cx.value, String::new())
2125 }
2126 fn render_default(&self, vm: &CellVm) -> Markup {
2127 render_text(vm)
2128 }
2129 }
2130 let reg = ColumnTypeReg::new(Arc::new(Impostor));
2131 assert_eq!(reg.name(), "text");
2132 assert!(builtin_column_registry().contains_key(reg.name()));
2134 }
2135
2136 #[test]
2137 fn status_pill_renders_a_slugged_markup_span() {
2138 let vm = StatusPillColumn.view_model(&CellCx {
2139 value: "In Progress",
2140 tz: Tz::UTC,
2141 locale: chrono::Locale::en_US,
2142 });
2143 let html = StatusPillColumn.render_default(&vm).into_string();
2144 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
2145 assert!(html.contains(">In Progress<"));
2146 }
2147
2148 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
2151 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
2152 }
2153
2154 #[tokio::test]
2155 async fn query_returns_display_rows() {
2156 let (db, _guard) = test_db().await;
2157 let hash = laterite_auth::password::hash_password("pw").unwrap();
2158 laterite_auth::store::create_user(
2159 &db,
2160 "root",
2161 "root@example.test",
2162 "Ada",
2163 None,
2164 &hash,
2165 true,
2166 )
2167 .await
2168 .unwrap();
2169
2170 let c = config();
2171 let result = query(&db, &c, &plain(&c)).await.unwrap();
2172 assert_eq!(result.total, 1);
2173 assert_eq!(result.rows.len(), 1);
2174 assert_eq!(result.rows[0].cells[0], "root");
2175 assert_eq!(result.rows[0].cells[1], "1");
2178 assert!(!result.rows[0].id.is_empty());
2179 }
2180
2181 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
2182 get_with(params, HashMap::new(), headers).await
2183 }
2184
2185 async fn get_with(
2186 params: ListParams,
2187 raw: HashMap<String, String>,
2188 headers: axum::http::HeaderMap,
2189 ) -> String {
2190 let (db, _guard) = test_db().await;
2191 let state = AdminState::new(
2192 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2193 db,
2194 );
2195 let resp = handle(
2196 &state,
2197 &config(),
2198 "/admin/users",
2199 params,
2200 &raw,
2201 &crate::audit::test_actor(),
2202 crate::Shell::test(),
2203 &headers,
2204 )
2205 .await;
2206 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2207 .await
2208 .unwrap();
2209 String::from_utf8(bytes.to_vec()).unwrap()
2210 }
2211
2212 #[test]
2213 fn a_typed_filter_accepts_only_its_own_shape() {
2214 let mut c = config();
2215 c.filters = vec![
2216 ListFilter::number("views", "Views"),
2217 ListFilter::date("created_at", "Created"),
2218 ListFilter::text("username", "Username"),
2219 ];
2220 let accepted = |param: &str, value: &str| {
2221 !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
2222 };
2223 assert!(accepted("f_views", "42"));
2224 assert!(
2225 !accepted("f_views", "4.2"),
2226 "a number filter takes whole numbers"
2227 );
2228 assert!(!accepted("f_views", "; drop table"), "and nothing else");
2229
2230 assert!(accepted("f_created_at", "2026-09-21"));
2231 assert!(
2232 !accepted("f_created_at", "2026-9-21"),
2233 "a date is ISO or nothing"
2234 );
2235 assert!(
2236 !accepted("f_created_at", "2026-09-21%"),
2237 "so a pattern cannot slip in"
2238 );
2239
2240 assert!(accepted("f_username", "ada"));
2241 }
2242
2243 #[test]
2244 fn a_filter_default_applies_only_until_the_operator_decides() {
2245 let mut c = config();
2246 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
2247 assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
2249 assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
2251 let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
2253 assert_eq!(active.len(), 1);
2254 }
2255
2256 #[test]
2257 fn a_gated_filter_is_narrowed_away_with_its_default() {
2258 let mut c = config();
2259 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
2260 .default_value("1")
2261 .require("acme.secret")];
2262 let plain = laterite_auth::AuthenticatedUser {
2263 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2264 ..crate::audit::test_actor()
2265 };
2266 assert!(allowed_filters(&c, &plain).is_empty());
2267 let narrowed = narrowed(&c, None, &plain);
2269 assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
2270 assert_eq!(allowed_filters(&c, &root()).len(), 1);
2271 }
2272
2273 #[test]
2274 fn a_stored_page_size_counts_only_while_it_is_offered() {
2275 let mut cfg = config();
2276 assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
2277 cfg.per_page_options = vec![25, 50, 100];
2278 assert_eq!(offered_per_page(&cfg, Some(50)), 50);
2279 assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
2280 assert_eq!(offered_per_page(&cfg, None), 25);
2281 }
2282
2283 fn params(sort: Option<&str>) -> ListParams {
2284 ListParams {
2285 page: None,
2286 sort: sort.map(|s| s.to_string()),
2287 dir: Some("asc".to_string()),
2288 q: None,
2289 }
2290 }
2291
2292 fn plain(config: &ListConfig) -> ListQuery<'_> {
2294 ListQuery {
2295 offset: 0,
2296 order_by: &config.order_by,
2297 order_dir: config.order_dir,
2298 q: "",
2299 filters: &[],
2300 per_page: None,
2301 }
2302 }
2303
2304 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
2305 ListQuery {
2306 offset: 0,
2307 order_by: &config.order_by,
2308 order_dir: config.order_dir,
2309 q,
2310 filters: &[],
2311 per_page: None,
2312 }
2313 }
2314
2315 fn amps(html: &str) -> String {
2318 html.replace("&", "&").replace("&", "&")
2319 }
2320
2321 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2323 pairs
2324 .iter()
2325 .map(|(k, v)| (k.to_string(), v.to_string()))
2326 .collect()
2327 }
2328
2329 #[tokio::test]
2330 async fn search_filters_rows_and_the_total() {
2331 let (db, _guard) = test_db().await;
2332 for name in ["ada", "brendan", "clara"] {
2333 let hash = laterite_auth::password::hash_password("x").unwrap();
2334 laterite_auth::store::create_user(
2335 &db,
2336 name,
2337 &format!("{name}@example.test"),
2338 name,
2339 None,
2340 &hash,
2341 false,
2342 )
2343 .await
2344 .unwrap();
2345 }
2346 let c = config();
2347
2348 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
2349 assert_eq!(hit.rows.len(), 1);
2350 assert_eq!(hit.rows[0].cells[0], "brendan");
2351 assert_eq!(hit.total, 1);
2353
2354 assert_eq!(
2356 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
2357 1
2358 );
2359
2360 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
2362 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
2363
2364 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
2366 }
2367
2368 #[tokio::test]
2371 async fn search_skips_a_non_text_column() {
2372 let (db, _guard) = test_db().await;
2373 let hash = laterite_auth::password::hash_password("x").unwrap();
2374 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
2375 .await
2376 .unwrap();
2377 let c = config();
2378 assert!(c.columns[0].is_searchable(), "username is text");
2379 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
2380 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
2382 }
2383
2384 #[test]
2385 fn a_column_can_override_its_searchability() {
2386 assert!(ListColumn::new("code", "Code").is_searchable());
2387 assert!(!ListColumn::new("code", "Code")
2388 .searchable(false)
2389 .is_searchable());
2390 assert!(ListColumn::new("at", "At")
2391 .datetime()
2392 .searchable(true)
2393 .is_searchable());
2394 }
2395
2396 async fn seed_users(db: &Db) {
2397 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
2398 let hash = laterite_auth::password::hash_password("x").unwrap();
2399 laterite_auth::store::create_user(
2400 db,
2401 name,
2402 &format!("{name}@example.test"),
2403 name,
2404 None,
2405 &hash,
2406 su,
2407 )
2408 .await
2409 .unwrap();
2410 }
2411 }
2412
2413 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
2414 let active = resolve_filters(c, params);
2415 let req = ListQuery {
2416 offset: 0,
2417 order_by: &c.order_by,
2418 order_dir: c.order_dir,
2419 q: "",
2420 filters: &active,
2421 per_page: None,
2422 };
2423 query(db, c, &req).await.unwrap().total
2424 }
2425
2426 #[tokio::test]
2427 async fn a_filter_narrows_the_rows_and_the_total() {
2428 let (db, _guard) = test_db().await;
2429 seed_users(&db).await;
2430 let c = config();
2431
2432 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
2433 assert_eq!(
2434 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
2435 1
2436 );
2437 assert_eq!(
2438 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
2439 2
2440 );
2441 }
2442
2443 #[test]
2446 fn an_undeclared_filter_or_value_is_ignored() {
2447 let c = config();
2448 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
2450 for bad in ["yes", "true", "1 or 1=1", "", " "] {
2452 assert!(
2453 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
2454 "{bad} must not reach the query"
2455 );
2456 }
2457
2458 let with_select = ListConfig {
2459 filters: vec![ListFilter::select(
2460 "status",
2461 "Status",
2462 vec![FilterOption::new("live", "Live")],
2463 )],
2464 ..config()
2465 };
2466 assert_eq!(
2467 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
2468 1
2469 );
2470 assert!(
2471 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
2472 "an option the descriptor never offered is dropped"
2473 );
2474 }
2475
2476 #[tokio::test]
2477 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
2478 let mut p = params(Some("username"));
2479 p.q = None;
2480 let html = get_with(
2481 p,
2482 filter_params(&[("f_is_superuser", "1")]),
2483 axum::http::HeaderMap::new(),
2484 )
2485 .await;
2486 let links = amps(&html);
2487 assert!(
2488 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
2489 "a sort link carries the filter"
2490 );
2491 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
2493 assert!(
2494 html.contains(r#"<option value="1" selected>"#),
2495 "the control shows the active value"
2496 );
2497 assert!(html.contains("Clear"), "and a way back to the full list");
2498 assert!(
2499 html.contains(r#"data-filtered="true""#),
2500 "the region says the list is narrowed, which is what reveals Clear"
2501 );
2502 }
2503
2504 #[tokio::test]
2507 async fn the_empty_state_distinguishes_no_records_from_no_match() {
2508 let (db, _guard) = test_db().await;
2509 let state = AdminState::new(
2510 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2511 db,
2512 );
2513 let render = |raw: HashMap<String, String>, q: Option<&str>| {
2514 let q = q.map(String::from);
2515 let state = state.clone();
2516 async move {
2517 let p = ListParams {
2518 page: None,
2519 sort: None,
2520 dir: None,
2521 q,
2522 };
2523 let resp = handle(
2524 &state,
2525 &config(),
2526 "/admin/users",
2527 p,
2528 &raw,
2529 &crate::audit::test_actor(),
2530 crate::Shell::test(),
2531 &axum::http::HeaderMap::new(),
2532 )
2533 .await;
2534 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2535 .await
2536 .unwrap();
2537 String::from_utf8(bytes.to_vec()).unwrap()
2538 }
2539 };
2540
2541 let bare = render(HashMap::new(), None).await;
2543 assert!(bare.contains("No records yet."));
2544
2545 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
2547 assert!(filtered.contains("No records match."));
2548 assert!(!filtered.contains("No records yet."));
2549
2550 let searched = render(HashMap::new(), Some("zzz")).await;
2552 assert!(searched.contains("No records match."));
2553 }
2554
2555 fn root() -> laterite_auth::AuthenticatedUser {
2557 laterite_auth::AuthenticatedUser {
2558 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2559 ..crate::audit::test_actor()
2560 }
2561 }
2562
2563 #[test]
2564 fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
2565 let mut c = config();
2566 c.columns[1].invisible = true;
2567 assert_eq!(visible_columns(&c, None, &root()).len(), 1);
2568 assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
2569 assert_eq!(
2570 visible_columns(&c, Some("is_superuser"), &root())[0].field,
2571 "is_superuser"
2572 );
2573 }
2574
2575 #[test]
2576 fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
2577 let mut c = config();
2578 c.columns[1].permission = Some("acme.secret".into());
2579 let plain = laterite_auth::AuthenticatedUser {
2580 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2581 ..crate::audit::test_actor()
2582 };
2583 assert_eq!(visible_columns(&c, None, &plain).len(), 1);
2584 assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
2586 assert_eq!(visible_columns(&c, None, &root()).len(), 2);
2587 }
2588
2589 #[test]
2590 fn an_unsortable_column_is_not_sorted_by() {
2591 let mut c = config();
2592 c.columns[0].sortable = false;
2593 let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
2594 assert_eq!(by, c.order_by, "falls back to the descriptor's order");
2595 }
2596
2597 #[test]
2598 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
2599 let c = config();
2600 let kept = visible_columns(&c, Some("is_superuser,username"), &root());
2603 assert_eq!(
2604 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
2605 ["username", "is_superuser"]
2606 );
2607 }
2608
2609 #[test]
2610 fn no_stored_choice_shows_every_column() {
2611 let c = config();
2612 assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
2613 }
2614
2615 #[test]
2618 fn a_stale_choice_falls_back_to_every_column() {
2619 let c = config();
2620 assert_eq!(
2621 visible_columns(&c, Some("gone,removed"), &root()).len(),
2622 c.columns.len()
2623 );
2624 assert_eq!(
2625 visible_columns(&c, Some(""), &root()).len(),
2626 c.columns.len()
2627 );
2628 assert_eq!(
2629 visible_columns(&c, Some(" , "), &root()).len(),
2630 c.columns.len()
2631 );
2632 assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
2634 }
2635
2636 #[test]
2639 fn the_toolbar_hides_what_the_operator_may_not_do() {
2640 let shell = crate::Shell::test();
2641 let cfg = ListConfig {
2642 creatable: false,
2643 toolbar: vec![
2644 ToolbarButton::new("Open", "/reports"),
2645 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
2646 ],
2647 ..config()
2648 };
2649
2650 let root = laterite_auth::AuthenticatedUser {
2652 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2653 ..crate::audit::test_actor()
2654 };
2655 let views = toolbar_views(&cfg, "/admin", &root, &shell);
2656 assert_eq!(
2657 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2658 ["Open", "Audit"]
2659 );
2660
2661 let granted = laterite_auth::AuthenticatedUser {
2663 permissions: laterite_auth::PermissionSet::with_overrides(
2664 false,
2665 ["backend.view_audit".to_string()],
2666 [],
2667 [],
2668 ),
2669 ..crate::audit::test_actor()
2670 };
2671 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
2672
2673 let plain = laterite_auth::AuthenticatedUser {
2675 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2676 ..crate::audit::test_actor()
2677 };
2678 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
2679 assert_eq!(
2680 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2681 ["Open"]
2682 );
2683 }
2684
2685 #[test]
2688 fn a_toolbar_href_follows_the_admin_mount() {
2689 let shell = crate::Shell::test();
2690 let cfg = ListConfig {
2691 creatable: true,
2692 edit_base: Some("/backoffice/roles".to_string()),
2694 toolbar: vec![
2695 ToolbarButton::new("Reports", "/reports"),
2696 ToolbarButton::new("Docs", "https://example.test/docs"),
2697 ],
2698 ..config()
2699 };
2700 let root = laterite_auth::AuthenticatedUser {
2701 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2702 ..crate::audit::test_actor()
2703 };
2704 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
2705 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
2706 assert_eq!(views[1].href, "/backoffice/reports");
2707 assert_eq!(views[2].href, "https://example.test/docs");
2709 }
2710
2711 #[tokio::test]
2712 async fn an_htmx_sort_returns_the_list_region_alone() {
2713 let mut headers = axum::http::HeaderMap::new();
2714 headers.insert("hx-request", "true".parse().unwrap());
2715 let html = get(params(Some("username")), headers).await;
2716 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
2717 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
2718 assert!(!html.contains("<body"), "no page chrome");
2721 assert!(html.contains(r#"data-filtered="false""#));
2723 }
2724
2725 #[tokio::test]
2728 async fn the_search_term_survives_a_sort_link() {
2729 let mut p = params(Some("username"));
2730 p.q = Some("bren".to_string());
2731 let html = get(p, axum::http::HeaderMap::new()).await;
2732 assert!(
2733 amps(&html).contains("?sort=username&dir=desc&q=bren"),
2734 "sort and pager links carry the term"
2735 );
2736 assert!(
2737 html.contains(r#"value="bren""#),
2738 "and the box still shows it"
2739 );
2740 }
2741
2742 #[tokio::test]
2743 async fn a_plain_sort_returns_the_whole_page() {
2744 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
2745 assert!(html.contains("<body"), "lists still work without htmx");
2746 assert!(html.contains(r#"id="lat-list""#));
2747 assert!(html.contains(r#"href="?sort=username"#));
2749 }
2750
2751 #[tokio::test]
2752 async fn query_rejects_bad_identifiers() {
2753 let (db, _guard) = test_db().await;
2754 let mut bad = config();
2755 bad.entity = "backend_users; drop table backend_users".to_string();
2756 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2757 }
2758}