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