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, DbBackend, Text};
21use sea_query::{Alias, Expr, Order, Query, SimpleExpr};
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    /// The cell is the form field's control, saved on change: a column whose
107    /// field is on the resource's form. Off by default.
108    pub editable: bool,
109}
110
111impl ListColumn {
112    pub fn new(field: &str, label: impl Into<Text>) -> Self {
113        Self {
114            field: field.to_string(),
115            label: label.into(),
116            column_type: "text".to_string(),
117            sortable: true,
118            invisible: false,
119            width: None,
120            align: None,
121            permission: None,
122            searchable: None,
123            options: serde_json::Value::Null,
124            labels: Vec::new(),
125            editable: false,
126        }
127    }
128
129    /// What each stored value reads as. A value not listed shows as stored.
130    pub fn labels(mut self, labels: Vec<ValueLabel>) -> Self {
131        self.labels = labels;
132        self
133    }
134
135    /// Edits the cell in the row through the form field of the same name.
136    pub fn editable(mut self) -> Self {
137        self.editable = true;
138        self
139    }
140
141    fn of(mut self, column_type: &str) -> Self {
142        self.column_type = column_type.to_string();
143        self
144    }
145
146    /// Render as a date and time in the display timezone.
147    pub fn datetime(self) -> Self {
148        self.of("datetime")
149    }
150    /// Render as a date in the display timezone.
151    pub fn date(self) -> Self {
152        self.of("date")
153    }
154    /// Render as a time in the display timezone.
155    pub fn time(self) -> Self {
156        self.of("time")
157    }
158    /// Render a boolean as Yes/No.
159    pub fn yes_no(self) -> Self {
160        self.of("boolean")
161    }
162    /// Render as a coloured status pill.
163    pub fn pill(self) -> Self {
164        self.of("status_pill")
165    }
166
167    /// Overrides whether the search box looks in this column.
168    pub fn searchable(mut self, searchable: bool) -> Self {
169        self.searchable = Some(searchable);
170        self
171    }
172
173    pub fn sortable(mut self, sortable: bool) -> Self {
174        self.sortable = sortable;
175        self
176    }
177
178    /// Hidden by default; the column chooser still offers it.
179    pub fn invisible(mut self) -> Self {
180        self.invisible = true;
181        self
182    }
183
184    pub fn width(mut self, width: impl Into<String>) -> Self {
185        self.width = Some(width.into());
186        self
187    }
188
189    pub fn align(mut self, align: Align) -> Self {
190        self.align = Some(align);
191        self
192    }
193
194    /// Shown only to an operator holding `permission`.
195    pub fn require(mut self, permission: impl Into<String>) -> Self {
196        self.permission = Some(permission.into());
197        self
198    }
199
200    pub(crate) fn align_class(&self) -> &'static str {
201        self.align.map(Align::class).unwrap_or("")
202    }
203
204    /// Whether search looks here: the explicit choice, else text columns only.
205    pub(crate) fn is_searchable(&self) -> bool {
206        self.searchable.unwrap_or(self.column_type == "text")
207    }
208}
209
210/// The per-cell state a column type renders from: the raw (text-cast) value, the
211/// display timezone, and the locale for date month/day names.
212pub struct CellCx<'a> {
213    pub value: &'a str,
214    pub tz: Tz,
215    pub locale: chrono::Locale,
216}
217
218/// The chrono locale for month/day names, parsed from the operator's locale tag.
219/// chrono locales are language + territory (`kn_IN`, `de_DE`, `ar_EG`), so a tag with
220/// no territory (a bare `kn`) or one chrono does not know formats in neutral English;
221/// a deployment gets localized dates by carrying a territory-bearing locale tag. No
222/// language is special-cased: any locale the locale table knows resolves.
223pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
224    chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
225}
226
227/// A rendered cell's serialisable payload: raw value, display text, and any
228/// richer data (a status pill's slug), so an override presents the same data.
229#[derive(Serialize)]
230pub struct CellVm {
231    pub view_key: String,
232    pub value: String,
233    pub display: String,
234    pub data: serde_json::Value,
235}
236
237/// A column type: how a list cell renders. The list counterpart of a field type,
238/// sharing [`Markup`] and the override resolver ([`Surface::Column`]).
239pub trait ColumnType: Send + Sync + 'static {
240    fn view_key(&self) -> &'static str;
241    fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
242    fn render_default(&self, vm: &CellVm) -> Markup;
243    /// The keys this type reads from a column's entry, beyond the ones every
244    /// column has. A key outside the list is refused when the file is read.
245    /// `None` accepts any key unchecked; declare yours.
246    fn option_keys(&self) -> Option<Vec<&'static str>> {
247        None
248    }
249    /// Asset-registry keys this column's cell needs (a heavy cell widget). The
250    /// page shell collects and emits them; keys must exist in the registry.
251    fn assets(&self) -> Vec<&'static str> {
252        Vec::new()
253    }
254}
255
256/// A column type a module contributes, so a list can render a cell the framework
257/// has no built-in for (a rating, a map thumbnail, a currency amount).
258///
259/// The key is the type's own [`ColumnType::view_key`], so a registration cannot
260/// disagree with the type it registers. Use a dotted `vendor.name` to stay clear
261/// of the built-ins and of other modules.
262pub struct ColumnTypeReg {
263    pub column_type: Arc<dyn ColumnType>,
264}
265
266impl ColumnTypeReg {
267    pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
268        Self { column_type }
269    }
270
271    /// The key this type registers under.
272    pub fn name(&self) -> &'static str {
273        self.column_type.view_key()
274    }
275}
276
277/// The column-type registry, keyed by [`ColumnType::view_key`].
278pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
279
280/// Renders a cell: an override if the resolver supplies one, else the default.
281/// Renders one cell: the type's view-model, an override when one is registered,
282/// else the type's own markup. A stored value with a label is shown as it,
283/// whatever the column's type.
284pub(crate) fn render_cell(
285    ct: &dyn ColumnType,
286    resolver: &dyn OverrideResolver,
287    scope: &OverrideScope<'_>,
288    cx: &CellCx<'_>,
289    labels: Option<&HashMap<String, String>>,
290) -> Markup {
291    let mut vm = ct.view_model(cx);
292    if let Some(label) = labels.and_then(|l| l.get(&vm.value)) {
293        vm.display = label.clone();
294    }
295    match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
296        Some(Ok(html)) => Markup::from_override(html),
297        Some(Err(_)) | None => ct.render_default(&vm),
298    }
299}
300
301pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
302    vec![
303        Arc::new(TextColumn),
304        Arc::new(DateTimeColumn),
305        Arc::new(DateColumn),
306        Arc::new(TimeColumn),
307        Arc::new(BoolColumn),
308        Arc::new(StatusPillColumn),
309    ]
310}
311
312/// The column registry seeded with the built-in types.
313pub(crate) fn builtin_column_registry() -> ColumnRegistry {
314    builtin_column_types()
315        .into_iter()
316        .map(|c| (c.view_key().to_string(), c))
317        .collect()
318}
319
320#[derive(Template)]
321#[template(path = "cells/text.html")]
322struct CellTextTmpl<'a> {
323    display: &'a str,
324}
325
326/// The view-model for a text-like cell: raw value plus its display text.
327fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
328    CellVm {
329        view_key: view_key.to_string(),
330        value: value.to_string(),
331        display,
332        data: serde_json::Value::Null,
333    }
334}
335
336fn render_text(vm: &CellVm) -> Markup {
337    Markup::from_template(&CellTextTmpl {
338        display: &vm.display,
339    })
340    .unwrap_or_default()
341}
342
343/// Formats a stored UTC RFC3339 timestamp in `tz`, with month/day names in `locale`;
344/// an unparseable value passes through unchanged.
345pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
346    match DateTime::parse_from_rfc3339(raw) {
347        Ok(dt) => dt
348            .with_timezone(&tz)
349            .format_localized(pattern, locale)
350            .to_string(),
351        Err(_) => raw.to_string(),
352    }
353}
354
355struct TextColumn;
356impl ColumnType for TextColumn {
357    fn option_keys(&self) -> Option<Vec<&'static str>> {
358        Some(Vec::new())
359    }
360    fn view_key(&self) -> &'static str {
361        "text"
362    }
363    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
364        text_cell("text", cx.value, cx.value.to_string())
365    }
366    fn render_default(&self, vm: &CellVm) -> Markup {
367        render_text(vm)
368    }
369}
370
371struct DateTimeColumn;
372impl ColumnType for DateTimeColumn {
373    fn option_keys(&self) -> Option<Vec<&'static str>> {
374        Some(Vec::new())
375    }
376    fn view_key(&self) -> &'static str {
377        "datetime"
378    }
379    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
380        text_cell(
381            "datetime",
382            cx.value,
383            format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
384        )
385    }
386    fn render_default(&self, vm: &CellVm) -> Markup {
387        render_text(vm)
388    }
389}
390
391struct DateColumn;
392impl ColumnType for DateColumn {
393    fn option_keys(&self) -> Option<Vec<&'static str>> {
394        Some(Vec::new())
395    }
396    fn view_key(&self) -> &'static str {
397        "date"
398    }
399    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
400        text_cell(
401            "date",
402            cx.value,
403            format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
404        )
405    }
406    fn render_default(&self, vm: &CellVm) -> Markup {
407        render_text(vm)
408    }
409}
410
411struct TimeColumn;
412impl ColumnType for TimeColumn {
413    fn option_keys(&self) -> Option<Vec<&'static str>> {
414        Some(Vec::new())
415    }
416    fn view_key(&self) -> &'static str {
417        "time"
418    }
419    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
420        text_cell(
421            "time",
422            cx.value,
423            format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
424        )
425    }
426    fn render_default(&self, vm: &CellVm) -> Markup {
427        render_text(vm)
428    }
429}
430
431struct BoolColumn;
432impl ColumnType for BoolColumn {
433    fn option_keys(&self) -> Option<Vec<&'static str>> {
434        Some(Vec::new())
435    }
436    fn view_key(&self) -> &'static str {
437        "boolean"
438    }
439    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
440        let display = match cx.value {
441            "1" | "true" => "Yes",
442            "0" | "false" | "" => "No",
443            other => other,
444        };
445        text_cell("boolean", cx.value, display.to_string())
446    }
447    fn render_default(&self, vm: &CellVm) -> Markup {
448        render_text(vm)
449    }
450}
451
452#[derive(Template)]
453#[template(path = "cells/status.html")]
454struct CellStatusTmpl<'a> {
455    label: &'a str,
456    slug: &'a str,
457}
458
459/// A status shown as a coloured pill: the first Markup-bearing cell.
460struct StatusPillColumn;
461impl ColumnType for StatusPillColumn {
462    fn option_keys(&self) -> Option<Vec<&'static str>> {
463        Some(Vec::new())
464    }
465    fn view_key(&self) -> &'static str {
466        "status_pill"
467    }
468    fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
469        CellVm {
470            view_key: "status_pill".to_string(),
471            value: cx.value.to_string(),
472            display: cx.value.to_string(),
473            data: serde_json::json!({ "slug": status_slug(cx.value) }),
474        }
475    }
476    fn render_default(&self, vm: &CellVm) -> Markup {
477        let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
478        Markup::from_template(&CellStatusTmpl {
479            label: &vm.display,
480            slug,
481        })
482        .unwrap_or_default()
483    }
484}
485
486/// A CSS-safe modifier from a status value (lowercased, non-alphanumerics to `-`).
487fn status_slug(value: &str) -> String {
488    value
489        .trim()
490        .to_lowercase()
491        .chars()
492        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
493        .collect()
494}
495
496impl Default for ListColumn {
497    /// A text column. The name comes from the key it is written under, and the
498    /// label from the name when a file gives none.
499    fn default() -> Self {
500        Self::new("", Text::new(""))
501    }
502}
503
504impl Default for ListFilter {
505    /// A text filter, named and labelled like a column.
506    fn default() -> Self {
507        Self::of("", Text::new(""), "text")
508    }
509}
510
511impl<'de> Deserialize<'de> for ListColumn {
512    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
513        struct Entry;
514        impl<'de> serde::de::Visitor<'de> for Entry {
515            type Value = ListColumn;
516
517            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
518                f.write_str("a column: its own keys and its type's")
519            }
520
521            fn visit_map<A: serde::de::MapAccess<'de>>(
522                self,
523                map: A,
524            ) -> Result<ListColumn, A::Error> {
525                let mut column = ListColumn::default();
526                let theirs = crate::keyed::split_entry(map, |key, map| {
527                    match key {
528                        "label" => column.label = map.next_value()?,
529                        "type" => column.column_type = map.next_value()?,
530                        "sortable" => column.sortable = map.next_value()?,
531                        "invisible" => column.invisible = map.next_value()?,
532                        "width" => column.width = map.next_value()?,
533                        "align" => column.align = map.next_value()?,
534                        "permission" => column.permission = map.next_value()?,
535                        "searchable" => column.searchable = map.next_value()?,
536                        "labels" => column.labels = map.next_value()?,
537                        "editable" => column.editable = map.next_value()?,
538                        _ => return Ok(false),
539                    }
540                    Ok(true)
541                })?;
542                column.options = theirs;
543                Ok(column)
544            }
545
546            /// A key written bare (`title:`) is a column with every default.
547            fn visit_unit<E: serde::de::Error>(self) -> Result<ListColumn, E> {
548                Ok(ListColumn::default())
549            }
550        }
551        de.deserialize_map(Entry)
552    }
553}
554
555impl Serialize for ListColumn {
556    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
557        use serde::ser::SerializeMap;
558        let mut map = ser.serialize_map(None)?;
559        map.serialize_entry("label", &self.label)?;
560        map.serialize_entry("type", &self.column_type)?;
561        map.serialize_entry("sortable", &self.sortable)?;
562        map.serialize_entry("editable", &self.editable)?;
563        map.serialize_entry("invisible", &self.invisible)?;
564        map.serialize_entry("width", &self.width)?;
565        map.serialize_entry("align", &self.align)?;
566        map.serialize_entry("permission", &self.permission)?;
567        map.serialize_entry("searchable", &self.searchable)?;
568        if !self.labels.is_empty() {
569            map.serialize_entry("labels", &self.labels)?;
570        }
571        crate::keyed::serialize_theirs(&mut map, &self.options)?;
572        map.end()
573    }
574}
575
576impl<'de> Deserialize<'de> for ListFilter {
577    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
578        struct Entry;
579        impl<'de> serde::de::Visitor<'de> for Entry {
580            type Value = ListFilter;
581
582            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
583                f.write_str("a filter: its own keys and its type's")
584            }
585
586            fn visit_map<A: serde::de::MapAccess<'de>>(
587                self,
588                map: A,
589            ) -> Result<ListFilter, A::Error> {
590                let mut filter = ListFilter::default();
591                let theirs = crate::keyed::split_entry(map, |key, map| {
592                    match key {
593                        "label" => filter.label = map.next_value()?,
594                        "type" => filter.filter_type = map.next_value()?,
595                        "default_value" => filter.default_value = map.next_value()?,
596                        "permission" => filter.permission = map.next_value()?,
597                        _ => return Ok(false),
598                    }
599                    Ok(true)
600                })?;
601                filter.options = theirs;
602                Ok(filter)
603            }
604
605            /// A key written bare is a text filter with every default.
606            fn visit_unit<E: serde::de::Error>(self) -> Result<ListFilter, E> {
607                Ok(ListFilter::default())
608            }
609        }
610        de.deserialize_map(Entry)
611    }
612}
613
614impl Serialize for ListFilter {
615    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
616        use serde::ser::SerializeMap;
617        let mut map = ser.serialize_map(None)?;
618        map.serialize_entry("label", &self.label)?;
619        map.serialize_entry("type", &self.filter_type)?;
620        map.serialize_entry("default_value", &self.default_value)?;
621        map.serialize_entry("permission", &self.permission)?;
622        crate::keyed::serialize_theirs(&mut map, &self.options)?;
623        map.end()
624    }
625}
626
627impl crate::keyed::Keyed for ListColumn {
628    fn key(&self) -> &str {
629        &self.field
630    }
631    fn set_key(&mut self, key: String) {
632        self.field = key;
633    }
634}
635
636impl crate::keyed::Keyed for ListFilter {
637    fn key(&self) -> &str {
638        &self.field
639    }
640    fn set_key(&mut self, key: String) {
641        self.field = key;
642    }
643}
644
645/// One value a `select` filter offers.
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct FilterOption {
648    pub value: String,
649    /// The option's label, localized at render.
650    pub label: Text,
651}
652
653impl FilterOption {
654    pub fn new(value: &str, label: impl Into<Text>) -> Self {
655        Self {
656            value: value.to_string(),
657            label: label.into(),
658        }
659    }
660}
661
662/// One filter offered above a list: a column, its label, and what it offers.
663#[derive(Debug, Clone)]
664#[non_exhaustive]
665pub struct ListFilter {
666    /// Comes from the key this was written under, never from the body.
667    pub field: String,
668    /// The control's label, localized at render.
669    pub label: Text,
670    /// What the filter offers: `boolean`, `select`, `text`, `number` or `date`.
671    pub filter_type: String,
672    /// The keys that belong to the filter's type, as an object: in a file they
673    /// are written on the filter itself. A `select` carries its choices here.
674    pub options: serde_json::Value,
675    /// Applied when the request names no value for this filter at all, so a
676    /// screen can open already narrowed. Clearing it is still "no filter".
677    pub default_value: Option<String>,
678    /// A permission the operator must hold to see this filter, and to have its
679    /// default applied.
680    pub permission: Option<String>,
681}
682
683impl ListFilter {
684    /// A filter of any declared type, with no options.
685    pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
686        Self {
687            field: field.to_string(),
688            label: label.into(),
689            filter_type: filter_type.to_string(),
690            options: serde_json::Value::Null,
691            default_value: None,
692            permission: None,
693        }
694    }
695
696    /// A yes/no filter over a boolean column.
697    pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
698        Self::of(field, label, "boolean")
699    }
700
701    /// A filter offering a fixed set of values. Only a declared one reaches the
702    /// query.
703    pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
704        let mut filter = Self::of(field, label, "select");
705        filter.options = serde_json::json!({ "options": options });
706        filter
707    }
708
709    /// A substring match, folded the way the search box folds.
710    pub fn text(field: &str, label: impl Into<Text>) -> Self {
711        Self::of(field, label, "text")
712    }
713
714    /// An exact match on a whole number.
715    pub fn number(field: &str, label: impl Into<Text>) -> Self {
716        Self::of(field, label, "number")
717    }
718
719    /// Rows on one calendar day, whether the column stores a date or a
720    /// timestamp.
721    pub fn date(field: &str, label: impl Into<Text>) -> Self {
722        Self::of(field, label, "date")
723    }
724
725    /// The choices a `select` offers, or none for any other type.
726    pub(crate) fn choices(&self) -> Vec<FilterOption> {
727        self.options
728            .get("options")
729            .and_then(|v| serde_json::from_value(v.clone()).ok())
730            .unwrap_or_default()
731    }
732
733    /// The value to apply when the request names none.
734    pub fn default_value(mut self, value: impl Into<String>) -> Self {
735        self.default_value = Some(value.into());
736        self
737    }
738
739    /// Shown only to an operator holding `permission`.
740    pub fn require(mut self, permission: impl Into<String>) -> Self {
741        self.permission = Some(permission.into());
742        self
743    }
744
745    pub(crate) fn param(&self) -> String {
746        format!("f_{}", self.field)
747    }
748}
749
750/// A button a list offers in its toolbar, beside the built-in New.
751///
752/// Where a list's Import button goes, and who sees it.
753#[derive(Clone, Debug)]
754pub(crate) struct ImportLink {
755    pub href: String,
756    pub permission: String,
757}
758
759/// A resource declares its own; the framework contributes New and the export
760/// menu the same way, so the toolbar is one list of buttons rather than a
761/// hardcoded bar with special cases.
762#[derive(Debug, Clone, Serialize, Deserialize)]
763#[serde(deny_unknown_fields)]
764pub struct ToolbarButton {
765    /// The button's text, localized at render.
766    pub label: Text,
767    /// Where it goes: a path under the admin root (starting with a slash), or an
768    /// absolute URL.
769    pub href: String,
770    /// An icon name from the shared set. Absent renders the label alone.
771    pub icon: Option<String>,
772    /// Hidden from an operator who lacks this permission. Absent shows it to
773    /// anyone who can reach the screen.
774    pub permission: Option<String>,
775    /// Rendered as the prominent action rather than a secondary one.
776    pub primary: bool,
777}
778
779impl ToolbarButton {
780    pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
781        Self {
782            label: label.into(),
783            href: href.into(),
784            icon: None,
785            permission: None,
786            primary: false,
787        }
788    }
789
790    pub fn icon(mut self, icon: &str) -> Self {
791        self.icon = Some(icon.to_string());
792        self
793    }
794
795    /// Hides the button from an operator without this permission.
796    pub fn require(mut self, permission: impl Into<String>) -> Self {
797        self.permission = Some(permission.into());
798        self
799    }
800
801    pub fn primary(mut self) -> Self {
802        self.primary = true;
803        self
804    }
805}
806
807/// One rendered toolbar button.
808pub struct ToolbarView {
809    pub label: String,
810    pub href: String,
811    /// Inline SVG for the icon, empty when the button has none.
812    pub icon: String,
813    pub primary: bool,
814}
815
816/// The toolbar an operator sees: the framework's New (when the resource creates)
817/// followed by the resource's own buttons, minus anything they lack permission
818/// for.
819fn toolbar_views(
820    config: &ListConfig,
821    admin_path: &str,
822    user: &laterite_auth::AuthenticatedUser,
823    shell: &crate::Shell,
824) -> Vec<ToolbarView> {
825    let mut out = Vec::new();
826    if config.creatable {
827        if let Some(base) = &config.edit_base {
828            out.push(ToolbarView {
829                label: shell.tt(&laterite_core::t!("New")),
830                // `edit_base` is already under the admin mount (see
831                // `prefix_resource`), like the row links that use it.
832                href: format!("{base}/new"),
833                icon: String::new(),
834                primary: true,
835            });
836        }
837    }
838    for button in &config.toolbar {
839        if let Some(needed) = &button.permission {
840            if !user.permissions.allows(needed) {
841                continue;
842            }
843        }
844        let href = if button.href.starts_with('/') {
845            format!("{admin_path}{}", button.href)
846        } else {
847            button.href.clone()
848        };
849        out.push(ToolbarView {
850            label: shell.tt(&button.label),
851            href,
852            icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
853            primary: button.primary,
854        });
855    }
856    out
857}
858
859/// The preference key holding one operator's chosen columns for a list. Keyed by
860/// the list's own path, so two resources over the same table stay separate.
861pub(crate) fn columns_preference_key(base_path: &str) -> String {
862    format!("list.columns.{base_path}")
863}
864
865/// One operator's stored column choice for a list, or `None` if they have none.
866///
867/// A failed read is a warning, not an error: a preference is a convenience, so
868/// the screen shows every column rather than failing.
869pub(crate) async fn stored_columns(
870    state: &AdminState,
871    path: &str,
872    user: &laterite_auth::AuthenticatedUser,
873) -> Option<String> {
874    match laterite_auth::store::user_preference(
875        &state.db,
876        user.user.id,
877        &columns_preference_key(path),
878    )
879    .await
880    {
881        Ok(stored) => stored,
882        Err(e) => {
883            tracing::warn!(error = %e, "reading the column preference failed");
884            None
885        }
886    }
887}
888
889/// The preference key holding one operator's page size for a list.
890pub(crate) fn per_page_preference_key(base_path: &str) -> String {
891    format!("list.per_page:{base_path}")
892}
893
894/// This operator's page size for the list: their stored choice when the
895/// descriptor still offers it, the descriptor's own otherwise. A failed read
896/// is a warning, the way the column preference is.
897pub(crate) async fn stored_per_page(
898    state: &AdminState,
899    config: &ListConfig,
900    path: &str,
901    user: &laterite_auth::AuthenticatedUser,
902) -> i64 {
903    let stored = match laterite_auth::store::user_preference(
904        &state.db,
905        user.user.id,
906        &per_page_preference_key(path),
907    )
908    .await
909    {
910        Ok(stored) => stored,
911        Err(e) => {
912            tracing::warn!(error = %e, "reading the page-size preference failed");
913            None
914        }
915    };
916    offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
917}
918
919/// A size the descriptor offers, else its own.
920pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
921    size.filter(|n| config.per_page_options.contains(n))
922        .unwrap_or(config.per_page)
923}
924
925/// The columns this operator may see at all: every one without a permission,
926/// plus those whose permission they hold.
927pub(crate) fn allowed_columns<'a>(
928    config: &'a ListConfig,
929    user: &laterite_auth::AuthenticatedUser,
930) -> Vec<&'a ListColumn> {
931    config
932        .columns
933        .iter()
934        .filter(|c| {
935            c.permission
936                .as_deref()
937                .is_none_or(|p| user.permissions.allows(p))
938        })
939        .collect()
940}
941
942/// The filters this operator may use: every one without a permission, plus
943/// those whose permission they hold.
944pub(crate) fn allowed_filters<'a>(
945    config: &'a ListConfig,
946    user: &laterite_auth::AuthenticatedUser,
947) -> Vec<&'a ListFilter> {
948    config
949        .filters
950        .iter()
951        .filter(|f| {
952            f.permission
953                .as_deref()
954                .is_none_or(|p| user.permissions.allows(p))
955        })
956        .collect()
957}
958
959/// The descriptor with only the columns this operator sees. One place, so the
960/// query, the headers, sorting, searching and the export all work from it.
961pub(crate) fn narrowed(
962    config: &ListConfig,
963    stored: Option<&str>,
964    user: &laterite_auth::AuthenticatedUser,
965) -> ListConfig {
966    ListConfig {
967        columns: visible_columns(config, stored, user)
968            .into_iter()
969            .cloned()
970            .collect(),
971        filters: allowed_filters(config, user).into_iter().cloned().collect(),
972        ..config.clone()
973    }
974}
975
976/// The columns to show: the operator's stored choice, narrowed to what the
977/// descriptor still declares and they may see; without a choice, every column
978/// they may see that is not `invisible`.
979///
980/// A stored choice that no longer names any such column falls back to that
981/// default, so a descriptor that drops or renames a column leaves an operator
982/// with a working list rather than an empty one.
983pub(crate) fn visible_columns<'a>(
984    config: &'a ListConfig,
985    stored: Option<&str>,
986    user: &laterite_auth::AuthenticatedUser,
987) -> Vec<&'a ListColumn> {
988    let allowed = allowed_columns(config, user);
989    let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
990    let Some(stored) = stored else {
991        return by_default();
992    };
993    let chosen: Vec<&str> = stored
994        .split(',')
995        .map(str::trim)
996        .filter(|s| !s.is_empty())
997        .collect();
998    let kept: Vec<&ListColumn> = allowed
999        .iter()
1000        .copied()
1001        .filter(|c| chosen.iter().any(|n| *n == c.field))
1002        .collect();
1003    if kept.is_empty() {
1004        return by_default();
1005    }
1006    kept
1007}
1008
1009/// Checks a list against the column-type registry at router build, so a
1010/// descriptor naming a type nobody registered aborts boot naming the column,
1011/// the way a form does for a field type. Without this the cell rendered empty.
1012pub(crate) fn check(
1013    config: &ListConfig,
1014    column_types: &ColumnRegistry,
1015    permission_codes: &std::collections::HashSet<String>,
1016) -> Result<(), String> {
1017    if let Some(tree) = &config.tree {
1018        if !valid_ident(&tree.parent_key) {
1019            return Err(format!(
1020                "tree names an invalid parent column `{}`",
1021                tree.parent_key
1022            ));
1023        }
1024    }
1025    if let Some(reorder) = &config.reorder {
1026        if !valid_ident(&reorder.column) {
1027            return Err(format!(
1028                "reorder names an invalid column `{}`",
1029                reorder.column
1030            ));
1031        }
1032    }
1033    for col in &config.columns {
1034        if column_types.get(&col.column_type).is_none() {
1035            return Err(format!(
1036                "column `{}` uses unregistered type `{}`",
1037                col.field, col.column_type
1038            ));
1039        }
1040        // An unregistered permission can never be granted, so the column would
1041        // be hidden from everyone but a superuser: a wiring bug, not a choice.
1042        if let Some(p) = &col.permission {
1043            if !permission_codes.contains(p) {
1044                return Err(format!(
1045                    "column `{}` requires unregistered permission `{p}`",
1046                    col.field
1047                ));
1048            }
1049        }
1050    }
1051    for filter in &config.filters {
1052        if let Some(p) = &filter.permission {
1053            if !permission_codes.contains(p) {
1054                return Err(format!(
1055                    "filter `{}` requires unregistered permission `{p}`",
1056                    filter.field
1057                ));
1058            }
1059        }
1060    }
1061    Ok(())
1062}
1063
1064/// The list's search box. On by default, over the columns marked searchable.
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1066#[serde(deny_unknown_fields, default)]
1067pub struct SearchConfig {
1068    /// `false` removes the box.
1069    pub enabled: bool,
1070    /// The placeholder, localized at render. `None` shows "Search".
1071    pub prompt: Option<Text>,
1072    /// Ask on Enter only, instead of as the operator types.
1073    pub on_enter: bool,
1074}
1075
1076impl Default for SearchConfig {
1077    fn default() -> Self {
1078        Self {
1079            enabled: true,
1080            prompt: None,
1081            on_enter: false,
1082        }
1083    }
1084}
1085
1086impl SearchConfig {
1087    /// No search box at all.
1088    pub fn off() -> Self {
1089        Self {
1090            enabled: false,
1091            ..Self::default()
1092        }
1093    }
1094
1095    pub fn prompt(mut self, text: impl Into<Text>) -> Self {
1096        self.prompt = Some(text.into());
1097        self
1098    }
1099
1100    pub fn on_enter(mut self) -> Self {
1101        self.on_enter = true;
1102        self
1103    }
1104}
1105
1106/// The order rows keep of their own, held in an integer column (not null,
1107/// default `0`). Keys are spaced by a thousand so a move rewrites one row; a
1108/// move into a closed gap renumbers the list.
1109#[derive(Debug, Clone, Serialize, Deserialize)]
1110#[serde(deny_unknown_fields, default)]
1111#[non_exhaustive]
1112pub struct ReorderConfig {
1113    /// The integer column holding each row's position. Default `sort_order`.
1114    pub column: String,
1115}
1116
1117impl Default for ReorderConfig {
1118    fn default() -> Self {
1119        Self {
1120            column: "sort_order".to_string(),
1121        }
1122    }
1123}
1124
1125/// `reorder: true` takes the default column; `reorder: { column: position }`
1126/// names one; `false` or absent is off.
1127fn deserialize_reorder<'de, D>(deserializer: D) -> Result<Option<ReorderConfig>, D::Error>
1128where
1129    D: serde::Deserializer<'de>,
1130{
1131    #[derive(Deserialize)]
1132    #[serde(untagged)]
1133    enum Key {
1134        Flag(bool),
1135        Config(ReorderConfig),
1136    }
1137    Ok(match Option::<Key>::deserialize(deserializer)? {
1138        Some(Key::Flag(true)) => Some(ReorderConfig::default()),
1139        Some(Key::Flag(false)) | None => None,
1140        Some(Key::Config(config)) => Some(config),
1141    })
1142}
1143
1144/// The spacing between keys after a renumber, and the step past either end.
1145const REORDER_STEP: i64 = 1000;
1146
1147/// Rows that form a tree through a parent column (adjacency): the list opens
1148/// on the rows with no parent, each row with children expands in place, and
1149/// a row can be moved under another parent.
1150#[derive(Debug, Clone, Serialize, Deserialize)]
1151#[serde(deny_unknown_fields, default)]
1152#[non_exhaustive]
1153pub struct TreeConfig {
1154    /// The column holding a row's parent id, null at the top level. Default
1155    /// `parent_id`.
1156    pub parent_key: String,
1157    /// A registered picker source over this entity. When set, every row
1158    /// offers "Move to", which picks the new parent from it; empty means the
1159    /// top level.
1160    pub source: Option<String>,
1161}
1162
1163impl TreeConfig {
1164    /// A tree on `parent_key`.
1165    pub fn new(parent_key: impl Into<String>) -> Self {
1166        Self {
1167            parent_key: parent_key.into(),
1168            source: None,
1169        }
1170    }
1171
1172    /// Offers "Move to" on every row, picking the new parent from `source`.
1173    pub fn move_to(mut self, source: impl Into<String>) -> Self {
1174        self.source = Some(source.into());
1175        self
1176    }
1177}
1178
1179impl Default for TreeConfig {
1180    fn default() -> Self {
1181        Self::new("parent_id")
1182    }
1183}
1184
1185/// `tree: true` takes the default parent column; `tree: { parent_key, source }`
1186/// names them; `false` or absent is off.
1187fn deserialize_tree<'de, D>(deserializer: D) -> Result<Option<TreeConfig>, D::Error>
1188where
1189    D: serde::Deserializer<'de>,
1190{
1191    #[derive(Deserialize)]
1192    #[serde(untagged)]
1193    enum Key {
1194        Flag(bool),
1195        Config(TreeConfig),
1196    }
1197    Ok(match Option::<Key>::deserialize(deserializer)? {
1198        Some(Key::Flag(true)) => Some(TreeConfig::default()),
1199        Some(Key::Flag(false)) | None => None,
1200        Some(Key::Config(config)) => Some(config),
1201    })
1202}
1203
1204/// Children shown under one parent in a single request.
1205const TREE_CHILDREN_LIMIT: i64 = 1000;
1206
1207/// The form an editable cell writes through.
1208#[derive(Clone)]
1209pub(crate) struct Editor(pub Arc<crate::form::PreparedForm>);
1210
1211impl std::fmt::Debug for Editor {
1212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1213        f.write_str("Editor")
1214    }
1215}
1216
1217/// A list view descriptor: which table, which columns, default ordering, page
1218/// size, and (optionally) where per-row edit links point.
1219///
1220/// Built with [`ListConfig::new`] plus its builder methods. Non-exhaustive, so a
1221/// struct literal outside this crate cannot freeze the field set at 1.0; that
1222/// rules out `..Default::default()` too, which is why the builders exist.
1223#[derive(Debug, Clone, Serialize, Deserialize)]
1224#[serde(deny_unknown_fields, default)]
1225#[non_exhaustive]
1226pub struct ListConfig {
1227    pub entity: String,
1228    /// The screen title, localized at render. Serde stays a plain string.
1229    pub title: Text,
1230    #[serde(
1231        deserialize_with = "crate::keyed::deserialize",
1232        serialize_with = "crate::keyed::serialize"
1233    )]
1234    pub columns: Vec<ListColumn>,
1235    pub order_by: String,
1236    pub order_dir: SortDir,
1237    pub per_page: i64,
1238    pub id_field: String,
1239    /// When set, rows link to `{edit_base}/{id}/edit`.
1240    pub edit_base: Option<String>,
1241    /// Whether to offer a "New" link to `{edit_base}/new`. A resource that only
1242    /// edits existing records (no create screen) sets this false.
1243    pub creatable: bool,
1244    /// Filters offered above the table. Empty hides the bar.
1245    #[serde(
1246        deserialize_with = "crate::keyed::deserialize",
1247        serialize_with = "crate::keyed::serialize"
1248    )]
1249    pub filters: Vec<ListFilter>,
1250    /// Whether rows can be selected and deleted from this list. Off by default:
1251    /// a list that shows a log, or records another screen owns, has no business
1252    /// offering it.
1253    pub deletable: bool,
1254    /// Whether this resource offers an export. Off by default: exporting is a
1255    /// capability a resource opts into, not something every list should carry.
1256    pub exportable: bool,
1257    /// The import pages mounted under this resource, when it declared
1258    /// `import`; shown beside the export menu to an operator holding the
1259    /// permission. Set by the router from the resource, never by a descriptor.
1260    #[serde(skip)]
1261    pub(crate) import: Option<ImportLink>,
1262    /// A fixed condition every query carries. Set per request by a relation
1263    /// section, never by a descriptor.
1264    #[serde(skip)]
1265    pub(crate) scope: Option<Scope>,
1266    /// Rows kept in an order of their own, held in an integer column. Off by
1267    /// default; `reorder: true` in a file orders by `sort_order`.
1268    #[serde(deserialize_with = "deserialize_reorder")]
1269    pub reorder: Option<ReorderConfig>,
1270    /// Rows shown as a tree on their own parent column. Off by default;
1271    /// `tree: true` in a file reads `parent_id`.
1272    #[serde(deserialize_with = "deserialize_tree")]
1273    pub tree: Option<TreeConfig>,
1274    /// The resource's form, prepared, behind the editable cells. Set by the
1275    /// router, never by a descriptor.
1276    #[serde(skip)]
1277    pub(crate) editor: Option<Editor>,
1278    /// Extra buttons in the toolbar, beside New and the export menu.
1279    pub toolbar: Vec<ToolbarButton>,
1280    pub search: SearchConfig,
1281    /// Page sizes the operator may choose from in the list setup, remembered
1282    /// with their column choice. Empty offers no choice.
1283    pub per_page_options: Vec<i64>,
1284    /// Shown when the list has no records at all. `None` shows "No records yet."
1285    pub no_records_message: Option<Text>,
1286    /// What clicking a row does. Default `open`: the row opens its record when
1287    /// the list has an `edit_base`.
1288    pub row_click: RowClick,
1289    /// Whether the sort, search, filters and page an operator last used come
1290    /// back when they return to the list. Default `true`.
1291    pub remember: bool,
1292}
1293
1294/// What clicking a list row does.
1295#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1296#[serde(rename_all = "lowercase")]
1297pub enum RowClick {
1298    /// Opens the record, when the list links rows to a form.
1299    #[default]
1300    Open,
1301    /// Nothing: only the Edit link opens it.
1302    None,
1303}
1304
1305impl ListConfig {
1306    /// A list over `entity` titled `title`, showing `columns`.
1307    ///
1308    /// Everything else takes the defaults below and is opted into through a
1309    /// builder method, so a field added later costs existing descriptors nothing.
1310    pub fn new(
1311        entity: impl Into<String>,
1312        title: impl Into<Text>,
1313        columns: Vec<ListColumn>,
1314    ) -> Self {
1315        Self {
1316            entity: entity.into(),
1317            title: title.into(),
1318            columns,
1319            ..Self::default()
1320        }
1321    }
1322
1323    /// Orders by `column` in `dir` rather than newest id first.
1324    pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
1325        self.order_by = column.into();
1326        self.order_dir = dir;
1327        self
1328    }
1329
1330    pub fn per_page(mut self, rows: i64) -> Self {
1331        self.per_page = rows;
1332        self
1333    }
1334
1335    /// The primary-key column, when it is not `id`.
1336    pub fn id_field(mut self, column: impl Into<String>) -> Self {
1337        self.id_field = column.into();
1338        self
1339    }
1340
1341    /// Links rows to `{base}/{id}/edit`.
1342    pub fn edit_base(mut self, base: impl Into<String>) -> Self {
1343        self.edit_base = Some(base.into());
1344        self
1345    }
1346
1347    /// Offers a New link to `{edit_base}/new`. Opt in, matching the read-only
1348    /// default: a list over a log, or over records another screen owns, should
1349    /// not invite an operator to create one.
1350    pub fn creatable(mut self) -> Self {
1351        self.creatable = true;
1352        self
1353    }
1354
1355    /// Offers CSV and JSON exports of the current query. Opt in: a resource
1356    /// whose rows should not leave the panel simply never calls this, and the
1357    /// route is not registered for it.
1358    pub fn exportable(mut self) -> Self {
1359        self.exportable = true;
1360        self
1361    }
1362
1363    pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
1364        self.filters = filters;
1365        self
1366    }
1367
1368    /// Lets rows be selected and deleted. Off by default: a list showing a log,
1369    /// or records another screen owns, has no business offering it.
1370    pub fn deletable(mut self) -> Self {
1371        self.deletable = true;
1372        self
1373    }
1374
1375    /// Shows the rows as a tree on their parent column: the list opens on the
1376    /// top level, and a row with children expands in place.
1377    pub fn tree(mut self, tree: TreeConfig) -> Self {
1378        self.tree = Some(tree);
1379        self
1380    }
1381
1382    /// Keeps the rows in an order of their own, held in `column`: the list
1383    /// opens in that order with a grip and Move up / Move down on every row.
1384    pub fn reorder(mut self, column: impl Into<String>) -> Self {
1385        self.reorder = Some(ReorderConfig {
1386            column: column.into(),
1387        });
1388        self
1389    }
1390
1391    pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
1392        self.toolbar = buttons;
1393        self
1394    }
1395
1396    pub fn search(mut self, search: SearchConfig) -> Self {
1397        self.search = search;
1398        self
1399    }
1400
1401    pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
1402        self.per_page_options = sizes;
1403        self
1404    }
1405
1406    pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
1407        self.no_records_message = Some(text.into());
1408        self
1409    }
1410
1411    /// What clicking a row does. Default: it opens the record.
1412    pub fn row_click(mut self, action: RowClick) -> Self {
1413        self.row_click = action;
1414        self
1415    }
1416
1417    /// Whether the query an operator last used comes back when they return.
1418    /// Default `true`.
1419    pub fn remember(mut self, remember: bool) -> Self {
1420        self.remember = remember;
1421        self
1422    }
1423}
1424
1425impl Default for ListConfig {
1426    /// A minimal list: no columns, newest-id first, 25 to a page, read-only.
1427    ///
1428    /// Descriptors use this as the tail of a struct literal
1429    /// (`..Default::default()`) and set only what they mean. A field added here
1430    /// later is then additive for every descriptor rather than breaking it, which
1431    /// three consecutive additions made the case for.
1432    fn default() -> Self {
1433        Self {
1434            entity: String::new(),
1435            title: Text::new(""),
1436            columns: Vec::new(),
1437            order_by: "id".to_string(),
1438            order_dir: SortDir::Desc,
1439            per_page: 25,
1440            id_field: "id".to_string(),
1441            edit_base: None,
1442            creatable: false,
1443            filters: Vec::new(),
1444            deletable: false,
1445            exportable: false,
1446            import: None,
1447            scope: None,
1448            reorder: None,
1449            tree: None,
1450            editor: None,
1451            toolbar: Vec::new(),
1452            search: SearchConfig::default(),
1453            per_page_options: Vec::new(),
1454            no_records_message: None,
1455            row_click: RowClick::Open,
1456            remember: true,
1457        }
1458    }
1459}
1460
1461/// Query-string parameters for a list view.
1462#[derive(Deserialize, Default)]
1463pub struct ListParams {
1464    page: Option<i64>,
1465    sort: Option<String>,
1466    dir: Option<String>,
1467    q: Option<String>,
1468    /// A tree's parent to show the children of; absent is the top level.
1469    parent: Option<String>,
1470    /// How deep those children sit, for their indent.
1471    depth: Option<i64>,
1472}
1473
1474/// The ordering a request resolves to: the submitted `sort` when it names a
1475/// column the descriptor declares, the descriptor's own order otherwise. Only a
1476/// declared column reaches the query, so a crafted `sort` cannot order by an
1477/// arbitrary column. An unrecognised `dir` falls back the same way.
1478pub(crate) fn resolve_sort(
1479    config: &ListConfig,
1480    sort: Option<&str>,
1481    dir: Option<&str>,
1482) -> (String, SortDir) {
1483    let (natural, natural_dir) = natural_order(config);
1484    let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1485        Some(field) => field.to_string(),
1486        None => natural.to_string(),
1487    };
1488    let dir = match dir {
1489        Some("asc") => SortDir::Asc,
1490        Some("desc") => SortDir::Desc,
1491        // No explicit direction: the configured one for the configured column, so
1492        // a list ordered newest-first stays that way until a header is clicked.
1493        _ if by == natural => natural_dir,
1494        _ => SortDir::Asc,
1495    };
1496    (by, dir)
1497}
1498
1499/// The order a list opens in: its own column ascending when rows keep an
1500/// order, else the descriptor's `order_by`.
1501fn natural_order(config: &ListConfig) -> (&str, SortDir) {
1502    match &config.reorder {
1503        Some(reorder) => (&reorder.column, SortDir::Asc),
1504        None => (&config.order_by, config.order_dir),
1505    }
1506}
1507
1508/// One rendered row: its id (for edit links) and its display cells.
1509pub struct RowView {
1510    pub id: String,
1511    pub cells: Vec<String>,
1512    /// How many rows name this one as parent, when the query asked.
1513    pub children: i64,
1514}
1515
1516/// One rendered cell and the class its column's alignment gives it.
1517pub struct CellView {
1518    pub html: String,
1519    pub class: String,
1520}
1521
1522/// A row as the template receives it: each cell already rendered.
1523pub struct RenderedRow {
1524    pub id: String,
1525    pub cells: Vec<CellView>,
1526    /// The tree position: how deep the row sits, and where its children load
1527    /// from when it has any.
1528    pub depth: i64,
1529    pub indent: String,
1530    pub toggle: Option<String>,
1531}
1532
1533/// Display-ready rows plus the total row count for the pager.
1534pub struct ListPage {
1535    pub rows: Vec<RowView>,
1536    pub total: i64,
1537}
1538
1539/// What one list request asks for: the page window, the resolved ordering, and
1540/// the search term (empty for no search).
1541pub(crate) struct ListQuery<'a> {
1542    pub offset: i64,
1543    pub order_by: &'a str,
1544    pub order_dir: SortDir,
1545    pub q: &'a str,
1546    pub filters: &'a [ActiveFilter<'a>],
1547    /// Rows per round trip. `None` uses the descriptor's page size; the export
1548    /// reads larger pages because it is building a file, not a screen.
1549    pub per_page: Option<i64>,
1550    /// A tree's parent column: each row then carries how many rows name it.
1551    pub children_of: Option<&'a str>,
1552}
1553
1554/// A filter the request actually selected: the declared filter and its accepted
1555/// value. Built only from declared filters and, for a select, declared options,
1556/// so a crafted parameter cannot reach the query.
1557pub(crate) struct ActiveFilter<'a> {
1558    filter: &'a ListFilter,
1559    /// The raw value as submitted, for re-rendering the control and the links.
1560    raw: String,
1561    /// How the accepted value matches. Owned and `Send`, because a built
1562    /// `sea_query` expression is neither and this is held across the query.
1563    matcher: FilterMatch,
1564}
1565
1566/// How an accepted filter value matches its column.
1567pub(crate) enum FilterMatch {
1568    /// Exactly this value.
1569    Equals(sea_query::Value),
1570    /// A folded substring, the way the search box matches.
1571    Contains(String),
1572    /// Anything starting with this, for a day against a stored timestamp.
1573    StartsWith(String),
1574}
1575
1576/// How one filter matches for a submitted value, or `None` when the value is
1577/// not one this type accepts.
1578///
1579/// Every type validates before returning, so a crafted parameter reaches the
1580/// query only in a shape the filter declared.
1581fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1582    match filter.filter_type.as_str() {
1583        // Only the two spellings the control emits; anything else is dropped
1584        // rather than guessed at.
1585        "boolean" => match raw {
1586            "1" => Some(FilterMatch::Equals(true.into())),
1587            "0" => Some(FilterMatch::Equals(false.into())),
1588            _ => None,
1589        },
1590        "select" => filter
1591            .choices()
1592            .iter()
1593            .any(|o| o.value == raw)
1594            .then(|| FilterMatch::Equals(raw.into())),
1595        "text" => Some(FilterMatch::Contains(raw.to_string())),
1596        "number" => raw
1597            .parse::<i64>()
1598            .ok()
1599            .map(|n| FilterMatch::Equals(n.into())),
1600        // A day, matched as a prefix so a date column and a stored timestamp
1601        // both answer. The shape is checked here, so the pattern that reaches
1602        // the query has nothing in it to escape.
1603        "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1604        _ => None,
1605    }
1606}
1607
1608/// `YYYY-MM-DD`, and nothing else.
1609fn is_iso_date(raw: &str) -> bool {
1610    raw.len() == 10
1611        && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1612            if i == 4 || i == 7 {
1613                *b == b'-'
1614            } else {
1615                b.is_ascii_digit()
1616            }
1617        })
1618}
1619
1620/// Reads the declared filters out of the request's query parameters, dropping
1621/// anything undeclared, unrecognised, or blank.
1622pub(crate) fn resolve_filters<'a>(
1623    config: &'a ListConfig,
1624    params: &HashMap<String, String>,
1625) -> Vec<ActiveFilter<'a>> {
1626    let mut out = Vec::new();
1627    for filter in &config.filters {
1628        let submitted = params.get(&filter.param()).map(|v| v.trim());
1629        let raw = match submitted {
1630            // Named, so the operator decided: a blank value clears the filter.
1631            Some(value) => value,
1632            // Not named at all, so nothing has been decided yet.
1633            None => filter.default_value.as_deref().unwrap_or_default(),
1634        };
1635        if raw.is_empty() {
1636            continue;
1637        }
1638        let Some(matcher) = filter_match(filter, raw) else {
1639            continue;
1640        };
1641        out.push(ActiveFilter {
1642            filter,
1643            raw: raw.to_string(),
1644            matcher,
1645        });
1646    }
1647    out
1648}
1649
1650/// The filters ANDed together, or `None` when none are active.
1651fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1652    if active.is_empty() {
1653        return None;
1654    }
1655    let caps = laterite_core::capabilities::CapabilitySet::default();
1656    let mut all = sea_query::Condition::all();
1657    for f in active {
1658        let col = Expr::col(Alias::new(&f.filter.field));
1659        all = match &f.matcher {
1660            FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1661            // A term that folds away to nothing narrows nothing.
1662            FilterMatch::Contains(term) => {
1663                match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1664                    Some(cond) => all.add(cond),
1665                    None => all,
1666                }
1667            }
1668            FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1669        };
1670    }
1671    Some(all)
1672}
1673
1674/// The condition matching `q` across the searchable columns, or `None` when
1675/// there is nothing to match: a blank term, a term that folds away, or a list
1676/// whose columns are all unsearchable.
1677fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1678    // Capability-gated matchers are deferred and `Db` carries no
1679    // capability set yet, so the portable default applies. Descriptor-named
1680    // profiles are PR4; until then a list folds case, like the picker.
1681    let caps = laterite_core::capabilities::CapabilitySet::default();
1682    let profile = SearchProfile::new();
1683    let mut any = sea_query::Condition::any();
1684    let mut matched = false;
1685    for column in config.columns.iter().filter(|c| c.is_searchable()) {
1686        if let Some(cond) = profile.condition(&caps, &column.field, q) {
1687            any = any.add(cond);
1688            matched = true;
1689        }
1690    }
1691    matched.then_some(any)
1692}
1693
1694/// What narrows a list to one parent record's rows: a relation section sets
1695/// it per request.
1696#[derive(Clone, Debug)]
1697pub(crate) enum Scope {
1698    /// Rows whose `column` equals `value`, or is null when there is none: a
1699    /// has-many's foreign key, a tree's parent column.
1700    Column {
1701        column: String,
1702        value: Option<String>,
1703    },
1704    /// Rows a pivot table joins to the parent: those whose id appears as
1705    /// `related_key` on a pivot row whose `parent_key` equals `value`. Each row
1706    /// is then identified by its pivot row's `id_field`, so the section's
1707    /// links and removals name the membership, never the record.
1708    Pivot {
1709        table: String,
1710        parent_key: String,
1711        related_key: String,
1712        id_field: String,
1713        value: String,
1714    },
1715}
1716
1717impl Scope {
1718    fn idents_valid(&self) -> bool {
1719        match self {
1720            Scope::Column { column, .. } => valid_ident(column),
1721            Scope::Pivot {
1722                table,
1723                parent_key,
1724                related_key,
1725                id_field,
1726                ..
1727            } => {
1728                valid_ident(table)
1729                    && valid_ident(parent_key)
1730                    && valid_ident(related_key)
1731                    && valid_ident(id_field)
1732            }
1733        }
1734    }
1735
1736    /// The condition every query and count carries.
1737    fn condition(&self, backend: DbBackend, entity_id: &str) -> SimpleExpr {
1738        let cast = Alias::new(text_cast(backend));
1739        match self {
1740            Scope::Column {
1741                column,
1742                value: Some(value),
1743            } => Expr::col(Alias::new(column))
1744                .cast_as(cast)
1745                .eq(value.clone()),
1746            Scope::Column {
1747                column,
1748                value: None,
1749            } => Expr::col(Alias::new(column)).is_null(),
1750            Scope::Pivot {
1751                table,
1752                parent_key,
1753                related_key,
1754                value,
1755                ..
1756            } => Expr::col(Alias::new(entity_id)).in_subquery(
1757                Query::select()
1758                    .column(Alias::new(related_key))
1759                    .from(Alias::new(table))
1760                    .and_where(
1761                        Expr::col(Alias::new(parent_key))
1762                            .cast_as(cast)
1763                            .eq(value.clone()),
1764                    )
1765                    .to_owned(),
1766            ),
1767        }
1768    }
1769
1770    /// The expression identifying each row, when the scope changes it: the
1771    /// pivot row's id, found by a scalar subquery on the entity's own id.
1772    fn row_id(&self, backend: DbBackend, entity: &str, entity_id: &str) -> Option<SimpleExpr> {
1773        match self {
1774            Scope::Column { .. } => None,
1775            Scope::Pivot {
1776                table,
1777                parent_key,
1778                related_key,
1779                id_field,
1780                value,
1781            } => {
1782                let pivot = Alias::new("lat_pivot");
1783                Some(SimpleExpr::SubQuery(
1784                    None,
1785                    Box::new(
1786                        Query::select()
1787                            .column((pivot.clone(), Alias::new(id_field)))
1788                            .from_as(Alias::new(table), pivot.clone())
1789                            .and_where(
1790                                Expr::col((pivot.clone(), Alias::new(related_key)))
1791                                    .equals((Alias::new(entity), Alias::new(entity_id))),
1792                            )
1793                            .and_where(
1794                                Expr::col((pivot, Alias::new(parent_key)))
1795                                    .cast_as(Alias::new(text_cast(backend)))
1796                                    .eq(value.clone()),
1797                            )
1798                            .to_owned()
1799                            .into_sub_query_statement(),
1800                    ),
1801                ))
1802            }
1803        }
1804    }
1805}
1806
1807/// Runs the list query for a config, returning display-ready rows and the total.
1808/// Built with `sea-query` and dynamic identifiers (`Alias`), and every selected
1809/// column is cast to text so a value of any type reads back uniformly as a
1810/// string for display, without a Postgres-specific `row_to_json`.
1811pub(crate) async fn query(
1812    db: &Db,
1813    config: &ListConfig,
1814    req: &ListQuery<'_>,
1815) -> anyhow::Result<ListPage> {
1816    if !valid_ident(&config.entity)
1817        || !valid_ident(&config.order_by)
1818        || !valid_ident(req.order_by)
1819        || !valid_ident(&config.id_field)
1820        || !config.columns.iter().all(|c| valid_ident(&c.field))
1821        || !config.filters.iter().all(|f| valid_ident(&f.field))
1822        || !config.scope.iter().all(Scope::idents_valid)
1823    {
1824        anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1825    }
1826
1827    let dir = match req.order_dir {
1828        SortDir::Asc => Order::Asc,
1829        SortDir::Desc => Order::Desc,
1830    };
1831    // Scope each sea-query builder so it drops before the await that follows: its
1832    // identifiers are reference-counted (not `Send`), and a live builder across
1833    // the await would make this future non-`Send`.
1834    let (sql, values) = {
1835        let mut select = Query::select();
1836        for column in &config.columns {
1837            select.expr_as(
1838                Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1839                Alias::new(&column.field),
1840            );
1841        }
1842        let row_id = config
1843            .scope
1844            .as_ref()
1845            .and_then(|scope| scope.row_id(db.backend, &config.entity, &config.id_field))
1846            .unwrap_or_else(|| Expr::col(Alias::new(&config.id_field)).into());
1847        select
1848            .expr_as(
1849                row_id.cast_as(Alias::new(text_cast(db.backend))),
1850                Alias::new(ID_ALIAS),
1851            )
1852            .from(Alias::new(&config.entity))
1853            .order_by(Alias::new(req.order_by), dir);
1854        if let Some(parent_key) = req.children_of {
1855            let child = Alias::new("lat_child");
1856            select.expr_as(
1857                SimpleExpr::SubQuery(
1858                    None,
1859                    Box::new(
1860                        Query::select()
1861                            .expr(Expr::col((child.clone(), Alias::new(&config.id_field))).count())
1862                            .from_as(Alias::new(&config.entity), child.clone())
1863                            .and_where(
1864                                Expr::col((child, Alias::new(parent_key))).equals((
1865                                    Alias::new(&config.entity),
1866                                    Alias::new(&config.id_field),
1867                                )),
1868                            )
1869                            .to_owned()
1870                            .into_sub_query_statement(),
1871                    ),
1872                )
1873                .cast_as(Alias::new(text_cast(db.backend))),
1874                Alias::new("lat_children"),
1875            );
1876        }
1877        // Ties fall back to the id, so paging and reordering see one order.
1878        if req.order_by != config.id_field {
1879            select.order_by(Alias::new(&config.id_field), Order::Asc);
1880        }
1881        select
1882            .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1883            .offset(req.offset.max(0) as u64);
1884        if let Some(cond) = search_condition(config, req.q) {
1885            select.cond_where(cond);
1886        }
1887        if let Some(cond) = filter_condition(req.filters) {
1888            select.cond_where(cond);
1889        }
1890        if let Some(scope) = &config.scope {
1891            select.and_where(scope.condition(db.backend, &config.id_field));
1892        }
1893        build(db.backend, select)
1894    };
1895    let raw = bind_values(sqlx::query(&sql), values)
1896        .fetch_all(&db.pool)
1897        .await?;
1898
1899    let (csql, cvalues) = {
1900        let mut count = Query::select();
1901        count
1902            .expr(Expr::col(Alias::new(&config.id_field)).count())
1903            .from(Alias::new(&config.entity));
1904        // The same conditions, so the pager counts what the list returns.
1905        if let Some(cond) = search_condition(config, req.q) {
1906            count.cond_where(cond);
1907        }
1908        if let Some(cond) = filter_condition(req.filters) {
1909            count.cond_where(cond);
1910        }
1911        if let Some(scope) = &config.scope {
1912            count.and_where(scope.condition(db.backend, &config.id_field));
1913        }
1914        build(db.backend, count)
1915    };
1916    let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1917        .fetch_one(&db.pool)
1918        .await?
1919        .0;
1920
1921    let rows = raw
1922        .iter()
1923        .map(|row| RowView {
1924            id: get_text(row, ID_ALIAS),
1925            cells: config
1926                .columns
1927                .iter()
1928                .map(|c| get_text(row, &c.field))
1929                .collect(),
1930            children: match req.children_of {
1931                Some(_) => get_text(row, "lat_children").trim().parse().unwrap_or(0),
1932                None => 0,
1933            },
1934        })
1935        .collect();
1936    Ok(ListPage { rows, total })
1937}
1938
1939/// Reads a text-cast column as a display string, treating null or a decode
1940/// error as empty.
1941fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1942    // `get_text_opt` falls back to a byte read for MySQL, where a cast-to-char of
1943    // a `text` column still comes back typed as BLOB.
1944    row.get_text_opt(column).ok().flatten().unwrap_or_default()
1945}
1946
1947/// Renders a list view for the given config, remembering nothing.
1948#[cfg(test)]
1949#[allow(clippy::too_many_arguments)]
1950pub(crate) async fn handle(
1951    state: &AdminState,
1952    config: &ListConfig,
1953    path: &str,
1954    params: ListParams,
1955    raw: &HashMap<String, String>,
1956    user: &laterite_auth::AuthenticatedUser,
1957    shell: crate::Shell,
1958    headers: &axum::http::HeaderMap,
1959) -> Response {
1960    handle_remembering(
1961        state, config, path, params, raw, user, shell, headers, None, None,
1962    )
1963    .await
1964}
1965
1966/// The query as a request narrows the list, with the parts a return should
1967/// bring back: the sort when it is not the descriptor's own, the search, the
1968/// filters. The page is left out, since the rows behind it move.
1969fn query_to_remember(
1970    config: &ListConfig,
1971    params: &ListParams,
1972    active: &[ActiveFilter<'_>],
1973) -> Option<String> {
1974    let mut parts = Vec::new();
1975    let (by, dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1976    let (natural, natural_dir) = natural_order(config);
1977    if by != natural || dir != natural_dir {
1978        let dir = match dir {
1979            SortDir::Asc => "asc",
1980            SortDir::Desc => "desc",
1981        };
1982        parts.push(format!("sort={}&dir={dir}", urlencode(&by)));
1983    }
1984    if let Some(q) = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
1985        parts.push(format!("q={}", urlencode(q)));
1986    }
1987    for f in active {
1988        parts.push(format!("{}={}", f.filter.param(), urlencode(&f.raw)));
1989    }
1990    (!parts.is_empty()).then(|| parts.join("&"))
1991}
1992
1993/// [`handle`], remembering the query in the session when the list asks to.
1994///
1995/// A request with no query at all is sent to the query the operator last used;
1996/// a request with one records it, and one that narrows nothing forgets it. Only
1997/// a plain page load is redirected: a swap always names what it wants.
1998#[allow(clippy::too_many_arguments)]
1999pub(crate) async fn handle_remembering(
2000    state: &AdminState,
2001    config: &ListConfig,
2002    path: &str,
2003    params: ListParams,
2004    raw: &HashMap<String, String>,
2005    user: &laterite_auth::AuthenticatedUser,
2006    shell: crate::Shell,
2007    headers: &axum::http::HeaderMap,
2008    query_string: Option<&str>,
2009    session: Option<&crate::session::SessionHandle>,
2010) -> Response {
2011    if let Some(session) = session.filter(|_| config.remember) {
2012        match query_string.filter(|q| !q.is_empty()) {
2013            None if !crate::form::is_htmx(headers) => {
2014                if let Some(remembered) = session.remembered_list(path) {
2015                    return axum::response::Redirect::to(&format!("{path}?{remembered}"))
2016                        .into_response();
2017                }
2018            }
2019            None => {}
2020            Some(_) => {
2021                let active = resolve_filters(config, raw);
2022                let keep = query_to_remember(config, &params, &active);
2023                session.remember_list(path, keep.as_deref());
2024            }
2025        }
2026    }
2027    show(state, config, path, params, raw, user, shell, headers).await
2028}
2029
2030/// Renders the list for the request as it stands.
2031#[allow(clippy::too_many_arguments)]
2032async fn show(
2033    state: &AdminState,
2034    config: &ListConfig,
2035    path: &str,
2036    params: ListParams,
2037    raw: &HashMap<String, String>,
2038    user: &laterite_auth::AuthenticatedUser,
2039    shell: crate::Shell,
2040    headers: &axum::http::HeaderMap,
2041) -> Response {
2042    let children = params.parent.is_some();
2043    match build_page(state, config, path, params, raw, user, shell).await {
2044        // An htmx request gets the list region alone, which replaces itself in
2045        // place; anything else gets the whole page, so sorting and paging still
2046        // work as ordinary links with scripting off. A tree row expanding asks
2047        // for its children and gets the rows alone.
2048        Some(page_view) => {
2049            if crate::form::is_htmx(headers) && children && page_view.tree.is_some() {
2050                render(rows_of(page_view, "lat-list", None))
2051            } else if crate::form::is_htmx(headers) {
2052                render(fragment_of(page_view, "lat-list", None))
2053            } else {
2054                render(page_view)
2055            }
2056        }
2057        None => render_error(),
2058    }
2059}
2060
2061/// The list page as a template, or `None` when the query failed.
2062async fn build_page(
2063    state: &AdminState,
2064    config: &ListConfig,
2065    path: &str,
2066    params: ListParams,
2067    raw: &HashMap<String, String>,
2068    user: &laterite_auth::AuthenticatedUser,
2069    shell: crate::Shell,
2070) -> Option<ListTemplate> {
2071    // The operator's chosen columns narrow the descriptor once, here, so
2072    // everything downstream (the query, the headers, sorting, searching) works
2073    // from one list: what is shown is what is queried.
2074    let stored = stored_columns(state, path, user).await;
2075    let declared = config;
2076    let narrowed = narrowed(config, stored.as_deref(), user);
2077    let config = &narrowed;
2078
2079    let page = params.page.unwrap_or(1).max(1);
2080    let per_page = stored_per_page(state, config, path, user).await;
2081    let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
2082    let q = params.q.unwrap_or_default();
2083    let active = resolve_filters(config, raw);
2084    // The list's own order, whole: what the grip and the tree both need. A
2085    // sorted, searched or filtered view is a flat result.
2086    let (natural, natural_dir) = natural_order(config);
2087    let own_order =
2088        order_by == natural && order_dir == natural_dir && q.trim().is_empty() && active.is_empty();
2089    let reorder = config
2090        .reorder
2091        .as_ref()
2092        .filter(|_| own_order)
2093        .map(|_| ReorderView {
2094            path: format!("{path}/reorder"),
2095            page,
2096            parent: params.parent.clone().unwrap_or_default(),
2097        });
2098    // In structure the query is scoped to one parent's children: the top
2099    // level by default, or the parent asked for.
2100    let structure = config.tree.as_ref().filter(|_| own_order);
2101    let scoped;
2102    let config = match structure {
2103        Some(tree) => {
2104            let mut c = config.clone();
2105            c.scope = Some(Scope::Column {
2106                column: tree.parent_key.clone(),
2107                value: params.parent.clone(),
2108            });
2109            scoped = c;
2110            &scoped
2111        }
2112        None => config,
2113    };
2114    let under_parent = structure.is_some() && params.parent.is_some();
2115    let depth = if under_parent {
2116        params.depth.unwrap_or(1).max(1)
2117    } else {
2118        0
2119    };
2120    // A parent's children show together, since they expand in place.
2121    let per_page = if under_parent {
2122        TREE_CHILDREN_LIMIT
2123    } else {
2124        per_page
2125    };
2126    let offset = (page - 1) * per_page;
2127    let tree = structure.map(|t| TreeView {
2128        move_path: t.source.as_ref().map(|_| path.to_string()),
2129    });
2130    let crumbs = match (structure, &params.parent) {
2131        (Some(t), Some(parent)) => crumbs(&state.db, config, t, parent, path).await,
2132        _ => Vec::new(),
2133    };
2134    let req = ListQuery {
2135        offset,
2136        order_by: &order_by,
2137        order_dir,
2138        q: q.trim(),
2139        filters: &active,
2140        per_page: Some(per_page),
2141        children_of: structure.map(|t| t.parent_key.as_str()),
2142    };
2143    // Everything a sort or pager link must preserve, as raw `&k=v` pairs. Askama
2144    // escapes it into the href, so the ampersands are correct in the markup.
2145    let mut carry = String::new();
2146    if !q.trim().is_empty() {
2147        carry.push_str(&format!("&q={}", urlencode(q.trim())));
2148    }
2149    for f in &active {
2150        carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
2151    }
2152    let result = query(&state.db, config, &req).await.ok()?;
2153    {
2154        {
2155            let total_pages = ((result.total + per_page - 1) / per_page).max(1);
2156            let date_loc = date_locale(shell.locale());
2157            // Each column's labels, localized once for the page.
2158            let labels: Vec<Option<HashMap<String, String>>> = config
2159                .columns
2160                .iter()
2161                .map(|c| {
2162                    (!c.labels.is_empty()).then(|| {
2163                        c.labels
2164                            .iter()
2165                            .map(|l| (l.value.clone(), shell.tt(&l.label)))
2166                            .collect()
2167                    })
2168                })
2169                .collect();
2170            let rows: Vec<RenderedRow> = result
2171                .rows
2172                .into_iter()
2173                .map(|row| RenderedRow {
2174                    depth,
2175                    indent: format!("{}px", 16 + depth * 18),
2176                    toggle: (structure.is_some() && row.children > 0)
2177                        .then(|| format!("{path}?parent={}&depth={}", row.id, depth + 1)),
2178                    id: row.id.clone(),
2179                    cells: row
2180                        .cells
2181                        .iter()
2182                        .zip(config.columns.iter().zip(&labels))
2183                        .map(|(raw, (col, labels))| {
2184                            let cx = CellCx {
2185                                value: raw,
2186                                tz: shell.tz,
2187                                locale: date_loc,
2188                            };
2189                            let scope = OverrideScope {
2190                                surface: Surface::Column,
2191                                view_key: &col.column_type,
2192                                resource: Some(&config.entity),
2193                                field: Some(&col.field),
2194                            };
2195                            let html = match (&config.editor, col.editable) {
2196                                (Some(editor), true) => cell_markup(
2197                                    state, &editor.0, path, &row.id, &col.field, raw, &shell, None,
2198                                ),
2199                                _ => match state.column_types.get(&col.column_type) {
2200                                    Some(ct) => render_cell(
2201                                        ct.as_ref(),
2202                                        state.overrides.as_ref(),
2203                                        &scope,
2204                                        &cx,
2205                                        labels.as_ref(),
2206                                    )
2207                                    .into_string(),
2208                                    // Unreachable: `check` refused the type at boot.
2209                                    None => String::new(),
2210                                },
2211                            };
2212                            CellView {
2213                                html,
2214                                class: col.align_class().to_string(),
2215                            }
2216                        })
2217                        .collect(),
2218                })
2219                .collect();
2220            let editable_assets: Vec<&'static str> = match &config.editor {
2221                Some(editor) => config
2222                    .columns
2223                    .iter()
2224                    .filter(|c| c.editable)
2225                    .flat_map(|c| editor.0.field_assets(&c.field))
2226                    .collect(),
2227                None => Vec::new(),
2228            };
2229            let keys: Vec<&str> = config
2230                .columns
2231                .iter()
2232                .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
2233                .flatten()
2234                .chain(editable_assets)
2235                .collect();
2236            let mut shell = shell;
2237            shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
2238            let shown = rows.len() as i64;
2239            // Localize the title and column headers before the shell is moved in.
2240            let title = shell.tt(&config.title);
2241            let active_dir = match order_dir {
2242                SortDir::Asc => "asc",
2243                SortDir::Desc => "desc",
2244            };
2245            let columns: Vec<ColumnHead> = config
2246                .columns
2247                .iter()
2248                .map(|c| {
2249                    let active = c.field == order_by;
2250                    ColumnHead {
2251                        label: shell.tt(&c.label),
2252                        field: c.field.clone(),
2253                        // Clicking the sorted column flips it; any other column
2254                        // starts ascending.
2255                        next_dir: if active && active_dir == "asc" {
2256                            "desc".to_string()
2257                        } else {
2258                            "asc".to_string()
2259                        },
2260                        active: if active {
2261                            active_dir.to_string()
2262                        } else {
2263                            String::new()
2264                        },
2265                        sortable: c.sortable,
2266                        width: c.width.clone(),
2267                        align_class: c.align_class().to_string(),
2268                    }
2269                })
2270                .collect();
2271            let shell_for_toolbar = shell.clone();
2272            let filter_views = filter_views(config, &active, &shell);
2273            let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
2274                .into_iter()
2275                .map(|c| ColumnChoice {
2276                    field: c.field.clone(),
2277                    label: shell.tt(&c.label),
2278                    shown: config.columns.iter().any(|v| v.field == c.field),
2279                })
2280                .collect();
2281            // Built before the literal moves `order_by` into `sort`.
2282            let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
2283            let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
2284            // Localized before the literal moves `shell` in.
2285            let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
2286            let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
2287            let page_view = ListTemplate {
2288                shell,
2289                title,
2290                columns,
2291                rows,
2292                shown,
2293                page,
2294                total: result.total,
2295                total_pages,
2296                edit_base: config.edit_base.clone(),
2297                row_link: config.row_click == RowClick::Open,
2298                sort: order_by,
2299                dir: active_dir.to_string(),
2300                q: q.trim().to_string(),
2301                searchable: config.search.enabled
2302                    && config.columns.iter().any(|c| c.is_searchable()),
2303                search_prompt,
2304                search_on_enter: config.search.on_enter,
2305                no_records_message,
2306                per_page,
2307                per_page_options: config.per_page_options.clone(),
2308                pickers,
2309                toolbar,
2310                export_query,
2311                path: path.to_string(),
2312                filters: filter_views,
2313                deletable: config.deletable,
2314                exportable: config.exportable,
2315                import_href: config
2316                    .import
2317                    .as_ref()
2318                    .filter(|link| user.permissions.allows(&link.permission))
2319                    .map(|link| link.href.clone()),
2320                filtered: !active.is_empty() || !q.trim().is_empty(),
2321                carry: carry.clone(),
2322                region: "lat-list".to_string(),
2323                delete_path: None,
2324                reorder,
2325                tree,
2326                crumbs,
2327            };
2328            Some(page_view)
2329        }
2330    }
2331}
2332
2333/// A per-row action posted with the row's id, for a list with no action bar of
2334/// its own: a section's Delete, or a membership's Remove.
2335#[derive(Clone, Debug)]
2336pub(crate) struct RowAction {
2337    pub path: String,
2338    pub label: String,
2339    pub confirm: String,
2340    /// The glyph on the button; the label is its name for assistive tech.
2341    pub icon: &'static str,
2342}
2343
2344/// What the grip and Move buttons post to, and the page to come back to.
2345#[derive(Clone, Debug)]
2346pub(crate) struct ReorderView {
2347    pub path: String,
2348    pub page: i64,
2349    /// The tree parent the rows sit under, empty at the top level, so a move
2350    /// stays among siblings.
2351    pub parent: String,
2352}
2353
2354/// The tree's row furniture.
2355#[derive(Clone, Debug)]
2356pub(crate) struct TreeView {
2357    /// The list path, when rows offer "Move to"; the link is `{path}/{id}/move`.
2358    pub move_path: Option<String>,
2359}
2360
2361/// One step of the way down to a parent.
2362pub(crate) struct Crumb {
2363    pub label: String,
2364    pub href: String,
2365}
2366
2367/// The way down to `parent`: the top level, then each ancestor by its first
2368/// column, nearest last.
2369async fn crumbs(
2370    db: &Db,
2371    config: &ListConfig,
2372    tree: &TreeConfig,
2373    parent: &str,
2374    path: &str,
2375) -> Vec<Crumb> {
2376    let mut trail = Vec::new();
2377    let mut current = Some(parent.to_string());
2378    // A cycle in the data would otherwise walk forever.
2379    for _ in 0..64 {
2380        let Some(id) = current.take() else { break };
2381        match parent_row(db, config, tree, &id).await {
2382            Ok(Some((label, above))) => {
2383                trail.push(Crumb {
2384                    label,
2385                    href: format!("{path}?parent={id}"),
2386                });
2387                current = above;
2388            }
2389            _ => break,
2390        }
2391    }
2392    trail.push(Crumb {
2393        label: String::new(),
2394        href: path.to_string(),
2395    });
2396    trail.reverse();
2397    trail
2398}
2399
2400/// A row's label (its first column) and its parent, or `None` when there is
2401/// no such row.
2402async fn parent_row(
2403    db: &Db,
2404    config: &ListConfig,
2405    tree: &TreeConfig,
2406    id: &str,
2407) -> anyhow::Result<Option<(String, Option<String>)>> {
2408    let label_col = config
2409        .columns
2410        .first()
2411        .map(|c| c.field.as_str())
2412        .unwrap_or(&config.id_field);
2413    let (sql, values) = {
2414        let cast = Alias::new(text_cast(db.backend));
2415        build(
2416            db.backend,
2417            Query::select()
2418                .expr_as(
2419                    Expr::col(Alias::new(label_col)).cast_as(cast.clone()),
2420                    Alias::new("lat_label"),
2421                )
2422                .expr_as(
2423                    Expr::col(Alias::new(&tree.parent_key)).cast_as(cast.clone()),
2424                    Alias::new("lat_parent"),
2425                )
2426                .from(Alias::new(&config.entity))
2427                .and_where(
2428                    Expr::col(Alias::new(&config.id_field))
2429                        .cast_as(cast)
2430                        .eq(id.to_string()),
2431                )
2432                .to_owned(),
2433        )
2434    };
2435    let row = bind_values(sqlx::query(&sql), values)
2436        .fetch_optional(&db.pool)
2437        .await?;
2438    Ok(row.map(|row| {
2439        let above = row.get_text("lat_parent").ok().filter(|p| !p.is_empty());
2440        (get_text(&row, "lat_label"), above)
2441    }))
2442}
2443
2444/// One row's id and its position key, in the list's own order.
2445async fn ordered_keys(
2446    db: &Db,
2447    config: &ListConfig,
2448    column: &str,
2449) -> anyhow::Result<Vec<(String, i64)>> {
2450    let (sql, values) = {
2451        let cast = Alias::new(text_cast(db.backend));
2452        let mut select = Query::select();
2453        select
2454            .expr_as(
2455                Expr::col(Alias::new(&config.id_field)).cast_as(cast.clone()),
2456                Alias::new(ID_ALIAS),
2457            )
2458            .expr_as(
2459                Expr::col(Alias::new(column)).cast_as(cast),
2460                Alias::new("lat_key"),
2461            )
2462            .from(Alias::new(&config.entity))
2463            .order_by(Alias::new(column), Order::Asc)
2464            .order_by(Alias::new(&config.id_field), Order::Asc);
2465        if let Some(scope) = &config.scope {
2466            select.and_where(scope.condition(db.backend, &config.id_field));
2467        }
2468        build(db.backend, select)
2469    };
2470    let rows = bind_values(sqlx::query(&sql), values)
2471        .fetch_all(&db.pool)
2472        .await?;
2473    Ok(rows
2474        .iter()
2475        .map(|row| {
2476            let key = get_text(row, "lat_key").trim().parse().unwrap_or(0);
2477            (get_text(row, ID_ALIAS), key)
2478        })
2479        .collect())
2480}
2481
2482/// Writes new position keys, all in one transaction.
2483async fn write_keys(
2484    db: &Db,
2485    config: &ListConfig,
2486    column: &str,
2487    changes: &[(String, i64)],
2488) -> anyhow::Result<()> {
2489    let mut tx = db.pool.begin().await?;
2490    for (id, key) in changes {
2491        let (sql, values) = build(
2492            db.backend,
2493            Query::update()
2494                .table(Alias::new(&config.entity))
2495                .value(Alias::new(column), *key)
2496                .and_where(
2497                    Expr::col(Alias::new(&config.id_field))
2498                        .cast_as(Alias::new(text_cast(db.backend)))
2499                        .eq(id.clone()),
2500                )
2501                .to_owned(),
2502        );
2503        bind_values(sqlx::query(&sql), values)
2504            .execute(&mut *tx)
2505            .await?;
2506    }
2507    tx.commit().await?;
2508    Ok(())
2509}
2510
2511/// Where a row goes: one step either way, or beside a named row.
2512enum Move {
2513    Up,
2514    Down,
2515    Before(String),
2516    After(String),
2517}
2518
2519/// The keys to write so that `id` lands at `target` in `order`: the moved
2520/// row's alone when a gap holds it, every row's when the list renumbers.
2521fn placement(order: &[(String, i64)], id: &str, target: usize) -> Vec<(String, i64)> {
2522    let mut ids: Vec<&str> = order.iter().map(|(i, _)| i.as_str()).collect();
2523    let from = match ids.iter().position(|i| *i == id) {
2524        Some(from) => from,
2525        None => return Vec::new(),
2526    };
2527    ids.remove(from);
2528    let target = target.min(ids.len());
2529    ids.insert(target, id);
2530    let key_of = |i: &str| {
2531        order
2532            .iter()
2533            .find(|(o, _)| o == i)
2534            .map(|(_, k)| *k)
2535            .unwrap_or(0)
2536    };
2537    let prev = target.checked_sub(1).map(|i| key_of(ids[i]));
2538    let next = ids.get(target + 1).map(|i| key_of(i));
2539    let key = match (prev, next) {
2540        (Some(a), Some(b)) if b - a >= 2 => Some(a + (b - a) / 2),
2541        (Some(a), None) => Some(a + REORDER_STEP),
2542        (None, Some(b)) => Some(b - REORDER_STEP),
2543        (None, None) => Some(key_of(id)),
2544        _ => None,
2545    };
2546    match key {
2547        Some(key) => vec![(id.to_string(), key)],
2548        None => ids
2549            .iter()
2550            .enumerate()
2551            .map(|(i, row)| (row.to_string(), (i as i64 + 1) * REORDER_STEP))
2552            .filter(|(row, key)| key_of(row) != *key)
2553            .collect(),
2554    }
2555}
2556
2557/// Handles a posted move: `id` with `move=up|down`, `before=<id>` or
2558/// `after=<id>`, and the `page` to show again. An htmx request gets the list
2559/// region back; a plain one returns to that page of the list.
2560#[allow(clippy::too_many_arguments)]
2561pub(crate) async fn reorder_handler(
2562    state: &AdminState,
2563    config: &ListConfig,
2564    path: &str,
2565    region: &str,
2566    row_action: Option<RowAction>,
2567    user: &laterite_auth::AuthenticatedUser,
2568    session: &crate::session::SessionHandle,
2569    shell: crate::Shell,
2570    headers: &axum::http::HeaderMap,
2571    pairs: &[(String, String)],
2572) -> Response {
2573    let Some(reorder) = &config.reorder else {
2574        return crate::not_found();
2575    };
2576    let field = |name: &str| {
2577        pairs
2578            .iter()
2579            .find(|(k, _)| k == name)
2580            .map(|(_, v)| v.trim().to_string())
2581            .filter(|v| !v.is_empty())
2582    };
2583    let page = field("page")
2584        .and_then(|p| p.parse().ok())
2585        .unwrap_or(1)
2586        .max(1);
2587    // Under a tree the move stays among one parent's children.
2588    let parent = field("parent");
2589    let scoped;
2590    let config = match &config.tree {
2591        Some(tree) => {
2592            let mut c = config.clone();
2593            c.scope = Some(Scope::Column {
2594                column: tree.parent_key.clone(),
2595                value: parent.clone(),
2596            });
2597            scoped = c;
2598            &scoped
2599        }
2600        None => config,
2601    };
2602    let fatal = 'apply: {
2603        let (Some(id), Some(mv)) = (
2604            field("id"),
2605            match (field("move").as_deref(), field("before"), field("after")) {
2606                (Some("up"), _, _) => Some(Move::Up),
2607                (Some("down"), _, _) => Some(Move::Down),
2608                (_, Some(before), _) => Some(Move::Before(before)),
2609                (_, _, Some(after)) => Some(Move::After(after)),
2610                _ => None,
2611            },
2612        ) else {
2613            break 'apply false;
2614        };
2615        let order = match ordered_keys(&state.db, config, &reorder.column).await {
2616            Ok(order) => order,
2617            Err(e) => {
2618                tracing::error!(entity = %config.entity, error = %e, "reorder read failed");
2619                break 'apply true;
2620            }
2621        };
2622        let position = |needle: &str| order.iter().position(|(i, _)| i == needle);
2623        let Some(from) = position(&id) else {
2624            session.push_flash(
2625                crate::session::FlashLevel::Error,
2626                laterite_core::t!("That record is no longer in the list."),
2627            );
2628            break 'apply false;
2629        };
2630        let target = match mv {
2631            Move::Up => from.saturating_sub(1),
2632            Move::Down => from + 1,
2633            Move::Before(other) => match position(&other) {
2634                Some(p) if p > from => p - 1,
2635                Some(p) => p,
2636                None => from,
2637            },
2638            Move::After(other) => match position(&other) {
2639                Some(p) if p > from => p,
2640                Some(p) => p + 1,
2641                None => from,
2642            },
2643        };
2644        let changes = placement(&order, &id, target);
2645        if changes.is_empty() {
2646            break 'apply false;
2647        }
2648        if let Err(e) = write_keys(&state.db, config, &reorder.column, &changes).await {
2649            tracing::error!(entity = %config.entity, error = %e, "reorder write failed");
2650            break 'apply true;
2651        }
2652        crate::audit::record(
2653            state,
2654            user,
2655            &format!("{}.reorder", config.entity),
2656            Some(&config.entity),
2657            Some(&id),
2658            None,
2659            Some(&format!("position {}", target.min(order.len() - 1) + 1)),
2660        )
2661        .await;
2662        false
2663    };
2664    if fatal {
2665        return render_error();
2666    }
2667    if crate::form::is_htmx(headers) {
2668        let params = ListParams {
2669            page: Some(page),
2670            parent: parent.clone(),
2671            depth: field("depth").and_then(|d| d.parse().ok()),
2672            ..Default::default()
2673        };
2674        let Some(view) =
2675            build_page(state, config, path, params, &HashMap::new(), user, shell).await
2676        else {
2677            return render_error();
2678        };
2679        return if parent.is_some() && view.tree.is_some() {
2680            render(rows_of(view, region, row_action))
2681        } else {
2682            render(fragment_of(view, region, row_action))
2683        };
2684    }
2685    let back = match parent {
2686        Some(parent) => format!("{path}?parent={parent}&page={page}"),
2687        None => format!("{path}?page={page}"),
2688    };
2689    axum::response::Redirect::to(&back).into_response()
2690}
2691
2692/// The "Move to" page: the row's parent, chosen from the tree's picker.
2693pub(crate) async fn move_form(
2694    state: &AdminState,
2695    config: &ListConfig,
2696    form: &crate::form::PreparedForm,
2697    path: &str,
2698    id: &str,
2699    shell: crate::Shell,
2700) -> Response {
2701    let Some(tree) = &config.tree else {
2702        return crate::not_found();
2703    };
2704    let current = match parent_row(&state.db, config, tree, id).await {
2705        Ok(Some((_, parent))) => parent.unwrap_or_default(),
2706        Ok(None) => return crate::not_found(),
2707        Err(_) => return render_error(),
2708    };
2709    let mut values = HashMap::new();
2710    values.insert(tree.parent_key.clone(), current);
2711    let action = format!("{path}/{id}/move");
2712    crate::form::page(state, form, &action, &values, &shell)
2713}
2714
2715/// Moves a row under the chosen parent (none for the top level), refusing a
2716/// parent that is the row itself or sits beneath it.
2717#[allow(clippy::too_many_arguments)]
2718pub(crate) async fn move_record(
2719    state: &AdminState,
2720    config: &ListConfig,
2721    form: &crate::form::PreparedForm,
2722    path: &str,
2723    id: &str,
2724    user: &laterite_auth::AuthenticatedUser,
2725    session: &crate::session::SessionHandle,
2726    shell: crate::Shell,
2727    headers: &axum::http::HeaderMap,
2728    data: HashMap<String, String>,
2729) -> Response {
2730    let Some(tree) = &config.tree else {
2731        return crate::not_found();
2732    };
2733    match parent_row(&state.db, config, tree, id).await {
2734        Ok(Some(_)) => {}
2735        Ok(None) => return crate::not_found(),
2736        Err(_) => return render_error(),
2737    }
2738    let action = format!("{path}/{id}/move");
2739    let chosen = data
2740        .get(&tree.parent_key)
2741        .map(|v| v.trim().to_string())
2742        .filter(|v| !v.is_empty());
2743    let mut bag = laterite_core::validation::ErrorBag::default();
2744    if let Some(parent) = &chosen {
2745        // Walk up from the chosen parent: reaching the row itself would close
2746        // a loop, and a parent that is not there is no parent.
2747        let mut current = Some(parent.clone());
2748        let mut steps = 0;
2749        while let Some(at) = current.take() {
2750            if at == id {
2751                bag.add(
2752                    &tree.parent_key,
2753                    laterite_core::t!("A record cannot be moved under itself."),
2754                );
2755                break;
2756            }
2757            match parent_row(&state.db, config, tree, &at).await {
2758                Ok(Some((_, above))) => current = above,
2759                Ok(None) => {
2760                    bag.add(
2761                        &tree.parent_key,
2762                        laterite_core::t!("There is no such record."),
2763                    );
2764                    break;
2765                }
2766                Err(_) => return render_error(),
2767            }
2768            steps += 1;
2769            if steps > 64 {
2770                break;
2771            }
2772        }
2773    }
2774    let htmx = crate::form::is_htmx(headers);
2775    if !bag.is_empty() {
2776        return crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell);
2777    }
2778    let actor = laterite_core::Actor::from(user);
2779    let outcome = async {
2780        let mut tx = state
2781            .db
2782            .pool
2783            .begin()
2784            .await
2785            .map_err(|e| MoveFailure::Failed(e.to_string()))?;
2786        let mut cx = laterite_core::SaveCx::new(state.db.backend, &mut tx, actor);
2787        let request = crate::persist::MoveTo {
2788            entity: &config.entity,
2789            id_field: &config.id_field,
2790            id,
2791            parent_key: &tree.parent_key,
2792            parent: chosen.as_deref(),
2793        };
2794        form.persister()
2795            .move_to(&mut cx, &request)
2796            .await
2797            .map_err(|e| match e {
2798                crate::persist::SaveError::Invalid(bag) => MoveFailure::Refused(bag),
2799                crate::persist::SaveError::Failed(m) => MoveFailure::Failed(m),
2800            })?;
2801        tx.commit()
2802            .await
2803            .map_err(|e| MoveFailure::Failed(e.to_string()))
2804    }
2805    .await;
2806    match outcome {
2807        Ok(()) => {
2808            crate::audit::record(
2809                state,
2810                user,
2811                &format!("{}.move", config.entity),
2812                Some(&config.entity),
2813                Some(id),
2814                None,
2815                Some(&match &chosen {
2816                    Some(parent) => format!("under {parent}"),
2817                    None => "top level".to_string(),
2818                }),
2819            )
2820            .await;
2821            session.push_flash(
2822                crate::session::FlashLevel::Success,
2823                laterite_core::t!("Moved."),
2824            );
2825            let back = match chosen {
2826                Some(parent) => format!("{path}?parent={parent}"),
2827                None => path.to_string(),
2828            };
2829            crate::form::saved_response(htmx, &back)
2830        }
2831        Err(MoveFailure::Refused(bag)) => {
2832            crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell)
2833        }
2834        Err(MoveFailure::Failed(m)) => {
2835            tracing::error!(entity = %config.entity, id, error = %m, "move failed");
2836            crate::form::refused(
2837                htmx,
2838                state,
2839                form,
2840                &action,
2841                Some(laterite_core::t!("Could not move. Try again.")),
2842                &data,
2843                &bag,
2844                &shell,
2845            )
2846        }
2847    }
2848}
2849
2850enum MoveFailure {
2851    Refused(laterite_core::validation::ErrorBag),
2852    Failed(String),
2853}
2854
2855/// An editable cell: the field's control in a form of its own that saves on
2856/// change, with a Save button for a page without scripting. A refused value
2857/// comes back with its message beneath the control.
2858#[allow(clippy::too_many_arguments)]
2859fn cell_markup(
2860    state: &AdminState,
2861    form: &crate::form::PreparedForm,
2862    path: &str,
2863    id: &str,
2864    field: &str,
2865    value: &str,
2866    shell: &crate::Shell,
2867    error: Option<&str>,
2868) -> String {
2869    let dom_id = format!("cell-{id}-{field}");
2870    let control = form
2871        .control(state, field, &dom_id, value, shell)
2872        .unwrap_or_default();
2873    let action = format!("{path}/{id}/cell");
2874    let mut html = format!(
2875        r#"<form class="lat-cell{}" method="post" action="{action}" hx-post="{action}" hx-trigger="change, submit" hx-target="this" hx-swap="outerHTML" data-lat-widget="cell" data-lat-enter-scope><input type="hidden" name="_csrf" value="{}"><input type="hidden" name="field" value="{}">{control}<button class="lat-btn lat-btn--ghost lat-btn--sm lat-cell__save" type="submit">{}</button>"#,
2876        if error.is_some() { " is-invalid" } else { "" },
2877        crate::form::escape_attr(&shell.csrf_token),
2878        crate::form::escape_attr(field),
2879        shell.tt(&laterite_core::t!("Save")),
2880    );
2881    if let Some(message) = error {
2882        html.push_str(&format!(
2883            r#"<span class="lat-cell__error">{}</span>"#,
2884            crate::form::escape_attr(message)
2885        ));
2886    }
2887    html.push_str("</form>");
2888    html
2889}
2890
2891/// Saves one cell: the row as it stands with the submitted field over it, written
2892/// through the form's rules, persister and listeners. An htmx request gets the
2893/// cell back, holding the stored value or the refusal; a plain one returns to
2894/// the list with a flash.
2895#[allow(clippy::too_many_arguments)]
2896pub(crate) async fn cell_handler(
2897    state: &AdminState,
2898    config: &ListConfig,
2899    path: &str,
2900    id: &str,
2901    user: &laterite_auth::AuthenticatedUser,
2902    session: &crate::session::SessionHandle,
2903    shell: crate::Shell,
2904    headers: &axum::http::HeaderMap,
2905    mut data: HashMap<String, String>,
2906) -> Response {
2907    let Some(editor) = &config.editor else {
2908        return crate::not_found();
2909    };
2910    let form = &editor.0;
2911    let Some(field) = data.remove("field") else {
2912        return crate::not_found();
2913    };
2914    data.remove("_csrf");
2915    if !config
2916        .columns
2917        .iter()
2918        .any(|c| c.editable && c.field == field)
2919        || !form.has_field(&field)
2920    {
2921        return crate::not_found();
2922    }
2923    let htmx = crate::form::is_htmx(headers);
2924    let back = path.to_string();
2925    let current = match crate::form::load_values(&state.db, form, id).await {
2926        Ok(Some(values)) => values,
2927        Ok(None) => return crate::not_found(),
2928        Err(_) => return render_error(),
2929    };
2930    // The row as stored, with the submission over it. The edited field is
2931    // taken from the submission alone, so a control that posts nothing when
2932    // cleared (a switch) clears it rather than keeping the stored value.
2933    let submitted = data.get(&field).cloned().unwrap_or_default();
2934    let mut merged = current.clone();
2935    merged.remove(&field);
2936    for (key, value) in data {
2937        merged.insert(key, value);
2938    }
2939    let merged = crate::form::gathered(form, merged);
2940    let actor = laterite_core::Actor::from(user);
2941    match crate::form::write(&state.db, form, &merged, Some(id), &actor).await {
2942        Ok(_) => {
2943            if htmx {
2944                let stored = crate::form::load_values(&state.db, form, id)
2945                    .await
2946                    .ok()
2947                    .flatten()
2948                    .and_then(|values| values.get(&field).cloned())
2949                    .unwrap_or(submitted);
2950                let html = cell_markup(state, form, path, id, &field, &stored, &shell, None);
2951                return axum::response::Html(html).into_response();
2952            }
2953            session.push_flash(
2954                crate::session::FlashLevel::Success,
2955                laterite_core::t!("Saved."),
2956            );
2957            crate::form::saved_response(false, &back)
2958        }
2959        Err(crate::persist::SaveError::Invalid(bag)) => {
2960            let message = bag
2961                .messages(&field)
2962                .first()
2963                .map(|m| shell.tt(m))
2964                .or_else(|| {
2965                    bag.iter()
2966                        .next()
2967                        .and_then(|(_, m)| m.first())
2968                        .map(|m| shell.tt(m))
2969                })
2970                .unwrap_or_else(|| shell.tt(&laterite_core::t!("Could not save.")));
2971            if htmx {
2972                let html = cell_markup(
2973                    state,
2974                    form,
2975                    path,
2976                    id,
2977                    &field,
2978                    &submitted,
2979                    &shell,
2980                    Some(&message),
2981                );
2982                return (
2983                    axum::http::StatusCode::UNPROCESSABLE_ENTITY,
2984                    axum::response::Html(html),
2985                )
2986                    .into_response();
2987            }
2988            session.push_flash(crate::session::FlashLevel::Error, Text::dynamic(message));
2989            crate::form::saved_response(false, &back)
2990        }
2991        Err(crate::persist::SaveError::Failed(m)) => {
2992            tracing::error!(entity = %config.entity, id, field, error = %m, "cell save failed");
2993            render_error()
2994        }
2995    }
2996}
2997
2998/// The list region alone, cut out of a built page.
2999fn fragment_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> ListFragment {
3000    ListFragment {
3001        shell: page.shell,
3002        columns: page.columns,
3003        rows: page.rows,
3004        shown: page.shown,
3005        page: page.page,
3006        total: page.total,
3007        total_pages: page.total_pages,
3008        edit_base: page.edit_base,
3009        row_link: page.row_link,
3010        sort: page.sort,
3011        dir: page.dir,
3012        carry: page.carry,
3013        filtered: page.filtered,
3014        deletable: page.deletable,
3015        no_records_message: page.no_records_message,
3016        path: page.path,
3017        region: region.to_string(),
3018        delete_path,
3019        reorder: page.reorder,
3020        tree: page.tree,
3021    }
3022}
3023
3024/// The rows alone, cut out of a built page.
3025fn rows_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> RowsFragment {
3026    RowsFragment {
3027        shell: page.shell,
3028        rows: page.rows,
3029        page: page.page,
3030        total_pages: page.total_pages,
3031        edit_base: page.edit_base,
3032        row_link: page.row_link,
3033        deletable: page.deletable,
3034        region: region.to_string(),
3035        delete_path,
3036        reorder: page.reorder,
3037        tree: page.tree,
3038    }
3039}
3040
3041/// A list rendered as a region for another page to hold: a relation section on
3042/// a form. The query, sorting, paging and search work from `path`, and a
3043/// per-row delete posts to `delete_path` when there is one.
3044#[allow(clippy::too_many_arguments)]
3045pub(crate) async fn fragment(
3046    state: &AdminState,
3047    config: &ListConfig,
3048    path: &str,
3049    params: ListParams,
3050    raw: &HashMap<String, String>,
3051    user: &laterite_auth::AuthenticatedUser,
3052    shell: crate::Shell,
3053    region: &str,
3054    delete_path: Option<RowAction>,
3055) -> Result<crate::html::Markup, ()> {
3056    let page = build_page(state, config, path, params, raw, user, shell)
3057        .await
3058        .ok_or(())?;
3059    crate::html::Markup::from_template(&fragment_of(page, region, delete_path)).map_err(|_| ())
3060}
3061
3062/// Stores which columns this operator wants on this list.
3063///
3064/// Choosing every column clears the preference rather than storing them all, so
3065/// the operator keeps following the descriptor as it gains or loses columns.
3066/// Choosing none is refused: a list with no columns shows nothing and offers no
3067/// way back, so the request is treated as "no preference".
3068pub(crate) async fn set_columns(
3069    state: &AdminState,
3070    config: &ListConfig,
3071    path: &str,
3072    user: &laterite_auth::AuthenticatedUser,
3073    session: &crate::session::SessionHandle,
3074    headers: &axum::http::HeaderMap,
3075    pairs: &[(String, String)],
3076) -> Response {
3077    let allowed = allowed_columns(config, user);
3078    let chosen: Vec<&str> = pairs
3079        .iter()
3080        .filter(|(k, _)| k == "column")
3081        .map(|(_, v)| v.trim())
3082        .filter(|v| allowed.iter().any(|c| c.field == *v))
3083        .collect();
3084    // "Every column" means the default set: what shows with no preference. An
3085    // `invisible` column chosen on purpose must not clear the choice that
3086    // revealed it.
3087    let mut by_default: Vec<&str> = allowed
3088        .iter()
3089        .filter(|c| !c.invisible)
3090        .map(|c| c.field.as_str())
3091        .collect();
3092    let mut as_chosen = chosen.clone();
3093    by_default.sort_unstable();
3094    as_chosen.sort_unstable();
3095    as_chosen.dedup();
3096
3097    // The page size travels on the same form. Only an offered size is kept, and
3098    // the descriptor's own clears the preference, like choosing every column.
3099    let size_key = per_page_preference_key(path);
3100    let size = pairs
3101        .iter()
3102        .find(|(k, _)| k == "per_page")
3103        .and_then(|(_, v)| v.trim().parse::<i64>().ok())
3104        .filter(|n| config.per_page_options.contains(n));
3105    let size_result = match size {
3106        Some(n) if n != config.per_page => {
3107            laterite_auth::store::set_user_preference(
3108                &state.db,
3109                user.user.id,
3110                &size_key,
3111                &n.to_string(),
3112            )
3113            .await
3114        }
3115        _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
3116    };
3117
3118    let key = columns_preference_key(path);
3119    let result = if chosen.is_empty() || as_chosen == by_default {
3120        laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
3121    } else {
3122        laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
3123            .await
3124    };
3125    match result.and(size_result) {
3126        Ok(()) => session.push_flash(
3127            crate::session::FlashLevel::Success,
3128            laterite_core::t!("List setup saved."),
3129        ),
3130        Err(e) => {
3131            tracing::error!(error = %e, "storing the list setup failed");
3132            session.push_flash(
3133                crate::session::FlashLevel::Error,
3134                laterite_core::t!("Could not save your list setup."),
3135            );
3136        }
3137    }
3138    let back = format!("{}{}", state.admin_path, path);
3139    crate::form::saved_response(crate::form::is_htmx(headers), &back)
3140}
3141
3142/// One row of the column picker.
3143pub struct ColumnChoice {
3144    pub field: String,
3145    pub label: String,
3146    pub shown: bool,
3147}
3148
3149/// One column header: its label, the field a click sorts by, the direction that
3150/// click asks for, and the direction it is sorted in now (empty when it is not).
3151pub struct ColumnHead {
3152    pub label: String,
3153    pub field: String,
3154    pub next_dir: String,
3155    pub active: String,
3156    pub sortable: bool,
3157    pub width: Option<String>,
3158    pub align_class: String,
3159}
3160
3161#[derive(Template)]
3162#[template(path = "list.html")]
3163struct ListTemplate {
3164    shell: crate::Shell,
3165    title: String,
3166    columns: Vec<ColumnHead>,
3167    rows: Vec<RenderedRow>,
3168    /// The count on this page (`rows.len()`), precomputed as `i64` for the footer.
3169    shown: i64,
3170    page: i64,
3171    total: i64,
3172    total_pages: i64,
3173    edit_base: Option<String>,
3174    /// A row opens its record; the Edit link is there regardless.
3175    row_link: bool,
3176    /// The active ordering, carried on the pager links so paging keeps the sort.
3177    sort: String,
3178    dir: String,
3179    /// The active search term, shown in the box and carried on every link.
3180    q: String,
3181    /// Whether the box appears: search is on and some column is searchable.
3182    searchable: bool,
3183    /// The placeholder when the descriptor set one, localized.
3184    search_prompt: Option<String>,
3185    search_on_enter: bool,
3186    /// The empty-state message when the descriptor set one, localized.
3187    no_records_message: Option<String>,
3188    /// This operator's page size, and the sizes the list setup offers; empty
3189    /// renders no chooser.
3190    per_page: i64,
3191    per_page_options: Vec<i64>,
3192    /// Whether rows carry a checkbox and the Delete button is offered.
3193    deletable: bool,
3194    /// Whether this resource offers an export, which puts the menu beside the
3195    /// title. Off leaves the title row bare.
3196    exportable: bool,
3197    /// The import pages, when the resource declared them and this operator may
3198    /// use them.
3199    import_href: Option<String>,
3200    /// Every declared column, with the shown ones ticked, for the picker.
3201    pickers: Vec<ColumnChoice>,
3202    /// The toolbar buttons this operator may see.
3203    toolbar: Vec<ToolbarView>,
3204    /// The search, filters and sort, as `&k=v` pairs, so an export link carries
3205    /// the same view the screen is showing.
3206    export_query: String,
3207    /// This list's own path, for the search form to post back to.
3208    path: String,
3209    /// The filter controls, with the active value marked.
3210    filters: Vec<FilterView>,
3211    /// Whether anything is narrowing the list, so a Clear link is offered.
3212    filtered: bool,
3213    /// The query-string pairs a sort or pager link must carry.
3214    carry: String,
3215    /// The list region's element id; a page's own list is `lat-list`.
3216    region: String,
3217    /// A per-row action, for a list with no action bar; a page's list has the bar.
3218    delete_path: Option<RowAction>,
3219    /// The grip and Move buttons, when the list is in its own order.
3220    reorder: Option<ReorderView>,
3221    /// The tree, when the list shows one: the toggles and the Move to link.
3222    tree: Option<TreeView>,
3223    /// The way down to the parent whose children a page shows.
3224    crumbs: Vec<Crumb>,
3225}
3226
3227/// One rendered filter control.
3228pub struct FilterView {
3229    pub param: String,
3230    pub label: String,
3231    /// The input kind when there are no options: `text`, `number` or `date`.
3232    pub input_type: String,
3233    pub options: Vec<FilterOptionView>,
3234    /// The selected value, empty when the filter is off.
3235    pub value: String,
3236}
3237
3238pub struct FilterOptionView {
3239    pub value: String,
3240    pub label: String,
3241}
3242
3243/// Builds the filter controls, marking whichever value the request selected.
3244fn filter_views(
3245    config: &ListConfig,
3246    active: &[ActiveFilter<'_>],
3247    shell: &crate::Shell,
3248) -> Vec<FilterView> {
3249    config
3250        .filters
3251        .iter()
3252        .map(|f| {
3253            let value = active
3254                .iter()
3255                .find(|a| a.filter.field == f.field)
3256                .map(|a| a.raw.clone())
3257                .unwrap_or_default();
3258            // A list of choices renders as a select; anything else as an
3259            // input of the matching kind.
3260            let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
3261                "boolean" => vec![
3262                    FilterOptionView {
3263                        value: "1".to_string(),
3264                        label: shell.tt(&laterite_core::t!("Yes")),
3265                    },
3266                    FilterOptionView {
3267                        value: "0".to_string(),
3268                        label: shell.tt(&laterite_core::t!("No")),
3269                    },
3270                ],
3271                "select" => f
3272                    .choices()
3273                    .iter()
3274                    .map(|o| FilterOptionView {
3275                        value: o.value.clone(),
3276                        label: shell.tt(&o.label),
3277                    })
3278                    .collect(),
3279                _ => Vec::new(),
3280            };
3281            let input_type = match f.filter_type.as_str() {
3282                "number" => "number",
3283                "date" => "date",
3284                _ => "text",
3285            };
3286            FilterView {
3287                param: f.param(),
3288                label: shell.tt(&f.label),
3289                input_type: input_type.to_string(),
3290                options,
3291                value,
3292            }
3293        })
3294        .collect()
3295}
3296
3297/// Percent-encodes a query-string value.
3298fn urlencode(value: &str) -> String {
3299    let mut out = String::with_capacity(value.len());
3300    for byte in value.as_bytes() {
3301        match byte {
3302            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3303                out.push(*byte as char)
3304            }
3305            b' ' => out.push('+'),
3306            _ => out.push_str(&format!("%{byte:02X}")),
3307        }
3308    }
3309    out
3310}
3311
3312/// The list region alone, for an htmx sort or page that swaps it in place.
3313#[derive(Template)]
3314#[template(path = "_list_table.html")]
3315struct ListFragment {
3316    shell: crate::Shell,
3317    columns: Vec<ColumnHead>,
3318    rows: Vec<RenderedRow>,
3319    shown: i64,
3320    page: i64,
3321    total: i64,
3322    total_pages: i64,
3323    edit_base: Option<String>,
3324    /// A row opens its record; the Edit link is there regardless.
3325    row_link: bool,
3326    sort: String,
3327    dir: String,
3328    carry: String,
3329    /// Rendered onto the region as `data-filtered`, so the bar's Clear link can
3330    /// react without the bar itself having to swap. Also picks the empty state:
3331    /// a filtered list with no rows has not run out of records, it has no match.
3332    filtered: bool,
3333    deletable: bool,
3334    no_records_message: Option<String>,
3335    /// The list's own path, so sort and pager links stay right when the
3336    /// fragment sits inside another page.
3337    path: String,
3338    /// The region's element id: `lat-list` for a page's list, one of its own
3339    /// for a section inside a form.
3340    region: String,
3341    /// A per-row action, posted with the row's id, for a list with no action
3342    /// bar of its own.
3343    delete_path: Option<RowAction>,
3344    /// The grip and Move buttons, when the list is in its own order.
3345    reorder: Option<ReorderView>,
3346    tree: Option<TreeView>,
3347}
3348
3349/// A parent's children alone, for a tree row that expands in place.
3350#[derive(Template)]
3351#[template(path = "_list_rows.html")]
3352struct RowsFragment {
3353    shell: crate::Shell,
3354    rows: Vec<RenderedRow>,
3355    page: i64,
3356    total_pages: i64,
3357    edit_base: Option<String>,
3358    row_link: bool,
3359    deletable: bool,
3360    region: String,
3361    delete_path: Option<RowAction>,
3362    reorder: Option<ReorderView>,
3363    tree: Option<TreeView>,
3364}
3365
3366#[cfg(test)]
3367mod tests {
3368    use super::*;
3369
3370    fn config() -> ListConfig {
3371        ListConfig {
3372            entity: "backend_users".to_string(),
3373            title: "Users".into(),
3374            columns: vec![
3375                ListColumn::new("username", "Username"),
3376                ListColumn::new("is_superuser", "Superuser").yes_no(),
3377            ],
3378            order_by: "created_at".to_string(),
3379            order_dir: SortDir::Desc,
3380            per_page: 25,
3381            id_field: "id".to_string(),
3382            edit_base: None,
3383            creatable: false,
3384            filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
3385            deletable: true,
3386            ..Default::default()
3387        }
3388    }
3389
3390    #[test]
3391    fn column_types_format_their_cell_display() {
3392        let ist: Tz = "Asia/Kolkata".parse().unwrap();
3393        let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
3394            ct.view_model(&CellCx {
3395                value,
3396                tz,
3397                locale: chrono::Locale::en_US,
3398            })
3399            .display
3400        };
3401        // 10:00 UTC is 15:30 in Asia/Kolkata (UTC+5:30)
3402        assert_eq!(
3403            display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
3404            "13 Aug 2026, 15:30"
3405        );
3406        assert_eq!(
3407            display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
3408            "13 Aug 2026"
3409        );
3410        assert_eq!(
3411            display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
3412            "15:30"
3413        );
3414        assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
3415        assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
3416        assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
3417        // An unparseable timestamp falls through unchanged.
3418        assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
3419    }
3420
3421    #[test]
3422    fn sort_falls_back_to_the_configured_order() {
3423        let c = config();
3424        assert_eq!(
3425            resolve_sort(&c, None, None),
3426            ("created_at".to_string(), SortDir::Desc)
3427        );
3428    }
3429
3430    #[test]
3431    fn a_declared_column_can_be_sorted_either_way() {
3432        let c = config();
3433        assert_eq!(
3434            resolve_sort(&c, Some("username"), Some("asc")),
3435            ("username".to_string(), SortDir::Asc)
3436        );
3437        assert_eq!(
3438            resolve_sort(&c, Some("username"), Some("desc")),
3439            ("username".to_string(), SortDir::Desc)
3440        );
3441    }
3442
3443    /// The whitelist: a column the descriptor does not declare never reaches the
3444    /// query, however it is spelled.
3445    #[test]
3446    fn an_undeclared_sort_column_is_ignored() {
3447        let c = config();
3448        for crafted in [
3449            "password_hash",
3450            "id) --",
3451            "username; drop table backend_users",
3452            "",
3453        ] {
3454            assert_eq!(
3455                resolve_sort(&c, Some(crafted), Some("asc")).0,
3456                "created_at",
3457                "{crafted} must not reach the query"
3458            );
3459        }
3460    }
3461
3462    #[test]
3463    fn an_unknown_direction_falls_back() {
3464        let c = config();
3465        // On a column other than the configured one, ascending.
3466        assert_eq!(
3467            resolve_sort(&c, Some("username"), Some("sideways")).1,
3468            SortDir::Asc
3469        );
3470        // On the configured column, its configured direction.
3471        assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
3472    }
3473
3474    /// A module's own column type renders its cells, which is the point of the
3475    /// registry being open rather than a fixed set.
3476    #[test]
3477    fn a_contributed_column_type_renders_its_cell() {
3478        struct Rating;
3479        impl ColumnType for Rating {
3480            fn view_key(&self) -> &'static str {
3481                "acme.rating"
3482            }
3483            fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
3484                let stars = cx.value.parse::<usize>().unwrap_or(0);
3485                text_cell("acme.rating", cx.value, "*".repeat(stars))
3486            }
3487            fn render_default(&self, vm: &CellVm) -> Markup {
3488                render_text(vm)
3489            }
3490        }
3491
3492        let reg = ColumnTypeReg::new(Arc::new(Rating));
3493        assert_eq!(reg.name(), "acme.rating", "the type names itself");
3494
3495        let mut registry = builtin_column_registry();
3496        registry.insert(reg.name().to_string(), reg.column_type);
3497        let rendered = render_cell(
3498            registry.get("acme.rating").unwrap().as_ref(),
3499            &crate::field::NoOverrides,
3500            &OverrideScope {
3501                surface: Surface::Column,
3502                view_key: "acme.rating",
3503                resource: Some("products"),
3504                field: Some("stars"),
3505            },
3506            &CellCx {
3507                value: "3",
3508                tz: Tz::UTC,
3509                locale: chrono::Locale::en_US,
3510            },
3511            None,
3512        );
3513        assert!(rendered.into_string().contains("***"));
3514    }
3515
3516    /// A contributed type cannot quietly replace a built-in: the key is the
3517    /// type's own `view_key`, and the router refuses a duplicate at boot.
3518    #[test]
3519    fn a_contributed_key_is_the_types_own_and_collides_visibly() {
3520        struct Impostor;
3521        impl ColumnType for Impostor {
3522            fn view_key(&self) -> &'static str {
3523                "text"
3524            }
3525            fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
3526                text_cell("text", cx.value, String::new())
3527            }
3528            fn render_default(&self, vm: &CellVm) -> Markup {
3529                render_text(vm)
3530            }
3531        }
3532        let reg = ColumnTypeReg::new(Arc::new(Impostor));
3533        assert_eq!(reg.name(), "text");
3534        // The router inserts into this map and panics when one is already there.
3535        assert!(builtin_column_registry().contains_key(reg.name()));
3536    }
3537
3538    #[test]
3539    fn status_pill_renders_a_slugged_markup_span() {
3540        let vm = StatusPillColumn.view_model(&CellCx {
3541            value: "In Progress",
3542            tz: Tz::UTC,
3543            locale: chrono::Locale::en_US,
3544        });
3545        let html = StatusPillColumn.render_default(&vm).into_string();
3546        assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
3547        assert!(html.contains(">In Progress<"));
3548    }
3549
3550    /// A fresh test database with the auth tables migrated in, on whichever
3551    /// backend the run targets. Hold the returned guard for the test's lifetime.
3552    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
3553        laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
3554    }
3555
3556    #[tokio::test]
3557    async fn query_returns_display_rows() {
3558        let (db, _guard) = test_db().await;
3559        let hash = laterite_auth::password::hash_password("pw").unwrap();
3560        laterite_auth::store::create_user(
3561            &db,
3562            "root",
3563            "root@example.test",
3564            "Ada",
3565            None,
3566            &hash,
3567            true,
3568        )
3569        .await
3570        .unwrap();
3571
3572        let c = config();
3573        let result = query(&db, &c, &plain(&c)).await.unwrap();
3574        assert_eq!(result.total, 1);
3575        assert_eq!(result.rows.len(), 1);
3576        assert_eq!(result.rows[0].cells[0], "root");
3577        // Booleans store as 0/1 integers everywhere, so a cast-to-text superuser
3578        // flag reads back as "1"; the display layer maps it to "Yes".
3579        assert_eq!(result.rows[0].cells[1], "1");
3580        assert!(!result.rows[0].id.is_empty());
3581    }
3582
3583    async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
3584        get_with(params, HashMap::new(), headers).await
3585    }
3586
3587    async fn get_with(
3588        params: ListParams,
3589        raw: HashMap<String, String>,
3590        headers: axum::http::HeaderMap,
3591    ) -> String {
3592        let (db, _guard) = test_db().await;
3593        let state = AdminState::new(
3594            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3595            db,
3596        );
3597        let resp = handle(
3598            &state,
3599            &config(),
3600            "/admin/users",
3601            params,
3602            &raw,
3603            &crate::audit::test_actor(),
3604            crate::Shell::test(),
3605            &headers,
3606        )
3607        .await;
3608        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3609            .await
3610            .unwrap();
3611        String::from_utf8(bytes.to_vec()).unwrap()
3612    }
3613
3614    #[test]
3615    fn a_typed_filter_accepts_only_its_own_shape() {
3616        let mut c = config();
3617        c.filters = vec![
3618            ListFilter::number("views", "Views"),
3619            ListFilter::date("created_at", "Created"),
3620            ListFilter::text("username", "Username"),
3621        ];
3622        let accepted = |param: &str, value: &str| {
3623            !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
3624        };
3625        assert!(accepted("f_views", "42"));
3626        assert!(
3627            !accepted("f_views", "4.2"),
3628            "a number filter takes whole numbers"
3629        );
3630        assert!(!accepted("f_views", "; drop table"), "and nothing else");
3631
3632        assert!(accepted("f_created_at", "2026-09-21"));
3633        assert!(
3634            !accepted("f_created_at", "2026-9-21"),
3635            "a date is ISO or nothing"
3636        );
3637        assert!(
3638            !accepted("f_created_at", "2026-09-21%"),
3639            "so a pattern cannot slip in"
3640        );
3641
3642        assert!(accepted("f_username", "ada"));
3643    }
3644
3645    #[test]
3646    fn a_filter_default_applies_only_until_the_operator_decides() {
3647        let mut c = config();
3648        c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
3649        // Nothing named: the default narrows the list.
3650        assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
3651        // Named blank: the operator cleared it, so no filter.
3652        assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
3653        // Named otherwise: their value, not the default.
3654        let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
3655        assert_eq!(active.len(), 1);
3656    }
3657
3658    #[test]
3659    fn a_gated_filter_is_narrowed_away_with_its_default() {
3660        let mut c = config();
3661        c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
3662            .default_value("1")
3663            .require("acme.secret")];
3664        let plain = laterite_auth::AuthenticatedUser {
3665            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
3666            ..crate::audit::test_actor()
3667        };
3668        assert!(allowed_filters(&c, &plain).is_empty());
3669        // And the narrowed descriptor carries none, so the default cannot apply.
3670        let narrowed = narrowed(&c, None, &plain);
3671        assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
3672        assert_eq!(allowed_filters(&c, &root()).len(), 1);
3673    }
3674
3675    #[test]
3676    fn a_stored_page_size_counts_only_while_it_is_offered() {
3677        let mut cfg = config();
3678        assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
3679        cfg.per_page_options = vec![25, 50, 100];
3680        assert_eq!(offered_per_page(&cfg, Some(50)), 50);
3681        assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
3682        assert_eq!(offered_per_page(&cfg, None), 25);
3683    }
3684
3685    fn params(sort: Option<&str>) -> ListParams {
3686        ListParams {
3687            page: None,
3688            sort: sort.map(|s| s.to_string()),
3689            dir: Some("asc".to_string()),
3690            q: None,
3691            parent: None,
3692            depth: None,
3693        }
3694    }
3695
3696    /// The default window and ordering, with no search and no filter.
3697    fn plain(config: &ListConfig) -> ListQuery<'_> {
3698        ListQuery {
3699            offset: 0,
3700            order_by: &config.order_by,
3701            order_dir: config.order_dir,
3702            q: "",
3703            filters: &[],
3704            per_page: None,
3705            children_of: None,
3706        }
3707    }
3708
3709    fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
3710        ListQuery {
3711            offset: 0,
3712            order_by: &config.order_by,
3713            order_dir: config.order_dir,
3714            q,
3715            filters: &[],
3716            per_page: None,
3717            children_of: None,
3718        }
3719    }
3720
3721    /// Both `&amp;` and `&#38;` are an ampersand; the escaper picks one, and a
3722    /// test should assert on the link, not on which spelling it chose.
3723    fn amps(html: &str) -> String {
3724        html.replace("&#38;", "&").replace("&amp;", "&")
3725    }
3726
3727    /// The request's filter parameters, as the query string would carry them.
3728    fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3729        pairs
3730            .iter()
3731            .map(|(k, v)| (k.to_string(), v.to_string()))
3732            .collect()
3733    }
3734
3735    #[tokio::test]
3736    async fn search_filters_rows_and_the_total() {
3737        let (db, _guard) = test_db().await;
3738        for name in ["ada", "brendan", "clara"] {
3739            let hash = laterite_auth::password::hash_password("x").unwrap();
3740            laterite_auth::store::create_user(
3741                &db,
3742                name,
3743                &format!("{name}@example.test"),
3744                name,
3745                None,
3746                &hash,
3747                false,
3748            )
3749            .await
3750            .unwrap();
3751        }
3752        let c = config();
3753
3754        let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
3755        assert_eq!(hit.rows.len(), 1);
3756        assert_eq!(hit.rows[0].cells[0], "brendan");
3757        // The pager counts the filtered set, not the table.
3758        assert_eq!(hit.total, 1);
3759
3760        // Case folds, as the picker's default profile does.
3761        assert_eq!(
3762            query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
3763            1
3764        );
3765
3766        // A blank term is no filter, not a filter matching nothing.
3767        assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
3768        assert_eq!(query(&db, &c, &searched(&c, "   ")).await.unwrap().total, 3);
3769
3770        // A LIKE wildcard is literal, so it does not match every row.
3771        assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
3772    }
3773
3774    /// Only text columns are searched by default: a substring match against a
3775    /// boolean would make every row containing `1` a hit.
3776    #[tokio::test]
3777    async fn search_skips_a_non_text_column() {
3778        let (db, _guard) = test_db().await;
3779        let hash = laterite_auth::password::hash_password("x").unwrap();
3780        laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
3781            .await
3782            .unwrap();
3783        let c = config();
3784        assert!(c.columns[0].is_searchable(), "username is text");
3785        assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
3786        // The stored superuser flag is `1`, but searching it finds nothing.
3787        assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
3788    }
3789
3790    #[test]
3791    fn a_column_can_override_its_searchability() {
3792        assert!(ListColumn::new("code", "Code").is_searchable());
3793        assert!(!ListColumn::new("code", "Code")
3794            .searchable(false)
3795            .is_searchable());
3796        assert!(ListColumn::new("at", "At")
3797            .datetime()
3798            .searchable(true)
3799            .is_searchable());
3800    }
3801
3802    async fn seed_users(db: &Db) {
3803        for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
3804            let hash = laterite_auth::password::hash_password("x").unwrap();
3805            laterite_auth::store::create_user(
3806                db,
3807                name,
3808                &format!("{name}@example.test"),
3809                name,
3810                None,
3811                &hash,
3812                su,
3813            )
3814            .await
3815            .unwrap();
3816        }
3817    }
3818
3819    async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
3820        let active = resolve_filters(c, params);
3821        let req = ListQuery {
3822            offset: 0,
3823            order_by: &c.order_by,
3824            order_dir: c.order_dir,
3825            q: "",
3826            filters: &active,
3827            per_page: None,
3828            children_of: None,
3829        };
3830        query(db, c, &req).await.unwrap().total
3831    }
3832
3833    #[tokio::test]
3834    async fn a_filter_narrows_the_rows_and_the_total() {
3835        let (db, _guard) = test_db().await;
3836        seed_users(&db).await;
3837        let c = config();
3838
3839        assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
3840        assert_eq!(
3841            total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
3842            1
3843        );
3844        assert_eq!(
3845            total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
3846            2
3847        );
3848    }
3849
3850    /// The whitelist: only a declared filter, and for a select only a declared
3851    /// option, reaches the query. Anything else is dropped, not guessed at.
3852    #[test]
3853    fn an_undeclared_filter_or_value_is_ignored() {
3854        let c = config();
3855        // A column that exists but is not a declared filter.
3856        assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
3857        // A declared boolean filter with a value its control never emits.
3858        for bad in ["yes", "true", "1 or 1=1", "", "  "] {
3859            assert!(
3860                resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
3861                "{bad} must not reach the query"
3862            );
3863        }
3864
3865        let with_select = ListConfig {
3866            filters: vec![ListFilter::select(
3867                "status",
3868                "Status",
3869                vec![FilterOption::new("live", "Live")],
3870            )],
3871            ..config()
3872        };
3873        assert_eq!(
3874            resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
3875            1
3876        );
3877        assert!(
3878            resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
3879            "an option the descriptor never offered is dropped"
3880        );
3881    }
3882
3883    #[tokio::test]
3884    async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
3885        let mut p = params(Some("username"));
3886        p.q = None;
3887        let html = get_with(
3888            p,
3889            filter_params(&[("f_is_superuser", "1")]),
3890            axum::http::HeaderMap::new(),
3891        )
3892        .await;
3893        let links = amps(&html);
3894        assert!(
3895            links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
3896            "a sort link carries the filter"
3897        );
3898        // The sorted column's own link flips direction and keeps it too.
3899        assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
3900        assert!(
3901            html.contains(r#"<option value="1" selected>"#),
3902            "the control shows the active value"
3903        );
3904        assert!(html.contains("Clear"), "and a way back to the full list");
3905        assert!(
3906            html.contains(r#"data-filtered="true""#),
3907            "the region says the list is narrowed, which is what reveals Clear"
3908        );
3909    }
3910
3911    /// A filtered list with no rows has not run out of records; it has no match.
3912    /// Saying "No records yet" there reads as an empty table and hides the filter.
3913    #[tokio::test]
3914    async fn the_empty_state_distinguishes_no_records_from_no_match() {
3915        let (db, _guard) = test_db().await;
3916        let state = AdminState::new(
3917            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3918            db,
3919        );
3920        let render = |raw: HashMap<String, String>, q: Option<&str>| {
3921            let q = q.map(String::from);
3922            let state = state.clone();
3923            async move {
3924                let p = ListParams {
3925                    page: None,
3926                    sort: None,
3927                    dir: None,
3928                    q,
3929                    parent: None,
3930                    depth: None,
3931                };
3932                let resp = handle(
3933                    &state,
3934                    &config(),
3935                    "/admin/users",
3936                    p,
3937                    &raw,
3938                    &crate::audit::test_actor(),
3939                    crate::Shell::test(),
3940                    &axum::http::HeaderMap::new(),
3941                )
3942                .await;
3943                let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3944                    .await
3945                    .unwrap();
3946                String::from_utf8(bytes.to_vec()).unwrap()
3947            }
3948        };
3949
3950        // Nothing stored and nothing asked for: the table is genuinely empty.
3951        let bare = render(HashMap::new(), None).await;
3952        assert!(bare.contains("No records yet."));
3953
3954        // Filtered to nothing: the records may exist, the filter excluded them.
3955        let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
3956        assert!(filtered.contains("No records match."));
3957        assert!(!filtered.contains("No records yet."));
3958
3959        // A search that matches nothing reads the same way.
3960        let searched = render(HashMap::new(), Some("zzz")).await;
3961        assert!(searched.contains("No records match."));
3962    }
3963
3964    /// A superuser, who sees every column, for the narrowing tests.
3965    fn root() -> laterite_auth::AuthenticatedUser {
3966        laterite_auth::AuthenticatedUser {
3967            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
3968            ..crate::audit::test_actor()
3969        }
3970    }
3971
3972    #[test]
3973    fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
3974        let mut c = config();
3975        c.columns[1].invisible = true;
3976        assert_eq!(visible_columns(&c, None, &root()).len(), 1);
3977        assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
3978        assert_eq!(
3979            visible_columns(&c, Some("is_superuser"), &root())[0].field,
3980            "is_superuser"
3981        );
3982    }
3983
3984    #[test]
3985    fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
3986        let mut c = config();
3987        c.columns[1].permission = Some("acme.secret".into());
3988        let plain = laterite_auth::AuthenticatedUser {
3989            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
3990            ..crate::audit::test_actor()
3991        };
3992        assert_eq!(visible_columns(&c, None, &plain).len(), 1);
3993        // Choosing it by name does not reveal it either.
3994        assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
3995        assert_eq!(visible_columns(&c, None, &root()).len(), 2);
3996    }
3997
3998    #[test]
3999    fn an_unsortable_column_is_not_sorted_by() {
4000        let mut c = config();
4001        c.columns[0].sortable = false;
4002        let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
4003        assert_eq!(by, c.order_by, "falls back to the descriptor's order");
4004    }
4005
4006    #[test]
4007    fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
4008        let c = config();
4009        // Stored out of order; the descriptor's order still wins, because the
4010        // choice is about which columns, not where they sit.
4011        let kept = visible_columns(&c, Some("is_superuser,username"), &root());
4012        assert_eq!(
4013            kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
4014            ["username", "is_superuser"]
4015        );
4016    }
4017
4018    #[test]
4019    fn no_stored_choice_shows_every_column() {
4020        let c = config();
4021        assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
4022    }
4023
4024    /// A descriptor that drops or renames a column must not leave an operator
4025    /// staring at an empty table because of a choice they made months ago.
4026    #[test]
4027    fn a_stale_choice_falls_back_to_every_column() {
4028        let c = config();
4029        assert_eq!(
4030            visible_columns(&c, Some("gone,removed"), &root()).len(),
4031            c.columns.len()
4032        );
4033        assert_eq!(
4034            visible_columns(&c, Some(""), &root()).len(),
4035            c.columns.len()
4036        );
4037        assert_eq!(
4038            visible_columns(&c, Some("  , "), &root()).len(),
4039            c.columns.len()
4040        );
4041        // A partly stale choice keeps what still exists.
4042        assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
4043    }
4044
4045    /// A button the operator cannot use is not shown, rather than shown and
4046    /// answering 403 when they reach it.
4047    #[test]
4048    fn the_toolbar_hides_what_the_operator_may_not_do() {
4049        let shell = crate::Shell::test();
4050        let cfg = ListConfig {
4051            creatable: false,
4052            toolbar: vec![
4053                ToolbarButton::new("Open", "/reports"),
4054                ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
4055            ],
4056            ..config()
4057        };
4058
4059        // A superuser sees everything, gated or not.
4060        let root = laterite_auth::AuthenticatedUser {
4061            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
4062            ..crate::audit::test_actor()
4063        };
4064        let views = toolbar_views(&cfg, "/admin", &root, &shell);
4065        assert_eq!(
4066            views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
4067            ["Open", "Audit"]
4068        );
4069
4070        // Someone holding the named permission sees it too.
4071        let granted = laterite_auth::AuthenticatedUser {
4072            permissions: laterite_auth::PermissionSet::with_overrides(
4073                false,
4074                ["backend.view_audit".to_string()],
4075                [],
4076                [],
4077            ),
4078            ..crate::audit::test_actor()
4079        };
4080        assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
4081
4082        // Someone holding nothing sees only the ungated button.
4083        let plain = laterite_auth::AuthenticatedUser {
4084            permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
4085            ..crate::audit::test_actor()
4086        };
4087        let views = toolbar_views(&cfg, "/admin", &plain, &shell);
4088        assert_eq!(
4089            views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
4090            ["Open"]
4091        );
4092    }
4093
4094    /// A relative href is resolved against the configured admin mount, so a moved
4095    /// panel moves its buttons with it.
4096    #[test]
4097    fn a_toolbar_href_follows_the_admin_mount() {
4098        let shell = crate::Shell::test();
4099        let cfg = ListConfig {
4100            creatable: true,
4101            // Already under the mount, as `prefix_resource` leaves it at boot.
4102            edit_base: Some("/backoffice/roles".to_string()),
4103            toolbar: vec![
4104                ToolbarButton::new("Reports", "/reports"),
4105                ToolbarButton::new("Docs", "https://example.test/docs"),
4106            ],
4107            ..config()
4108        };
4109        let root = laterite_auth::AuthenticatedUser {
4110            permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
4111            ..crate::audit::test_actor()
4112        };
4113        let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
4114        assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
4115        assert_eq!(views[1].href, "/backoffice/reports");
4116        // An absolute URL is left alone.
4117        assert_eq!(views[2].href, "https://example.test/docs");
4118    }
4119
4120    #[tokio::test]
4121    async fn an_htmx_sort_returns_the_list_region_alone() {
4122        let mut headers = axum::http::HeaderMap::new();
4123        headers.insert("hx-request", "true".parse().unwrap());
4124        let html = get(params(Some("username")), headers).await;
4125        assert!(html.contains(r#"id="lat-list""#), "the region comes back");
4126        assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
4127        // A fragment, not a page: swapping a document into the region would nest
4128        // the admin inside itself.
4129        assert!(!html.contains("<body"), "no page chrome");
4130        // The bar cannot re-render per swap, so the region carries the state.
4131        assert!(html.contains(r#"data-filtered="false""#));
4132    }
4133
4134    /// A sort or a page must not silently drop the search, or the second click
4135    /// would widen the result back to the whole table.
4136    #[tokio::test]
4137    async fn the_search_term_survives_a_sort_link() {
4138        let mut p = params(Some("username"));
4139        p.q = Some("bren".to_string());
4140        let html = get(p, axum::http::HeaderMap::new()).await;
4141        assert!(
4142            amps(&html).contains("?sort=username&dir=desc&q=bren"),
4143            "sort and pager links carry the term"
4144        );
4145        assert!(
4146            html.contains(r#"value="bren""#),
4147            "and the box still shows it"
4148        );
4149    }
4150
4151    #[tokio::test]
4152    async fn a_plain_sort_returns_the_whole_page() {
4153        let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
4154        assert!(html.contains("<body"), "lists still work without htmx");
4155        assert!(html.contains(r#"id="lat-list""#));
4156        // The links work without scripting, so they carry a real href, built
4157        // on the list's own path so they hold inside another page too.
4158        assert!(
4159            html.contains(r#"?sort=username"#) && html.contains(r#"href="/admin/"#),
4160            "{html}"
4161        );
4162    }
4163
4164    #[tokio::test]
4165    async fn query_rejects_bad_identifiers() {
4166        let (db, _guard) = test_db().await;
4167        let mut bad = config();
4168        bad.entity = "backend_users; drop table backend_users".to_string();
4169        assert!(query(&db, &bad, &plain(&bad)).await.is_err());
4170    }
4171}