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, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum SortDir {
34    Asc,
35    Desc,
36}
37
38/// One column of a list view: the source field, its display label, and its
39/// column-type key (resolved through the column-type registry).
40/// How a column's cells and header align.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Align {
44    Left,
45    Center,
46    Right,
47}
48
49impl Align {
50    fn class(self) -> &'static str {
51        match self {
52            Align::Left => "lat-col--left",
53            Align::Center => "lat-col--center",
54            Align::Right => "lat-col--right",
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(deny_unknown_fields, default)]
61pub struct ListColumn {
62    /// Comes from the key this was written under, never from the body.
63    #[serde(skip)]
64    pub field: String,
65    /// The column header, localized at render. Serde stays a plain string.
66    pub label: Text,
67    /// The column-type registry key (`text`, `date`, `boolean`, `status_pill`, ...).
68    #[serde(rename = "type")]
69    pub column_type: String,
70    /// Whether the header sorts. Off for a column whose order means nothing.
71    pub sortable: bool,
72    /// Hidden until the operator picks it from the column chooser.
73    pub invisible: bool,
74    /// A CSS width, `10%` or `120px`. `None` shares the remaining space.
75    pub width: Option<String>,
76    pub align: Option<Align>,
77    /// A permission the operator must hold to see the column at all: it is
78    /// then not queried, sorted, searched, picked or exported for them.
79    pub permission: Option<String>,
80    /// Whether the list's search box looks in this column. `None` follows the
81    /// column type: text columns are searched, the rest are not, because a
82    /// substring match on a boolean or a stored timestamp answers nonsense.
83    pub searchable: Option<bool>,
84}
85
86impl ListColumn {
87    pub fn new(field: &str, label: impl Into<Text>) -> Self {
88        Self {
89            field: field.to_string(),
90            label: label.into(),
91            column_type: "text".to_string(),
92            sortable: true,
93            invisible: false,
94            width: None,
95            align: None,
96            permission: None,
97            searchable: None,
98        }
99    }
100
101    fn of(mut self, column_type: &str) -> Self {
102        self.column_type = column_type.to_string();
103        self
104    }
105
106    /// Render as a date and time in the display timezone.
107    pub fn datetime(self) -> Self {
108        self.of("datetime")
109    }
110    /// Render as a date in the display timezone.
111    pub fn date(self) -> Self {
112        self.of("date")
113    }
114    /// Render as a time in the display timezone.
115    pub fn time(self) -> Self {
116        self.of("time")
117    }
118    /// Render a boolean as Yes/No.
119    pub fn yes_no(self) -> Self {
120        self.of("boolean")
121    }
122    /// Render as a coloured status pill.
123    pub fn pill(self) -> Self {
124        self.of("status_pill")
125    }
126
127    /// Overrides whether the search box looks in this column.
128    pub fn searchable(mut self, searchable: bool) -> Self {
129        self.searchable = Some(searchable);
130        self
131    }
132
133    pub fn sortable(mut self, sortable: bool) -> Self {
134        self.sortable = sortable;
135        self
136    }
137
138    /// Hidden by default; the column chooser still offers it.
139    pub fn invisible(mut self) -> Self {
140        self.invisible = true;
141        self
142    }
143
144    pub fn width(mut self, width: impl Into<String>) -> Self {
145        self.width = Some(width.into());
146        self
147    }
148
149    pub fn align(mut self, align: Align) -> Self {
150        self.align = Some(align);
151        self
152    }
153
154    /// Shown only to an operator holding `permission`.
155    pub fn require(mut self, permission: impl Into<String>) -> Self {
156        self.permission = Some(permission.into());
157        self
158    }
159
160    pub(crate) fn align_class(&self) -> &'static str {
161        self.align.map(Align::class).unwrap_or("")
162    }
163
164    /// Whether search looks here: the explicit choice, else text columns only.
165    pub(crate) fn is_searchable(&self) -> bool {
166        self.searchable.unwrap_or(self.column_type == "text")
167    }
168}
169
170/// The per-cell state a column type renders from: the raw (text-cast) value, the
171/// display timezone, and the locale for date month/day names.
172pub struct CellCx<'a> {
173    pub value: &'a str,
174    pub tz: Tz,
175    pub locale: chrono::Locale,
176}
177
178/// The chrono locale for month/day names, parsed from the operator's locale tag.
179/// chrono locales are language + territory (`kn_IN`, `de_DE`, `ar_EG`), so a tag with
180/// no territory (a bare `kn`) or one chrono does not know formats in neutral English;
181/// a deployment gets localized dates by carrying a territory-bearing locale tag. No
182/// language is special-cased: any locale the locale table knows resolves.
183pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
184    chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
185}
186
187/// A rendered cell's serialisable payload: raw value, display text, and any
188/// richer data (a status pill's slug), so an override presents the same data.
189#[derive(Serialize)]
190pub struct CellVm {
191    pub view_key: String,
192    pub value: String,
193    pub display: String,
194    pub data: serde_json::Value,
195}
196
197/// A column type: how a list cell renders. The list counterpart of a field type,
198/// sharing [`Markup`] and the override resolver ([`Surface::Column`]).
199pub trait ColumnType: Send + Sync + 'static {
200    fn view_key(&self) -> &'static str;
201    fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
202    fn render_default(&self, vm: &CellVm) -> Markup;
203    /// Asset-registry keys this column's cell needs (a heavy cell widget). The
204    /// page shell collects and emits them; keys must exist in the registry.
205    fn assets(&self) -> Vec<&'static str> {
206        Vec::new()
207    }
208}
209
210/// A column type a module contributes, so a list can render a cell the framework
211/// has no built-in for (a rating, a map thumbnail, a currency amount).
212///
213/// The key is the type's own [`ColumnType::view_key`], so a registration cannot
214/// disagree with the type it registers. Use a dotted `vendor.name` to stay clear
215/// of the built-ins and of other modules.
216pub struct ColumnTypeReg {
217    pub column_type: Arc<dyn ColumnType>,
218}
219
220impl ColumnTypeReg {
221    pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
222        Self { column_type }
223    }
224
225    /// The key this type registers under.
226    pub fn name(&self) -> &'static str {
227        self.column_type.view_key()
228    }
229}
230
231/// The column-type registry, keyed by [`ColumnType::view_key`].
232pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
233
234/// Renders a cell: an override if the resolver supplies one, else the default.
235pub(crate) fn render_cell(
236    ct: &dyn ColumnType,
237    resolver: &dyn OverrideResolver,
238    scope: &OverrideScope<'_>,
239    cx: &CellCx<'_>,
240) -> Markup {
241    let vm = ct.view_model(cx);
242    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
243        Some(Ok(html)) => Markup::from_override(html),
244        Some(Err(_)) | None => ct.render_default(&vm),
245    }
246}
247
248pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
249    vec![
250        Arc::new(TextColumn),
251        Arc::new(DateTimeColumn),
252        Arc::new(DateColumn),
253        Arc::new(TimeColumn),
254        Arc::new(BoolColumn),
255        Arc::new(StatusPillColumn),
256    ]
257}
258
259/// The column registry seeded with the built-in types.
260pub(crate) fn builtin_column_registry() -> ColumnRegistry {
261    builtin_column_types()
262        .into_iter()
263        .map(|c| (c.view_key().to_string(), c))
264        .collect()
265}
266
267#[derive(Template)]
268#[template(path = "cells/text.html")]
269struct CellTextTmpl<'a> {
270    display: &'a str,
271}
272
273/// The view-model for a text-like cell: raw value plus its display text.
274fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
275    CellVm {
276        view_key: view_key.to_string(),
277        value: value.to_string(),
278        display,
279        data: serde_json::Value::Null,
280    }
281}
282
283fn render_text(vm: &CellVm) -> Markup {
284    Markup::from_template(&CellTextTmpl {
285        display: &vm.display,
286    })
287    .unwrap_or_default()
288}
289
290/// Formats a stored UTC RFC3339 timestamp in `tz`, with month/day names in `locale`;
291/// an unparseable value passes through unchanged.
292pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
293    match DateTime::parse_from_rfc3339(raw) {
294        Ok(dt) => dt
295            .with_timezone(&tz)
296            .format_localized(pattern, locale)
297            .to_string(),
298        Err(_) => raw.to_string(),
299    }
300}
301
302struct TextColumn;
303impl ColumnType for TextColumn {
304    fn view_key(&self) -> &'static str {
305        "text"
306    }
307    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
308        text_cell("text", cx.value, cx.value.to_string())
309    }
310    fn render_default(&self, vm: &CellVm) -> Markup {
311        render_text(vm)
312    }
313}
314
315struct DateTimeColumn;
316impl ColumnType for DateTimeColumn {
317    fn view_key(&self) -> &'static str {
318        "datetime"
319    }
320    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
321        text_cell(
322            "datetime",
323            cx.value,
324            format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
325        )
326    }
327    fn render_default(&self, vm: &CellVm) -> Markup {
328        render_text(vm)
329    }
330}
331
332struct DateColumn;
333impl ColumnType for DateColumn {
334    fn view_key(&self) -> &'static str {
335        "date"
336    }
337    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
338        text_cell(
339            "date",
340            cx.value,
341            format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
342        )
343    }
344    fn render_default(&self, vm: &CellVm) -> Markup {
345        render_text(vm)
346    }
347}
348
349struct TimeColumn;
350impl ColumnType for TimeColumn {
351    fn view_key(&self) -> &'static str {
352        "time"
353    }
354    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
355        text_cell(
356            "time",
357            cx.value,
358            format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
359        )
360    }
361    fn render_default(&self, vm: &CellVm) -> Markup {
362        render_text(vm)
363    }
364}
365
366struct BoolColumn;
367impl ColumnType for BoolColumn {
368    fn view_key(&self) -> &'static str {
369        "boolean"
370    }
371    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
372        let display = match cx.value {
373            "1" | "true" => "Yes",
374            "0" | "false" | "" => "No",
375            other => other,
376        };
377        text_cell("boolean", cx.value, display.to_string())
378    }
379    fn render_default(&self, vm: &CellVm) -> Markup {
380        render_text(vm)
381    }
382}
383
384#[derive(Template)]
385#[template(path = "cells/status.html")]
386struct CellStatusTmpl<'a> {
387    label: &'a str,
388    slug: &'a str,
389}
390
391/// A status shown as a coloured pill: the first Markup-bearing cell.
392struct StatusPillColumn;
393impl ColumnType for StatusPillColumn {
394    fn view_key(&self) -> &'static str {
395        "status_pill"
396    }
397    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
398        CellVm {
399            view_key: "status_pill".to_string(),
400            value: cx.value.to_string(),
401            display: cx.value.to_string(),
402            data: serde_json::json!({ "slug": status_slug(cx.value) }),
403        }
404    }
405    fn render_default(&self, vm: &CellVm) -> Markup {
406        let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
407        Markup::from_template(&CellStatusTmpl {
408            label: &vm.display,
409            slug,
410        })
411        .unwrap_or_default()
412    }
413}
414
415/// A CSS-safe modifier from a status value (lowercased, non-alphanumerics to `-`).
416fn status_slug(value: &str) -> String {
417    value
418        .trim()
419        .to_lowercase()
420        .chars()
421        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
422        .collect()
423}
424
425impl Default for ListColumn {
426    /// A text column. The name comes from the key it is written under, and the
427    /// label from the name when a file gives none.
428    fn default() -> Self {
429        Self::new("", Text::new(""))
430    }
431}
432
433impl Default for ListFilter {
434    /// A text filter, named and labelled like a column.
435    fn default() -> Self {
436        Self::of("", Text::new(""), "text")
437    }
438}
439
440impl crate::keyed::Keyed for ListColumn {
441    fn key(&self) -> &str {
442        &self.field
443    }
444    fn set_key(&mut self, key: String) {
445        self.field = key;
446    }
447}
448
449impl crate::keyed::Keyed for ListFilter {
450    fn key(&self) -> &str {
451        &self.field
452    }
453    fn set_key(&mut self, key: String) {
454        self.field = key;
455    }
456}
457
458/// One value a `select` filter offers.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct FilterOption {
461    pub value: String,
462    /// The option's label, localized at render.
463    pub label: Text,
464}
465
466impl FilterOption {
467    pub fn new(value: &str, label: impl Into<Text>) -> Self {
468        Self {
469            value: value.to_string(),
470            label: label.into(),
471        }
472    }
473}
474
475/// One filter offered above a list: a column, its label, and what it offers.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(deny_unknown_fields, default)]
478pub struct ListFilter {
479    /// Comes from the key this was written under, never from the body.
480    #[serde(skip)]
481    pub field: String,
482    /// The control's label, localized at render.
483    pub label: Text,
484    /// What the filter offers: `boolean`, `select`, `text`, `number` or `date`.
485    #[serde(rename = "type")]
486    pub filter_type: String,
487    /// Per-type options. A `select` carries its choices here; the rest none.
488    #[serde(default)]
489    pub options: serde_json::Value,
490    /// Applied when the request names no value for this filter at all, so a
491    /// screen can open already narrowed. Clearing it is still "no filter".
492    pub default_value: Option<String>,
493    /// A permission the operator must hold to see this filter, and to have its
494    /// default applied.
495    pub permission: Option<String>,
496}
497
498impl ListFilter {
499    /// A filter of any declared type, with no options.
500    pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
501        Self {
502            field: field.to_string(),
503            label: label.into(),
504            filter_type: filter_type.to_string(),
505            options: serde_json::Value::Null,
506            default_value: None,
507            permission: None,
508        }
509    }
510
511    /// A yes/no filter over a boolean column.
512    pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
513        Self::of(field, label, "boolean")
514    }
515
516    /// A filter offering a fixed set of values. Only a declared one reaches the
517    /// query.
518    pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
519        let mut filter = Self::of(field, label, "select");
520        filter.options = serde_json::json!({ "options": options });
521        filter
522    }
523
524    /// A substring match, folded the way the search box folds.
525    pub fn text(field: &str, label: impl Into<Text>) -> Self {
526        Self::of(field, label, "text")
527    }
528
529    /// An exact match on a whole number.
530    pub fn number(field: &str, label: impl Into<Text>) -> Self {
531        Self::of(field, label, "number")
532    }
533
534    /// Rows on one calendar day, whether the column stores a date or a
535    /// timestamp.
536    pub fn date(field: &str, label: impl Into<Text>) -> Self {
537        Self::of(field, label, "date")
538    }
539
540    /// The choices a `select` offers, or none for any other type.
541    pub(crate) fn choices(&self) -> Vec<FilterOption> {
542        self.options
543            .get("options")
544            .and_then(|v| serde_json::from_value(v.clone()).ok())
545            .unwrap_or_default()
546    }
547
548    /// The value to apply when the request names none.
549    pub fn default_value(mut self, value: impl Into<String>) -> Self {
550        self.default_value = Some(value.into());
551        self
552    }
553
554    /// Shown only to an operator holding `permission`.
555    pub fn require(mut self, permission: impl Into<String>) -> Self {
556        self.permission = Some(permission.into());
557        self
558    }
559
560    pub(crate) fn param(&self) -> String {
561        format!("f_{}", self.field)
562    }
563}
564
565/// A button a list offers in its toolbar, beside the built-in New.
566///
567/// A resource declares its own; the framework contributes New and the export
568/// menu the same way, so the toolbar is one list of buttons rather than a
569/// hardcoded bar with special cases.
570#[derive(Debug, Clone, Serialize, Deserialize)]
571#[serde(deny_unknown_fields)]
572pub struct ToolbarButton {
573    /// The button's text, localized at render.
574    pub label: Text,
575    /// Where it goes: a path under the admin root (starting with a slash), or an
576    /// absolute URL.
577    pub href: String,
578    /// An icon name from the shared set. Absent renders the label alone.
579    pub icon: Option<String>,
580    /// Hidden from an operator who lacks this permission. Absent shows it to
581    /// anyone who can reach the screen.
582    pub permission: Option<String>,
583    /// Rendered as the prominent action rather than a secondary one.
584    pub primary: bool,
585}
586
587impl ToolbarButton {
588    pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
589        Self {
590            label: label.into(),
591            href: href.into(),
592            icon: None,
593            permission: None,
594            primary: false,
595        }
596    }
597
598    pub fn icon(mut self, icon: &str) -> Self {
599        self.icon = Some(icon.to_string());
600        self
601    }
602
603    /// Hides the button from an operator without this permission.
604    pub fn require(mut self, permission: impl Into<String>) -> Self {
605        self.permission = Some(permission.into());
606        self
607    }
608
609    pub fn primary(mut self) -> Self {
610        self.primary = true;
611        self
612    }
613}
614
615/// One rendered toolbar button.
616pub struct ToolbarView {
617    pub label: String,
618    pub href: String,
619    /// Inline SVG for the icon, empty when the button has none.
620    pub icon: String,
621    pub primary: bool,
622}
623
624/// The toolbar an operator sees: the framework's New (when the resource creates)
625/// followed by the resource's own buttons, minus anything they lack permission
626/// for.
627fn toolbar_views(
628    config: &ListConfig,
629    admin_path: &str,
630    user: &laterite_auth::AuthenticatedUser,
631    shell: &crate::Shell,
632) -> Vec<ToolbarView> {
633    let mut out = Vec::new();
634    if config.creatable {
635        if let Some(base) = &config.edit_base {
636            out.push(ToolbarView {
637                label: shell.tt(&laterite_core::t!("New")),
638                // `edit_base` is already under the admin mount (see
639                // `prefix_resource`), like the row links that use it.
640                href: format!("{base}/new"),
641                icon: String::new(),
642                primary: true,
643            });
644        }
645    }
646    for button in &config.toolbar {
647        if let Some(needed) = &button.permission {
648            if !user.permissions.allows(needed) {
649                continue;
650            }
651        }
652        let href = if button.href.starts_with('/') {
653            format!("{admin_path}{}", button.href)
654        } else {
655            button.href.clone()
656        };
657        out.push(ToolbarView {
658            label: shell.tt(&button.label),
659            href,
660            icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
661            primary: button.primary,
662        });
663    }
664    out
665}
666
667/// The preference key holding one operator's chosen columns for a list. Keyed by
668/// the list's own path, so two resources over the same table stay separate.
669pub(crate) fn columns_preference_key(base_path: &str) -> String {
670    format!("list.columns.{base_path}")
671}
672
673/// One operator's stored column choice for a list, or `None` if they have none.
674///
675/// A failed read is a warning, not an error: a preference is a convenience, so
676/// the screen shows every column rather than failing.
677pub(crate) async fn stored_columns(
678    state: &AdminState,
679    path: &str,
680    user: &laterite_auth::AuthenticatedUser,
681) -> Option<String> {
682    match laterite_auth::store::user_preference(
683        &state.db,
684        user.user.id,
685        &columns_preference_key(path),
686    )
687    .await
688    {
689        Ok(stored) => stored,
690        Err(e) => {
691            tracing::warn!(error = %e, "reading the column preference failed");
692            None
693        }
694    }
695}
696
697/// The preference key holding one operator's page size for a list.
698pub(crate) fn per_page_preference_key(base_path: &str) -> String {
699    format!("list.per_page:{base_path}")
700}
701
702/// This operator's page size for the list: their stored choice when the
703/// descriptor still offers it, the descriptor's own otherwise. A failed read
704/// is a warning, the way the column preference is.
705pub(crate) async fn stored_per_page(
706    state: &AdminState,
707    config: &ListConfig,
708    path: &str,
709    user: &laterite_auth::AuthenticatedUser,
710) -> i64 {
711    let stored = match laterite_auth::store::user_preference(
712        &state.db,
713        user.user.id,
714        &per_page_preference_key(path),
715    )
716    .await
717    {
718        Ok(stored) => stored,
719        Err(e) => {
720            tracing::warn!(error = %e, "reading the page-size preference failed");
721            None
722        }
723    };
724    offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
725}
726
727/// A size the descriptor offers, else its own.
728pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
729    size.filter(|n| config.per_page_options.contains(n))
730        .unwrap_or(config.per_page)
731}
732
733/// The columns this operator may see at all: every one without a permission,
734/// plus those whose permission they hold.
735pub(crate) fn allowed_columns<'a>(
736    config: &'a ListConfig,
737    user: &laterite_auth::AuthenticatedUser,
738) -> Vec<&'a ListColumn> {
739    config
740        .columns
741        .iter()
742        .filter(|c| {
743            c.permission
744                .as_deref()
745                .is_none_or(|p| user.permissions.allows(p))
746        })
747        .collect()
748}
749
750/// The filters this operator may use: every one without a permission, plus
751/// those whose permission they hold.
752pub(crate) fn allowed_filters<'a>(
753    config: &'a ListConfig,
754    user: &laterite_auth::AuthenticatedUser,
755) -> Vec<&'a ListFilter> {
756    config
757        .filters
758        .iter()
759        .filter(|f| {
760            f.permission
761                .as_deref()
762                .is_none_or(|p| user.permissions.allows(p))
763        })
764        .collect()
765}
766
767/// The descriptor with only the columns this operator sees. One place, so the
768/// query, the headers, sorting, searching and the export all work from it.
769pub(crate) fn narrowed(
770    config: &ListConfig,
771    stored: Option<&str>,
772    user: &laterite_auth::AuthenticatedUser,
773) -> ListConfig {
774    ListConfig {
775        columns: visible_columns(config, stored, user)
776            .into_iter()
777            .cloned()
778            .collect(),
779        filters: allowed_filters(config, user).into_iter().cloned().collect(),
780        ..config.clone()
781    }
782}
783
784/// The columns to show: the operator's stored choice, narrowed to what the
785/// descriptor still declares and they may see; without a choice, every column
786/// they may see that is not `invisible`.
787///
788/// A stored choice that no longer names any such column falls back to that
789/// default, so a descriptor that drops or renames a column leaves an operator
790/// with a working list rather than an empty one.
791pub(crate) fn visible_columns<'a>(
792    config: &'a ListConfig,
793    stored: Option<&str>,
794    user: &laterite_auth::AuthenticatedUser,
795) -> Vec<&'a ListColumn> {
796    let allowed = allowed_columns(config, user);
797    let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
798    let Some(stored) = stored else {
799        return by_default();
800    };
801    let chosen: Vec<&str> = stored
802        .split(',')
803        .map(str::trim)
804        .filter(|s| !s.is_empty())
805        .collect();
806    let kept: Vec<&ListColumn> = allowed
807        .iter()
808        .copied()
809        .filter(|c| chosen.iter().any(|n| *n == c.field))
810        .collect();
811    if kept.is_empty() {
812        return by_default();
813    }
814    kept
815}
816
817/// Checks a list against the column-type registry at router build, so a
818/// descriptor naming a type nobody registered aborts boot naming the column,
819/// the way a form does for a field type. Without this the cell rendered empty.
820pub(crate) fn check(
821    config: &ListConfig,
822    column_types: &ColumnRegistry,
823    permission_codes: &std::collections::HashSet<String>,
824) -> Result<(), String> {
825    for col in &config.columns {
826        if column_types.get(&col.column_type).is_none() {
827            return Err(format!(
828                "column `{}` uses unregistered type `{}`",
829                col.field, col.column_type
830            ));
831        }
832        // An unregistered permission can never be granted, so the column would
833        // be hidden from everyone but a superuser: a wiring bug, not a choice.
834        if let Some(p) = &col.permission {
835            if !permission_codes.contains(p) {
836                return Err(format!(
837                    "column `{}` requires unregistered permission `{p}`",
838                    col.field
839                ));
840            }
841        }
842    }
843    for filter in &config.filters {
844        if let Some(p) = &filter.permission {
845            if !permission_codes.contains(p) {
846                return Err(format!(
847                    "filter `{}` requires unregistered permission `{p}`",
848                    filter.field
849                ));
850            }
851        }
852    }
853    Ok(())
854}
855
856/// The list's search box. On by default, over the columns marked searchable.
857#[derive(Debug, Clone, Serialize, Deserialize)]
858#[serde(deny_unknown_fields, default)]
859pub struct SearchConfig {
860    /// `false` removes the box.
861    pub enabled: bool,
862    /// The placeholder, localized at render. `None` shows "Search".
863    pub prompt: Option<Text>,
864    /// Ask on Enter only, instead of as the operator types.
865    pub on_enter: bool,
866}
867
868impl Default for SearchConfig {
869    fn default() -> Self {
870        Self {
871            enabled: true,
872            prompt: None,
873            on_enter: false,
874        }
875    }
876}
877
878impl SearchConfig {
879    /// No search box at all.
880    pub fn off() -> Self {
881        Self {
882            enabled: false,
883            ..Self::default()
884        }
885    }
886
887    pub fn prompt(mut self, text: impl Into<Text>) -> Self {
888        self.prompt = Some(text.into());
889        self
890    }
891
892    pub fn on_enter(mut self) -> Self {
893        self.on_enter = true;
894        self
895    }
896}
897
898/// A list view descriptor: which table, which columns, default ordering, page
899/// size, and (optionally) where per-row edit links point.
900///
901/// Built with [`ListConfig::new`] plus its builder methods. Non-exhaustive, so a
902/// struct literal outside this crate cannot freeze the field set at 1.0; that
903/// rules out `..Default::default()` too, which is why the builders exist.
904#[derive(Debug, Clone, Serialize, Deserialize)]
905#[serde(deny_unknown_fields, default)]
906#[non_exhaustive]
907pub struct ListConfig {
908    pub entity: String,
909    /// The screen title, localized at render. Serde stays a plain string.
910    pub title: Text,
911    #[serde(
912        deserialize_with = "crate::keyed::deserialize",
913        serialize_with = "crate::keyed::serialize"
914    )]
915    pub columns: Vec<ListColumn>,
916    pub order_by: String,
917    pub order_dir: SortDir,
918    pub per_page: i64,
919    pub id_field: String,
920    /// When set, rows link to `{edit_base}/{id}/edit`.
921    pub edit_base: Option<String>,
922    /// Whether to offer a "New" link to `{edit_base}/new`. A resource that only
923    /// edits existing records (no create screen) sets this false.
924    pub creatable: bool,
925    /// Filters offered above the table. Empty hides the bar.
926    #[serde(
927        deserialize_with = "crate::keyed::deserialize",
928        serialize_with = "crate::keyed::serialize"
929    )]
930    pub filters: Vec<ListFilter>,
931    /// Whether rows can be selected and deleted from this list. Off by default:
932    /// a list that shows a log, or records another screen owns, has no business
933    /// offering it.
934    pub deletable: bool,
935    /// Whether this resource offers an export. Off by default: exporting is a
936    /// capability a resource opts into, not something every list should carry.
937    pub exportable: bool,
938    /// Extra buttons in the toolbar, beside New and the export menu.
939    pub toolbar: Vec<ToolbarButton>,
940    pub search: SearchConfig,
941    /// Page sizes the operator may choose from in the list setup, remembered
942    /// with their column choice. Empty offers no choice.
943    pub per_page_options: Vec<i64>,
944    /// Shown when the list has no records at all. `None` shows "No records yet."
945    pub no_records_message: Option<Text>,
946}
947
948impl ListConfig {
949    /// A list over `entity` titled `title`, showing `columns`.
950    ///
951    /// Everything else takes the defaults below and is opted into through a
952    /// builder method, so a field added later costs existing descriptors nothing.
953    pub fn new(
954        entity: impl Into<String>,
955        title: impl Into<Text>,
956        columns: Vec<ListColumn>,
957    ) -> Self {
958        Self {
959            entity: entity.into(),
960            title: title.into(),
961            columns,
962            ..Self::default()
963        }
964    }
965
966    /// Orders by `column` in `dir` rather than newest id first.
967    pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
968        self.order_by = column.into();
969        self.order_dir = dir;
970        self
971    }
972
973    pub fn per_page(mut self, rows: i64) -> Self {
974        self.per_page = rows;
975        self
976    }
977
978    /// The primary-key column, when it is not `id`.
979    pub fn id_field(mut self, column: impl Into<String>) -> Self {
980        self.id_field = column.into();
981        self
982    }
983
984    /// Links rows to `{base}/{id}/edit`.
985    pub fn edit_base(mut self, base: impl Into<String>) -> Self {
986        self.edit_base = Some(base.into());
987        self
988    }
989
990    /// Offers a New link to `{edit_base}/new`. Opt in, matching the read-only
991    /// default: a list over a log, or over records another screen owns, should
992    /// not invite an operator to create one.
993    pub fn creatable(mut self) -> Self {
994        self.creatable = true;
995        self
996    }
997
998    /// Offers CSV and JSON exports of the current query. Opt in: a resource
999    /// whose rows should not leave the panel simply never calls this, and the
1000    /// route is not registered for it.
1001    pub fn exportable(mut self) -> Self {
1002        self.exportable = true;
1003        self
1004    }
1005
1006    pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
1007        self.filters = filters;
1008        self
1009    }
1010
1011    /// Lets rows be selected and deleted. Off by default: a list showing a log,
1012    /// or records another screen owns, has no business offering it.
1013    pub fn deletable(mut self) -> Self {
1014        self.deletable = true;
1015        self
1016    }
1017
1018    pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
1019        self.toolbar = buttons;
1020        self
1021    }
1022
1023    pub fn search(mut self, search: SearchConfig) -> Self {
1024        self.search = search;
1025        self
1026    }
1027
1028    pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
1029        self.per_page_options = sizes;
1030        self
1031    }
1032
1033    pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
1034        self.no_records_message = Some(text.into());
1035        self
1036    }
1037}
1038
1039impl Default for ListConfig {
1040    /// A minimal list: no columns, newest-id first, 25 to a page, read-only.
1041    ///
1042    /// Descriptors use this as the tail of a struct literal
1043    /// (`..Default::default()`) and set only what they mean. A field added here
1044    /// later is then additive for every descriptor rather than breaking it, which
1045    /// three consecutive additions made the case for.
1046    fn default() -> Self {
1047        Self {
1048            entity: String::new(),
1049            title: Text::new(""),
1050            columns: Vec::new(),
1051            order_by: "id".to_string(),
1052            order_dir: SortDir::Desc,
1053            per_page: 25,
1054            id_field: "id".to_string(),
1055            edit_base: None,
1056            creatable: false,
1057            filters: Vec::new(),
1058            deletable: false,
1059            exportable: false,
1060            toolbar: Vec::new(),
1061            search: SearchConfig::default(),
1062            per_page_options: Vec::new(),
1063            no_records_message: None,
1064        }
1065    }
1066}
1067
1068/// Query-string parameters for a list view.
1069#[derive(Deserialize)]
1070pub struct ListParams {
1071    page: Option<i64>,
1072    sort: Option<String>,
1073    dir: Option<String>,
1074    q: Option<String>,
1075}
1076
1077/// The ordering a request resolves to: the submitted `sort` when it names a
1078/// column the descriptor declares, the descriptor's own order otherwise. Only a
1079/// declared column reaches the query, so a crafted `sort` cannot order by an
1080/// arbitrary column. An unrecognised `dir` falls back the same way.
1081pub(crate) fn resolve_sort(
1082    config: &ListConfig,
1083    sort: Option<&str>,
1084    dir: Option<&str>,
1085) -> (String, SortDir) {
1086    let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1087        Some(field) => field.to_string(),
1088        None => config.order_by.clone(),
1089    };
1090    let dir = match dir {
1091        Some("asc") => SortDir::Asc,
1092        Some("desc") => SortDir::Desc,
1093        // No explicit direction: the configured one for the configured column, so
1094        // a list ordered newest-first stays that way until a header is clicked.
1095        _ if by == config.order_by => config.order_dir,
1096        _ => SortDir::Asc,
1097    };
1098    (by, dir)
1099}
1100
1101/// One rendered row: its id (for edit links) and its display cells.
1102pub struct RowView {
1103    pub id: String,
1104    pub cells: Vec<String>,
1105}
1106
1107/// One rendered cell and the class its column's alignment gives it.
1108pub struct CellView {
1109    pub html: String,
1110    pub class: String,
1111}
1112
1113/// A row as the template receives it: each cell already rendered.
1114pub struct RenderedRow {
1115    pub id: String,
1116    pub cells: Vec<CellView>,
1117}
1118
1119/// Display-ready rows plus the total row count for the pager.
1120pub struct ListPage {
1121    pub rows: Vec<RowView>,
1122    pub total: i64,
1123}
1124
1125/// What one list request asks for: the page window, the resolved ordering, and
1126/// the search term (empty for no search).
1127pub(crate) struct ListQuery<'a> {
1128    pub offset: i64,
1129    pub order_by: &'a str,
1130    pub order_dir: SortDir,
1131    pub q: &'a str,
1132    pub filters: &'a [ActiveFilter<'a>],
1133    /// Rows per round trip. `None` uses the descriptor's page size; the export
1134    /// reads larger pages because it is building a file, not a screen.
1135    pub per_page: Option<i64>,
1136}
1137
1138/// A filter the request actually selected: the declared filter and its accepted
1139/// value. Built only from declared filters and, for a select, declared options,
1140/// so a crafted parameter cannot reach the query.
1141pub(crate) struct ActiveFilter<'a> {
1142    filter: &'a ListFilter,
1143    /// The raw value as submitted, for re-rendering the control and the links.
1144    raw: String,
1145    /// How the accepted value matches. Owned and `Send`, because a built
1146    /// `sea_query` expression is neither and this is held across the query.
1147    matcher: FilterMatch,
1148}
1149
1150/// How an accepted filter value matches its column.
1151pub(crate) enum FilterMatch {
1152    /// Exactly this value.
1153    Equals(sea_query::Value),
1154    /// A folded substring, the way the search box matches.
1155    Contains(String),
1156    /// Anything starting with this, for a day against a stored timestamp.
1157    StartsWith(String),
1158}
1159
1160/// How one filter matches for a submitted value, or `None` when the value is
1161/// not one this type accepts.
1162///
1163/// Every type validates before returning, so a crafted parameter reaches the
1164/// query only in a shape the filter declared.
1165fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1166    match filter.filter_type.as_str() {
1167        // Only the two spellings the control emits; anything else is dropped
1168        // rather than guessed at.
1169        "boolean" => match raw {
1170            "1" => Some(FilterMatch::Equals(true.into())),
1171            "0" => Some(FilterMatch::Equals(false.into())),
1172            _ => None,
1173        },
1174        "select" => filter
1175            .choices()
1176            .iter()
1177            .any(|o| o.value == raw)
1178            .then(|| FilterMatch::Equals(raw.into())),
1179        "text" => Some(FilterMatch::Contains(raw.to_string())),
1180        "number" => raw
1181            .parse::<i64>()
1182            .ok()
1183            .map(|n| FilterMatch::Equals(n.into())),
1184        // A day, matched as a prefix so a date column and a stored timestamp
1185        // both answer. The shape is checked here, so the pattern that reaches
1186        // the query has nothing in it to escape.
1187        "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1188        _ => None,
1189    }
1190}
1191
1192/// `YYYY-MM-DD`, and nothing else.
1193fn is_iso_date(raw: &str) -> bool {
1194    raw.len() == 10
1195        && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1196            if i == 4 || i == 7 {
1197                *b == b'-'
1198            } else {
1199                b.is_ascii_digit()
1200            }
1201        })
1202}
1203
1204/// Reads the declared filters out of the request's query parameters, dropping
1205/// anything undeclared, unrecognised, or blank.
1206pub(crate) fn resolve_filters<'a>(
1207    config: &'a ListConfig,
1208    params: &HashMap<String, String>,
1209) -> Vec<ActiveFilter<'a>> {
1210    let mut out = Vec::new();
1211    for filter in &config.filters {
1212        let submitted = params.get(&filter.param()).map(|v| v.trim());
1213        let raw = match submitted {
1214            // Named, so the operator decided: a blank value clears the filter.
1215            Some(value) => value,
1216            // Not named at all, so nothing has been decided yet.
1217            None => filter.default_value.as_deref().unwrap_or_default(),
1218        };
1219        if raw.is_empty() {
1220            continue;
1221        }
1222        let Some(matcher) = filter_match(filter, raw) else {
1223            continue;
1224        };
1225        out.push(ActiveFilter {
1226            filter,
1227            raw: raw.to_string(),
1228            matcher,
1229        });
1230    }
1231    out
1232}
1233
1234/// The filters ANDed together, or `None` when none are active.
1235fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1236    if active.is_empty() {
1237        return None;
1238    }
1239    let caps = laterite_core::capabilities::CapabilitySet::default();
1240    let mut all = sea_query::Condition::all();
1241    for f in active {
1242        let col = Expr::col(Alias::new(&f.filter.field));
1243        all = match &f.matcher {
1244            FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1245            // A term that folds away to nothing narrows nothing.
1246            FilterMatch::Contains(term) => {
1247                match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1248                    Some(cond) => all.add(cond),
1249                    None => all,
1250                }
1251            }
1252            FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1253        };
1254    }
1255    Some(all)
1256}
1257
1258/// The condition matching `q` across the searchable columns, or `None` when
1259/// there is nothing to match: a blank term, a term that folds away, or a list
1260/// whose columns are all unsearchable.
1261fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1262    // Capability-gated matchers are deferred and `Db` carries no
1263    // capability set yet, so the portable default applies. Descriptor-named
1264    // profiles are PR4; until then a list folds case, like the picker.
1265    let caps = laterite_core::capabilities::CapabilitySet::default();
1266    let profile = SearchProfile::new();
1267    let mut any = sea_query::Condition::any();
1268    let mut matched = false;
1269    for column in config.columns.iter().filter(|c| c.is_searchable()) {
1270        if let Some(cond) = profile.condition(&caps, &column.field, q) {
1271            any = any.add(cond);
1272            matched = true;
1273        }
1274    }
1275    matched.then_some(any)
1276}
1277
1278/// Runs the list query for a config, returning display-ready rows and the total.
1279/// Built with `sea-query` and dynamic identifiers (`Alias`), and every selected
1280/// column is cast to text so a value of any type reads back uniformly as a
1281/// string for display, without a Postgres-specific `row_to_json`.
1282pub(crate) async fn query(
1283    db: &Db,
1284    config: &ListConfig,
1285    req: &ListQuery<'_>,
1286) -> anyhow::Result<ListPage> {
1287    if !valid_ident(&config.entity)
1288        || !valid_ident(&config.order_by)
1289        || !valid_ident(req.order_by)
1290        || !valid_ident(&config.id_field)
1291        || !config.columns.iter().all(|c| valid_ident(&c.field))
1292        || !config.filters.iter().all(|f| valid_ident(&f.field))
1293    {
1294        anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1295    }
1296
1297    let dir = match req.order_dir {
1298        SortDir::Asc => Order::Asc,
1299        SortDir::Desc => Order::Desc,
1300    };
1301    // Scope each sea-query builder so it drops before the await that follows: its
1302    // identifiers are reference-counted (not `Send`), and a live builder across
1303    // the await would make this future non-`Send`.
1304    let (sql, values) = {
1305        let mut select = Query::select();
1306        for column in &config.columns {
1307            select.expr_as(
1308                Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1309                Alias::new(&column.field),
1310            );
1311        }
1312        select
1313            .expr_as(
1314                Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
1315                Alias::new(ID_ALIAS),
1316            )
1317            .from(Alias::new(&config.entity))
1318            .order_by(Alias::new(req.order_by), dir)
1319            .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1320            .offset(req.offset.max(0) as u64);
1321        if let Some(cond) = search_condition(config, req.q) {
1322            select.cond_where(cond);
1323        }
1324        if let Some(cond) = filter_condition(req.filters) {
1325            select.cond_where(cond);
1326        }
1327        build(db.backend, select)
1328    };
1329    let raw = bind_values(sqlx::query(&sql), values)
1330        .fetch_all(&db.pool)
1331        .await?;
1332
1333    let (csql, cvalues) = {
1334        let mut count = Query::select();
1335        count
1336            .expr(Expr::col(Alias::new(&config.id_field)).count())
1337            .from(Alias::new(&config.entity));
1338        // The same conditions, so the pager counts what the list returns.
1339        if let Some(cond) = search_condition(config, req.q) {
1340            count.cond_where(cond);
1341        }
1342        if let Some(cond) = filter_condition(req.filters) {
1343            count.cond_where(cond);
1344        }
1345        build(db.backend, count)
1346    };
1347    let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1348        .fetch_one(&db.pool)
1349        .await?
1350        .0;
1351
1352    let rows = raw
1353        .iter()
1354        .map(|row| RowView {
1355            id: get_text(row, ID_ALIAS),
1356            cells: config
1357                .columns
1358                .iter()
1359                .map(|c| get_text(row, &c.field))
1360                .collect(),
1361        })
1362        .collect();
1363    Ok(ListPage { rows, total })
1364}
1365
1366/// Reads a text-cast column as a display string, treating null or a decode
1367/// error as empty.
1368fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1369    // `get_text_opt` falls back to a byte read for MySQL, where a cast-to-char of
1370    // a `text` column still comes back typed as BLOB.
1371    row.get_text_opt(column).ok().flatten().unwrap_or_default()
1372}
1373
1374/// Renders a list view for the given config.
1375#[allow(clippy::too_many_arguments)]
1376pub(crate) async fn handle(
1377    state: &AdminState,
1378    config: &ListConfig,
1379    path: &str,
1380    params: ListParams,
1381    raw: &HashMap<String, String>,
1382    user: &laterite_auth::AuthenticatedUser,
1383    shell: crate::Shell,
1384    headers: &axum::http::HeaderMap,
1385) -> Response {
1386    // The operator's chosen columns narrow the descriptor once, here, so
1387    // everything downstream (the query, the headers, sorting, searching) works
1388    // from one list: what is shown is what is queried.
1389    let stored = stored_columns(state, path, user).await;
1390    let declared = config;
1391    let narrowed = narrowed(config, stored.as_deref(), user);
1392    let config = &narrowed;
1393
1394    let page = params.page.unwrap_or(1).max(1);
1395    let per_page = stored_per_page(state, config, path, user).await;
1396    let offset = (page - 1) * per_page;
1397    let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1398    let q = params.q.unwrap_or_default();
1399    let active = resolve_filters(config, raw);
1400    let req = ListQuery {
1401        offset,
1402        order_by: &order_by,
1403        order_dir,
1404        q: q.trim(),
1405        filters: &active,
1406        per_page: Some(per_page),
1407    };
1408    // Everything a sort or pager link must preserve, as raw `&k=v` pairs. Askama
1409    // escapes it into the href, so the ampersands are correct in the markup.
1410    let mut carry = String::new();
1411    if !q.trim().is_empty() {
1412        carry.push_str(&format!("&q={}", urlencode(q.trim())));
1413    }
1414    for f in &active {
1415        carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
1416    }
1417    match query(&state.db, config, &req).await {
1418        Ok(result) => {
1419            let total_pages = ((result.total + per_page - 1) / per_page).max(1);
1420            let date_loc = date_locale(shell.locale());
1421            let rows: Vec<RenderedRow> = result
1422                .rows
1423                .into_iter()
1424                .map(|row| RenderedRow {
1425                    id: row.id,
1426                    cells: row
1427                        .cells
1428                        .iter()
1429                        .zip(&config.columns)
1430                        .map(|(raw, col)| {
1431                            let cx = CellCx {
1432                                value: raw,
1433                                tz: shell.tz,
1434                                locale: date_loc,
1435                            };
1436                            let scope = OverrideScope {
1437                                surface: Surface::Column,
1438                                view_key: &col.column_type,
1439                                resource: Some(&config.entity),
1440                                field: Some(&col.field),
1441                            };
1442                            let html = match state.column_types.get(&col.column_type) {
1443                                Some(ct) => {
1444                                    render_cell(ct.as_ref(), state.overrides.as_ref(), &scope, &cx)
1445                                        .into_string()
1446                                }
1447                                // Unreachable: `check` refused the type at boot.
1448                                None => String::new(),
1449                            };
1450                            CellView {
1451                                html,
1452                                class: col.align_class().to_string(),
1453                            }
1454                        })
1455                        .collect(),
1456                })
1457                .collect();
1458            let keys: Vec<&str> = config
1459                .columns
1460                .iter()
1461                .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
1462                .flatten()
1463                .collect();
1464            let mut shell = shell;
1465            shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
1466            let shown = rows.len() as i64;
1467            // Localize the title and column headers before the shell is moved in.
1468            let title = shell.tt(&config.title);
1469            let active_dir = match order_dir {
1470                SortDir::Asc => "asc",
1471                SortDir::Desc => "desc",
1472            };
1473            let columns: Vec<ColumnHead> = config
1474                .columns
1475                .iter()
1476                .map(|c| {
1477                    let active = c.field == order_by;
1478                    ColumnHead {
1479                        label: shell.tt(&c.label),
1480                        field: c.field.clone(),
1481                        // Clicking the sorted column flips it; any other column
1482                        // starts ascending.
1483                        next_dir: if active && active_dir == "asc" {
1484                            "desc".to_string()
1485                        } else {
1486                            "asc".to_string()
1487                        },
1488                        active: if active {
1489                            active_dir.to_string()
1490                        } else {
1491                            String::new()
1492                        },
1493                        sortable: c.sortable,
1494                        width: c.width.clone(),
1495                        align_class: c.align_class().to_string(),
1496                    }
1497                })
1498                .collect();
1499            let shell_for_toolbar = shell.clone();
1500            let filter_views = filter_views(config, &active, &shell);
1501            let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
1502                .into_iter()
1503                .map(|c| ColumnChoice {
1504                    field: c.field.clone(),
1505                    label: shell.tt(&c.label),
1506                    shown: config.columns.iter().any(|v| v.field == c.field),
1507                })
1508                .collect();
1509            // Built before the literal moves `order_by` into `sort`.
1510            let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
1511            let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
1512            // Localized before the literal moves `shell` in.
1513            let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
1514            let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
1515            let page_view = ListTemplate {
1516                shell,
1517                title,
1518                columns,
1519                rows,
1520                shown,
1521                page,
1522                total: result.total,
1523                total_pages,
1524                edit_base: config.edit_base.clone(),
1525                sort: order_by,
1526                dir: active_dir.to_string(),
1527                q: q.trim().to_string(),
1528                searchable: config.search.enabled
1529                    && config.columns.iter().any(|c| c.is_searchable()),
1530                search_prompt,
1531                search_on_enter: config.search.on_enter,
1532                no_records_message,
1533                per_page,
1534                per_page_options: config.per_page_options.clone(),
1535                pickers,
1536                toolbar,
1537                export_query,
1538                path: path.to_string(),
1539                filters: filter_views,
1540                deletable: config.deletable,
1541                exportable: config.exportable,
1542                filtered: !active.is_empty() || !q.trim().is_empty(),
1543                carry: carry.clone(),
1544            };
1545            // An htmx request gets the list region alone, which replaces itself in
1546            // place; anything else gets the whole page, so sorting and paging still
1547            // work as ordinary links with scripting off.
1548            if crate::form::is_htmx(headers) {
1549                render(ListFragment {
1550                    shell: page_view.shell,
1551                    columns: page_view.columns,
1552                    rows: page_view.rows,
1553                    shown: page_view.shown,
1554                    page: page_view.page,
1555                    total: page_view.total,
1556                    total_pages: page_view.total_pages,
1557                    edit_base: page_view.edit_base,
1558                    sort: page_view.sort,
1559                    dir: page_view.dir,
1560                    carry: page_view.carry,
1561                    filtered: page_view.filtered,
1562                    deletable: page_view.deletable,
1563                    no_records_message: page_view.no_records_message,
1564                })
1565            } else {
1566                render(page_view)
1567            }
1568        }
1569        Err(_) => render_error(),
1570    }
1571}
1572
1573/// Stores which columns this operator wants on this list.
1574///
1575/// Choosing every column clears the preference rather than storing them all, so
1576/// the operator keeps following the descriptor as it gains or loses columns.
1577/// Choosing none is refused: a list with no columns shows nothing and offers no
1578/// way back, so the request is treated as "no preference".
1579pub(crate) async fn set_columns(
1580    state: &AdminState,
1581    config: &ListConfig,
1582    path: &str,
1583    user: &laterite_auth::AuthenticatedUser,
1584    session: &crate::session::SessionHandle,
1585    headers: &axum::http::HeaderMap,
1586    pairs: &[(String, String)],
1587) -> Response {
1588    let allowed = allowed_columns(config, user);
1589    let chosen: Vec<&str> = pairs
1590        .iter()
1591        .filter(|(k, _)| k == "column")
1592        .map(|(_, v)| v.trim())
1593        .filter(|v| allowed.iter().any(|c| c.field == *v))
1594        .collect();
1595    // "Every column" means the default set: what shows with no preference. An
1596    // `invisible` column chosen on purpose must not clear the choice that
1597    // revealed it.
1598    let mut by_default: Vec<&str> = allowed
1599        .iter()
1600        .filter(|c| !c.invisible)
1601        .map(|c| c.field.as_str())
1602        .collect();
1603    let mut as_chosen = chosen.clone();
1604    by_default.sort_unstable();
1605    as_chosen.sort_unstable();
1606    as_chosen.dedup();
1607
1608    // The page size travels on the same form. Only an offered size is kept, and
1609    // the descriptor's own clears the preference, like choosing every column.
1610    let size_key = per_page_preference_key(path);
1611    let size = pairs
1612        .iter()
1613        .find(|(k, _)| k == "per_page")
1614        .and_then(|(_, v)| v.trim().parse::<i64>().ok())
1615        .filter(|n| config.per_page_options.contains(n));
1616    let size_result = match size {
1617        Some(n) if n != config.per_page => {
1618            laterite_auth::store::set_user_preference(
1619                &state.db,
1620                user.user.id,
1621                &size_key,
1622                &n.to_string(),
1623            )
1624            .await
1625        }
1626        _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
1627    };
1628
1629    let key = columns_preference_key(path);
1630    let result = if chosen.is_empty() || as_chosen == by_default {
1631        laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
1632    } else {
1633        laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
1634            .await
1635    };
1636    match result.and(size_result) {
1637        Ok(()) => session.push_flash(
1638            crate::session::FlashLevel::Success,
1639            laterite_core::t!("List setup saved."),
1640        ),
1641        Err(e) => {
1642            tracing::error!(error = %e, "storing the list setup failed");
1643            session.push_flash(
1644                crate::session::FlashLevel::Error,
1645                laterite_core::t!("Could not save your list setup."),
1646            );
1647        }
1648    }
1649    let back = format!("{}{}", state.admin_path, path);
1650    crate::form::saved_response(crate::form::is_htmx(headers), &back)
1651}
1652
1653/// One row of the column picker.
1654pub struct ColumnChoice {
1655    pub field: String,
1656    pub label: String,
1657    pub shown: bool,
1658}
1659
1660/// One column header: its label, the field a click sorts by, the direction that
1661/// click asks for, and the direction it is sorted in now (empty when it is not).
1662pub struct ColumnHead {
1663    pub label: String,
1664    pub field: String,
1665    pub next_dir: String,
1666    pub active: String,
1667    pub sortable: bool,
1668    pub width: Option<String>,
1669    pub align_class: String,
1670}
1671
1672#[derive(Template)]
1673#[template(path = "list.html")]
1674struct ListTemplate {
1675    shell: crate::Shell,
1676    title: String,
1677    columns: Vec<ColumnHead>,
1678    rows: Vec<RenderedRow>,
1679    /// The count on this page (`rows.len()`), precomputed as `i64` for the footer.
1680    shown: i64,
1681    page: i64,
1682    total: i64,
1683    total_pages: i64,
1684    edit_base: Option<String>,
1685    /// The active ordering, carried on the pager links so paging keeps the sort.
1686    sort: String,
1687    dir: String,
1688    /// The active search term, shown in the box and carried on every link.
1689    q: String,
1690    /// Whether the box appears: search is on and some column is searchable.
1691    searchable: bool,
1692    /// The placeholder when the descriptor set one, localized.
1693    search_prompt: Option<String>,
1694    search_on_enter: bool,
1695    /// The empty-state message when the descriptor set one, localized.
1696    no_records_message: Option<String>,
1697    /// This operator's page size, and the sizes the list setup offers; empty
1698    /// renders no chooser.
1699    per_page: i64,
1700    per_page_options: Vec<i64>,
1701    /// Whether rows carry a checkbox and the Delete button is offered.
1702    deletable: bool,
1703    /// Whether this resource offers an export, which puts the menu beside the
1704    /// title. Off leaves the title row bare.
1705    exportable: bool,
1706    /// Every declared column, with the shown ones ticked, for the picker.
1707    pickers: Vec<ColumnChoice>,
1708    /// The toolbar buttons this operator may see.
1709    toolbar: Vec<ToolbarView>,
1710    /// The search, filters and sort, as `&k=v` pairs, so an export link carries
1711    /// the same view the screen is showing.
1712    export_query: String,
1713    /// This list's own path, for the search form to post back to.
1714    path: String,
1715    /// The filter controls, with the active value marked.
1716    filters: Vec<FilterView>,
1717    /// Whether anything is narrowing the list, so a Clear link is offered.
1718    filtered: bool,
1719    /// The query-string pairs a sort or pager link must carry.
1720    carry: String,
1721}
1722
1723/// One rendered filter control.
1724pub struct FilterView {
1725    pub param: String,
1726    pub label: String,
1727    /// The input kind when there are no options: `text`, `number` or `date`.
1728    pub input_type: String,
1729    pub options: Vec<FilterOptionView>,
1730    /// The selected value, empty when the filter is off.
1731    pub value: String,
1732}
1733
1734pub struct FilterOptionView {
1735    pub value: String,
1736    pub label: String,
1737}
1738
1739/// Builds the filter controls, marking whichever value the request selected.
1740fn filter_views(
1741    config: &ListConfig,
1742    active: &[ActiveFilter<'_>],
1743    shell: &crate::Shell,
1744) -> Vec<FilterView> {
1745    config
1746        .filters
1747        .iter()
1748        .map(|f| {
1749            let value = active
1750                .iter()
1751                .find(|a| a.filter.field == f.field)
1752                .map(|a| a.raw.clone())
1753                .unwrap_or_default();
1754            // A list of choices renders as a select; anything else as an
1755            // input of the matching kind.
1756            let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
1757                "boolean" => vec![
1758                    FilterOptionView {
1759                        value: "1".to_string(),
1760                        label: shell.tt(&laterite_core::t!("Yes")),
1761                    },
1762                    FilterOptionView {
1763                        value: "0".to_string(),
1764                        label: shell.tt(&laterite_core::t!("No")),
1765                    },
1766                ],
1767                "select" => f
1768                    .choices()
1769                    .iter()
1770                    .map(|o| FilterOptionView {
1771                        value: o.value.clone(),
1772                        label: shell.tt(&o.label),
1773                    })
1774                    .collect(),
1775                _ => Vec::new(),
1776            };
1777            let input_type = match f.filter_type.as_str() {
1778                "number" => "number",
1779                "date" => "date",
1780                _ => "text",
1781            };
1782            FilterView {
1783                param: f.param(),
1784                label: shell.tt(&f.label),
1785                input_type: input_type.to_string(),
1786                options,
1787                value,
1788            }
1789        })
1790        .collect()
1791}
1792
1793/// Percent-encodes a query-string value.
1794fn urlencode(value: &str) -> String {
1795    let mut out = String::with_capacity(value.len());
1796    for byte in value.as_bytes() {
1797        match byte {
1798            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1799                out.push(*byte as char)
1800            }
1801            b' ' => out.push('+'),
1802            _ => out.push_str(&format!("%{byte:02X}")),
1803        }
1804    }
1805    out
1806}
1807
1808/// The list region alone, for an htmx sort or page that swaps it in place.
1809#[derive(Template)]
1810#[template(path = "_list_table.html")]
1811struct ListFragment {
1812    shell: crate::Shell,
1813    columns: Vec<ColumnHead>,
1814    rows: Vec<RenderedRow>,
1815    shown: i64,
1816    page: i64,
1817    total: i64,
1818    total_pages: i64,
1819    edit_base: Option<String>,
1820    sort: String,
1821    dir: String,
1822    carry: String,
1823    /// Rendered onto the region as `data-filtered`, so the bar's Clear link can
1824    /// react without the bar itself having to swap. Also picks the empty state:
1825    /// a filtered list with no rows has not run out of records, it has no match.
1826    filtered: bool,
1827    deletable: bool,
1828    no_records_message: Option<String>,
1829}
1830
1831#[cfg(test)]
1832mod tests {
1833    use super::*;
1834
1835    fn config() -> ListConfig {
1836        ListConfig {
1837            entity: "backend_users".to_string(),
1838            title: "Users".into(),
1839            columns: vec![
1840                ListColumn::new("username", "Username"),
1841                ListColumn::new("is_superuser", "Superuser").yes_no(),
1842            ],
1843            order_by: "created_at".to_string(),
1844            order_dir: SortDir::Desc,
1845            per_page: 25,
1846            id_field: "id".to_string(),
1847            edit_base: None,
1848            creatable: false,
1849            filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
1850            deletable: true,
1851            ..Default::default()
1852        }
1853    }
1854
1855    #[test]
1856    fn column_types_format_their_cell_display() {
1857        let ist: Tz = "Asia/Kolkata".parse().unwrap();
1858        let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
1859            ct.view_model(&CellCx {
1860                value,
1861                tz,
1862                locale: chrono::Locale::en_US,
1863            })
1864            .display
1865        };
1866        // 10:00 UTC is 15:30 in Asia/Kolkata (UTC+5:30)
1867        assert_eq!(
1868            display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
1869            "13 Aug 2026, 15:30"
1870        );
1871        assert_eq!(
1872            display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
1873            "13 Aug 2026"
1874        );
1875        assert_eq!(
1876            display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
1877            "15:30"
1878        );
1879        assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
1880        assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
1881        assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
1882        // An unparseable timestamp falls through unchanged.
1883        assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
1884    }
1885
1886    #[test]
1887    fn sort_falls_back_to_the_configured_order() {
1888        let c = config();
1889        assert_eq!(
1890            resolve_sort(&c, None, None),
1891            ("created_at".to_string(), SortDir::Desc)
1892        );
1893    }
1894
1895    #[test]
1896    fn a_declared_column_can_be_sorted_either_way() {
1897        let c = config();
1898        assert_eq!(
1899            resolve_sort(&c, Some("username"), Some("asc")),
1900            ("username".to_string(), SortDir::Asc)
1901        );
1902        assert_eq!(
1903            resolve_sort(&c, Some("username"), Some("desc")),
1904            ("username".to_string(), SortDir::Desc)
1905        );
1906    }
1907
1908    /// The whitelist: a column the descriptor does not declare never reaches the
1909    /// query, however it is spelled.
1910    #[test]
1911    fn an_undeclared_sort_column_is_ignored() {
1912        let c = config();
1913        for crafted in [
1914            "password_hash",
1915            "id) --",
1916            "username; drop table backend_users",
1917            "",
1918        ] {
1919            assert_eq!(
1920                resolve_sort(&c, Some(crafted), Some("asc")).0,
1921                "created_at",
1922                "{crafted} must not reach the query"
1923            );
1924        }
1925    }
1926
1927    #[test]
1928    fn an_unknown_direction_falls_back() {
1929        let c = config();
1930        // On a column other than the configured one, ascending.
1931        assert_eq!(
1932            resolve_sort(&c, Some("username"), Some("sideways")).1,
1933            SortDir::Asc
1934        );
1935        // On the configured column, its configured direction.
1936        assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
1937    }
1938
1939    /// A module's own column type renders its cells, which is the point of the
1940    /// registry being open rather than a fixed set.
1941    #[test]
1942    fn a_contributed_column_type_renders_its_cell() {
1943        struct Rating;
1944        impl ColumnType for Rating {
1945            fn view_key(&self) -> &'static str {
1946                "acme.rating"
1947            }
1948            fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1949                let stars = cx.value.parse::<usize>().unwrap_or(0);
1950                text_cell("acme.rating", cx.value, "*".repeat(stars))
1951            }
1952            fn render_default(&self, vm: &CellVm) -> Markup {
1953                render_text(vm)
1954            }
1955        }
1956
1957        let reg = ColumnTypeReg::new(Arc::new(Rating));
1958        assert_eq!(reg.name(), "acme.rating", "the type names itself");
1959
1960        let mut registry = builtin_column_registry();
1961        registry.insert(reg.name().to_string(), reg.column_type);
1962        let rendered = render_cell(
1963            registry.get("acme.rating").unwrap().as_ref(),
1964            &crate::field::NoOverrides,
1965            &OverrideScope {
1966                surface: Surface::Column,
1967                view_key: "acme.rating",
1968                resource: Some("products"),
1969                field: Some("stars"),
1970            },
1971            &CellCx {
1972                value: "3",
1973                tz: Tz::UTC,
1974                locale: chrono::Locale::en_US,
1975            },
1976        );
1977        assert!(rendered.into_string().contains("***"));
1978    }
1979
1980    /// A contributed type cannot quietly replace a built-in: the key is the
1981    /// type's own `view_key`, and the router refuses a duplicate at boot.
1982    #[test]
1983    fn a_contributed_key_is_the_types_own_and_collides_visibly() {
1984        struct Impostor;
1985        impl ColumnType for Impostor {
1986            fn view_key(&self) -> &'static str {
1987                "text"
1988            }
1989            fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
1990                text_cell("text", cx.value, String::new())
1991            }
1992            fn render_default(&self, vm: &CellVm) -> Markup {
1993                render_text(vm)
1994            }
1995        }
1996        let reg = ColumnTypeReg::new(Arc::new(Impostor));
1997        assert_eq!(reg.name(), "text");
1998        // The router inserts into this map and panics when one is already there.
1999        assert!(builtin_column_registry().contains_key(reg.name()));
2000    }
2001
2002    #[test]
2003    fn status_pill_renders_a_slugged_markup_span() {
2004        let vm = StatusPillColumn.view_model(&CellCx {
2005            value: "In Progress",
2006            tz: Tz::UTC,
2007            locale: chrono::Locale::en_US,
2008        });
2009        let html = StatusPillColumn.render_default(&vm).into_string();
2010        assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
2011        assert!(html.contains(">In Progress<"));
2012    }
2013
2014    /// A fresh test database with the auth tables migrated in, on whichever
2015    /// backend the run targets. Hold the returned guard for the test's lifetime.
2016    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
2017        laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
2018    }
2019
2020    #[tokio::test]
2021    async fn query_returns_display_rows() {
2022        let (db, _guard) = test_db().await;
2023        let hash = laterite_auth::password::hash_password("pw").unwrap();
2024        laterite_auth::store::create_user(
2025            &db,
2026            "root",
2027            "root@example.test",
2028            "Ada",
2029            None,
2030            &hash,
2031            true,
2032        )
2033        .await
2034        .unwrap();
2035
2036        let c = config();
2037        let result = query(&db, &c, &plain(&c)).await.unwrap();
2038        assert_eq!(result.total, 1);
2039        assert_eq!(result.rows.len(), 1);
2040        assert_eq!(result.rows[0].cells[0], "root");
2041        // Booleans store as 0/1 integers everywhere, so a cast-to-text superuser
2042        // flag reads back as "1"; the display layer maps it to "Yes".
2043        assert_eq!(result.rows[0].cells[1], "1");
2044        assert!(!result.rows[0].id.is_empty());
2045    }
2046
2047    async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
2048        get_with(params, HashMap::new(), headers).await
2049    }
2050
2051    async fn get_with(
2052        params: ListParams,
2053        raw: HashMap<String, String>,
2054        headers: axum::http::HeaderMap,
2055    ) -> String {
2056        let (db, _guard) = test_db().await;
2057        let state = AdminState::new(
2058            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2059            db,
2060        );
2061        let resp = handle(
2062            &state,
2063            &config(),
2064            "/admin/users",
2065            params,
2066            &raw,
2067            &crate::audit::test_actor(),
2068            crate::Shell::test(),
2069            &headers,
2070        )
2071        .await;
2072        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2073            .await
2074            .unwrap();
2075        String::from_utf8(bytes.to_vec()).unwrap()
2076    }
2077
2078    #[test]
2079    fn a_typed_filter_accepts_only_its_own_shape() {
2080        let mut c = config();
2081        c.filters = vec![
2082            ListFilter::number("views", "Views"),
2083            ListFilter::date("created_at", "Created"),
2084            ListFilter::text("username", "Username"),
2085        ];
2086        let accepted = |param: &str, value: &str| {
2087            !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
2088        };
2089        assert!(accepted("f_views", "42"));
2090        assert!(
2091            !accepted("f_views", "4.2"),
2092            "a number filter takes whole numbers"
2093        );
2094        assert!(!accepted("f_views", "; drop table"), "and nothing else");
2095
2096        assert!(accepted("f_created_at", "2026-09-21"));
2097        assert!(
2098            !accepted("f_created_at", "2026-9-21"),
2099            "a date is ISO or nothing"
2100        );
2101        assert!(
2102            !accepted("f_created_at", "2026-09-21%"),
2103            "so a pattern cannot slip in"
2104        );
2105
2106        assert!(accepted("f_username", "ada"));
2107    }
2108
2109    #[test]
2110    fn a_filter_default_applies_only_until_the_operator_decides() {
2111        let mut c = config();
2112        c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
2113        // Nothing named: the default narrows the list.
2114        assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
2115        // Named blank: the operator cleared it, so no filter.
2116        assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
2117        // Named otherwise: their value, not the default.
2118        let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
2119        assert_eq!(active.len(), 1);
2120    }
2121
2122    #[test]
2123    fn a_gated_filter_is_narrowed_away_with_its_default() {
2124        let mut c = config();
2125        c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
2126            .default_value("1")
2127            .require("acme.secret")];
2128        let plain = laterite_auth::AuthenticatedUser {
2129            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2130            ..crate::audit::test_actor()
2131        };
2132        assert!(allowed_filters(&c, &plain).is_empty());
2133        // And the narrowed descriptor carries none, so the default cannot apply.
2134        let narrowed = narrowed(&c, None, &plain);
2135        assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
2136        assert_eq!(allowed_filters(&c, &root()).len(), 1);
2137    }
2138
2139    #[test]
2140    fn a_stored_page_size_counts_only_while_it_is_offered() {
2141        let mut cfg = config();
2142        assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
2143        cfg.per_page_options = vec![25, 50, 100];
2144        assert_eq!(offered_per_page(&cfg, Some(50)), 50);
2145        assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
2146        assert_eq!(offered_per_page(&cfg, None), 25);
2147    }
2148
2149    fn params(sort: Option<&str>) -> ListParams {
2150        ListParams {
2151            page: None,
2152            sort: sort.map(|s| s.to_string()),
2153            dir: Some("asc".to_string()),
2154            q: None,
2155        }
2156    }
2157
2158    /// The default window and ordering, with no search and no filter.
2159    fn plain(config: &ListConfig) -> ListQuery<'_> {
2160        ListQuery {
2161            offset: 0,
2162            order_by: &config.order_by,
2163            order_dir: config.order_dir,
2164            q: "",
2165            filters: &[],
2166            per_page: None,
2167        }
2168    }
2169
2170    fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
2171        ListQuery {
2172            offset: 0,
2173            order_by: &config.order_by,
2174            order_dir: config.order_dir,
2175            q,
2176            filters: &[],
2177            per_page: None,
2178        }
2179    }
2180
2181    /// Both `&amp;` and `&#38;` are an ampersand; the escaper picks one, and a
2182    /// test should assert on the link, not on which spelling it chose.
2183    fn amps(html: &str) -> String {
2184        html.replace("&#38;", "&").replace("&amp;", "&")
2185    }
2186
2187    /// The request's filter parameters, as the query string would carry them.
2188    fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2189        pairs
2190            .iter()
2191            .map(|(k, v)| (k.to_string(), v.to_string()))
2192            .collect()
2193    }
2194
2195    #[tokio::test]
2196    async fn search_filters_rows_and_the_total() {
2197        let (db, _guard) = test_db().await;
2198        for name in ["ada", "brendan", "clara"] {
2199            let hash = laterite_auth::password::hash_password("x").unwrap();
2200            laterite_auth::store::create_user(
2201                &db,
2202                name,
2203                &format!("{name}@example.test"),
2204                name,
2205                None,
2206                &hash,
2207                false,
2208            )
2209            .await
2210            .unwrap();
2211        }
2212        let c = config();
2213
2214        let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
2215        assert_eq!(hit.rows.len(), 1);
2216        assert_eq!(hit.rows[0].cells[0], "brendan");
2217        // The pager counts the filtered set, not the table.
2218        assert_eq!(hit.total, 1);
2219
2220        // Case folds, as the picker's default profile does.
2221        assert_eq!(
2222            query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
2223            1
2224        );
2225
2226        // A blank term is no filter, not a filter matching nothing.
2227        assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
2228        assert_eq!(query(&db, &c, &searched(&c, "   ")).await.unwrap().total, 3);
2229
2230        // A LIKE wildcard is literal, so it does not match every row.
2231        assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
2232    }
2233
2234    /// Only text columns are searched by default: a substring match against a
2235    /// boolean would make every row containing `1` a hit.
2236    #[tokio::test]
2237    async fn search_skips_a_non_text_column() {
2238        let (db, _guard) = test_db().await;
2239        let hash = laterite_auth::password::hash_password("x").unwrap();
2240        laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
2241            .await
2242            .unwrap();
2243        let c = config();
2244        assert!(c.columns[0].is_searchable(), "username is text");
2245        assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
2246        // The stored superuser flag is `1`, but searching it finds nothing.
2247        assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
2248    }
2249
2250    #[test]
2251    fn a_column_can_override_its_searchability() {
2252        assert!(ListColumn::new("code", "Code").is_searchable());
2253        assert!(!ListColumn::new("code", "Code")
2254            .searchable(false)
2255            .is_searchable());
2256        assert!(ListColumn::new("at", "At")
2257            .datetime()
2258            .searchable(true)
2259            .is_searchable());
2260    }
2261
2262    async fn seed_users(db: &Db) {
2263        for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
2264            let hash = laterite_auth::password::hash_password("x").unwrap();
2265            laterite_auth::store::create_user(
2266                db,
2267                name,
2268                &format!("{name}@example.test"),
2269                name,
2270                None,
2271                &hash,
2272                su,
2273            )
2274            .await
2275            .unwrap();
2276        }
2277    }
2278
2279    async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
2280        let active = resolve_filters(c, params);
2281        let req = ListQuery {
2282            offset: 0,
2283            order_by: &c.order_by,
2284            order_dir: c.order_dir,
2285            q: "",
2286            filters: &active,
2287            per_page: None,
2288        };
2289        query(db, c, &req).await.unwrap().total
2290    }
2291
2292    #[tokio::test]
2293    async fn a_filter_narrows_the_rows_and_the_total() {
2294        let (db, _guard) = test_db().await;
2295        seed_users(&db).await;
2296        let c = config();
2297
2298        assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
2299        assert_eq!(
2300            total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
2301            1
2302        );
2303        assert_eq!(
2304            total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
2305            2
2306        );
2307    }
2308
2309    /// The whitelist: only a declared filter, and for a select only a declared
2310    /// option, reaches the query. Anything else is dropped, not guessed at.
2311    #[test]
2312    fn an_undeclared_filter_or_value_is_ignored() {
2313        let c = config();
2314        // A column that exists but is not a declared filter.
2315        assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
2316        // A declared boolean filter with a value its control never emits.
2317        for bad in ["yes", "true", "1 or 1=1", "", "  "] {
2318            assert!(
2319                resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
2320                "{bad} must not reach the query"
2321            );
2322        }
2323
2324        let with_select = ListConfig {
2325            filters: vec![ListFilter::select(
2326                "status",
2327                "Status",
2328                vec![FilterOption::new("live", "Live")],
2329            )],
2330            ..config()
2331        };
2332        assert_eq!(
2333            resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
2334            1
2335        );
2336        assert!(
2337            resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
2338            "an option the descriptor never offered is dropped"
2339        );
2340    }
2341
2342    #[tokio::test]
2343    async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
2344        let mut p = params(Some("username"));
2345        p.q = None;
2346        let html = get_with(
2347            p,
2348            filter_params(&[("f_is_superuser", "1")]),
2349            axum::http::HeaderMap::new(),
2350        )
2351        .await;
2352        let links = amps(&html);
2353        assert!(
2354            links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
2355            "a sort link carries the filter"
2356        );
2357        // The sorted column's own link flips direction and keeps it too.
2358        assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
2359        assert!(
2360            html.contains(r#"<option value="1" selected>"#),
2361            "the control shows the active value"
2362        );
2363        assert!(html.contains("Clear"), "and a way back to the full list");
2364        assert!(
2365            html.contains(r#"data-filtered="true""#),
2366            "the region says the list is narrowed, which is what reveals Clear"
2367        );
2368    }
2369
2370    /// A filtered list with no rows has not run out of records; it has no match.
2371    /// Saying "No records yet" there reads as an empty table and hides the filter.
2372    #[tokio::test]
2373    async fn the_empty_state_distinguishes_no_records_from_no_match() {
2374        let (db, _guard) = test_db().await;
2375        let state = AdminState::new(
2376            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2377            db,
2378        );
2379        let render = |raw: HashMap<String, String>, q: Option<&str>| {
2380            let q = q.map(String::from);
2381            let state = state.clone();
2382            async move {
2383                let p = ListParams {
2384                    page: None,
2385                    sort: None,
2386                    dir: None,
2387                    q,
2388                };
2389                let resp = handle(
2390                    &state,
2391                    &config(),
2392                    "/admin/users",
2393                    p,
2394                    &raw,
2395                    &crate::audit::test_actor(),
2396                    crate::Shell::test(),
2397                    &axum::http::HeaderMap::new(),
2398                )
2399                .await;
2400                let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2401                    .await
2402                    .unwrap();
2403                String::from_utf8(bytes.to_vec()).unwrap()
2404            }
2405        };
2406
2407        // Nothing stored and nothing asked for: the table is genuinely empty.
2408        let bare = render(HashMap::new(), None).await;
2409        assert!(bare.contains("No records yet."));
2410
2411        // Filtered to nothing: the records may exist, the filter excluded them.
2412        let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
2413        assert!(filtered.contains("No records match."));
2414        assert!(!filtered.contains("No records yet."));
2415
2416        // A search that matches nothing reads the same way.
2417        let searched = render(HashMap::new(), Some("zzz")).await;
2418        assert!(searched.contains("No records match."));
2419    }
2420
2421    /// A superuser, who sees every column, for the narrowing tests.
2422    fn root() -> laterite_auth::AuthenticatedUser {
2423        laterite_auth::AuthenticatedUser {
2424            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2425            ..crate::audit::test_actor()
2426        }
2427    }
2428
2429    #[test]
2430    fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
2431        let mut c = config();
2432        c.columns[1].invisible = true;
2433        assert_eq!(visible_columns(&c, None, &root()).len(), 1);
2434        assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
2435        assert_eq!(
2436            visible_columns(&c, Some("is_superuser"), &root())[0].field,
2437            "is_superuser"
2438        );
2439    }
2440
2441    #[test]
2442    fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
2443        let mut c = config();
2444        c.columns[1].permission = Some("acme.secret".into());
2445        let plain = laterite_auth::AuthenticatedUser {
2446            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2447            ..crate::audit::test_actor()
2448        };
2449        assert_eq!(visible_columns(&c, None, &plain).len(), 1);
2450        // Choosing it by name does not reveal it either.
2451        assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
2452        assert_eq!(visible_columns(&c, None, &root()).len(), 2);
2453    }
2454
2455    #[test]
2456    fn an_unsortable_column_is_not_sorted_by() {
2457        let mut c = config();
2458        c.columns[0].sortable = false;
2459        let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
2460        assert_eq!(by, c.order_by, "falls back to the descriptor's order");
2461    }
2462
2463    #[test]
2464    fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
2465        let c = config();
2466        // Stored out of order; the descriptor's order still wins, because the
2467        // choice is about which columns, not where they sit.
2468        let kept = visible_columns(&c, Some("is_superuser,username"), &root());
2469        assert_eq!(
2470            kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
2471            ["username", "is_superuser"]
2472        );
2473    }
2474
2475    #[test]
2476    fn no_stored_choice_shows_every_column() {
2477        let c = config();
2478        assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
2479    }
2480
2481    /// A descriptor that drops or renames a column must not leave an operator
2482    /// staring at an empty table because of a choice they made months ago.
2483    #[test]
2484    fn a_stale_choice_falls_back_to_every_column() {
2485        let c = config();
2486        assert_eq!(
2487            visible_columns(&c, Some("gone,removed"), &root()).len(),
2488            c.columns.len()
2489        );
2490        assert_eq!(
2491            visible_columns(&c, Some(""), &root()).len(),
2492            c.columns.len()
2493        );
2494        assert_eq!(
2495            visible_columns(&c, Some("  , "), &root()).len(),
2496            c.columns.len()
2497        );
2498        // A partly stale choice keeps what still exists.
2499        assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
2500    }
2501
2502    /// A button the operator cannot use is not shown, rather than shown and
2503    /// answering 403 when they reach it.
2504    #[test]
2505    fn the_toolbar_hides_what_the_operator_may_not_do() {
2506        let shell = crate::Shell::test();
2507        let cfg = ListConfig {
2508            creatable: false,
2509            toolbar: vec![
2510                ToolbarButton::new("Open", "/reports"),
2511                ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
2512            ],
2513            ..config()
2514        };
2515
2516        // A superuser sees everything, gated or not.
2517        let root = laterite_auth::AuthenticatedUser {
2518            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2519            ..crate::audit::test_actor()
2520        };
2521        let views = toolbar_views(&cfg, "/admin", &root, &shell);
2522        assert_eq!(
2523            views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2524            ["Open", "Audit"]
2525        );
2526
2527        // Someone holding the named permission sees it too.
2528        let granted = laterite_auth::AuthenticatedUser {
2529            permissions: laterite_auth::PermissionSet::with_overrides(
2530                false,
2531                ["backend.view_audit".to_string()],
2532                [],
2533                [],
2534            ),
2535            ..crate::audit::test_actor()
2536        };
2537        assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
2538
2539        // Someone holding nothing sees only the ungated button.
2540        let plain = laterite_auth::AuthenticatedUser {
2541            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
2542            ..crate::audit::test_actor()
2543        };
2544        let views = toolbar_views(&cfg, "/admin", &plain, &shell);
2545        assert_eq!(
2546            views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
2547            ["Open"]
2548        );
2549    }
2550
2551    /// A relative href is resolved against the configured admin mount, so a moved
2552    /// panel moves its buttons with it.
2553    #[test]
2554    fn a_toolbar_href_follows_the_admin_mount() {
2555        let shell = crate::Shell::test();
2556        let cfg = ListConfig {
2557            creatable: true,
2558            // Already under the mount, as `prefix_resource` leaves it at boot.
2559            edit_base: Some("/backoffice/roles".to_string()),
2560            toolbar: vec![
2561                ToolbarButton::new("Reports", "/reports"),
2562                ToolbarButton::new("Docs", "https://example.test/docs"),
2563            ],
2564            ..config()
2565        };
2566        let root = laterite_auth::AuthenticatedUser {
2567            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
2568            ..crate::audit::test_actor()
2569        };
2570        let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
2571        assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
2572        assert_eq!(views[1].href, "/backoffice/reports");
2573        // An absolute URL is left alone.
2574        assert_eq!(views[2].href, "https://example.test/docs");
2575    }
2576
2577    #[tokio::test]
2578    async fn an_htmx_sort_returns_the_list_region_alone() {
2579        let mut headers = axum::http::HeaderMap::new();
2580        headers.insert("hx-request", "true".parse().unwrap());
2581        let html = get(params(Some("username")), headers).await;
2582        assert!(html.contains(r#"id="lat-list""#), "the region comes back");
2583        assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
2584        // A fragment, not a page: swapping a document into the region would nest
2585        // the admin inside itself.
2586        assert!(!html.contains("<body"), "no page chrome");
2587        // The bar cannot re-render per swap, so the region carries the state.
2588        assert!(html.contains(r#"data-filtered="false""#));
2589    }
2590
2591    /// A sort or a page must not silently drop the search, or the second click
2592    /// would widen the result back to the whole table.
2593    #[tokio::test]
2594    async fn the_search_term_survives_a_sort_link() {
2595        let mut p = params(Some("username"));
2596        p.q = Some("bren".to_string());
2597        let html = get(p, axum::http::HeaderMap::new()).await;
2598        assert!(
2599            amps(&html).contains("?sort=username&dir=desc&q=bren"),
2600            "sort and pager links carry the term"
2601        );
2602        assert!(
2603            html.contains(r#"value="bren""#),
2604            "and the box still shows it"
2605        );
2606    }
2607
2608    #[tokio::test]
2609    async fn a_plain_sort_returns_the_whole_page() {
2610        let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
2611        assert!(html.contains("<body"), "lists still work without htmx");
2612        assert!(html.contains(r#"id="lat-list""#));
2613        // The links work without scripting, so they carry a real href.
2614        assert!(html.contains(r#"href="?sort=username"#));
2615    }
2616
2617    #[tokio::test]
2618    async fn query_rejects_bad_identifiers() {
2619        let (db, _guard) = test_db().await;
2620        let mut bad = config();
2621        bad.entity = "backend_users; drop table backend_users".to_string();
2622        assert!(query(&db, &bad, &plain(&bad)).await.is_err());
2623    }
2624}