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.
222pub(crate) fn 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///
589/// Built with [`ListConfig::new`] plus its builder methods. Non-exhaustive, so a
590/// struct literal outside this crate cannot freeze the field set at 1.0; that
591/// rules out `..Default::default()` too, which is why the builders exist.
592#[derive(Debug, Clone, Serialize)]
593#[non_exhaustive]
594pub struct ListConfig {
595    pub entity: String,
596    /// The screen title, localized at render. Serde stays a plain string.
597    pub title: Text,
598    pub columns: Vec<ListColumn>,
599    pub order_by: String,
600    pub order_dir: SortDir,
601    pub per_page: i64,
602    pub id_field: String,
603    /// When set, rows link to `{edit_base}/{id}/edit`.
604    pub edit_base: Option<String>,
605    /// Whether to offer a "New" link to `{edit_base}/new`. A resource that only
606    /// edits existing records (no create screen) sets this false.
607    pub creatable: bool,
608    /// Filters offered above the table. Empty hides the bar.
609    pub filters: Vec<ListFilter>,
610    /// Whether rows can be selected and deleted from this list. Off by default:
611    /// a list that shows a log, or records another screen owns, has no business
612    /// offering it.
613    pub deletable: bool,
614    /// Extra buttons in the toolbar, beside New and the export menu.
615    pub toolbar: Vec<ToolbarButton>,
616}
617
618impl ListConfig {
619    /// A list over `entity` titled `title`, showing `columns`.
620    ///
621    /// Everything else takes the defaults below and is opted into through a
622    /// builder method, so a field added later costs existing descriptors nothing.
623    pub fn new(
624        entity: impl Into<String>,
625        title: impl Into<Text>,
626        columns: Vec<ListColumn>,
627    ) -> Self {
628        Self {
629            entity: entity.into(),
630            title: title.into(),
631            columns,
632            ..Self::default()
633        }
634    }
635
636    /// Orders by `column` in `dir` rather than newest id first.
637    pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
638        self.order_by = column.into();
639        self.order_dir = dir;
640        self
641    }
642
643    pub fn per_page(mut self, rows: i64) -> Self {
644        self.per_page = rows;
645        self
646    }
647
648    /// The primary-key column, when it is not `id`.
649    pub fn id_field(mut self, column: impl Into<String>) -> Self {
650        self.id_field = column.into();
651        self
652    }
653
654    /// Links rows to `{base}/{id}/edit`.
655    pub fn edit_base(mut self, base: impl Into<String>) -> Self {
656        self.edit_base = Some(base.into());
657        self
658    }
659
660    /// Offers a New link to `{edit_base}/new`. Opt in, matching the read-only
661    /// default: a list over a log, or over records another screen owns, should
662    /// not invite an operator to create one.
663    pub fn creatable(mut self) -> Self {
664        self.creatable = true;
665        self
666    }
667
668    pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
669        self.filters = filters;
670        self
671    }
672
673    /// Lets rows be selected and deleted. Off by default: a list showing a log,
674    /// or records another screen owns, has no business offering it.
675    pub fn deletable(mut self) -> Self {
676        self.deletable = true;
677        self
678    }
679
680    pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
681        self.toolbar = buttons;
682        self
683    }
684}
685
686impl Default for ListConfig {
687    /// A minimal list: no columns, newest-id first, 25 to a page, read-only.
688    ///
689    /// Descriptors use this as the tail of a struct literal
690    /// (`..Default::default()`) and set only what they mean. A field added here
691    /// later is then additive for every descriptor rather than breaking it, which
692    /// three consecutive additions made the case for.
693    fn default() -> Self {
694        Self {
695            entity: String::new(),
696            title: Text::new(""),
697            columns: Vec::new(),
698            order_by: "id".to_string(),
699            order_dir: SortDir::Desc,
700            per_page: 25,
701            id_field: "id".to_string(),
702            edit_base: None,
703            creatable: false,
704            filters: Vec::new(),
705            deletable: false,
706            toolbar: Vec::new(),
707        }
708    }
709}
710
711/// Query-string parameters for a list view.
712#[derive(Deserialize)]
713pub struct ListParams {
714    page: Option<i64>,
715    sort: Option<String>,
716    dir: Option<String>,
717    q: Option<String>,
718}
719
720/// The ordering a request resolves to: the submitted `sort` when it names a
721/// column the descriptor declares, the descriptor's own order otherwise. Only a
722/// declared column reaches the query, so a crafted `sort` cannot order by an
723/// arbitrary column. An unrecognised `dir` falls back the same way.
724pub(crate) fn resolve_sort(
725    config: &ListConfig,
726    sort: Option<&str>,
727    dir: Option<&str>,
728) -> (String, SortDir) {
729    let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s)) {
730        Some(field) => field.to_string(),
731        None => config.order_by.clone(),
732    };
733    let dir = match dir {
734        Some("asc") => SortDir::Asc,
735        Some("desc") => SortDir::Desc,
736        // No explicit direction: the configured one for the configured column, so
737        // a list ordered newest-first stays that way until a header is clicked.
738        _ if by == config.order_by => config.order_dir,
739        _ => SortDir::Asc,
740    };
741    (by, dir)
742}
743
744/// One rendered row: its id (for edit links) and its display cells.
745pub struct RowView {
746    pub id: String,
747    pub cells: Vec<String>,
748}
749
750/// Display-ready rows plus the total row count for the pager.
751pub struct ListPage {
752    pub rows: Vec<RowView>,
753    pub total: i64,
754}
755
756/// What one list request asks for: the page window, the resolved ordering, and
757/// the search term (empty for no search).
758pub(crate) struct ListQuery<'a> {
759    pub offset: i64,
760    pub order_by: &'a str,
761    pub order_dir: SortDir,
762    pub q: &'a str,
763    pub filters: &'a [ActiveFilter<'a>],
764    /// Rows per round trip. `None` uses the descriptor's page size; the export
765    /// reads larger pages because it is building a file, not a screen.
766    pub per_page: Option<i64>,
767}
768
769/// A filter the request actually selected: the declared filter and its accepted
770/// value. Built only from declared filters and, for a select, declared options,
771/// so a crafted parameter cannot reach the query.
772pub(crate) struct ActiveFilter<'a> {
773    filter: &'a ListFilter,
774    /// The raw value as submitted, for re-rendering the control and the links.
775    raw: String,
776    value: sea_query::Value,
777}
778
779/// Reads the declared filters out of the request's query parameters, dropping
780/// anything undeclared, unrecognised, or blank.
781pub(crate) fn resolve_filters<'a>(
782    config: &'a ListConfig,
783    params: &HashMap<String, String>,
784) -> Vec<ActiveFilter<'a>> {
785    let mut out = Vec::new();
786    for filter in &config.filters {
787        let Some(raw) = params.get(&filter.param()).map(|v| v.trim()) else {
788            continue;
789        };
790        if raw.is_empty() {
791            continue;
792        }
793        let value = match &filter.kind {
794            // Only the two spellings the control emits; anything else is dropped
795            // rather than guessed at.
796            FilterKind::Boolean => match raw {
797                "1" => sea_query::Value::Bool(Some(true)),
798                "0" => sea_query::Value::Bool(Some(false)),
799                _ => continue,
800            },
801            FilterKind::Select { options } => {
802                if !options.iter().any(|o| o.value == raw) {
803                    continue;
804                }
805                sea_query::Value::String(Some(Box::new(raw.to_string())))
806            }
807        };
808        out.push(ActiveFilter {
809            filter,
810            raw: raw.to_string(),
811            value,
812        });
813    }
814    out
815}
816
817/// The filters ANDed together, or `None` when none are active.
818fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
819    if active.is_empty() {
820        return None;
821    }
822    let mut all = sea_query::Condition::all();
823    for f in active {
824        all = all.add(Expr::col(Alias::new(&f.filter.field)).eq(f.value.clone()));
825    }
826    Some(all)
827}
828
829/// The condition matching `q` across the searchable columns, or `None` when
830/// there is nothing to match: a blank term, a term that folds away, or a list
831/// whose columns are all unsearchable.
832fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
833    // Capability-gated matchers are deferred and `Db` carries no
834    // capability set yet, so the portable default applies. Descriptor-named
835    // profiles are PR4; until then a list folds case, like the picker.
836    let caps = laterite_core::capabilities::CapabilitySet::default();
837    let profile = SearchProfile::new();
838    let mut any = sea_query::Condition::any();
839    let mut matched = false;
840    for column in config.columns.iter().filter(|c| c.is_searchable()) {
841        if let Some(cond) = profile.condition(&caps, &column.field, q) {
842            any = any.add(cond);
843            matched = true;
844        }
845    }
846    matched.then_some(any)
847}
848
849/// Runs the list query for a config, returning display-ready rows and the total.
850/// Built with `sea-query` and dynamic identifiers (`Alias`), and every selected
851/// column is cast to text so a value of any type reads back uniformly as a
852/// string for display, without a Postgres-specific `row_to_json`.
853pub(crate) async fn query(
854    db: &Db,
855    config: &ListConfig,
856    req: &ListQuery<'_>,
857) -> anyhow::Result<ListPage> {
858    if !valid_ident(&config.entity)
859        || !valid_ident(&config.order_by)
860        || !valid_ident(req.order_by)
861        || !valid_ident(&config.id_field)
862        || !config.columns.iter().all(|c| valid_ident(&c.field))
863        || !config.filters.iter().all(|f| valid_ident(&f.field))
864    {
865        anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
866    }
867
868    let dir = match req.order_dir {
869        SortDir::Asc => Order::Asc,
870        SortDir::Desc => Order::Desc,
871    };
872    // Scope each sea-query builder so it drops before the await that follows: its
873    // identifiers are reference-counted (not `Send`), and a live builder across
874    // the await would make this future non-`Send`.
875    let (sql, values) = {
876        let mut select = Query::select();
877        for column in &config.columns {
878            select.expr_as(
879                Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
880                Alias::new(&column.field),
881            );
882        }
883        select
884            .expr_as(
885                Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
886                Alias::new(ID_ALIAS),
887            )
888            .from(Alias::new(&config.entity))
889            .order_by(Alias::new(req.order_by), dir)
890            .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
891            .offset(req.offset.max(0) as u64);
892        if let Some(cond) = search_condition(config, req.q) {
893            select.cond_where(cond);
894        }
895        if let Some(cond) = filter_condition(req.filters) {
896            select.cond_where(cond);
897        }
898        build(db.backend, select)
899    };
900    let raw = bind_values(sqlx::query(&sql), values)
901        .fetch_all(&db.pool)
902        .await?;
903
904    let (csql, cvalues) = {
905        let mut count = Query::select();
906        count
907            .expr(Expr::col(Alias::new(&config.id_field)).count())
908            .from(Alias::new(&config.entity));
909        // The same conditions, so the pager counts what the list returns.
910        if let Some(cond) = search_condition(config, req.q) {
911            count.cond_where(cond);
912        }
913        if let Some(cond) = filter_condition(req.filters) {
914            count.cond_where(cond);
915        }
916        build(db.backend, count)
917    };
918    let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
919        .fetch_one(&db.pool)
920        .await?
921        .0;
922
923    let rows = raw
924        .iter()
925        .map(|row| RowView {
926            id: get_text(row, ID_ALIAS),
927            cells: config
928                .columns
929                .iter()
930                .map(|c| get_text(row, &c.field))
931                .collect(),
932        })
933        .collect();
934    Ok(ListPage { rows, total })
935}
936
937/// Reads a text-cast column as a display string, treating null or a decode
938/// error as empty.
939fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
940    // `get_text_opt` falls back to a byte read for MySQL, where a cast-to-char of
941    // a `text` column still comes back typed as BLOB.
942    row.get_text_opt(column).ok().flatten().unwrap_or_default()
943}
944
945/// Renders a list view for the given config.
946#[allow(clippy::too_many_arguments)]
947pub(crate) async fn handle(
948    state: &AdminState,
949    config: &ListConfig,
950    path: &str,
951    params: ListParams,
952    raw: &HashMap<String, String>,
953    user: &laterite_auth::AuthenticatedUser,
954    shell: crate::Shell,
955    headers: &axum::http::HeaderMap,
956) -> Response {
957    // The operator's chosen columns narrow the descriptor once, here, so
958    // everything downstream (the query, the headers, sorting, searching) works
959    // from one list: what is shown is what is queried.
960    let stored = stored_columns(state, path, user).await;
961    let declared = config;
962    let narrowed = narrowed(config, stored.as_deref());
963    let config = &narrowed;
964
965    let page = params.page.unwrap_or(1).max(1);
966    let offset = (page - 1) * config.per_page;
967    let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
968    let q = params.q.unwrap_or_default();
969    let active = resolve_filters(config, raw);
970    let req = ListQuery {
971        offset,
972        order_by: &order_by,
973        order_dir,
974        q: q.trim(),
975        filters: &active,
976        per_page: None,
977    };
978    // Everything a sort or pager link must preserve, as raw `&k=v` pairs. Askama
979    // escapes it into the href, so the ampersands are correct in the markup.
980    let mut carry = String::new();
981    if !q.trim().is_empty() {
982        carry.push_str(&format!("&q={}", urlencode(q.trim())));
983    }
984    for f in &active {
985        carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
986    }
987    match query(&state.db, config, &req).await {
988        Ok(result) => {
989            let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
990            let date_loc = date_locale(shell.locale());
991            let rows: Vec<RowView> = result
992                .rows
993                .into_iter()
994                .map(|row| RowView {
995                    id: row.id,
996                    cells: row
997                        .cells
998                        .iter()
999                        .zip(&config.columns)
1000                        .map(|(raw, col)| {
1001                            let cx = CellCx {
1002                                value: raw,
1003                                tz: shell.tz,
1004                                locale: date_loc,
1005                            };
1006                            let scope = OverrideScope {
1007                                surface: Surface::Column,
1008                                view_key: &col.column_type,
1009                                resource: Some(&config.entity),
1010                                field: Some(&col.field),
1011                            };
1012                            match state.column_types.get(&col.column_type) {
1013                                Some(ct) => {
1014                                    render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
1015                                        .into_string()
1016                                }
1017                                None => String::new(),
1018                            }
1019                        })
1020                        .collect(),
1021                })
1022                .collect();
1023            let keys: Vec<&str> = config
1024                .columns
1025                .iter()
1026                .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1027                .flatten()
1028                .collect();
1029            let mut shell = shell;
1030            shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1031            let shown = rows.len() as i64;
1032            // Localize the title and column headers before the shell is moved in.
1033            let title = shell.tt(&config.title);
1034            let active_dir = match order_dir {
1035                SortDir::Asc => "asc",
1036                SortDir::Desc => "desc",
1037            };
1038            let columns: Vec<ColumnHead> = config
1039                .columns
1040                .iter()
1041                .map(|c| {
1042                    let active = c.field == order_by;
1043                    ColumnHead {
1044                        label: shell.tt(&c.label),
1045                        field: c.field.clone(),
1046                        // Clicking the sorted column flips it; any other column
1047                        // starts ascending.
1048                        next_dir: if active && active_dir == "asc" {
1049                            "desc".to_string()
1050                        } else {
1051                            "asc".to_string()
1052                        },
1053                        active: if active {
1054                            active_dir.to_string()
1055                        } else {
1056                            String::new()
1057                        },
1058                    }
1059                })
1060                .collect();
1061            let shell_for_toolbar = shell.clone();
1062            let filter_views = filter_views(config, &active, &shell);
1063            let pickers: Vec<ColumnChoice> = declared
1064                .columns
1065                .iter()
1066                .map(|c| ColumnChoice {
1067                    field: c.field.clone(),
1068                    label: shell.tt(&c.label),
1069                    shown: config.columns.iter().any(|v| v.field == c.field),
1070                })
1071                .collect();
1072            // Built before the literal moves `order_by` into `sort`.
1073            let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1074            let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1075            let page_view = ListTemplate {
1076                shell,
1077                title,
1078                columns,
1079                rows,
1080                shown,
1081                page,
1082                total: result.total,
1083                total_pages,
1084                edit_base: config.edit_base.clone(),
1085                creatable: config.creatable,
1086                sort: order_by,
1087                dir: active_dir.to_string(),
1088                q: q.trim().to_string(),
1089                searchable: config.columns.iter().any(|c| c.is_searchable()),
1090                pickers,
1091                toolbar,
1092                export_query,
1093                path: path.to_string(),
1094                filters: filter_views,
1095                deletable: config.deletable,
1096                filtered: !active.is_empty() || !q.trim().is_empty(),
1097                carry: carry.clone(),
1098            };
1099            // An htmx request gets the list region alone, which replaces itself in
1100            // place; anything else gets the whole page, so sorting and paging still
1101            // work as ordinary links with scripting off.
1102            if crate::form::is_htmx(headers) {
1103                render(ListFragment {
1104                    shell: page_view.shell,
1105                    columns: page_view.columns,
1106                    rows: page_view.rows,
1107                    shown: page_view.shown,
1108                    page: page_view.page,
1109                    total: page_view.total,
1110                    total_pages: page_view.total_pages,
1111                    edit_base: page_view.edit_base,
1112                    sort: page_view.sort,
1113                    dir: page_view.dir,
1114                    carry: page_view.carry,
1115                    filtered: page_view.filtered,
1116                    creatable: page_view.creatable,
1117                    deletable: page_view.deletable,
1118                })
1119            } else {
1120                render(page_view)
1121            }
1122        }
1123        Err(_) => render_error(),
1124    }
1125}
1126
1127/// Stores which columns this operator wants on this list.
1128///
1129/// Choosing every column clears the preference rather than storing them all, so
1130/// the operator keeps following the descriptor as it gains or loses columns.
1131/// Choosing none is refused: a list with no columns shows nothing and offers no
1132/// way back, so the request is treated as "no preference".
1133pub(crate) async fn set_columns(
1134    state: &AdminState,
1135    config: &ListConfig,
1136    path: &str,
1137    user: &laterite_auth::AuthenticatedUser,
1138    session: &crate::session::SessionHandle,
1139    headers: &axum::http::HeaderMap,
1140    pairs: &[(String, String)],
1141) -> Response {
1142    let chosen: Vec<&str> = pairs
1143        .iter()
1144        .filter(|(k, _)| k == "column")
1145        .map(|(_, v)| v.trim())
1146        .filter(|v| config.columns.iter().any(|c| c.field == *v))
1147        .collect();
1148
1149    let key = columns_preference_key(path);
1150    let result = if chosen.is_empty() || chosen.len() == config.columns.len() {
1151        laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1152    } else {
1153        laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1154            .await
1155    };
1156    match result {
1157        Ok(()) => session.push_flash(
1158            crate::session::FlashLevel::Success,
1159            laterite_core::t!("Columns updated."),
1160        ),
1161        Err(e) => {
1162            tracing::error!(error = %e, "storing the column choice failed");
1163            session.push_flash(
1164                crate::session::FlashLevel::Error,
1165                laterite_core::t!("Could not save your column choice."),
1166            );
1167        }
1168    }
1169    let back = format!("{}{}", state.admin_path, path);
1170    crate::form::saved_response(crate::form::is_htmx(headers), &back)
1171}
1172
1173/// One row of the column picker.
1174pub struct ColumnChoice {
1175    pub field: String,
1176    pub label: String,
1177    pub shown: bool,
1178}
1179
1180/// One column header: its label, the field a click sorts by, the direction that
1181/// click asks for, and the direction it is sorted in now (empty when it is not).
1182pub struct ColumnHead {
1183    pub label: String,
1184    pub field: String,
1185    pub next_dir: String,
1186    pub active: String,
1187}
1188
1189#[derive(Template)]
1190#[template(path = "list.html")]
1191struct ListTemplate {
1192    shell: crate::Shell,
1193    title: String,
1194    columns: Vec<ColumnHead>,
1195    rows: Vec<RowView>,
1196    /// The count on this page (`rows.len()`), precomputed as `i64` for the footer.
1197    shown: i64,
1198    page: i64,
1199    total: i64,
1200    total_pages: i64,
1201    edit_base: Option<String>,
1202    creatable: bool,
1203    /// The active ordering, carried on the pager links so paging keeps the sort.
1204    sort: String,
1205    dir: String,
1206    /// The active search term, shown in the box and carried on every link.
1207    q: String,
1208    /// Whether any column is searchable, so the box appears at all.
1209    searchable: bool,
1210    /// Whether rows carry a checkbox and the Delete button is offered.
1211    deletable: bool,
1212    /// Every declared column, with the shown ones ticked, for the picker.
1213    pickers: Vec<ColumnChoice>,
1214    /// The toolbar buttons this operator may see.
1215    toolbar: Vec<ToolbarView>,
1216    /// The search, filters and sort, as `&k=v` pairs, so an export link carries
1217    /// the same view the screen is showing.
1218    export_query: String,
1219    /// This list's own path, for the search form to post back to.
1220    path: String,
1221    /// The filter controls, with the active value marked.
1222    filters: Vec<FilterView>,
1223    /// Whether anything is narrowing the list, so a Clear link is offered.
1224    filtered: bool,
1225    /// The query-string pairs a sort or pager link must carry.
1226    carry: String,
1227}
1228
1229/// One rendered filter control.
1230pub struct FilterView {
1231    pub param: String,
1232    pub label: String,
1233    pub boolean: bool,
1234    pub options: Vec<FilterOptionView>,
1235    /// The selected value, empty when the filter is off.
1236    pub value: String,
1237}
1238
1239pub struct FilterOptionView {
1240    pub value: String,
1241    pub label: String,
1242}
1243
1244/// Builds the filter controls, marking whichever value the request selected.
1245fn filter_views(
1246    config: &ListConfig,
1247    active: &[ActiveFilter<'_>],
1248    shell: &crate::Shell,
1249) -> Vec<FilterView> {
1250    config
1251        .filters
1252        .iter()
1253        .map(|f| {
1254            let value = active
1255                .iter()
1256                .find(|a| a.filter.field == f.field)
1257                .map(|a| a.raw.clone())
1258                .unwrap_or_default();
1259            let (boolean, options) = match &f.kind {
1260                FilterKind::Boolean => (
1261                    true,
1262                    vec![
1263                        FilterOptionView {
1264                            value: "1".to_string(),
1265                            label: shell.tt(&laterite_core::t!("Yes")),
1266                        },
1267                        FilterOptionView {
1268                            value: "0".to_string(),
1269                            label: shell.tt(&laterite_core::t!("No")),
1270                        },
1271                    ],
1272                ),
1273                FilterKind::Select { options } => (
1274                    false,
1275                    options
1276                        .iter()
1277                        .map(|o| FilterOptionView {
1278                            value: o.value.clone(),
1279                            label: shell.tt(&o.label),
1280                        })
1281                        .collect(),
1282                ),
1283            };
1284            FilterView {
1285                param: f.param(),
1286                label: shell.tt(&f.label),
1287                boolean,
1288                options,
1289                value,
1290            }
1291        })
1292        .collect()
1293}
1294
1295/// Percent-encodes a query-string value.
1296fn urlencode(value: &str) -> String {
1297    let mut out = String::with_capacity(value.len());
1298    for byte in value.as_bytes() {
1299        match byte {
1300            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1301                out.push(*byte as char)
1302            }
1303            b' ' => out.push('+'),
1304            _ => out.push_str(&format!("%{byte:02X}")),
1305        }
1306    }
1307    out
1308}
1309
1310/// The list region alone, for an htmx sort or page that swaps it in place.
1311#[derive(Template)]
1312#[template(path = "_list_table.html")]
1313struct ListFragment {
1314    shell: crate::Shell,
1315    columns: Vec<ColumnHead>,
1316    rows: Vec<RowView>,
1317    shown: i64,
1318    page: i64,
1319    total: i64,
1320    total_pages: i64,
1321    edit_base: Option<String>,
1322    sort: String,
1323    dir: String,
1324    carry: String,
1325    /// Rendered onto the region as `data-filtered`, so the bar's Clear link can
1326    /// react without the bar itself having to swap. Also picks the empty state:
1327    /// a filtered list with no rows has not run out of records, it has no match.
1328    filtered: bool,
1329    creatable: bool,
1330    deletable: bool,
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335    use super::*;
1336
1337    fn config() -> ListConfig {
1338        ListConfig {
1339            entity: "backend_users".to_string(),
1340            title: "Users".into(),
1341            columns: vec![
1342                ListColumn::new("username", "Username"),
1343                ListColumn::new("is_superuser", "Superuser").yes_no(),
1344            ],
1345            order_by: "created_at".to_string(),
1346            order_dir: SortDir::Desc,
1347            per_page: 25,
1348            id_field: "id".to_string(),
1349            edit_base: None,
1350            creatable: false,
1351            filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1352            deletable: true,
1353            ..Default::default()
1354        }
1355    }
1356
1357    #[test]
1358    fn column_types_format_their_cell_display() {
1359        let ist: Tz = "Asia/Kolkata".parse().unwrap();
1360        let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1361            ct.view_model(&CellCx {
1362                value,
1363                tz,
1364                locale: chrono::Locale::en_US,
1365            })
1366            .display
1367        };
1368        // 10:00 UTC is 15:30 in Asia/Kolkata (UTC+5:30)
1369        assert_eq!(
1370            display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
1371            "13 Aug 2026, 15:30"
1372        );
1373        assert_eq!(
1374            display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
1375            "13 Aug 2026"
1376        );
1377        assert_eq!(
1378            display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
1379            "15:30"
1380        );
1381        assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
1382        assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
1383        assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
1384        // An unparseable timestamp falls through unchanged.
1385        assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
1386    }
1387
1388    #[test]
1389    fn sort_falls_back_to_the_configured_order() {
1390        let c = config();
1391        assert_eq!(
1392            resolve_sort(&c, None, None),
1393            ("created_at".to_string(), SortDir::Desc)
1394        );
1395    }
1396
1397    #[test]
1398    fn a_declared_column_can_be_sorted_either_way() {
1399        let c = config();
1400        assert_eq!(
1401            resolve_sort(&c, Some("username"), Some("asc")),
1402            ("username".to_string(), SortDir::Asc)
1403        );
1404        assert_eq!(
1405            resolve_sort(&c, Some("username"), Some("desc")),
1406            ("username".to_string(), SortDir::Desc)
1407        );
1408    }
1409
1410    /// The whitelist: a column the descriptor does not declare never reaches the
1411    /// query, however it is spelled.
1412    #[test]
1413    fn an_undeclared_sort_column_is_ignored() {
1414        let c = config();
1415        for crafted in [
1416            "password_hash",
1417            "id) --",
1418            "username; drop table backend_users",
1419            "",
1420        ] {
1421            assert_eq!(
1422                resolve_sort(&c, Some(crafted), Some("asc")).0,
1423                "created_at",
1424                "{crafted} must not reach the query"
1425            );
1426        }
1427    }
1428
1429    #[test]
1430    fn an_unknown_direction_falls_back() {
1431        let c = config();
1432        // On a column other than the configured one, ascending.
1433        assert_eq!(
1434            resolve_sort(&c, Some("username"), Some("sideways")).1,
1435            SortDir::Asc
1436        );
1437        // On the configured column, its configured direction.
1438        assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1439    }
1440
1441    /// A module's own column type renders its cells, which is the point of the
1442    /// registry being open rather than a fixed set.
1443    #[test]
1444    fn a_contributed_column_type_renders_its_cell() {
1445        struct Rating;
1446        impl ColumnType for Rating {
1447            fn view_key(&self) -> &'static str {
1448                "acme.rating"
1449            }
1450            fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1451                let stars = cx.value.parse::<usize>().unwrap_or(0);
1452                text_cell("acme.rating", cx.value, "*".repeat(stars))
1453            }
1454            fn render_default(&self, vm: &CellVm) -> Markup {
1455                render_text(vm)
1456            }
1457        }
1458
1459        let reg = ColumnTypeReg::new(Arc::new(Rating));
1460        assert_eq!(reg.name(), "acme.rating", "the type names itself");
1461
1462        let mut registry = builtin_column_registry();
1463        registry.insert(reg.name().to_string(), reg.column_type);
1464        let rendered = render_cell(
1465            registry.get("acme.rating").unwrap().as_ref(),
1466            &crate::field::NoOverrides,
1467            &OverrideScope {
1468                surface: Surface::Column,
1469                view_key: "acme.rating",
1470                resource: Some("products"),
1471                field: Some("stars"),
1472            },
1473            &CellCx {
1474                value: "3",
1475                tz: Tz::UTC,
1476                locale: chrono::Locale::en_US,
1477            },
1478        );
1479        assert!(rendered.into_string().contains("***"));
1480    }
1481
1482    /// A contributed type cannot quietly replace a built-in: the key is the
1483    /// type's own `view_key`, and the router refuses a duplicate at boot.
1484    #[test]
1485    fn a_contributed_key_is_the_types_own_and_collides_visibly() {
1486        struct Impostor;
1487        impl ColumnType for Impostor {
1488            fn view_key(&self) -> &'static str {
1489                "text"
1490            }
1491            fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1492                text_cell("text", cx.value, String::new())
1493            }
1494            fn render_default(&self, vm: &CellVm) -> Markup {
1495                render_text(vm)
1496            }
1497        }
1498        let reg = ColumnTypeReg::new(Arc::new(Impostor));
1499        assert_eq!(reg.name(), "text");
1500        // The router inserts into this map and panics when one is already there.
1501        assert!(builtin_column_registry().contains_key(reg.name()));
1502    }
1503
1504    #[test]
1505    fn status_pill_renders_a_slugged_markup_span() {
1506        let vm = StatusPillColumn.view_model(&CellCx {
1507            value: "In Progress",
1508            tz: Tz::UTC,
1509            locale: chrono::Locale::en_US,
1510        });
1511        let html = StatusPillColumn.render_default(&vm).into_string();
1512        assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
1513        assert!(html.contains(">In Progress<"));
1514    }
1515
1516    /// A fresh test database with the auth tables migrated in, on whichever
1517    /// backend the run targets. Hold the returned guard for the test's lifetime.
1518    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
1519        laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
1520    }
1521
1522    #[tokio::test]
1523    async fn query_returns_display_rows() {
1524        let (db, _guard) = test_db().await;
1525        let hash = laterite_auth::password::hash_password("pw").unwrap();
1526        laterite_auth::store::create_user(
1527            &db,
1528            "root",
1529            "root@example.test",
1530            "Ada",
1531            None,
1532            &hash,
1533            true,
1534        )
1535        .await
1536        .unwrap();
1537
1538        let c = config();
1539        let result = query(&db, &c, &plain(&c)).await.unwrap();
1540        assert_eq!(result.total, 1);
1541        assert_eq!(result.rows.len(), 1);
1542        assert_eq!(result.rows[0].cells[0], "root");
1543        // Booleans store as 0/1 integers everywhere, so a cast-to-text superuser
1544        // flag reads back as "1"; the display layer maps it to "Yes".
1545        assert_eq!(result.rows[0].cells[1], "1");
1546        assert!(!result.rows[0].id.is_empty());
1547    }
1548
1549    async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
1550        get_with(params, HashMap::new(), headers).await
1551    }
1552
1553    async fn get_with(
1554        params: ListParams,
1555        raw: HashMap<String, String>,
1556        headers: axum::http::HeaderMap,
1557    ) -> String {
1558        let (db, _guard) = test_db().await;
1559        let state = AdminState::new(
1560            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1561            db,
1562        );
1563        let resp = handle(
1564            &state,
1565            &config(),
1566            "/admin/users",
1567            params,
1568            &raw,
1569            &crate::audit::test_actor(),
1570            crate::Shell::test(),
1571            &headers,
1572        )
1573        .await;
1574        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1575            .await
1576            .unwrap();
1577        String::from_utf8(bytes.to_vec()).unwrap()
1578    }
1579
1580    fn params(sort: Option<&str>) -> ListParams {
1581        ListParams {
1582            page: None,
1583            sort: sort.map(|s| s.to_string()),
1584            dir: Some("asc".to_string()),
1585            q: None,
1586        }
1587    }
1588
1589    /// The default window and ordering, with no search and no filter.
1590    fn plain(config: &ListConfig) -> ListQuery<'_> {
1591        ListQuery {
1592            offset: 0,
1593            order_by: &config.order_by,
1594            order_dir: config.order_dir,
1595            q: "",
1596            filters: &[],
1597            per_page: None,
1598        }
1599    }
1600
1601    fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
1602        ListQuery {
1603            offset: 0,
1604            order_by: &config.order_by,
1605            order_dir: config.order_dir,
1606            q,
1607            filters: &[],
1608            per_page: None,
1609        }
1610    }
1611
1612    /// Both `&amp;` and `&#38;` are an ampersand; the escaper picks one, and a
1613    /// test should assert on the link, not on which spelling it chose.
1614    fn amps(html: &str) -> String {
1615        html.replace("&#38;", "&").replace("&amp;", "&")
1616    }
1617
1618    /// The request's filter parameters, as the query string would carry them.
1619    fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1620        pairs
1621            .iter()
1622            .map(|(k, v)| (k.to_string(), v.to_string()))
1623            .collect()
1624    }
1625
1626    #[tokio::test]
1627    async fn search_filters_rows_and_the_total() {
1628        let (db, _guard) = test_db().await;
1629        for name in ["ada", "brendan", "clara"] {
1630            let hash = laterite_auth::password::hash_password("x").unwrap();
1631            laterite_auth::store::create_user(
1632                &db,
1633                name,
1634                &format!("{name}@example.test"),
1635                name,
1636                None,
1637                &hash,
1638                false,
1639            )
1640            .await
1641            .unwrap();
1642        }
1643        let c = config();
1644
1645        let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
1646        assert_eq!(hit.rows.len(), 1);
1647        assert_eq!(hit.rows[0].cells[0], "brendan");
1648        // The pager counts the filtered set, not the table.
1649        assert_eq!(hit.total, 1);
1650
1651        // Case folds, as the picker's default profile does.
1652        assert_eq!(
1653            query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
1654            1
1655        );
1656
1657        // A blank term is no filter, not a filter matching nothing.
1658        assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
1659        assert_eq!(query(&db, &c, &searched(&c, "   ")).await.unwrap().total, 3);
1660
1661        // A LIKE wildcard is literal, so it does not match every row.
1662        assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
1663    }
1664
1665    /// Only text columns are searched by default: a substring match against a
1666    /// boolean would make every row containing `1` a hit.
1667    #[tokio::test]
1668    async fn search_skips_a_non_text_column() {
1669        let (db, _guard) = test_db().await;
1670        let hash = laterite_auth::password::hash_password("x").unwrap();
1671        laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
1672            .await
1673            .unwrap();
1674        let c = config();
1675        assert!(c.columns[0].is_searchable(), "username is text");
1676        assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
1677        // The stored superuser flag is `1`, but searching it finds nothing.
1678        assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
1679    }
1680
1681    #[test]
1682    fn a_column_can_override_its_searchability() {
1683        assert!(ListColumn::new("code", "Code").is_searchable());
1684        assert!(!ListColumn::new("code", "Code")
1685            .searchable(false)
1686            .is_searchable());
1687        assert!(ListColumn::new("at", "At")
1688            .datetime()
1689            .searchable(true)
1690            .is_searchable());
1691    }
1692
1693    async fn seed_users(db: &Db) {
1694        for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
1695            let hash = laterite_auth::password::hash_password("x").unwrap();
1696            laterite_auth::store::create_user(
1697                db,
1698                name,
1699                &format!("{name}@example.test"),
1700                name,
1701                None,
1702                &hash,
1703                su,
1704            )
1705            .await
1706            .unwrap();
1707        }
1708    }
1709
1710    async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
1711        let active = resolve_filters(c, params);
1712        let req = ListQuery {
1713            offset: 0,
1714            order_by: &c.order_by,
1715            order_dir: c.order_dir,
1716            q: "",
1717            filters: &active,
1718            per_page: None,
1719        };
1720        query(db, c, &req).await.unwrap().total
1721    }
1722
1723    #[tokio::test]
1724    async fn a_filter_narrows_the_rows_and_the_total() {
1725        let (db, _guard) = test_db().await;
1726        seed_users(&db).await;
1727        let c = config();
1728
1729        assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
1730        assert_eq!(
1731            total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
1732            1
1733        );
1734        assert_eq!(
1735            total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
1736            2
1737        );
1738    }
1739
1740    /// The whitelist: only a declared filter, and for a select only a declared
1741    /// option, reaches the query. Anything else is dropped, not guessed at.
1742    #[test]
1743    fn an_undeclared_filter_or_value_is_ignored() {
1744        let c = config();
1745        // A column that exists but is not a declared filter.
1746        assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
1747        // A declared boolean filter with a value its control never emits.
1748        for bad in ["yes", "true", "1 or 1=1", "", "  "] {
1749            assert!(
1750                resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
1751                "{bad} must not reach the query"
1752            );
1753        }
1754
1755        let with_select = ListConfig {
1756            filters: vec![ListFilter::select(
1757                "status",
1758                "Status",
1759                vec![FilterOption::new("live", "Live")],
1760            )],
1761            ..config()
1762        };
1763        assert_eq!(
1764            resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
1765            1
1766        );
1767        assert!(
1768            resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
1769            "an option the descriptor never offered is dropped"
1770        );
1771    }
1772
1773    #[tokio::test]
1774    async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
1775        let mut p = params(Some("username"));
1776        p.q = None;
1777        let html = get_with(
1778            p,
1779            filter_params(&[("f_is_superuser", "1")]),
1780            axum::http::HeaderMap::new(),
1781        )
1782        .await;
1783        let links = amps(&html);
1784        assert!(
1785            links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
1786            "a sort link carries the filter"
1787        );
1788        // The sorted column's own link flips direction and keeps it too.
1789        assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
1790        assert!(
1791            html.contains(r#"<option value="1" selected>"#),
1792            "the control shows the active value"
1793        );
1794        assert!(html.contains("Clear"), "and a way back to the full list");
1795        assert!(
1796            html.contains(r#"data-filtered="true""#),
1797            "the region says the list is narrowed, which is what reveals Clear"
1798        );
1799    }
1800
1801    /// A filtered list with no rows has not run out of records; it has no match.
1802    /// Saying "No records yet" there reads as an empty table and hides the filter.
1803    #[tokio::test]
1804    async fn the_empty_state_distinguishes_no_records_from_no_match() {
1805        let (db, _guard) = test_db().await;
1806        let state = AdminState::new(
1807            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1808            db,
1809        );
1810        let render = |raw: HashMap<String, String>, q: Option<&str>| {
1811            let q = q.map(String::from);
1812            let state = state.clone();
1813            async move {
1814                let p = ListParams {
1815                    page: None,
1816                    sort: None,
1817                    dir: None,
1818                    q,
1819                };
1820                let resp = handle(
1821                    &state,
1822                    &config(),
1823                    "/admin/users",
1824                    p,
1825                    &raw,
1826                    &crate::audit::test_actor(),
1827                    crate::Shell::test(),
1828                    &axum::http::HeaderMap::new(),
1829                )
1830                .await;
1831                let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1832                    .await
1833                    .unwrap();
1834                String::from_utf8(bytes.to_vec()).unwrap()
1835            }
1836        };
1837
1838        // Nothing stored and nothing asked for: the table is genuinely empty.
1839        let bare = render(HashMap::new(), None).await;
1840        assert!(bare.contains("No records yet."));
1841
1842        // Filtered to nothing: the records may exist, the filter excluded them.
1843        let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
1844        assert!(filtered.contains("No records match."));
1845        assert!(!filtered.contains("No records yet."));
1846
1847        // A search that matches nothing reads the same way.
1848        let searched = render(HashMap::new(), Some("zzz")).await;
1849        assert!(searched.contains("No records match."));
1850    }
1851
1852    #[test]
1853    fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
1854        let c = config();
1855        // Stored out of order; the descriptor's order still wins, because the
1856        // choice is about which columns, not where they sit.
1857        let kept = visible_columns(&c, Some("is_superuser,username"));
1858        assert_eq!(
1859            kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
1860            ["username", "is_superuser"]
1861        );
1862    }
1863
1864    #[test]
1865    fn no_stored_choice_shows_every_column() {
1866        let c = config();
1867        assert_eq!(visible_columns(&c, None).len(), c.columns.len());
1868    }
1869
1870    /// A descriptor that drops or renames a column must not leave an operator
1871    /// staring at an empty table because of a choice they made months ago.
1872    #[test]
1873    fn a_stale_choice_falls_back_to_every_column() {
1874        let c = config();
1875        assert_eq!(
1876            visible_columns(&c, Some("gone,removed")).len(),
1877            c.columns.len()
1878        );
1879        assert_eq!(visible_columns(&c, Some("")).len(), c.columns.len());
1880        assert_eq!(visible_columns(&c, Some("  , ")).len(), c.columns.len());
1881        // A partly stale choice keeps what still exists.
1882        assert_eq!(visible_columns(&c, Some("gone,username")).len(), 1);
1883    }
1884
1885    /// A button the operator cannot use is not shown, rather than shown and
1886    /// answering 403 when they reach it.
1887    #[test]
1888    fn the_toolbar_hides_what_the_operator_may_not_do() {
1889        let shell = crate::Shell::test();
1890        let cfg = ListConfig {
1891            creatable: false,
1892            toolbar: vec![
1893                ToolbarButton::new("Open", "/reports"),
1894                ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
1895            ],
1896            ..config()
1897        };
1898
1899        // A superuser sees everything, gated or not.
1900        let root = laterite_auth::AuthenticatedUser {
1901            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1902            ..crate::audit::test_actor()
1903        };
1904        let views = toolbar_views(&cfg, "/admin", &root, &shell);
1905        assert_eq!(
1906            views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1907            ["Open", "Audit"]
1908        );
1909
1910        // Someone holding the named permission sees it too.
1911        let granted = laterite_auth::AuthenticatedUser {
1912            permissions: laterite_auth::PermissionSet::with_overrides(
1913                false,
1914                ["backend.view_audit".to_string()],
1915                [],
1916                [],
1917            ),
1918            ..crate::audit::test_actor()
1919        };
1920        assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
1921
1922        // Someone holding nothing sees only the ungated button.
1923        let plain = laterite_auth::AuthenticatedUser {
1924            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
1925            ..crate::audit::test_actor()
1926        };
1927        let views = toolbar_views(&cfg, "/admin", &plain, &shell);
1928        assert_eq!(
1929            views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
1930            ["Open"]
1931        );
1932    }
1933
1934    /// A relative href is resolved against the configured admin mount, so a moved
1935    /// panel moves its buttons with it.
1936    #[test]
1937    fn a_toolbar_href_follows_the_admin_mount() {
1938        let shell = crate::Shell::test();
1939        let cfg = ListConfig {
1940            creatable: true,
1941            edit_base: Some("/roles".to_string()),
1942            toolbar: vec![
1943                ToolbarButton::new("Reports", "/reports"),
1944                ToolbarButton::new("Docs", "https://example.test/docs"),
1945            ],
1946            ..config()
1947        };
1948        let root = laterite_auth::AuthenticatedUser {
1949            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
1950            ..crate::audit::test_actor()
1951        };
1952        let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
1953        assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
1954        assert_eq!(views[1].href, "/backoffice/reports");
1955        // An absolute URL is left alone.
1956        assert_eq!(views[2].href, "https://example.test/docs");
1957    }
1958
1959    #[tokio::test]
1960    async fn an_htmx_sort_returns_the_list_region_alone() {
1961        let mut headers = axum::http::HeaderMap::new();
1962        headers.insert("hx-request", "true".parse().unwrap());
1963        let html = get(params(Some("username")), headers).await;
1964        assert!(html.contains(r#"id="lat-list""#), "the region comes back");
1965        assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
1966        // A fragment, not a page: swapping a document into the region would nest
1967        // the admin inside itself.
1968        assert!(!html.contains("<body"), "no page chrome");
1969        // The bar cannot re-render per swap, so the region carries the state.
1970        assert!(html.contains(r#"data-filtered="false""#));
1971    }
1972
1973    /// A sort or a page must not silently drop the search, or the second click
1974    /// would widen the result back to the whole table.
1975    #[tokio::test]
1976    async fn the_search_term_survives_a_sort_link() {
1977        let mut p = params(Some("username"));
1978        p.q = Some("bren".to_string());
1979        let html = get(p, axum::http::HeaderMap::new()).await;
1980        assert!(
1981            amps(&html).contains("?sort=username&dir=desc&q=bren"),
1982            "sort and pager links carry the term"
1983        );
1984        assert!(
1985            html.contains(r#"value="bren""#),
1986            "and the box still shows it"
1987        );
1988    }
1989
1990    #[tokio::test]
1991    async fn a_plain_sort_returns_the_whole_page() {
1992        let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
1993        assert!(html.contains("<body"), "lists still work without htmx");
1994        assert!(html.contains(r#"id="lat-list""#));
1995        // The links work without scripting, so they carry a real href.
1996        assert!(html.contains(r#"href="?sort=username"#));
1997    }
1998
1999    #[tokio::test]
2000    async fn query_rejects_bad_identifiers() {
2001        let (db, _guard) = test_db().await;
2002        let mut bad = config();
2003        bad.entity = "backend_users; drop table backend_users".to_string();
2004        assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2005    }
2006}