Skip to main content

laterite_admin/
list.rs

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