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