1use std::collections::HashMap;
12use std::sync::Arc;
13
14use askama::Template;
15use axum::response::Response;
16use chrono::DateTime;
17use chrono_tz::Tz;
18use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
19use laterite_core::search::SearchProfile;
20use laterite_core::{AnyRowExt, Db, Text};
21use sea_query::{Alias, Expr, Order, Query};
22use serde::{Deserialize, Serialize};
23
24use crate::field::{OverrideResolver, OverrideScope, Surface};
25use crate::html::Markup;
26use crate::sql::valid_ident;
27use crate::{render, render_error, AdminState};
28
29const ID_ALIAS: &str = "_lat_id";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32pub enum SortDir {
33 Asc,
34 Desc,
35}
36
37#[derive(Debug, Clone, Serialize)]
40pub struct ListColumn {
41 pub field: String,
42 pub label: Text,
44 pub column_type: String,
46 pub searchable: Option<bool>,
50}
51
52impl ListColumn {
53 pub fn new(field: &str, label: impl Into<Text>) -> Self {
54 Self {
55 field: field.to_string(),
56 label: label.into(),
57 column_type: "text".to_string(),
58 searchable: None,
59 }
60 }
61
62 fn of(mut self, column_type: &str) -> Self {
63 self.column_type = column_type.to_string();
64 self
65 }
66
67 pub fn datetime(self) -> Self {
69 self.of("datetime")
70 }
71 pub fn date(self) -> Self {
73 self.of("date")
74 }
75 pub fn time(self) -> Self {
77 self.of("time")
78 }
79 pub fn yes_no(self) -> Self {
81 self.of("boolean")
82 }
83 pub fn pill(self) -> Self {
85 self.of("status_pill")
86 }
87
88 pub fn searchable(mut self, searchable: bool) -> Self {
90 self.searchable = Some(searchable);
91 self
92 }
93
94 pub(crate) fn is_searchable(&self) -> bool {
96 self.searchable.unwrap_or(self.column_type == "text")
97 }
98}
99
100pub struct CellCx<'a> {
103 pub value: &'a str,
104 pub tz: Tz,
105 pub locale: chrono::Locale,
106}
107
108pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
114 chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
115}
116
117#[derive(Serialize)]
120pub struct CellVm {
121 pub view_key: String,
122 pub value: String,
123 pub display: String,
124 pub data: serde_json::Value,
125}
126
127pub trait ColumnType: Send + Sync + 'static {
130 fn view_key(&self) -> &'static str;
131 fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
132 fn render_default(&self, vm: &CellVm) -> Markup;
133 fn assets(&self) -> Vec<&'static str> {
136 Vec::new()
137 }
138}
139
140pub struct ColumnTypeReg {
147 pub column_type: Arc<dyn ColumnType>,
148}
149
150impl ColumnTypeReg {
151 pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
152 Self { column_type }
153 }
154
155 pub fn name(&self) -> &'static str {
157 self.column_type.view_key()
158 }
159}
160
161pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
163
164pub(crate) fn render_cell(
166 ct: &dyn ColumnType,
167 resolver: &dyn OverrideResolver,
168 scope: &OverrideScope<'_>,
169 cx: &CellCx<'_>,
170) -> Markup {
171 let vm = ct.view_model(cx);
172 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
173 Some(Ok(html)) => Markup::from_override(html),
174 Some(Err(_)) | None => ct.render_default(&vm),
175 }
176}
177
178pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
179 vec![
180 Arc::new(TextColumn),
181 Arc::new(DateTimeColumn),
182 Arc::new(DateColumn),
183 Arc::new(TimeColumn),
184 Arc::new(BoolColumn),
185 Arc::new(StatusPillColumn),
186 ]
187}
188
189pub(crate) fn builtin_column_registry() -> ColumnRegistry {
191 builtin_column_types()
192 .into_iter()
193 .map(|c| (c.view_key().to_string(), c))
194 .collect()
195}
196
197#[derive(Template)]
198#[template(path = "cells/text.html")]
199struct CellTextTmpl<'a> {
200 display: &'a str,
201}
202
203fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
205 CellVm {
206 view_key: view_key.to_string(),
207 value: value.to_string(),
208 display,
209 data: serde_json::Value::Null,
210 }
211}
212
213fn render_text(vm: &CellVm) -> Markup {
214 Markup::from_template(&CellTextTmpl {
215 display: &vm.display,
216 })
217 .unwrap_or_default()
218}
219
220fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
223 match DateTime::parse_from_rfc3339(raw) {
224 Ok(dt) => dt
225 .with_timezone(&tz)
226 .format_localized(pattern, locale)
227 .to_string(),
228 Err(_) => raw.to_string(),
229 }
230}
231
232struct TextColumn;
233impl ColumnType for TextColumn {
234 fn view_key(&self) -> &'static str {
235 "text"
236 }
237 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
238 text_cell("text", cx.value, cx.value.to_string())
239 }
240 fn render_default(&self, vm: &CellVm) -> Markup {
241 render_text(vm)
242 }
243}
244
245struct DateTimeColumn;
246impl ColumnType for DateTimeColumn {
247 fn view_key(&self) -> &'static str {
248 "datetime"
249 }
250 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
251 text_cell(
252 "datetime",
253 cx.value,
254 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
255 )
256 }
257 fn render_default(&self, vm: &CellVm) -> Markup {
258 render_text(vm)
259 }
260}
261
262struct DateColumn;
263impl ColumnType for DateColumn {
264 fn view_key(&self) -> &'static str {
265 "date"
266 }
267 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
268 text_cell(
269 "date",
270 cx.value,
271 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
272 )
273 }
274 fn render_default(&self, vm: &CellVm) -> Markup {
275 render_text(vm)
276 }
277}
278
279struct TimeColumn;
280impl ColumnType for TimeColumn {
281 fn view_key(&self) -> &'static str {
282 "time"
283 }
284 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
285 text_cell(
286 "time",
287 cx.value,
288 format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
289 )
290 }
291 fn render_default(&self, vm: &CellVm) -> Markup {
292 render_text(vm)
293 }
294}
295
296struct BoolColumn;
297impl ColumnType for BoolColumn {
298 fn view_key(&self) -> &'static str {
299 "boolean"
300 }
301 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
302 let display = match cx.value {
303 "1" | "true" => "Yes",
304 "0" | "false" | "" => "No",
305 other => other,
306 };
307 text_cell("boolean", cx.value, display.to_string())
308 }
309 fn render_default(&self, vm: &CellVm) -> Markup {
310 render_text(vm)
311 }
312}
313
314#[derive(Template)]
315#[template(path = "cells/status.html")]
316struct CellStatusTmpl<'a> {
317 label: &'a str,
318 slug: &'a str,
319}
320
321struct StatusPillColumn;
323impl ColumnType for StatusPillColumn {
324 fn view_key(&self) -> &'static str {
325 "status_pill"
326 }
327 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
328 CellVm {
329 view_key: "status_pill".to_string(),
330 value: cx.value.to_string(),
331 display: cx.value.to_string(),
332 data: serde_json::json!({ "slug": status_slug(cx.value) }),
333 }
334 }
335 fn render_default(&self, vm: &CellVm) -> Markup {
336 let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
337 Markup::from_template(&CellStatusTmpl {
338 label: &vm.display,
339 slug,
340 })
341 .unwrap_or_default()
342 }
343}
344
345fn status_slug(value: &str) -> String {
347 value
348 .trim()
349 .to_lowercase()
350 .chars()
351 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
352 .collect()
353}
354
355#[derive(Debug, Clone, Serialize)]
357pub struct FilterOption {
358 pub value: String,
359 pub label: Text,
361}
362
363impl FilterOption {
364 pub fn new(value: &str, label: impl Into<Text>) -> Self {
365 Self {
366 value: value.to_string(),
367 label: label.into(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize)]
374pub enum FilterKind {
375 Boolean,
378 Select { options: Vec<FilterOption> },
380}
381
382#[derive(Debug, Clone, Serialize)]
384pub struct ListFilter {
385 pub field: String,
386 pub label: Text,
388 pub kind: FilterKind,
389}
390
391impl ListFilter {
392 pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
394 Self {
395 field: field.to_string(),
396 label: label.into(),
397 kind: FilterKind::Boolean,
398 }
399 }
400
401 pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
403 Self {
404 field: field.to_string(),
405 label: label.into(),
406 kind: FilterKind::Select { options },
407 }
408 }
409
410 pub(crate) fn param(&self) -> String {
413 format!("f_{}", self.field)
414 }
415}
416
417#[derive(Debug, Clone, Serialize)]
423pub struct ToolbarButton {
424 pub label: Text,
426 pub href: String,
429 pub icon: Option<String>,
431 pub permission: Option<String>,
434 pub primary: bool,
436}
437
438impl ToolbarButton {
439 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
440 Self {
441 label: label.into(),
442 href: href.into(),
443 icon: None,
444 permission: None,
445 primary: false,
446 }
447 }
448
449 pub fn icon(mut self, icon: &str) -> Self {
450 self.icon = Some(icon.to_string());
451 self
452 }
453
454 pub fn require(mut self, permission: impl Into<String>) -> Self {
456 self.permission = Some(permission.into());
457 self
458 }
459
460 pub fn primary(mut self) -> Self {
461 self.primary = true;
462 self
463 }
464}
465
466pub struct ToolbarView {
468 pub label: String,
469 pub href: String,
470 pub icon: String,
472 pub primary: bool,
473}
474
475fn toolbar_views(
479 config: &ListConfig,
480 admin_path: &str,
481 user: &laterite_auth::AuthenticatedUser,
482 shell: &crate::Shell,
483) -> Vec<ToolbarView> {
484 let mut out = Vec::new();
485 if config.creatable {
486 if let Some(base) = &config.edit_base {
487 out.push(ToolbarView {
488 label: shell.tt(&laterite_core::t!("New")),
489 href: format!("{admin_path}{base}/new"),
490 icon: String::new(),
491 primary: true,
492 });
493 }
494 }
495 for button in &config.toolbar {
496 if let Some(needed) = &button.permission {
497 if !user.permissions.allows(needed) {
498 continue;
499 }
500 }
501 let href = if button.href.starts_with('/') {
502 format!("{admin_path}{}", button.href)
503 } else {
504 button.href.clone()
505 };
506 out.push(ToolbarView {
507 label: shell.tt(&button.label),
508 href,
509 icon: crate::icons::svg(button.icon.as_deref()).to_string(),
510 primary: button.primary,
511 });
512 }
513 out
514}
515
516pub(crate) fn columns_preference_key(base_path: &str) -> String {
519 format!("list.columns.{base_path}")
520}
521
522pub(crate) async fn stored_columns(
527 state: &AdminState,
528 path: &str,
529 user: &laterite_auth::AuthenticatedUser,
530) -> Option<String> {
531 match laterite_auth::store::user_preference(
532 &state.db,
533 user.user.id,
534 &columns_preference_key(path),
535 )
536 .await
537 {
538 Ok(stored) => stored,
539 Err(e) => {
540 tracing::warn!(error = %e, "reading the column preference failed");
541 None
542 }
543 }
544}
545
546pub(crate) fn narrowed(config: &ListConfig, stored: Option<&str>) -> ListConfig {
548 ListConfig {
549 columns: visible_columns(config, stored)
550 .into_iter()
551 .cloned()
552 .collect(),
553 ..config.clone()
554 }
555}
556
557pub(crate) fn visible_columns<'a>(
564 config: &'a ListConfig,
565 stored: Option<&str>,
566) -> Vec<&'a ListColumn> {
567 let Some(stored) = stored else {
568 return config.columns.iter().collect();
569 };
570 let chosen: Vec<&str> = stored
571 .split(',')
572 .map(str::trim)
573 .filter(|s| !s.is_empty())
574 .collect();
575 let kept: Vec<&ListColumn> = config
576 .columns
577 .iter()
578 .filter(|c| chosen.iter().any(|n| *n == c.field))
579 .collect();
580 if kept.is_empty() {
581 return config.columns.iter().collect();
582 }
583 kept
584}
585
586#[derive(Debug, Clone, Serialize)]
589pub struct ListConfig {
590 pub entity: String,
591 pub title: Text,
593 pub columns: Vec<ListColumn>,
594 pub order_by: String,
595 pub order_dir: SortDir,
596 pub per_page: i64,
597 pub id_field: String,
598 pub edit_base: Option<String>,
600 pub creatable: bool,
603 pub filters: Vec<ListFilter>,
605 pub deletable: bool,
609 pub toolbar: Vec<ToolbarButton>,
611}
612
613impl Default for ListConfig {
614 fn default() -> Self {
621 Self {
622 entity: String::new(),
623 title: Text::new(""),
624 columns: Vec::new(),
625 order_by: "id".to_string(),
626 order_dir: SortDir::Desc,
627 per_page: 25,
628 id_field: "id".to_string(),
629 edit_base: None,
630 creatable: false,
631 filters: Vec::new(),
632 deletable: false,
633 toolbar: Vec::new(),
634 }
635 }
636}
637
638#[derive(Deserialize)]
640pub struct ListParams {
641 page: Option<i64>,
642 sort: Option<String>,
643 dir: Option<String>,
644 q: Option<String>,
645}
646
647pub(crate) fn resolve_sort(
652 config: &ListConfig,
653 sort: Option<&str>,
654 dir: Option<&str>,
655) -> (String, SortDir) {
656 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s)) {
657 Some(field) => field.to_string(),
658 None => config.order_by.clone(),
659 };
660 let dir = match dir {
661 Some("asc") => SortDir::Asc,
662 Some("desc") => SortDir::Desc,
663 _ if by == config.order_by => config.order_dir,
666 _ => SortDir::Asc,
667 };
668 (by, dir)
669}
670
671pub struct RowView {
673 pub id: String,
674 pub cells: Vec<String>,
675}
676
677pub struct ListPage {
679 pub rows: Vec<RowView>,
680 pub total: i64,
681}
682
683pub(crate) struct ListQuery<'a> {
686 pub offset: i64,
687 pub order_by: &'a str,
688 pub order_dir: SortDir,
689 pub q: &'a str,
690 pub filters: &'a [ActiveFilter<'a>],
691 pub per_page: Option<i64>,
694}
695
696pub(crate) struct ActiveFilter<'a> {
700 filter: &'a ListFilter,
701 raw: String,
703 value: sea_query::Value,
704}
705
706pub(crate) fn resolve_filters<'a>(
709 config: &'a ListConfig,
710 params: &HashMap<String, String>,
711) -> Vec<ActiveFilter<'a>> {
712 let mut out = Vec::new();
713 for filter in &config.filters {
714 let Some(raw) = params.get(&filter.param()).map(|v| v.trim()) else {
715 continue;
716 };
717 if raw.is_empty() {
718 continue;
719 }
720 let value = match &filter.kind {
721 FilterKind::Boolean => match raw {
724 "1" => sea_query::Value::Bool(Some(true)),
725 "0" => sea_query::Value::Bool(Some(false)),
726 _ => continue,
727 },
728 FilterKind::Select { options } => {
729 if !options.iter().any(|o| o.value == raw) {
730 continue;
731 }
732 sea_query::Value::String(Some(Box::new(raw.to_string())))
733 }
734 };
735 out.push(ActiveFilter {
736 filter,
737 raw: raw.to_string(),
738 value,
739 });
740 }
741 out
742}
743
744fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
746 if active.is_empty() {
747 return None;
748 }
749 let mut all = sea_query::Condition::all();
750 for f in active {
751 all = all.add(Expr::col(Alias::new(&f.filter.field)).eq(f.value.clone()));
752 }
753 Some(all)
754}
755
756fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
760 let caps = laterite_core::capabilities::CapabilitySet::default();
764 let profile = SearchProfile::new();
765 let mut any = sea_query::Condition::any();
766 let mut matched = false;
767 for column in config.columns.iter().filter(|c| c.is_searchable()) {
768 if let Some(cond) = profile.condition(&caps, &column.field, q) {
769 any = any.add(cond);
770 matched = true;
771 }
772 }
773 matched.then_some(any)
774}
775
776pub(crate) async fn query(
781 db: &Db,
782 config: &ListConfig,
783 req: &ListQuery<'_>,
784) -> anyhow::Result<ListPage> {
785 if !valid_ident(&config.entity)
786 || !valid_ident(&config.order_by)
787 || !valid_ident(req.order_by)
788 || !valid_ident(&config.id_field)
789 || !config.columns.iter().all(|c| valid_ident(&c.field))
790 || !config.filters.iter().all(|f| valid_ident(&f.field))
791 {
792 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
793 }
794
795 let dir = match req.order_dir {
796 SortDir::Asc => Order::Asc,
797 SortDir::Desc => Order::Desc,
798 };
799 let (sql, values) = {
803 let mut select = Query::select();
804 for column in &config.columns {
805 select.expr_as(
806 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
807 Alias::new(&column.field),
808 );
809 }
810 select
811 .expr_as(
812 Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
813 Alias::new(ID_ALIAS),
814 )
815 .from(Alias::new(&config.entity))
816 .order_by(Alias::new(req.order_by), dir)
817 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
818 .offset(req.offset.max(0) as u64);
819 if let Some(cond) = search_condition(config, req.q) {
820 select.cond_where(cond);
821 }
822 if let Some(cond) = filter_condition(req.filters) {
823 select.cond_where(cond);
824 }
825 build(db.backend, select)
826 };
827 let raw = bind_values(sqlx::query(&sql), values)
828 .fetch_all(&db.pool)
829 .await?;
830
831 let (csql, cvalues) = {
832 let mut count = Query::select();
833 count
834 .expr(Expr::col(Alias::new(&config.id_field)).count())
835 .from(Alias::new(&config.entity));
836 if let Some(cond) = search_condition(config, req.q) {
838 count.cond_where(cond);
839 }
840 if let Some(cond) = filter_condition(req.filters) {
841 count.cond_where(cond);
842 }
843 build(db.backend, count)
844 };
845 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
846 .fetch_one(&db.pool)
847 .await?
848 .0;
849
850 let rows = raw
851 .iter()
852 .map(|row| RowView {
853 id: get_text(row, ID_ALIAS),
854 cells: config
855 .columns
856 .iter()
857 .map(|c| get_text(row, &c.field))
858 .collect(),
859 })
860 .collect();
861 Ok(ListPage { rows, total })
862}
863
864fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
867 row.get_text_opt(column).ok().flatten().unwrap_or_default()
870}
871
872#[allow(clippy::too_many_arguments)]
874pub(crate) async fn handle(
875 state: &AdminState,
876 config: &ListConfig,
877 path: &str,
878 params: ListParams,
879 raw: &HashMap<String, String>,
880 user: &laterite_auth::AuthenticatedUser,
881 shell: crate::Shell,
882 headers: &axum::http::HeaderMap,
883) -> Response {
884 let stored = stored_columns(state, path, user).await;
888 let declared = config;
889 let narrowed = narrowed(config, stored.as_deref());
890 let config = &narrowed;
891
892 let page = params.page.unwrap_or(1).max(1);
893 let offset = (page - 1) * config.per_page;
894 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
895 let q = params.q.unwrap_or_default();
896 let active = resolve_filters(config, raw);
897 let req = ListQuery {
898 offset,
899 order_by: &order_by,
900 order_dir,
901 q: q.trim(),
902 filters: &active,
903 per_page: None,
904 };
905 let mut carry = String::new();
908 if !q.trim().is_empty() {
909 carry.push_str(&format!("&q={}", urlencode(q.trim())));
910 }
911 for f in &active {
912 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
913 }
914 match query(&state.db, config, &req).await {
915 Ok(result) => {
916 let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
917 let date_loc = date_locale(shell.locale());
918 let rows: Vec<RowView> = result
919 .rows
920 .into_iter()
921 .map(|row| RowView {
922 id: row.id,
923 cells: row
924 .cells
925 .iter()
926 .zip(&config.columns)
927 .map(|(raw, col)| {
928 let cx = CellCx {
929 value: raw,
930 tz: shell.tz,
931 locale: date_loc,
932 };
933 let scope = OverrideScope {
934 surface: Surface::Column,
935 view_key: &col.column_type,
936 resource: Some(&config.entity),
937 field: Some(&col.field),
938 };
939 match state.column_types.get(&col.column_type) {
940 Some(ct) => {
941 render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
942 .into_string()
943 }
944 None => String::new(),
945 }
946 })
947 .collect(),
948 })
949 .collect();
950 let keys: Vec<&str> = config
951 .columns
952 .iter()
953 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
954 .flatten()
955 .collect();
956 let mut shell = shell;
957 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
958 let shown = rows.len() as i64;
959 let title = shell.tt(&config.title);
961 let active_dir = match order_dir {
962 SortDir::Asc => "asc",
963 SortDir::Desc => "desc",
964 };
965 let columns: Vec<ColumnHead> = config
966 .columns
967 .iter()
968 .map(|c| {
969 let active = c.field == order_by;
970 ColumnHead {
971 label: shell.tt(&c.label),
972 field: c.field.clone(),
973 next_dir: if active && active_dir == "asc" {
976 "desc".to_string()
977 } else {
978 "asc".to_string()
979 },
980 active: if active {
981 active_dir.to_string()
982 } else {
983 String::new()
984 },
985 }
986 })
987 .collect();
988 let shell_for_toolbar = shell.clone();
989 let filter_views = filter_views(config, &active, &shell);
990 let pickers: Vec<ColumnChoice> = declared
991 .columns
992 .iter()
993 .map(|c| ColumnChoice {
994 field: c.field.clone(),
995 label: shell.tt(&c.label),
996 shown: config.columns.iter().any(|v| v.field == c.field),
997 })
998 .collect();
999 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1001 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1002 let page_view = ListTemplate {
1003 shell,
1004 title,
1005 columns,
1006 rows,
1007 shown,
1008 page,
1009 total: result.total,
1010 total_pages,
1011 edit_base: config.edit_base.clone(),
1012 creatable: config.creatable,
1013 sort: order_by,
1014 dir: active_dir.to_string(),
1015 q: q.trim().to_string(),
1016 searchable: config.columns.iter().any(|c| c.is_searchable()),
1017 pickers,
1018 toolbar,
1019 export_query,
1020 path: path.to_string(),
1021 filters: filter_views,
1022 deletable: config.deletable,
1023 filtered: !active.is_empty() || !q.trim().is_empty(),
1024 carry: carry.clone(),
1025 };
1026 if crate::form::is_htmx(headers) {
1030 render(ListFragment {
1031 shell: page_view.shell,
1032 columns: page_view.columns,
1033 rows: page_view.rows,
1034 shown: page_view.shown,
1035 page: page_view.page,
1036 total: page_view.total,
1037 total_pages: page_view.total_pages,
1038 edit_base: page_view.edit_base,
1039 sort: page_view.sort,
1040 dir: page_view.dir,
1041 carry: page_view.carry,
1042 filtered: page_view.filtered,
1043 creatable: page_view.creatable,
1044 deletable: page_view.deletable,
1045 })
1046 } else {
1047 render(page_view)
1048 }
1049 }
1050 Err(_) => render_error(),
1051 }
1052}
1053
1054pub(crate) async fn set_columns(
1061 state: &AdminState,
1062 config: &ListConfig,
1063 path: &str,
1064 user: &laterite_auth::AuthenticatedUser,
1065 session: &crate::session::SessionHandle,
1066 headers: &axum::http::HeaderMap,
1067 pairs: &[(String, String)],
1068) -> Response {
1069 let chosen: Vec<&str> = pairs
1070 .iter()
1071 .filter(|(k, _)| k == "column")
1072 .map(|(_, v)| v.trim())
1073 .filter(|v| config.columns.iter().any(|c| c.field == *v))
1074 .collect();
1075
1076 let key = columns_preference_key(path);
1077 let result = if chosen.is_empty() || chosen.len() == config.columns.len() {
1078 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1079 } else {
1080 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1081 .await
1082 };
1083 match result {
1084 Ok(()) => session.push_flash(
1085 crate::session::FlashLevel::Success,
1086 laterite_core::t!("Columns updated."),
1087 ),
1088 Err(e) => {
1089 tracing::error!(error = %e, "storing the column choice failed");
1090 session.push_flash(
1091 crate::session::FlashLevel::Error,
1092 laterite_core::t!("Could not save your column choice."),
1093 );
1094 }
1095 }
1096 let back = format!("{}{}", state.admin_path, path);
1097 crate::form::saved_response(crate::form::is_htmx(headers), &back)
1098}
1099
1100pub struct ColumnChoice {
1102 pub field: String,
1103 pub label: String,
1104 pub shown: bool,
1105}
1106
1107pub struct ColumnHead {
1110 pub label: String,
1111 pub field: String,
1112 pub next_dir: String,
1113 pub active: String,
1114}
1115
1116#[derive(Template)]
1117#[template(path = "list.html")]
1118struct ListTemplate {
1119 shell: crate::Shell,
1120 title: String,
1121 columns: Vec<ColumnHead>,
1122 rows: Vec<RowView>,
1123 shown: i64,
1125 page: i64,
1126 total: i64,
1127 total_pages: i64,
1128 edit_base: Option<String>,
1129 creatable: bool,
1130 sort: String,
1132 dir: String,
1133 q: String,
1135 searchable: bool,
1137 deletable: bool,
1139 pickers: Vec<ColumnChoice>,
1141 toolbar: Vec<ToolbarView>,
1143 export_query: String,
1146 path: String,
1148 filters: Vec<FilterView>,
1150 filtered: bool,
1152 carry: String,
1154}
1155
1156pub struct FilterView {
1158 pub param: String,
1159 pub label: String,
1160 pub boolean: bool,
1161 pub options: Vec<FilterOptionView>,
1162 pub value: String,
1164}
1165
1166pub struct FilterOptionView {
1167 pub value: String,
1168 pub label: String,
1169}
1170
1171fn filter_views(
1173 config: &ListConfig,
1174 active: &[ActiveFilter<'_>],
1175 shell: &crate::Shell,
1176) -> Vec<FilterView> {
1177 config
1178 .filters
1179 .iter()
1180 .map(|f| {
1181 let value = active
1182 .iter()
1183 .find(|a| a.filter.field == f.field)
1184 .map(|a| a.raw.clone())
1185 .unwrap_or_default();
1186 let (boolean, options) = match &f.kind {
1187 FilterKind::Boolean => (
1188 true,
1189 vec![
1190 FilterOptionView {
1191 value: "1".to_string(),
1192 label: shell.tt(&laterite_core::t!("Yes")),
1193 },
1194 FilterOptionView {
1195 value: "0".to_string(),
1196 label: shell.tt(&laterite_core::t!("No")),
1197 },
1198 ],
1199 ),
1200 FilterKind::Select { options } => (
1201 false,
1202 options
1203 .iter()
1204 .map(|o| FilterOptionView {
1205 value: o.value.clone(),
1206 label: shell.tt(&o.label),
1207 })
1208 .collect(),
1209 ),
1210 };
1211 FilterView {
1212 param: f.param(),
1213 label: shell.tt(&f.label),
1214 boolean,
1215 options,
1216 value,
1217 }
1218 })
1219 .collect()
1220}
1221
1222fn urlencode(value: &str) -> String {
1224 let mut out = String::with_capacity(value.len());
1225 for byte in value.as_bytes() {
1226 match byte {
1227 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1228 out.push(*byte as char)
1229 }
1230 b' ' => out.push('+'),
1231 _ => out.push_str(&format!("%{byte:02X}")),
1232 }
1233 }
1234 out
1235}
1236
1237#[derive(Template)]
1239#[template(path = "_list_table.html")]
1240struct ListFragment {
1241 shell: crate::Shell,
1242 columns: Vec<ColumnHead>,
1243 rows: Vec<RowView>,
1244 shown: i64,
1245 page: i64,
1246 total: i64,
1247 total_pages: i64,
1248 edit_base: Option<String>,
1249 sort: String,
1250 dir: String,
1251 carry: String,
1252 filtered: bool,
1256 creatable: bool,
1257 deletable: bool,
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262 use super::*;
1263
1264 fn config() -> ListConfig {
1265 ListConfig {
1266 entity: "backend_users".to_string(),
1267 title: "Users".into(),
1268 columns: vec![
1269 ListColumn::new("username", "Username"),
1270 ListColumn::new("is_superuser", "Superuser").yes_no(),
1271 ],
1272 order_by: "created_at".to_string(),
1273 order_dir: SortDir::Desc,
1274 per_page: 25,
1275 id_field: "id".to_string(),
1276 edit_base: None,
1277 creatable: false,
1278 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1279 deletable: true,
1280 ..Default::default()
1281 }
1282 }
1283
1284 #[test]
1285 fn column_types_format_their_cell_display() {
1286 let ist: Tz = "Asia/Kolkata".parse().unwrap();
1287 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1288 ct.view_model(&CellCx {
1289 value,
1290 tz,
1291 locale: chrono::Locale::en_US,
1292 })
1293 .display
1294 };
1295 assert_eq!(
1297 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
1298 "13 Aug 2026, 15:30"
1299 );
1300 assert_eq!(
1301 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
1302 "13 Aug 2026"
1303 );
1304 assert_eq!(
1305 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
1306 "15:30"
1307 );
1308 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
1309 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
1310 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
1311 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
1313 }
1314
1315 #[test]
1316 fn sort_falls_back_to_the_configured_order() {
1317 let c = config();
1318 assert_eq!(
1319 resolve_sort(&c, None, None),
1320 ("created_at".to_string(), SortDir::Desc)
1321 );
1322 }
1323
1324 #[test]
1325 fn a_declared_column_can_be_sorted_either_way() {
1326 let c = config();
1327 assert_eq!(
1328 resolve_sort(&c, Some("username"), Some("asc")),
1329 ("username".to_string(), SortDir::Asc)
1330 );
1331 assert_eq!(
1332 resolve_sort(&c, Some("username"), Some("desc")),
1333 ("username".to_string(), SortDir::Desc)
1334 );
1335 }
1336
1337 #[test]
1340 fn an_undeclared_sort_column_is_ignored() {
1341 let c = config();
1342 for crafted in [
1343 "password_hash",
1344 "id) --",
1345 "username; drop table backend_users",
1346 "",
1347 ] {
1348 assert_eq!(
1349 resolve_sort(&c, Some(crafted), Some("asc")).0,
1350 "created_at",
1351 "{crafted} must not reach the query"
1352 );
1353 }
1354 }
1355
1356 #[test]
1357 fn an_unknown_direction_falls_back() {
1358 let c = config();
1359 assert_eq!(
1361 resolve_sort(&c, Some("username"), Some("sideways")).1,
1362 SortDir::Asc
1363 );
1364 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1366 }
1367
1368 #[test]
1371 fn a_contributed_column_type_renders_its_cell() {
1372 struct Rating;
1373 impl ColumnType for Rating {
1374 fn view_key(&self) -> &'static str {
1375 "acme.rating"
1376 }
1377 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1378 let stars = cx.value.parse::<usize>().unwrap_or(0);
1379 text_cell("acme.rating", cx.value, "*".repeat(stars))
1380 }
1381 fn render_default(&self, vm: &CellVm) -> Markup {
1382 render_text(vm)
1383 }
1384 }
1385
1386 let reg = ColumnTypeReg::new(Arc::new(Rating));
1387 assert_eq!(reg.name(), "acme.rating", "the type names itself");
1388
1389 let mut registry = builtin_column_registry();
1390 registry.insert(reg.name().to_string(), reg.column_type);
1391 let rendered = render_cell(
1392 registry.get("acme.rating").unwrap().as_ref(),
1393 &crate::field::NoOverrides,
1394 &OverrideScope {
1395 surface: Surface::Column,
1396 view_key: "acme.rating",
1397 resource: Some("products"),
1398 field: Some("stars"),
1399 },
1400 &CellCx {
1401 value: "3",
1402 tz: Tz::UTC,
1403 locale: chrono::Locale::en_US,
1404 },
1405 );
1406 assert!(rendered.into_string().contains("***"));
1407 }
1408
1409 #[test]
1412 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
1413 struct Impostor;
1414 impl ColumnType for Impostor {
1415 fn view_key(&self) -> &'static str {
1416 "text"
1417 }
1418 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1419 text_cell("text", cx.value, String::new())
1420 }
1421 fn render_default(&self, vm: &CellVm) -> Markup {
1422 render_text(vm)
1423 }
1424 }
1425 let reg = ColumnTypeReg::new(Arc::new(Impostor));
1426 assert_eq!(reg.name(), "text");
1427 assert!(builtin_column_registry().contains_key(reg.name()));
1429 }
1430
1431 #[test]
1432 fn status_pill_renders_a_slugged_markup_span() {
1433 let vm = StatusPillColumn.view_model(&CellCx {
1434 value: "In Progress",
1435 tz: Tz::UTC,
1436 locale: chrono::Locale::en_US,
1437 });
1438 let html = StatusPillColumn.render_default(&vm).into_string();
1439 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
1440 assert!(html.contains(">In Progress<"));
1441 }
1442
1443 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1446 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
1447 }
1448
1449 #[tokio::test]
1450 async fn query_returns_display_rows() {
1451 let (db, _guard) = test_db().await;
1452 let hash = laterite_auth::password::hash_password("pw").unwrap();
1453 laterite_auth::store::create_user(
1454 &db,
1455 "root",
1456 "root@example.test",
1457 "Ada",
1458 None,
1459 &hash,
1460 true,
1461 )
1462 .await
1463 .unwrap();
1464
1465 let c = config();
1466 let result = query(&db, &c, &plain(&c)).await.unwrap();
1467 assert_eq!(result.total, 1);
1468 assert_eq!(result.rows.len(), 1);
1469 assert_eq!(result.rows[0].cells[0], "root");
1470 assert_eq!(result.rows[0].cells[1], "1");
1473 assert!(!result.rows[0].id.is_empty());
1474 }
1475
1476 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
1477 get_with(params, HashMap::new(), headers).await
1478 }
1479
1480 async fn get_with(
1481 params: ListParams,
1482 raw: HashMap<String, String>,
1483 headers: axum::http::HeaderMap,
1484 ) -> String {
1485 let (db, _guard) = test_db().await;
1486 let state = AdminState::new(
1487 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1488 db,
1489 );
1490 let resp = handle(
1491 &state,
1492 &config(),
1493 "/admin/users",
1494 params,
1495 &raw,
1496 &crate::audit::test_actor(),
1497 crate::Shell::test(),
1498 &headers,
1499 )
1500 .await;
1501 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1502 .await
1503 .unwrap();
1504 String::from_utf8(bytes.to_vec()).unwrap()
1505 }
1506
1507 fn params(sort: Option<&str>) -> ListParams {
1508 ListParams {
1509 page: None,
1510 sort: sort.map(|s| s.to_string()),
1511 dir: Some("asc".to_string()),
1512 q: None,
1513 }
1514 }
1515
1516 fn plain(config: &ListConfig) -> ListQuery<'_> {
1518 ListQuery {
1519 offset: 0,
1520 order_by: &config.order_by,
1521 order_dir: config.order_dir,
1522 q: "",
1523 filters: &[],
1524 per_page: None,
1525 }
1526 }
1527
1528 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
1529 ListQuery {
1530 offset: 0,
1531 order_by: &config.order_by,
1532 order_dir: config.order_dir,
1533 q,
1534 filters: &[],
1535 per_page: None,
1536 }
1537 }
1538
1539 fn amps(html: &str) -> String {
1542 html.replace("&", "&").replace("&", "&")
1543 }
1544
1545 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1547 pairs
1548 .iter()
1549 .map(|(k, v)| (k.to_string(), v.to_string()))
1550 .collect()
1551 }
1552
1553 #[tokio::test]
1554 async fn search_filters_rows_and_the_total() {
1555 let (db, _guard) = test_db().await;
1556 for name in ["ada", "brendan", "clara"] {
1557 let hash = laterite_auth::password::hash_password("x").unwrap();
1558 laterite_auth::store::create_user(
1559 &db,
1560 name,
1561 &format!("{name}@example.test"),
1562 name,
1563 None,
1564 &hash,
1565 false,
1566 )
1567 .await
1568 .unwrap();
1569 }
1570 let c = config();
1571
1572 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
1573 assert_eq!(hit.rows.len(), 1);
1574 assert_eq!(hit.rows[0].cells[0], "brendan");
1575 assert_eq!(hit.total, 1);
1577
1578 assert_eq!(
1580 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
1581 1
1582 );
1583
1584 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
1586 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
1587
1588 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
1590 }
1591
1592 #[tokio::test]
1595 async fn search_skips_a_non_text_column() {
1596 let (db, _guard) = test_db().await;
1597 let hash = laterite_auth::password::hash_password("x").unwrap();
1598 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
1599 .await
1600 .unwrap();
1601 let c = config();
1602 assert!(c.columns[0].is_searchable(), "username is text");
1603 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
1604 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
1606 }
1607
1608 #[test]
1609 fn a_column_can_override_its_searchability() {
1610 assert!(ListColumn::new("code", "Code").is_searchable());
1611 assert!(!ListColumn::new("code", "Code")
1612 .searchable(false)
1613 .is_searchable());
1614 assert!(ListColumn::new("at", "At")
1615 .datetime()
1616 .searchable(true)
1617 .is_searchable());
1618 }
1619
1620 async fn seed_users(db: &Db) {
1621 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
1622 let hash = laterite_auth::password::hash_password("x").unwrap();
1623 laterite_auth::store::create_user(
1624 db,
1625 name,
1626 &format!("{name}@example.test"),
1627 name,
1628 None,
1629 &hash,
1630 su,
1631 )
1632 .await
1633 .unwrap();
1634 }
1635 }
1636
1637 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
1638 let active = resolve_filters(c, params);
1639 let req = ListQuery {
1640 offset: 0,
1641 order_by: &c.order_by,
1642 order_dir: c.order_dir,
1643 q: "",
1644 filters: &active,
1645 per_page: None,
1646 };
1647 query(db, c, &req).await.unwrap().total
1648 }
1649
1650 #[tokio::test]
1651 async fn a_filter_narrows_the_rows_and_the_total() {
1652 let (db, _guard) = test_db().await;
1653 seed_users(&db).await;
1654 let c = config();
1655
1656 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
1657 assert_eq!(
1658 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
1659 1
1660 );
1661 assert_eq!(
1662 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
1663 2
1664 );
1665 }
1666
1667 #[test]
1670 fn an_undeclared_filter_or_value_is_ignored() {
1671 let c = config();
1672 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
1674 for bad in ["yes", "true", "1 or 1=1", "", " "] {
1676 assert!(
1677 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
1678 "{bad} must not reach the query"
1679 );
1680 }
1681
1682 let with_select = ListConfig {
1683 filters: vec![ListFilter::select(
1684 "status",
1685 "Status",
1686 vec![FilterOption::new("live", "Live")],
1687 )],
1688 ..config()
1689 };
1690 assert_eq!(
1691 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
1692 1
1693 );
1694 assert!(
1695 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
1696 "an option the descriptor never offered is dropped"
1697 );
1698 }
1699
1700 #[tokio::test]
1701 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
1702 let mut p = params(Some("username"));
1703 p.q = None;
1704 let html = get_with(
1705 p,
1706 filter_params(&[("f_is_superuser", "1")]),
1707 axum::http::HeaderMap::new(),
1708 )
1709 .await;
1710 let links = amps(&html);
1711 assert!(
1712 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
1713 "a sort link carries the filter"
1714 );
1715 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
1717 assert!(
1718 html.contains(r#"<option value="1" selected>"#),
1719 "the control shows the active value"
1720 );
1721 assert!(html.contains("Clear"), "and a way back to the full list");
1722 assert!(
1723 html.contains(r#"data-filtered="true""#),
1724 "the region says the list is narrowed, which is what reveals Clear"
1725 );
1726 }
1727
1728 #[tokio::test]
1731 async fn the_empty_state_distinguishes_no_records_from_no_match() {
1732 let (db, _guard) = test_db().await;
1733 let state = AdminState::new(
1734 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1735 db,
1736 );
1737 let render = |raw: HashMap<String, String>, q: Option<&str>| {
1738 let q = q.map(String::from);
1739 let state = state.clone();
1740 async move {
1741 let p = ListParams {
1742 page: None,
1743 sort: None,
1744 dir: None,
1745 q,
1746 };
1747 let resp = handle(
1748 &state,
1749 &config(),
1750 "/admin/users",
1751 p,
1752 &raw,
1753 &crate::audit::test_actor(),
1754 crate::Shell::test(),
1755 &axum::http::HeaderMap::new(),
1756 )
1757 .await;
1758 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1759 .await
1760 .unwrap();
1761 String::from_utf8(bytes.to_vec()).unwrap()
1762 }
1763 };
1764
1765 let bare = render(HashMap::new(), None).await;
1767 assert!(bare.contains("No records yet."));
1768
1769 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
1771 assert!(filtered.contains("No records match."));
1772 assert!(!filtered.contains("No records yet."));
1773
1774 let searched = render(HashMap::new(), Some("zzz")).await;
1776 assert!(searched.contains("No records match."));
1777 }
1778
1779 #[test]
1780 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
1781 let c = config();
1782 let kept = visible_columns(&c, Some("is_superuser,username"));
1785 assert_eq!(
1786 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
1787 ["username", "is_superuser"]
1788 );
1789 }
1790
1791 #[test]
1792 fn no_stored_choice_shows_every_column() {
1793 let c = config();
1794 assert_eq!(visible_columns(&c, None).len(), c.columns.len());
1795 }
1796
1797 #[test]
1800 fn a_stale_choice_falls_back_to_every_column() {
1801 let c = config();
1802 assert_eq!(
1803 visible_columns(&c, Some("gone,removed")).len(),
1804 c.columns.len()
1805 );
1806 assert_eq!(visible_columns(&c, Some("")).len(), c.columns.len());
1807 assert_eq!(visible_columns(&c, Some(" , ")).len(), c.columns.len());
1808 assert_eq!(visible_columns(&c, Some("gone,username")).len(), 1);
1810 }
1811
1812 #[test]
1815 fn the_toolbar_hides_what_the_operator_may_not_do() {
1816 let shell = crate::Shell::test();
1817 let cfg = ListConfig {
1818 creatable: false,
1819 toolbar: vec![
1820 ToolbarButton::new("Open", "/reports"),
1821 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
1822 ],
1823 ..config()
1824 };
1825
1826 let root = laterite_auth::AuthenticatedUser {
1828 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1829 ..crate::audit::test_actor()
1830 };
1831 let views = toolbar_views(&cfg, "/admin", &root, &shell);
1832 assert_eq!(
1833 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1834 ["Open", "Audit"]
1835 );
1836
1837 let granted = laterite_auth::AuthenticatedUser {
1839 permissions: laterite_auth::PermissionSet::with_overrides(
1840 false,
1841 ["backend.view_audit".to_string()],
1842 [],
1843 [],
1844 ),
1845 ..crate::audit::test_actor()
1846 };
1847 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
1848
1849 let plain = laterite_auth::AuthenticatedUser {
1851 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
1852 ..crate::audit::test_actor()
1853 };
1854 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
1855 assert_eq!(
1856 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1857 ["Open"]
1858 );
1859 }
1860
1861 #[test]
1864 fn a_toolbar_href_follows_the_admin_mount() {
1865 let shell = crate::Shell::test();
1866 let cfg = ListConfig {
1867 creatable: true,
1868 edit_base: Some("/roles".to_string()),
1869 toolbar: vec![
1870 ToolbarButton::new("Reports", "/reports"),
1871 ToolbarButton::new("Docs", "https://example.test/docs"),
1872 ],
1873 ..config()
1874 };
1875 let root = laterite_auth::AuthenticatedUser {
1876 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1877 ..crate::audit::test_actor()
1878 };
1879 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
1880 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
1881 assert_eq!(views[1].href, "/backoffice/reports");
1882 assert_eq!(views[2].href, "https://example.test/docs");
1884 }
1885
1886 #[tokio::test]
1887 async fn an_htmx_sort_returns_the_list_region_alone() {
1888 let mut headers = axum::http::HeaderMap::new();
1889 headers.insert("hx-request", "true".parse().unwrap());
1890 let html = get(params(Some("username")), headers).await;
1891 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
1892 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
1893 assert!(!html.contains("<body"), "no page chrome");
1896 assert!(html.contains(r#"data-filtered="false""#));
1898 }
1899
1900 #[tokio::test]
1903 async fn the_search_term_survives_a_sort_link() {
1904 let mut p = params(Some("username"));
1905 p.q = Some("bren".to_string());
1906 let html = get(p, axum::http::HeaderMap::new()).await;
1907 assert!(
1908 amps(&html).contains("?sort=username&dir=desc&q=bren"),
1909 "sort and pager links carry the term"
1910 );
1911 assert!(
1912 html.contains(r#"value="bren""#),
1913 "and the box still shows it"
1914 );
1915 }
1916
1917 #[tokio::test]
1918 async fn a_plain_sort_returns_the_whole_page() {
1919 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
1920 assert!(html.contains("<body"), "lists still work without htmx");
1921 assert!(html.contains(r#"id="lat-list""#));
1922 assert!(html.contains(r#"href="?sort=username"#));
1924 }
1925
1926 #[tokio::test]
1927 async fn query_rejects_bad_identifiers() {
1928 let (db, _guard) = test_db().await;
1929 let mut bad = config();
1930 bad.entity = "backend_users; drop table backend_users".to_string();
1931 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
1932 }
1933}