Skip to main content

laterite_admin/
list.rs

1//! Descriptor-driven list views.
2//!
3//! A [`ListConfig`] describes a table and the columns to show. A generic handler
4//! renders it, fetching rows with dynamic SQL built from the descriptor. This is
5//! the first slice of the descriptor system: admin screens are data, rendered by
6//! generic code, not hand-written per entity.
7//!
8//! The admin is inherently generic, so unlike the typed, compile-time-checked
9//! queries in `laterite-auth`, list queries are built and checked at runtime.
10
11use 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/// One column of a list view: the source field, its display label, and its
38/// column-type key (resolved through the column-type registry).
39#[derive(Debug, Clone, Serialize)]
40pub struct ListColumn {
41    pub field: String,
42    /// The column header, localized at render. Serde stays a plain string.
43    pub label: Text,
44    /// The column-type registry key (`text`, `date`, `boolean`, `status_pill`, ...).
45    pub column_type: String,
46    /// Whether the list's search box looks in this column. `None` follows the
47    /// column type: text columns are searched, the rest are not, because a
48    /// substring match on a boolean or a stored timestamp answers nonsense.
49    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    /// Render as a date and time in the display timezone.
68    pub fn datetime(self) -> Self {
69        self.of("datetime")
70    }
71    /// Render as a date in the display timezone.
72    pub fn date(self) -> Self {
73        self.of("date")
74    }
75    /// Render as a time in the display timezone.
76    pub fn time(self) -> Self {
77        self.of("time")
78    }
79    /// Render a boolean as Yes/No.
80    pub fn yes_no(self) -> Self {
81        self.of("boolean")
82    }
83    /// Render as a coloured status pill.
84    pub fn pill(self) -> Self {
85        self.of("status_pill")
86    }
87
88    /// Overrides whether the search box looks in this column.
89    pub fn searchable(mut self, searchable: bool) -> Self {
90        self.searchable = Some(searchable);
91        self
92    }
93
94    /// Whether search looks here: the explicit choice, else text columns only.
95    pub(crate) fn is_searchable(&self) -> bool {
96        self.searchable.unwrap_or(self.column_type == "text")
97    }
98}
99
100/// The per-cell state a column type renders from: the raw (text-cast) value, the
101/// display timezone, and the locale for date month/day names.
102pub struct CellCx<'a> {
103    pub value: &'a str,
104    pub tz: Tz,
105    pub locale: chrono::Locale,
106}
107
108/// The chrono locale for month/day names, parsed from the operator's locale tag.
109/// chrono locales are language + territory (`kn_IN`, `de_DE`, `ar_EG`), so a tag with
110/// no territory (a bare `kn`) or one chrono does not know formats in neutral English;
111/// a deployment gets localized dates by carrying a territory-bearing locale tag. No
112/// language is special-cased: any locale the locale table knows resolves.
113pub(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/// A rendered cell's serialisable payload: raw value, display text, and any
118/// richer data (a status pill's slug), so an override presents the same data.
119#[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
127/// A column type: how a list cell renders. The list counterpart of a field type,
128/// sharing [`Markup`] and the override resolver ([`Surface::Column`]).
129pub 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    /// Asset-registry keys this column's cell needs (a heavy cell widget). The
134    /// page shell collects and emits them; keys must exist in the registry.
135    fn assets(&self) -> Vec<&'static str> {
136        Vec::new()
137    }
138}
139
140/// A column type a module contributes, so a list can render a cell the framework
141/// has no built-in for (a rating, a map thumbnail, a currency amount).
142///
143/// The key is the type's own [`ColumnType::view_key`], so a registration cannot
144/// disagree with the type it registers. Use a dotted `vendor.name` to stay clear
145/// of the built-ins and of other modules.
146pub 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    /// The key this type registers under.
156    pub fn name(&self) -> &'static str {
157        self.column_type.view_key()
158    }
159}
160
161/// The column-type registry, keyed by [`ColumnType::view_key`].
162pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
163
164/// Renders a cell: an override if the resolver supplies one, else the default.
165pub(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
189/// The column registry seeded with the built-in types.
190pub(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
203/// The view-model for a text-like cell: raw value plus its display text.
204fn 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
220/// Formats a stored UTC RFC3339 timestamp in `tz`, with month/day names in `locale`;
221/// an unparseable value passes through unchanged.
222fn 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
321/// A status shown as a coloured pill: the first Markup-bearing cell.
322struct 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
345/// A CSS-safe modifier from a status value (lowercased, non-alphanumerics to `-`).
346fn 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/// One value a [`FilterKind::Select`] offers.
356#[derive(Debug, Clone, Serialize)]
357pub struct FilterOption {
358    pub value: String,
359    /// The option's label, localized at render.
360    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/// What a filter offers and how its value becomes a condition.
373#[derive(Debug, Clone, Serialize)]
374pub enum FilterKind {
375    /// A yes/no column. Booleans are integer columns on every backend, so the
376    /// value binds as an integer and compares portably.
377    Boolean,
378    /// One of a declared set of values. Only a declared value reaches the query.
379    Select { options: Vec<FilterOption> },
380}
381
382/// One filter offered above a list: a column, its label, and what it offers.
383#[derive(Debug, Clone, Serialize)]
384pub struct ListFilter {
385    pub field: String,
386    /// The control's label, localized at render.
387    pub label: Text,
388    pub kind: FilterKind,
389}
390
391impl ListFilter {
392    /// A yes/no filter over a boolean column.
393    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    /// A filter offering a fixed set of values.
402    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    /// The query-string key carrying this filter's value. Prefixed so a filter
411    /// on a column named `q` or `sort` cannot collide with the list's own params.
412    pub(crate) fn param(&self) -> String {
413        format!("f_{}", self.field)
414    }
415}
416
417/// A button a list offers in its toolbar, beside the built-in New.
418///
419/// A resource declares its own; the framework contributes New and the export
420/// menu the same way, so the toolbar is one list of buttons rather than a
421/// hardcoded bar with special cases.
422#[derive(Debug, Clone, Serialize)]
423pub struct ToolbarButton {
424    /// The button's text, localized at render.
425    pub label: Text,
426    /// Where it goes: a path under the admin root (starting with a slash), or an
427    /// absolute URL.
428    pub href: String,
429    /// An icon name from the shared set. Absent renders the label alone.
430    pub icon: Option<String>,
431    /// Hidden from an operator who lacks this permission. Absent shows it to
432    /// anyone who can reach the screen.
433    pub permission: Option<String>,
434    /// Rendered as the prominent action rather than a secondary one.
435    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    /// Hides the button from an operator without this permission.
455    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
466/// One rendered toolbar button.
467pub struct ToolbarView {
468    pub label: String,
469    pub href: String,
470    /// Inline SVG for the icon, empty when the button has none.
471    pub icon: String,
472    pub primary: bool,
473}
474
475/// The toolbar an operator sees: the framework's New (when the resource creates)
476/// followed by the resource's own buttons, minus anything they lack permission
477/// for.
478fn 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
516/// The preference key holding one operator's chosen columns for a list. Keyed by
517/// the list's own path, so two resources over the same table stay separate.
518pub(crate) fn columns_preference_key(base_path: &str) -> String {
519    format!("list.columns.{base_path}")
520}
521
522/// One operator's stored column choice for a list, or `None` if they have none.
523///
524/// A failed read is a warning, not an error: a preference is a convenience, so
525/// the screen shows every column rather than failing.
526pub(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
546/// The descriptor with only the columns this operator sees.
547pub(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
557/// The columns to show: the operator's stored choice, narrowed to what the
558/// descriptor still declares.
559///
560/// A stored choice that no longer names any declared column falls back to all of
561/// them, so a descriptor that drops or renames a column leaves an operator with a
562/// working list rather than an empty one.
563pub(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/// A list view descriptor: which table, which columns, default ordering, page
587/// size, and (optionally) where per-row edit links point.
588#[derive(Debug, Clone, Serialize)]
589pub struct ListConfig {
590    pub entity: String,
591    /// The screen title, localized at render. Serde stays a plain string.
592    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    /// When set, rows link to `{edit_base}/{id}/edit`.
599    pub edit_base: Option<String>,
600    /// Whether to offer a "New" link to `{edit_base}/new`. A resource that only
601    /// edits existing records (no create screen) sets this false.
602    pub creatable: bool,
603    /// Filters offered above the table. Empty hides the bar.
604    pub filters: Vec<ListFilter>,
605    /// Whether rows can be selected and deleted from this list. Off by default:
606    /// a list that shows a log, or records another screen owns, has no business
607    /// offering it.
608    pub deletable: bool,
609    /// Extra buttons in the toolbar, beside New and the export menu.
610    pub toolbar: Vec<ToolbarButton>,
611}
612
613impl Default for ListConfig {
614    /// A minimal list: no columns, newest-id first, 25 to a page, read-only.
615    ///
616    /// Descriptors use this as the tail of a struct literal
617    /// (`..Default::default()`) and set only what they mean. A field added here
618    /// later is then additive for every descriptor rather than breaking it, which
619    /// three consecutive additions made the case for.
620    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/// Query-string parameters for a list view.
639#[derive(Deserialize)]
640pub struct ListParams {
641    page: Option<i64>,
642    sort: Option<String>,
643    dir: Option<String>,
644    q: Option<String>,
645}
646
647/// The ordering a request resolves to: the submitted `sort` when it names a
648/// column the descriptor declares, the descriptor's own order otherwise. Only a
649/// declared column reaches the query, so a crafted `sort` cannot order by an
650/// arbitrary column. An unrecognised `dir` falls back the same way.
651pub(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        // No explicit direction: the configured one for the configured column, so
664        // a list ordered newest-first stays that way until a header is clicked.
665        _ if by == config.order_by => config.order_dir,
666        _ => SortDir::Asc,
667    };
668    (by, dir)
669}
670
671/// One rendered row: its id (for edit links) and its display cells.
672pub struct RowView {
673    pub id: String,
674    pub cells: Vec<String>,
675}
676
677/// Display-ready rows plus the total row count for the pager.
678pub struct ListPage {
679    pub rows: Vec<RowView>,
680    pub total: i64,
681}
682
683/// What one list request asks for: the page window, the resolved ordering, and
684/// the search term (empty for no search).
685pub(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    /// Rows per round trip. `None` uses the descriptor's page size; the export
692    /// reads larger pages because it is building a file, not a screen.
693    pub per_page: Option<i64>,
694}
695
696/// A filter the request actually selected: the declared filter and its accepted
697/// value. Built only from declared filters and, for a select, declared options,
698/// so a crafted parameter cannot reach the query.
699pub(crate) struct ActiveFilter<'a> {
700    filter: &'a ListFilter,
701    /// The raw value as submitted, for re-rendering the control and the links.
702    raw: String,
703    value: sea_query::Value,
704}
705
706/// Reads the declared filters out of the request's query parameters, dropping
707/// anything undeclared, unrecognised, or blank.
708pub(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            // Only the two spellings the control emits; anything else is dropped
722            // rather than guessed at.
723            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
744/// The filters ANDed together, or `None` when none are active.
745fn 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
756/// The condition matching `q` across the searchable columns, or `None` when
757/// there is nothing to match: a blank term, a term that folds away, or a list
758/// whose columns are all unsearchable.
759fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
760    // Capability-gated matchers are deferred and `Db` carries no
761    // capability set yet, so the portable default applies. Descriptor-named
762    // profiles are PR4; until then a list folds case, like the picker.
763    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
776/// Runs the list query for a config, returning display-ready rows and the total.
777/// Built with `sea-query` and dynamic identifiers (`Alias`), and every selected
778/// column is cast to text so a value of any type reads back uniformly as a
779/// string for display, without a Postgres-specific `row_to_json`.
780pub(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    // Scope each sea-query builder so it drops before the await that follows: its
800    // identifiers are reference-counted (not `Send`), and a live builder across
801    // the await would make this future non-`Send`.
802    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        // The same conditions, so the pager counts what the list returns.
837        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
864/// Reads a text-cast column as a display string, treating null or a decode
865/// error as empty.
866fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
867    // `get_text_opt` falls back to a byte read for MySQL, where a cast-to-char of
868    // a `text` column still comes back typed as BLOB.
869    row.get_text_opt(column).ok().flatten().unwrap_or_default()
870}
871
872/// Renders a list view for the given config.
873#[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    // The operator's chosen columns narrow the descriptor once, here, so
885    // everything downstream (the query, the headers, sorting, searching) works
886    // from one list: what is shown is what is queried.
887    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    // Everything a sort or pager link must preserve, as raw `&k=v` pairs. Askama
906    // escapes it into the href, so the ampersands are correct in the markup.
907    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            // Localize the title and column headers before the shell is moved in.
960            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                        // Clicking the sorted column flips it; any other column
974                        // starts ascending.
975                        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            // Built before the literal moves `order_by` into `sort`.
1000            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            // An htmx request gets the list region alone, which replaces itself in
1027            // place; anything else gets the whole page, so sorting and paging still
1028            // work as ordinary links with scripting off.
1029            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
1054/// Stores which columns this operator wants on this list.
1055///
1056/// Choosing every column clears the preference rather than storing them all, so
1057/// the operator keeps following the descriptor as it gains or loses columns.
1058/// Choosing none is refused: a list with no columns shows nothing and offers no
1059/// way back, so the request is treated as "no preference".
1060pub(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
1100/// One row of the column picker.
1101pub struct ColumnChoice {
1102    pub field: String,
1103    pub label: String,
1104    pub shown: bool,
1105}
1106
1107/// One column header: its label, the field a click sorts by, the direction that
1108/// click asks for, and the direction it is sorted in now (empty when it is not).
1109pub 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    /// The count on this page (`rows.len()`), precomputed as `i64` for the footer.
1124    shown: i64,
1125    page: i64,
1126    total: i64,
1127    total_pages: i64,
1128    edit_base: Option<String>,
1129    creatable: bool,
1130    /// The active ordering, carried on the pager links so paging keeps the sort.
1131    sort: String,
1132    dir: String,
1133    /// The active search term, shown in the box and carried on every link.
1134    q: String,
1135    /// Whether any column is searchable, so the box appears at all.
1136    searchable: bool,
1137    /// Whether rows carry a checkbox and the Delete button is offered.
1138    deletable: bool,
1139    /// Every declared column, with the shown ones ticked, for the picker.
1140    pickers: Vec<ColumnChoice>,
1141    /// The toolbar buttons this operator may see.
1142    toolbar: Vec<ToolbarView>,
1143    /// The search, filters and sort, as `&k=v` pairs, so an export link carries
1144    /// the same view the screen is showing.
1145    export_query: String,
1146    /// This list's own path, for the search form to post back to.
1147    path: String,
1148    /// The filter controls, with the active value marked.
1149    filters: Vec<FilterView>,
1150    /// Whether anything is narrowing the list, so a Clear link is offered.
1151    filtered: bool,
1152    /// The query-string pairs a sort or pager link must carry.
1153    carry: String,
1154}
1155
1156/// One rendered filter control.
1157pub struct FilterView {
1158    pub param: String,
1159    pub label: String,
1160    pub boolean: bool,
1161    pub options: Vec<FilterOptionView>,
1162    /// The selected value, empty when the filter is off.
1163    pub value: String,
1164}
1165
1166pub struct FilterOptionView {
1167    pub value: String,
1168    pub label: String,
1169}
1170
1171/// Builds the filter controls, marking whichever value the request selected.
1172fn 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
1222/// Percent-encodes a query-string value.
1223fn 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/// The list region alone, for an htmx sort or page that swaps it in place.
1238#[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    /// Rendered onto the region as `data-filtered`, so the bar's Clear link can
1253    /// react without the bar itself having to swap. Also picks the empty state:
1254    /// a filtered list with no rows has not run out of records, it has no match.
1255    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        // 10:00 UTC is 15:30 in Asia/Kolkata (UTC+5:30)
1296        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        // An unparseable timestamp falls through unchanged.
1312        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    /// The whitelist: a column the descriptor does not declare never reaches the
1338    /// query, however it is spelled.
1339    #[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        // On a column other than the configured one, ascending.
1360        assert_eq!(
1361            resolve_sort(&c, Some("username"), Some("sideways")).1,
1362            SortDir::Asc
1363        );
1364        // On the configured column, its configured direction.
1365        assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1366    }
1367
1368    /// A module's own column type renders its cells, which is the point of the
1369    /// registry being open rather than a fixed set.
1370    #[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    /// A contributed type cannot quietly replace a built-in: the key is the
1410    /// type's own `view_key`, and the router refuses a duplicate at boot.
1411    #[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        // The router inserts into this map and panics when one is already there.
1428        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    /// A fresh test database with the auth tables migrated in, on whichever
1444    /// backend the run targets. Hold the returned guard for the test's lifetime.
1445    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        // Booleans store as 0/1 integers everywhere, so a cast-to-text superuser
1471        // flag reads back as "1"; the display layer maps it to "Yes".
1472        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    /// The default window and ordering, with no search and no filter.
1517    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    /// Both `&amp;` and `&#38;` are an ampersand; the escaper picks one, and a
1540    /// test should assert on the link, not on which spelling it chose.
1541    fn amps(html: &str) -> String {
1542        html.replace("&#38;", "&").replace("&amp;", "&")
1543    }
1544
1545    /// The request's filter parameters, as the query string would carry them.
1546    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        // The pager counts the filtered set, not the table.
1576        assert_eq!(hit.total, 1);
1577
1578        // Case folds, as the picker's default profile does.
1579        assert_eq!(
1580            query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
1581            1
1582        );
1583
1584        // A blank term is no filter, not a filter matching nothing.
1585        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        // A LIKE wildcard is literal, so it does not match every row.
1589        assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
1590    }
1591
1592    /// Only text columns are searched by default: a substring match against a
1593    /// boolean would make every row containing `1` a hit.
1594    #[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        // The stored superuser flag is `1`, but searching it finds nothing.
1605        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    /// The whitelist: only a declared filter, and for a select only a declared
1668    /// option, reaches the query. Anything else is dropped, not guessed at.
1669    #[test]
1670    fn an_undeclared_filter_or_value_is_ignored() {
1671        let c = config();
1672        // A column that exists but is not a declared filter.
1673        assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
1674        // A declared boolean filter with a value its control never emits.
1675        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        // The sorted column's own link flips direction and keeps it too.
1716        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    /// A filtered list with no rows has not run out of records; it has no match.
1729    /// Saying "No records yet" there reads as an empty table and hides the filter.
1730    #[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        // Nothing stored and nothing asked for: the table is genuinely empty.
1766        let bare = render(HashMap::new(), None).await;
1767        assert!(bare.contains("No records yet."));
1768
1769        // Filtered to nothing: the records may exist, the filter excluded them.
1770        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        // A search that matches nothing reads the same way.
1775        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        // Stored out of order; the descriptor's order still wins, because the
1783        // choice is about which columns, not where they sit.
1784        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    /// A descriptor that drops or renames a column must not leave an operator
1798    /// staring at an empty table because of a choice they made months ago.
1799    #[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        // A partly stale choice keeps what still exists.
1809        assert_eq!(visible_columns(&c, Some("gone,username")).len(), 1);
1810    }
1811
1812    /// A button the operator cannot use is not shown, rather than shown and
1813    /// answering 403 when they reach it.
1814    #[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        // A superuser sees everything, gated or not.
1827        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        // Someone holding the named permission sees it too.
1838        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        // Someone holding nothing sees only the ungated button.
1850        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    /// A relative href is resolved against the configured admin mount, so a moved
1862    /// panel moves its buttons with it.
1863    #[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        // An absolute URL is left alone.
1883        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        // A fragment, not a page: swapping a document into the region would nest
1894        // the admin inside itself.
1895        assert!(!html.contains("<body"), "no page chrome");
1896        // The bar cannot re-render per swap, so the region carries the state.
1897        assert!(html.contains(r#"data-filtered="false""#));
1898    }
1899
1900    /// A sort or a page must not silently drop the search, or the second click
1901    /// would widen the result back to the whole table.
1902    #[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        // The links work without scripting, so they carry a real href.
1923        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}