Skip to main content

laterite_admin/
list.rs

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