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