Skip to main content

renox_core/grid/
mod.rs

1//! Data grids: a table that fills the screen, with server-side filters,
2//! sorting and pagination, columns each user picks per screen size, frozen
3//! columns, grouped headers and cells of any kind.
4//!
5//! A grid is defined once in Rust and rendered with the `grid` macro of
6//! `renox/grid.html`:
7//!
8//! ```
9//! use renox::prelude::*;
10//! use renox::grid::{Column, Grid, GridRequest};
11//!
12//! #[derive(Model, serde::Serialize, Default)]
13//! struct Order { id: i64, number: String, customer: String, status: String, total: i64 }
14//!
15//! fn orders() -> Grid {
16//!     Grid::new("orders")
17//!         .title("Orders")
18//!         .column(Column::text("number", "Order").frozen().mobile())
19//!         .column(Column::text("customer", "Customer").mobile())
20//!         .column(Column::select("status", "Status", [("new", "New"), ("paid", "Paid")]).mobile())
21//!         .column(Column::money("total", "Total").under(["Amounts"]))
22//!         .sort_by("-id")
23//! }
24//!
25//! async fn index(request: GridRequest) -> Result<View> {
26//!     let page = orders().page(Order::query(), &request).await?;
27//!     Ok(view("orders/index.html", context! { orders => page }))
28//! }
29//! ```
30//!
31//! ```jinja
32//! {% from "renox/grid.html" import grid %}
33//! <main class="rx-grid-fill">{{ grid(orders) }}</main>
34//! ```
35//!
36//! Filters, sorting and pages travel in the query string (`q.number=A%`,
37//! `min.total=1000`, `from.ordered_on=2026-01-01`, `in.status=paid`,
38//! `sort=-total`, `page=2`, `per_page=50`), so a filtered page has a link of
39//! its own. Only columns the grid defines are filtered or sorted, and the
40//! query builder checks the names again. Columns of kind `custom` are drawn
41//! by the template: `{% call(row, column) grid(orders) %}…{% endcall %}`.
42//!
43//! What a user picks in the column menu (visible columns on small and wide
44//! screens, order, frozen columns) is kept per user in `grid_preferences`,
45//! or in the session for guests.
46
47use std::collections::BTreeMap;
48
49use axum::Router;
50use axum::extract::{FromRequestParts, State};
51use axum::http::StatusCode;
52use axum::http::request::Parts;
53use axum::response::{IntoResponse, Response};
54use axum::routing::post;
55use chrono::{Duration, NaiveDate};
56use serde::{Deserialize, Serialize};
57use serde_json::{Map, Value, json};
58
59mod export;
60
61pub use export::{ExportFormat, MAX_EXPORT_ROWS, Workbook};
62
63use crate::auth::AuthUser;
64use crate::db::{Db, DbValue, Model, Paginated, Query, ToDbValue};
65use crate::{AppState, Error, Result, Session};
66
67/// The framework migration creating `grid_preferences` (every app gets it).
68pub(crate) const MIGRATION: crate::db::Migration =
69    crate::db::framework_migration!("grid", "00010101000220_create_grid_preferences_table");
70
71/// Rows per page a user may pick when the grid doesn't say.
72const PER_PAGE_OPTIONS: [u32; 4] = [10, 25, 50, 100];
73
74/// What a column holds, which decides how its cells look and how it filters.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum Kind {
79    /// Text; filtered with "contains", "starts with", "ends with", "equals",
80    /// or a pattern with `%`.
81    Text,
82    /// A number, right-aligned; filtered with a minimum and a maximum.
83    Number,
84    /// An amount in the smallest unit (cents, fils), with thousands
85    /// separators; filtered like a number.
86    Money,
87    /// A date (`NaiveDate`); filtered with a date range.
88    Date,
89    /// A moment (`DateTime`); filtered with a date range.
90    DateTime,
91    /// Yes or no; filtered with a choice.
92    Bool,
93    /// One of a few values; filtered by picking some of them.
94    Select,
95    /// Several of a few values, stored as a JSON array of strings
96    /// (`Json<Vec<String>>`); filtered by rows having any of the picked ones.
97    Tags,
98    /// Drawn by the page's template (`caller(row, column)`): charts, buttons,
99    /// links. Not sorted or filtered.
100    Custom,
101    /// An image whose URL is the value (an avatar, a product photo).
102    Image,
103    /// A CSS color (`#0a7d5a`) shown as a swatch with its code.
104    Color,
105}
106
107/// Which edge a frozen column sticks to while the grid scrolls sideways.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum Pin {
111    /// The left edge.
112    Left,
113    /// The right edge.
114    Right,
115}
116
117/// A figure a column shows in the footer and under each group
118/// ([`Column::summary`]).
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum Summary {
123    /// The total.
124    Sum,
125    /// The average.
126    Average,
127    /// The smallest and the largest value.
128    Range,
129    /// How many rows have a value.
130    Count,
131}
132
133/// One column of a [`Grid`]; build it with a constructor per [`Kind`].
134#[derive(Debug, Clone)]
135#[non_exhaustive]
136pub struct Column {
137    key: String,
138    label: String,
139    kind: Kind,
140    under: Vec<String>,
141    sortable: bool,
142    filterable: bool,
143    mobile: bool,
144    hidden: bool,
145    pin: Option<Pin>,
146    decimals: Option<u8>,
147    options: Vec<(String, String)>,
148    width: Option<String>,
149    editable: bool,
150    merge: bool,
151    searchable: bool,
152    summaries: Vec<Summary>,
153    badges: Option<Vec<(String, String)>>,
154    icons: bool,
155    description: Option<String>,
156    tooltip: Option<String>,
157    wrap: bool,
158    limit: Option<usize>,
159    link: Option<String>,
160    copyable: bool,
161    round: bool,
162    /// A value from another table: SQL with `{T}` for this model's table.
163    expr: Option<String>,
164}
165
166impl Column {
167    fn new(key: &str, label: &str, kind: Kind) -> Self {
168        let plain = kind != Kind::Custom;
169        Self {
170            key: key.to_owned(),
171            label: label.to_owned(),
172            kind,
173            under: Vec::new(),
174            sortable: plain && kind != Kind::Tags,
175            filterable: plain,
176            mobile: false,
177            hidden: false,
178            pin: None,
179            decimals: None,
180            options: Vec::new(),
181            width: None,
182            editable: false,
183            merge: false,
184            searchable: false,
185            summaries: Vec::new(),
186            badges: None,
187            icons: false,
188            description: None,
189            tooltip: None,
190            wrap: false,
191            limit: None,
192            link: None,
193            copyable: false,
194            round: false,
195            expr: None,
196        }
197    }
198
199    /// A value of the row this one belongs to: `table`'s `column` where its
200    /// `id` is this row's `foreign_key`
201    /// (`Column::related("customer", "Customer", "customers", "customer_id", "name")`).
202    /// Shown, sorted, filtered and searched like a text column (`.numeric()`
203    /// for a number), in one query per page.
204    pub fn related(key: &str, label: &str, table: &str, foreign_key: &str, column: &str) -> Self {
205        let mut c = Self::new(key, label, Kind::Text);
206        if [table, foreign_key, column].iter().all(|n| plain_name(n)) {
207            c.expr = Some(format!(
208                "(SELECT \"{table}\".\"{column}\" FROM \"{table}\" WHERE \"{table}\".\"id\" = {{T}}.\"{foreign_key}\")"
209            ));
210        }
211        c
212    }
213
214    /// How many rows of `table` point at this row through `foreign_key`
215    /// (`Column::count_of("notes", "Notes", "order_notes", "order_id")`).
216    pub fn count_of(key: &str, label: &str, table: &str, foreign_key: &str) -> Self {
217        let mut c = Self::new(key, label, Kind::Number);
218        if [table, foreign_key].iter().all(|n| plain_name(n)) {
219            c.expr = Some(format!(
220                "(SELECT COUNT(*) FROM \"{table}\" WHERE \"{table}\".\"{foreign_key}\" = {{T}}.\"id\")"
221            ));
222        }
223        c
224    }
225
226    /// The total of `column` over the rows of `table` that point at this
227    /// row through `foreign_key`.
228    pub fn sum_of(key: &str, label: &str, table: &str, foreign_key: &str, column: &str) -> Self {
229        let mut c = Self::new(key, label, Kind::Number);
230        if [table, foreign_key, column].iter().all(|n| plain_name(n)) {
231            c.expr = Some(format!(
232                "(SELECT COALESCE(SUM(\"{table}\".\"{column}\"), 0) FROM \"{table}\" WHERE \"{table}\".\"{foreign_key}\" = {{T}}.\"id\")"
233            ));
234        }
235        c
236    }
237
238    /// The column's value is `sql`, a scalar SQL expression over the row,
239    /// where `{T}` stands for the model's table: stock value as
240    /// `Column::money("value", "Value").computed("{T}.\"on_hand\" * (SELECT \"cost\" FROM \"variants\" WHERE \"id\" = {T}.\"variant_id\")")`.
241    /// It is shown, sorted, filtered, searched, summed ([`Column::summary`])
242    /// and grouped by ([`Grid::groups`]) like a related column, with no
243    /// database view. `sql` is written by the app, never taken from a
244    /// request: values go in the model's columns and subqueries, not in
245    /// the string.
246    pub fn computed(mut self, sql: &str) -> Self {
247        self.expr = Some(sql.to_owned());
248        self
249    }
250
251    /// Shows a related value as a number (filtered with a range).
252    pub fn numeric(mut self) -> Self {
253        if self.expr.is_some() {
254            self.kind = Kind::Number;
255        }
256        self
257    }
258
259    /// The SQL this column reads, for `M`'s table: its own column or the
260    /// related value.
261    fn target<M: Model>(&self) -> Target {
262        match &self.expr {
263            Some(expr) => Target::Expr(expr.replace("{T}", &format!("\"{}\"", M::TABLE))),
264            None => Target::Column(self.key.clone()),
265        }
266    }
267
268    /// An image column: the value is the image's URL (`.round()` for
269    /// avatars).
270    pub fn image(key: &str, label: &str) -> Self {
271        let mut column = Self::new(key, label, Kind::Image);
272        column.sortable = false;
273        column.filterable = false;
274        column
275    }
276
277    /// A color column: a swatch of the CSS color in the value, with its code.
278    pub fn color(key: &str, label: &str) -> Self {
279        Self::new(key, label, Kind::Color)
280    }
281
282    /// Values as colored badges: `(value, tone)` pairs, the tone one of
283    /// `success`, `warning`, `danger`, `info` or `neutral`. Values not
284    /// listed get a neutral badge; `.badges(&[])` makes every value one.
285    pub fn badges(mut self, tones: &[(&str, &str)]) -> Self {
286        self.badges = Some(
287            tones
288                .iter()
289                .map(|(v, t)| ((*v).to_owned(), (*t).to_owned()))
290                .collect(),
291        );
292        self
293    }
294
295    /// Yes/no columns as a check and a cross instead of words.
296    pub fn icons(mut self) -> Self {
297        self.icons = true;
298        self
299    }
300
301    /// Another of the row's values, small under this one (an email under a
302    /// name).
303    pub fn description(mut self, key: &str) -> Self {
304        self.description = Some(key.to_owned());
305        self
306    }
307
308    /// Another of the row's values, shown when the cell is hovered.
309    pub fn tooltip(mut self, key: &str) -> Self {
310        self.tooltip = Some(key.to_owned());
311        self
312    }
313
314    /// Long text wraps instead of widening the column.
315    pub fn wrap(mut self) -> Self {
316        self.wrap = true;
317        self
318    }
319
320    /// Text past `chars` characters is cut with `…` (the whole text shows on
321    /// hover).
322    pub fn limit(mut self, chars: usize) -> Self {
323        self.limit = Some(chars);
324        self
325    }
326
327    /// The value links to this URL (`{id}` is the row's id).
328    pub fn link(mut self, url: &str) -> Self {
329        self.link = Some(url.to_owned());
330        self
331    }
332
333    /// A button next to the value copies it.
334    pub fn copyable(mut self) -> Self {
335        self.copyable = true;
336        self
337    }
338
339    /// Images round (avatars).
340    pub fn round(mut self) -> Self {
341        self.round = true;
342        self
343    }
344
345    /// A text column; `key` is the model's column.
346    pub fn text(key: &str, label: &str) -> Self {
347        Self::new(key, label, Kind::Text)
348    }
349
350    /// A number column (`.decimals(n)` for a fixed number of decimals).
351    pub fn number(key: &str, label: &str) -> Self {
352        Self::new(key, label, Kind::Number)
353    }
354
355    /// An amount in the smallest unit (cents, fils), shown in whole units
356    /// of `APP_CURRENCY` with its usual decimals (`129900` cents is
357    /// `1,299.00` in USD, `400,000` in IDR), as the `money` template filter
358    /// takes it. Summaries and exports show whole units too,
359    /// and range filters take them. An inline edit still sends the stored
360    /// value (the smallest unit).
361    pub fn money(key: &str, label: &str) -> Self {
362        Self::new(key, label, Kind::Money)
363    }
364
365    /// A date column.
366    pub fn date(key: &str, label: &str) -> Self {
367        Self::new(key, label, Kind::Date)
368    }
369
370    /// A date and time column (shown in `APP_TIMEZONE`).
371    pub fn datetime(key: &str, label: &str) -> Self {
372        Self::new(key, label, Kind::DateTime)
373    }
374
375    /// A yes/no column.
376    pub fn bool(key: &str, label: &str) -> Self {
377        Self::new(key, label, Kind::Bool)
378    }
379
380    /// One of `options` (stored value, label shown).
381    pub fn select<V: Into<String>, L: Into<String>>(
382        key: &str,
383        label: &str,
384        options: impl IntoIterator<Item = (V, L)>,
385    ) -> Self {
386        let mut column = Self::new(key, label, Kind::Select);
387        column.options = options
388            .into_iter()
389            .map(|(v, l)| (v.into(), l.into()))
390            .collect();
391        column
392    }
393
394    /// Several of `options`, stored as a JSON array of the values.
395    pub fn tags<V: Into<String>, L: Into<String>>(
396        key: &str,
397        label: &str,
398        options: impl IntoIterator<Item = (V, L)>,
399    ) -> Self {
400        let mut column = Self::select(key, label, options);
401        column.kind = Kind::Tags;
402        column
403    }
404
405    /// A column the page's template draws (`{% call(row, column) grid(…) %}`).
406    /// `key` needn't be a database column.
407    pub fn custom(key: &str, label: &str) -> Self {
408        Self::new(key, label, Kind::Custom)
409    }
410
411    /// Puts the column under grouped headings, outermost first:
412    /// `.under(["Amounts"])`, `.under(["Sales", "Q1"])`. Neighbouring
413    /// columns under the same headings share them.
414    pub fn under<S: Into<String>>(mut self, groups: impl IntoIterator<Item = S>) -> Self {
415        self.under = groups.into_iter().map(Into::into).collect();
416        self
417    }
418
419    /// Whether a click on the heading sorts by this column (default: yes,
420    /// except for tags and custom columns).
421    pub fn sortable(mut self, sortable: bool) -> Self {
422        self.sortable = sortable && self.kind != Kind::Custom;
423        self
424    }
425
426    /// Whether the heading has a filter (default: yes, except for custom
427    /// columns).
428    pub fn filterable(mut self, filterable: bool) -> Self {
429        self.filterable = filterable && self.kind != Kind::Custom;
430        self
431    }
432
433    /// Shown on small screens until the user picks otherwise. When no column
434    /// says so, the first three are.
435    pub fn mobile(mut self) -> Self {
436        self.mobile = true;
437        self
438    }
439
440    /// Hidden on wide screens too until the user turns it on.
441    pub fn hidden(mut self) -> Self {
442        self.hidden = true;
443        self
444    }
445
446    /// Frozen at the left edge until the user picks otherwise.
447    pub fn frozen(mut self) -> Self {
448        self.pin = Some(Pin::Left);
449        self
450    }
451
452    /// Frozen at the right edge (e.g. a column of actions).
453    pub fn frozen_right(mut self) -> Self {
454        self.pin = Some(Pin::Right);
455        self
456    }
457
458    /// Shows a number with exactly `decimals` decimals.
459    pub fn decimals(mut self, decimals: u8) -> Self {
460        self.decimals = Some(decimals);
461        self
462    }
463
464    /// A minimum width in CSS units, e.g. `"14rem"`.
465    pub fn width(mut self, width: &str) -> Self {
466        self.width = Some(width.to_owned());
467        self
468    }
469
470    /// Cells can be edited in place (double-click, Enter or F2; Enter saves,
471    /// Escape cancels) and in the row's edit mode. Saving sends the value
472    /// to the grid's [`Grid::edit_url`]. Not for custom columns, nor for
473    /// merged ones.
474    pub fn editable(mut self) -> Self {
475        self.editable = self.kind != Kind::Custom;
476        self
477    }
478
479    /// Neighbouring rows with the same value share one cell. Merged columns
480    /// nest from left to right: a column merges only within the merged
481    /// groups of the merged columns before it (Region, then City). Merging
482    /// is off while rows are dragged into order.
483    pub fn merge(mut self) -> Self {
484        self.merge = true;
485        self.editable = false;
486        self
487    }
488
489    /// The toolbar's search box looks in this column (as text, ignoring
490    /// case). The box shows when a column is searchable. Not for custom
491    /// columns.
492    ///
493    /// When the grid's model has a full-text index (`#[model(search = …)]`,
494    /// see [`renox::db::search`](crate::db::search)), the box searches
495    /// through it instead: every column of the index counts (also those the
496    /// grid doesn't show), words match their other forms and prefixes, and
497    /// rows come best match first until the user sorts. Searchable columns
498    /// outside the index are still matched as text.
499    pub fn searchable(mut self) -> Self {
500        self.searchable = self.kind != Kind::Custom;
501        self
502    }
503
504    /// A figure over every row the filters match, in the grid's footer and
505    /// under each group: `.summary(Summary::Sum)`, several in a row
506    /// (`.summary(Summary::Sum).summary(Summary::Average)`). Sum, average
507    /// and range are for number and money columns; count for any.
508    pub fn summary(mut self, summary: Summary) -> Self {
509        let numeric = matches!(self.kind, Kind::Number | Kind::Money);
510        if (numeric || summary == Summary::Count) && !self.summaries.contains(&summary) {
511            self.summaries.push(summary);
512        }
513        self
514    }
515
516    /// The column's key.
517    pub fn key(&self) -> &str {
518        &self.key
519    }
520
521    /// The column's kind.
522    pub fn kind(&self) -> Kind {
523        self.kind
524    }
525
526    /// The column's heading.
527    pub fn label(&self) -> &str {
528        &self.label
529    }
530
531    /// The same column under another heading, e.g. a translation.
532    pub fn titled(mut self, label: &str) -> Self {
533        label.clone_into(&mut self.label);
534        self
535    }
536
537    /// The `(value, label)` choices of a `select` or `tags` column (empty
538    /// for the other kinds).
539    pub fn options(&self) -> &[(String, String)] {
540        &self.options
541    }
542}
543
544/// A data grid's definition: its columns and defaults. Cheap to build, so
545/// a function returning it is the usual place for one.
546#[derive(Debug, Clone)]
547#[non_exhaustive]
548pub struct Grid {
549    id: String,
550    title: Option<String>,
551    columns: Vec<Column>,
552    per_page: u32,
553    per_page_options: Vec<u32>,
554    sort: Option<String>,
555    audit: bool,
556    details: bool,
557    edit_url: Option<String>,
558    reorder: Option<(String, String)>,
559    exports: bool,
560    prefix: Option<String>,
561    row_url: Option<String>,
562    empty: Option<(String, Option<String>)>,
563    bulk: Vec<Action>,
564    row_actions: Vec<Action>,
565    groups: Vec<String>,
566    group: Option<String>,
567    cards: bool,
568    advanced: bool,
569    poll: Option<u32>,
570    remember: bool,
571}
572
573impl Grid {
574    /// A grid named `id` (letters, digits, `_` and `-`), which keys each
575    /// user's preferences and the element's id.
576    pub fn new(id: &str) -> Self {
577        Self {
578            id: id.to_owned(),
579            title: None,
580            columns: Vec::new(),
581            per_page: 25,
582            per_page_options: PER_PAGE_OPTIONS.to_vec(),
583            sort: None,
584            audit: false,
585            details: false,
586            edit_url: None,
587            reorder: None,
588            exports: false,
589            prefix: None,
590            row_url: None,
591            empty: None,
592            bulk: Vec::new(),
593            row_actions: Vec::new(),
594            groups: Vec::new(),
595            group: None,
596            cards: false,
597            advanced: false,
598            poll: None,
599            remember: false,
600        }
601    }
602
603    /// Reloads the grid's page every `seconds` (at least 5) while the tab is
604    /// visible and nobody is editing, selecting or filtering in it.
605    pub fn poll(mut self, seconds: u32) -> Self {
606        self.poll = Some(seconds.max(5));
607        self
608    }
609
610    /// Keeps the user's filters, search, sort, group and page size in the
611    /// session: coming back to the page shows the grid as they left it.
612    pub fn remember(mut self) -> Self {
613        self.remember = true;
614        self
615    }
616
617    /// The request's values for this grid (without the prefix); with
618    /// [`Grid::remember`], the session's when the request has none of its
619    /// own (a fresh visit), and saved when it does.
620    fn effective_params(&self, request: &GridRequest) -> Vec<(String, String)> {
621        let own = self.own_params(&request.params);
622        if !self.remember {
623            return own;
624        }
625        let key = format!("_grid_q.{}", self.id);
626        let Some(session) = &request.session else {
627            return own;
628        };
629        if own.iter().any(|(k, _)| k == "state") {
630            let kept: Vec<(String, String)> = own
631                .iter()
632                .filter(|(k, _)| !matches!(k.as_str(), "state" | "export" | "page"))
633                .cloned()
634                .collect();
635            let _ = session.put(&key, &kept);
636            own
637        } else {
638            session.get::<Vec<(String, String)>>(&key).unwrap_or(own)
639        }
640    }
641
642    /// An advanced filter in the toolbar: rules on any filterable column
643    /// ("Total is greater than 1,000,000", "Name doesn't contain coffee",
644    /// "Ordered before 2026-03-01", "Email is empty"), all or any of them
645    /// holding. They travel in the query string (`match=any&r.0.c=total&
646    /// r.0.o=gt&r.0.v=1000000`), next to the headings' filters.
647    pub fn advanced_filter(mut self) -> Self {
648        self.advanced = true;
649        self
650    }
651
652    /// On phones (under 768 px) each row is a card: the columns picked for
653    /// small screens stacked as label and value, with sorting and filters
654    /// in the toolbar. Wider screens keep the table.
655    pub fn cards_on_mobile(mut self) -> Self {
656        self.cards = true;
657        self
658    }
659
660    /// Columns the user may group rows by (a "Group" choice in the
661    /// toolbar): each group starts with a heading (its value and how many
662    /// rows it has, folding its rows away on a click) and ends with its own
663    /// summaries. Rows are sorted by the group first.
664    pub fn groups(mut self, keys: &[&str]) -> Self {
665        self.groups = keys.iter().map(|k| (*k).to_owned()).collect();
666        self
667    }
668
669    /// Groups rows by `key` until the user picks otherwise (it joins
670    /// [`Grid::groups`]).
671    pub fn group_by(mut self, key: &str) -> Self {
672        if !self.groups.iter().any(|g| g == key) {
673            self.groups.insert(0, key.to_owned());
674        }
675        self.group = Some(key.to_owned());
676        self
677    }
678
679    /// An action on the selected rows: a checkbox starts each row, and
680    /// selecting some shows the actions over the grid. The action is sent
681    /// to its URL (with the grid's query string, for "all matching") as
682    /// [`Selection`]; the grid reloads after a 2xx.
683    pub fn bulk_action(mut self, action: Action) -> Self {
684        self.bulk.push(action);
685        self
686    }
687
688    /// An action in each row's menu (`⋯`); `{id}` in its URL is the row's id.
689    pub fn row_action(mut self, action: Action) -> Self {
690        self.row_actions.push(action);
691        self
692    }
693
694    /// `query` narrowed to what a bulk action was sent for: the selected
695    /// ids, or every row the grid's filters match when the user chose
696    /// "all matching".
697    ///
698    /// ```
699    /// # use renox::prelude::*;
700    /// use renox::grid::{Action, Column, Grid, GridRequest, Selection};
701    /// # #[derive(Model, serde::Serialize, Default)] struct Order { id: i64, status: String }
702    /// fn orders() -> Grid {
703    ///     Grid::new("orders")
704    ///         .column(Column::text("status", "Status"))
705    ///         .bulk_action(Action::new("Mark paid", "/orders/paid"))
706    /// }
707    ///
708    /// async fn mark_paid(State(db): State<Db>, request: GridRequest, Form(selection): Form<Selection>) -> Result<String> {
709    ///     let changed = orders()
710    ///         .selected(Order::query(), &request, &selection)?
711    ///         .update(&db, &[("status", &"paid")])
712    ///         .await?;
713    ///     Ok(format!("{changed} orders paid"))
714    /// }
715    /// ```
716    pub fn selected<M: Model>(
717        &self,
718        query: Query<M>,
719        request: &GridRequest,
720        selection: &Selection,
721    ) -> Result<Query<M>> {
722        if selection.all {
723            return Ok(self.filter(query, request));
724        }
725        let mut keys = Vec::with_capacity(selection.ids.len());
726        for id in selection.ids.iter().take(10_000) {
727            let key: M::Key = id
728                .parse()
729                .map_err(|_| Error::BadRequest(format!("`{id}` is not an id")))?;
730            keys.push(key);
731        }
732        Ok(query.where_in("id", keys))
733    }
734
735    /// Prefixes the grid's query string names (`orders.page=2`,
736    /// `orders.q.number=…`), for pages with more than one grid. Without
737    /// it the names are plain (`page=2`). Either way a grid keeps the query
738    /// string values that aren't its own.
739    pub fn prefix(mut self, prefix: &str) -> Self {
740        self.prefix = Some(prefix.to_owned());
741        self
742    }
743
744    /// A click on a row (outside its buttons and links) opens this URL,
745    /// `{id}` replaced by the row's id; Ctrl/Cmd-click opens it in a new tab.
746    /// With row details ([`Grid::audit`], [`Grid::details`]) a click opens
747    /// those instead and the row tools get a link.
748    pub fn row_url(mut self, url: &str) -> Self {
749        self.row_url = Some(url.to_owned());
750        self
751    }
752
753    /// What an empty grid (before any filter) says; the page's call block
754    /// can add buttons for `column.key == "_empty"`.
755    pub fn empty_state(mut self, heading: &str, description: Option<&str>) -> Self {
756        self.empty = Some((heading.to_owned(), description.map(str::to_owned)));
757        self
758    }
759
760    /// Whether a query string name belongs to this grid, and its name
761    /// without the prefix.
762    fn own<'a>(&self, name: &'a str) -> Option<&'a str> {
763        let name = match &self.prefix {
764            Some(prefix) => name.strip_prefix(prefix.as_str())?.strip_prefix('.')?,
765            None => name,
766        };
767        let known = matches!(
768            name,
769            "page" | "per_page" | "sort" | "search" | "export" | "group" | "match" | "state"
770        ) || name.split_once('.').is_some_and(|(part, _)| {
771            matches!(part, "q" | "m" | "min" | "max" | "from" | "to" | "in" | "r")
772        });
773        known.then_some(name)
774    }
775
776    /// The request's values for this grid, without the prefix.
777    fn own_params(&self, params: &[(String, String)]) -> Vec<(String, String)> {
778        params
779            .iter()
780            .filter_map(|(k, v)| self.own(k).map(|k| (k.to_owned(), v.clone())))
781            .collect()
782    }
783
784    /// A name of this grid's, with the prefix.
785    fn name(&self, name: &str) -> String {
786        match &self.prefix {
787            Some(prefix) => format!("{prefix}.{name}"),
788            None => name.to_owned(),
789        }
790    }
791
792    /// An export menu in the toolbar: CSV, Excel (with the `xlsx` feature)
793    /// and a page to print or save as PDF, each of every row the filters
794    /// match (not just the page), in the user's columns. The handler answers
795    /// them with [`Grid::export`].
796    pub fn exports(mut self) -> Self {
797        self.exports = true;
798        self
799    }
800
801    /// A click on a row (or its chevron) opens details under it: who
802    /// created and last changed the row, and when, from its `created_by`,
803    /// `created_at`, `updated_by` and `updated_at` values (those it has).
804    pub fn audit(mut self) -> Self {
805        self.audit = true;
806        self
807    }
808
809    /// A click on a row opens details under it, drawn by the page's
810    /// template: `caller(row, column)` with `column.key == "_details"`.
811    /// With [`Grid::audit`], the audit fields come first.
812    pub fn details(mut self) -> Self {
813        self.details = true;
814        self
815    }
816
817    /// Where edits are sent: `PATCH` to this URL with `{id}` replaced by the
818    /// row's id, the changed fields as a form (`customer=…`, `tags=a&tags=b`,
819    /// `paid=true`). Answer with a 2xx (a `Toast` shows), or with
820    /// `Valid<T>`'s 422 and the errors show next to the fields; the grid
821    /// then reloads its page.
822    pub fn edit_url(mut self, url: &str) -> Self {
823        self.edit_url = Some(url.to_owned());
824        self
825    }
826
827    /// Rows can be dragged into order (or moved with the arrow keys on
828    /// their handle) while the grid is sorted by `column` ascending; the
829    /// new order of the page is `POST`ed to `url` as `ids` and `offset`
830    /// (see [`RowOrder`]).
831    pub fn reorder(mut self, column: &str, url: &str) -> Self {
832        self.reorder = Some((column.to_owned(), url.to_owned()));
833        self
834    }
835
836    /// The heading shown above the grid.
837    pub fn title(mut self, title: &str) -> Self {
838        self.title = Some(title.to_owned());
839        self
840    }
841
842    /// Adds a column.
843    pub fn column(mut self, column: Column) -> Self {
844        self.columns.push(column);
845        self
846    }
847
848    /// Rows per page until the user picks another number (default 25).
849    pub fn per_page(mut self, per_page: u32) -> Self {
850        self.per_page = per_page.clamp(1, 500);
851        if !self.per_page_options.contains(&self.per_page) {
852            self.per_page_options.push(self.per_page);
853            self.per_page_options.sort_unstable();
854        }
855        self
856    }
857
858    /// The order before the user sorts: a column key, `-` first for
859    /// descending (`"-created_at"`), or several separated by commas
860    /// (`"region,city,-total"`, what merged columns want). Ties are broken
861    /// by `id`.
862    pub fn sort_by(mut self, sort: &str) -> Self {
863        self.sort = Some(sort.to_owned());
864        self
865    }
866
867    /// The grid's id.
868    pub fn id(&self) -> &str {
869        &self.id
870    }
871
872    /// The grid's columns, in their defined order.
873    pub fn columns(&self) -> &[Column] {
874        &self.columns
875    }
876
877    fn find(&self, key: &str) -> Option<&Column> {
878        self.columns.iter().find(|c| c.key == key)
879    }
880
881    /// One page of `query` with the request's filters, sort and page
882    /// applied, ready for the `grid` macro.
883    pub async fn page<M: Model + Serialize>(
884        &self,
885        query: Query<M>,
886        request: &GridRequest,
887    ) -> Result<GridPage<M>> {
888        if !valid_key(&self.id) {
889            return Err(Error::Internal(anyhow::anyhow!(
890                "grid id `{}` may only have letters, digits, `_` and `-`",
891                self.id
892            )));
893        }
894        let prefs = load_prefs(request, &self.id).await;
895        let state = State_::parse(self, &self.effective_params(request));
896        let query = self.filtered(query, &state, request);
897        let unsorted = query.clone();
898        let query = self.sorted(query, &state);
899        let rows = query
900            .paginate(&request.db, state.page, state.per_page)
901            .await?;
902        let related = self.related_values(&request.db, &rows.items).await?;
903        let summaries = self.summarize(&unsorted, &request.db, None).await?;
904        let group_summaries = match &state.group {
905            Some(group) if self.find(group).is_some() => {
906                self.summarize(&unsorted, &request.db, Some(group)).await?
907            }
908            _ => BTreeMap::new(),
909        };
910        let keep = request
911            .params
912            .iter()
913            .filter(|(k, _)| self.own(k).is_none())
914            .cloned()
915            .collect();
916        Ok(GridPage {
917            grid: self.clone(),
918            prefs,
919            state,
920            path: request.path.clone(),
921            keep,
922            rows,
923            extra: Vec::new(),
924            related,
925            summaries,
926            group_summaries,
927            money_decimals: request.money_decimals,
928        })
929    }
930
931    /// The summaries of every row `query` matches: per group value (as
932    /// text) when `group` is set, else under the key `""`. Each value maps
933    /// column keys to `{sum, average, min, max, count}`; groups also get
934    /// `_rows`.
935    async fn summarize<M: Model>(
936        &self,
937        query: &Query<M>,
938        db: &Db,
939        group: Option<&String>,
940    ) -> Result<BTreeMap<String, Map<String, Value>>> {
941        let mut out: BTreeMap<String, Map<String, Value>> = BTreeMap::new();
942        // The SQL a column reads: its expression or the model's column.
943        let sql_of = |key: &str| -> Option<String> {
944            match self.find(key)?.target::<M>() {
945                Target::Expr(expr) => Some(expr),
946                Target::Column(key) if M::COLUMNS.contains(&key.as_str()) => {
947                    Some(format!("\"{key}\""))
948                }
949                Target::Column(_) => None,
950            }
951        };
952        let group_expr = match group {
953            Some(g) => match sql_of(g) {
954                Some(expr) => Some(expr),
955                None => return Ok(out),
956            },
957            None => None,
958        };
959        let group_sql = group_expr.as_ref().map(|e| format!("CAST({e} AS TEXT)"));
960        if let (Some(group_sql), Some(expr)) = (&group_sql, &group_expr) {
961            let counts: Vec<(Option<String>, i64)> = query
962                .clone()
963                .group_by_raw(expr)
964                .select_as(db, &format!("{group_sql}, COUNT(*)"))
965                .await?;
966            for (value, count) in counts {
967                out.entry(value.unwrap_or_default())
968                    .or_default()
969                    .insert("_rows".into(), count.into());
970            }
971        }
972        for column in self.columns.iter().filter(|c| !c.summaries.is_empty()) {
973            let Some(sql) = sql_of(&column.key) else {
974                continue;
975            };
976            let key = &column.key;
977            let numeric = matches!(column.kind, Kind::Number | Kind::Money);
978            let figures = if numeric {
979                format!(
980                    "CAST(SUM({sql}) AS DOUBLE PRECISION), CAST(AVG({sql}) AS DOUBLE PRECISION), \
981                     CAST(MIN({sql}) AS DOUBLE PRECISION), CAST(MAX({sql}) AS DOUBLE PRECISION), COUNT({sql})"
982                )
983            } else {
984                format!("NULL, NULL, NULL, NULL, COUNT({sql})")
985            };
986            type Figures = (Option<f64>, Option<f64>, Option<f64>, Option<f64>, i64);
987            let rows: Vec<(String, Figures)> = match (&group_sql, &group_expr) {
988                (Some(group_sql), Some(group)) => query
989                    .clone()
990                    .group_by_raw(group)
991                    .select_as::<(
992                        Option<String>,
993                        Option<f64>,
994                        Option<f64>,
995                        Option<f64>,
996                        Option<f64>,
997                        i64,
998                    ), _>(db, &format!("{group_sql}, {figures}"))
999                    .await?
1000                    .into_iter()
1001                    .map(|(g, a, b, c, d, e)| (g.unwrap_or_default(), (a, b, c, d, e)))
1002                    .collect(),
1003                _ => query
1004                    .clone()
1005                    .select_as::<Figures, _>(db, &figures)
1006                    .await?
1007                    .into_iter()
1008                    .map(|f| (String::new(), f))
1009                    .collect(),
1010            };
1011            for (value, (sum, average, min, max, count)) in rows {
1012                out.entry(value).or_default().insert(
1013                    key.clone(),
1014                    json!({ "sum": sum, "average": average, "min": min, "max": max, "count": count }),
1015                );
1016            }
1017        }
1018        Ok(out)
1019    }
1020
1021    /// The related columns' values for `items`, one query per column.
1022    async fn related_values<M: Model + Serialize>(
1023        &self,
1024        db: &Db,
1025        items: &[M],
1026    ) -> Result<Vec<Map<String, Value>>> {
1027        let mut out = vec![Map::new(); items.len()];
1028        let columns: Vec<&Column> = self.columns.iter().filter(|c| c.expr.is_some()).collect();
1029        if columns.is_empty() || items.is_empty() {
1030            return Ok(out);
1031        }
1032        let ids: Vec<String> = items
1033            .iter()
1034            .map(|item| {
1035                match serde_json::to_value(item)
1036                    .ok()
1037                    .and_then(|v| v.get("id").cloned())
1038                {
1039                    Some(Value::String(id)) => id,
1040                    Some(other) => other.to_string(),
1041                    None => String::new(),
1042                }
1043            })
1044            .collect();
1045        let keys: Vec<M::Key> = ids.iter().filter_map(|id| id.parse().ok()).collect();
1046        // Unscoped: the ids come from the rows the caller's own query returned,
1047        // so a default scope (none outside a tenant context) mustn't drop them.
1048        for column in columns {
1049            let Target::Expr(expr) = column.target::<M>() else {
1050                continue;
1051            };
1052            let numeric = matches!(column.kind, Kind::Number | Kind::Money);
1053            let values: BTreeMap<String, Value> = if numeric {
1054                M::unscoped()
1055                    .where_in("id", keys.clone())
1056                    .select_as::<(String, Option<f64>), _>(
1057                        db,
1058                        &format!("CAST(\"id\" AS TEXT), CAST({expr} AS DOUBLE PRECISION)"),
1059                    )
1060                    .await?
1061                    .into_iter()
1062                    .map(|(id, v)| {
1063                        let v = match v {
1064                            Some(f) if f.fract() == 0.0 && f.abs() < 9e15 => Value::from(f as i64),
1065                            Some(f) => Value::from(f),
1066                            None => Value::Null,
1067                        };
1068                        (id, v)
1069                    })
1070                    .collect()
1071            } else {
1072                M::unscoped()
1073                    .where_in("id", keys.clone())
1074                    .select_as::<(String, Option<String>), _>(
1075                        db,
1076                        &format!("CAST(\"id\" AS TEXT), CAST({expr} AS TEXT)"),
1077                    )
1078                    .await?
1079                    .into_iter()
1080                    .map(|(id, v)| (id, v.map_or(Value::Null, Value::from)))
1081                    .collect()
1082            };
1083            for (i, id) in ids.iter().enumerate() {
1084                out[i].insert(
1085                    column.key.clone(),
1086                    values.get(id).cloned().unwrap_or(Value::Null),
1087                );
1088            }
1089        }
1090        Ok(out)
1091    }
1092
1093    /// `query` with the request's filters applied (also used by `page`):
1094    /// for exports or totals over every filtered row.
1095    pub fn filter<M: Model>(&self, query: Query<M>, request: &GridRequest) -> Query<M> {
1096        let state = State_::parse(self, &self.effective_params(request));
1097        let query = self.filtered(query, &state, request);
1098        self.sorted(query, &state)
1099    }
1100
1101    fn filtered<M: Model>(
1102        &self,
1103        mut query: Query<M>,
1104        state: &State_,
1105        request: &GridRequest,
1106    ) -> Query<M> {
1107        let zone = &request.zone;
1108        let money = 10f64.powi(request.money_decimals as i32);
1109        for (key, filter) in &state.filters {
1110            let Some(column) = self.find(key) else {
1111                continue;
1112            };
1113            query = filter.apply(query, column, zone, money);
1114        }
1115        if !state.rules.is_empty() {
1116            let mut parts = Vec::new();
1117            let mut binds: Vec<DbValue> = Vec::new();
1118            for rule in &state.rules {
1119                let Some(column) = self.find(&rule.column) else {
1120                    continue;
1121                };
1122                let target = match column.target::<M>() {
1123                    Target::Expr(expr) => expr,
1124                    Target::Column(key) if M::COLUMNS.contains(&key.as_str()) => {
1125                        format!("\"{key}\"")
1126                    }
1127                    Target::Column(_) => continue,
1128                };
1129                if let Some((sql, values)) = rule.sql(&target, column.kind, zone, money) {
1130                    parts.push(format!("({sql})"));
1131                    binds.extend(values);
1132                }
1133            }
1134            if !parts.is_empty() {
1135                let joined = parts.join(if state.any { " OR " } else { " AND " });
1136                query = query.where_raw(&joined, binds);
1137            }
1138        }
1139        if let Some(search) = &state.search {
1140            // A model with a full-text index (`#[model(search = …)]`) is
1141            // searched through it; the searchable columns it doesn't cover
1142            // (related values, other columns) with LIKE.
1143            let indexed = !M::SEARCHABLE.is_empty();
1144            let columns: Vec<String> = self
1145                .columns
1146                .iter()
1147                .filter(|c| c.searchable)
1148                .filter_map(|c| match c.target::<M>() {
1149                    Target::Expr(expr) => Some(expr),
1150                    Target::Column(key) if indexed && M::SEARCHABLE.contains(&key.as_str()) => None,
1151                    Target::Column(key) if M::COLUMNS.contains(&key.as_str()) => {
1152                        Some(format!("\"{key}\""))
1153                    }
1154                    Target::Column(_) => None,
1155                })
1156                .collect();
1157            if indexed && columns.is_empty() {
1158                query = query.where_search(search);
1159            } else if indexed || !columns.is_empty() {
1160                // Every word somewhere: in the index or a searchable column.
1161                for word in search.split_whitespace().take(8) {
1162                    let pattern = format!("%{}%", word.to_lowercase());
1163                    let sql = columns
1164                        .iter()
1165                        .map(|target| format!("LOWER(CAST({target} AS TEXT)) LIKE ?"))
1166                        .collect::<Vec<_>>()
1167                        .join(" OR ");
1168                    query = if indexed {
1169                        query.where_any(|q| {
1170                            q.where_search(word)
1171                                .where_raw(&sql, vec![pattern.clone(); columns.len()])
1172                        })
1173                    } else {
1174                        query.where_raw(&sql, vec![pattern; columns.len()])
1175                    };
1176                }
1177            }
1178        }
1179        query
1180    }
1181
1182    fn sorted<M: Model>(&self, mut query: Query<M>, state: &State_) -> Query<M> {
1183        let mut keys: Vec<(String, bool)> = if state.defaulted {
1184            self.default_sort()
1185        } else {
1186            state.sort.iter().cloned().collect()
1187        };
1188        // Grouped rows come group by group.
1189        if let Some(group) = &state.group
1190            && keys.first().is_none_or(|(k, _)| k != group)
1191        {
1192            let desc = keys
1193                .iter()
1194                .find(|(k, _)| k == group)
1195                .is_some_and(|(_, d)| *d);
1196            keys.retain(|(k, _)| k != group);
1197            keys.insert(0, (group.clone(), desc));
1198        }
1199        // Searching a model with a full-text index, without a sort of the
1200        // user's: best matches first (inside the groups, when grouped).
1201        let mut relevance = match &state.search {
1202            Some(search) if state.defaulted && !M::SEARCHABLE.is_empty() => Some(search),
1203            _ => None,
1204        };
1205        if state.group.is_none()
1206            && let Some(search) = relevance.take()
1207        {
1208            query = query.order_by_relevance(search);
1209        }
1210        for (key, desc) in &keys {
1211            let dir = if *desc { "DESC" } else { "ASC" };
1212            // Empty values last either way, on both databases (PostgreSQL
1213            // puts NULLs first when descending, SQLite last).
1214            query = match self.find(key).map(Column::target::<M>) {
1215                Some(Target::Expr(expr)) => {
1216                    query.order_by_raw(&format!("({expr} IS NULL), {expr} {dir}"))
1217                }
1218                _ if M::COLUMNS.contains(&key.as_str()) => {
1219                    query.order_by_raw(&format!("(\"{key}\" IS NULL), \"{key}\" {dir}"))
1220                }
1221                _ if *desc => query.order_by_desc(key),
1222                _ => query.order_by(key),
1223            };
1224            // After the group's key.
1225            if let Some(search) = relevance.take() {
1226                query = query.order_by_relevance(search);
1227            }
1228        }
1229        if keys.iter().any(|(key, _)| key == "id") {
1230            query
1231        } else {
1232            query.order_by("id")
1233        }
1234    }
1235
1236    /// `sort_by`'s keys: `"region,city,-total"`.
1237    fn default_sort(&self) -> Vec<(String, bool)> {
1238        self.sort
1239            .as_deref()
1240            .unwrap_or("")
1241            .split(',')
1242            .map(str::trim)
1243            .filter(|k| !k.is_empty())
1244            .map(|k| match k.strip_prefix('-') {
1245                Some(key) => (key.to_owned(), true),
1246                None => (k.to_owned(), false),
1247            })
1248            .collect()
1249    }
1250
1251    /// The columns in the user's order: frozen-left ones first, then the
1252    /// others, then frozen-right ones, each with where it's pinned.
1253    fn ordered(&self, prefs: &GridPrefs) -> Vec<(&Column, Option<Pin>)> {
1254        let mut columns: Vec<&Column> = self.columns.iter().collect();
1255        if let Some(order) = &prefs.order {
1256            columns.sort_by_key(|c| order.iter().position(|k| *k == c.key).unwrap_or(usize::MAX));
1257        }
1258        let pin = |c: &Column| match (&prefs.left, &prefs.right) {
1259            (None, None) => c.pin,
1260            (left, right) => {
1261                if left.as_ref().is_some_and(|l| l.contains(&c.key)) {
1262                    Some(Pin::Left)
1263                } else if right.as_ref().is_some_and(|r| r.contains(&c.key)) {
1264                    Some(Pin::Right)
1265                } else {
1266                    None
1267                }
1268            }
1269        };
1270        let mut out: Vec<(&Column, Option<Pin>)> = Vec::new();
1271        for side in [Some(Pin::Left), None, Some(Pin::Right)] {
1272            out.extend(
1273                columns
1274                    .iter()
1275                    .filter(|c| pin(c) == side)
1276                    .map(|c| (*c, side)),
1277            );
1278        }
1279        out
1280    }
1281
1282    fn default_visible(&self, compact: bool) -> Vec<String> {
1283        if compact {
1284            let flagged: Vec<String> = self
1285                .columns
1286                .iter()
1287                .filter(|c| c.mobile)
1288                .map(|c| c.key.clone())
1289                .collect();
1290            if !flagged.is_empty() {
1291                return flagged;
1292            }
1293            return self
1294                .columns
1295                .iter()
1296                .filter(|c| !c.hidden)
1297                .take(3)
1298                .map(|c| c.key.clone())
1299                .collect();
1300        }
1301        self.columns
1302            .iter()
1303            .filter(|c| !c.hidden)
1304            .map(|c| c.key.clone())
1305            .collect()
1306    }
1307}
1308
1309/// A table or column name: letters, digits and `_`.
1310fn plain_name(name: &str) -> bool {
1311    !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1312}
1313
1314/// Where a filter or sort looks: the model's column, or a related value.
1315enum Target {
1316    Column(String),
1317    Expr(String),
1318}
1319
1320impl Target {
1321    fn compare<M: Model>(&self, query: Query<M>, op: &str, value: impl ToDbValue) -> Query<M> {
1322        match self {
1323            Target::Column(key) => query.where_op(key, op, value),
1324            Target::Expr(expr) => query.where_raw(&format!("{expr} {op} ?"), [value]),
1325        }
1326    }
1327
1328    fn like<M: Model>(&self, query: Query<M>, pattern: String) -> Query<M> {
1329        match self {
1330            Target::Column(key) => query.where_like(key, pattern),
1331            Target::Expr(expr) => query.where_raw(
1332                &format!("LOWER(CAST({expr} AS TEXT)) LIKE ?"),
1333                [pattern.to_lowercase()],
1334            ),
1335        }
1336    }
1337
1338    fn among<M: Model, V: ToDbValue>(&self, query: Query<M>, values: Vec<V>) -> Query<M> {
1339        match self {
1340            Target::Column(key) => query.where_in(key, values),
1341            Target::Expr(expr) => {
1342                let marks = vec!["?"; values.len()].join(", ");
1343                query.where_raw(&format!("{expr} IN ({marks})"), values)
1344            }
1345        }
1346    }
1347}
1348
1349fn valid_key(key: &str) -> bool {
1350    !key.is_empty()
1351        && key.len() <= 64
1352        && key
1353            .chars()
1354            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1355}
1356
1357/// What one column is filtered on, from the query string.
1358#[derive(Debug, Clone, Default, PartialEq)]
1359struct Filter {
1360    text: Option<String>,
1361    mode: Option<String>,
1362    min: Option<f64>,
1363    max: Option<f64>,
1364    from: Option<NaiveDate>,
1365    to: Option<NaiveDate>,
1366    any_of: Vec<String>,
1367}
1368
1369impl Filter {
1370    fn is_empty(&self) -> bool {
1371        *self == Self::default()
1372    }
1373
1374    /// The `LIKE` pattern for a text filter.
1375    fn pattern(&self) -> Option<String> {
1376        let text = self.text.as_deref()?.trim();
1377        if text.is_empty() {
1378            return None;
1379        }
1380        if text.contains('%') {
1381            return Some(text.to_owned());
1382        }
1383        Some(match self.mode.as_deref() {
1384            Some("starts") => format!("{text}%"),
1385            Some("ends") => format!("%{text}"),
1386            Some("equals") => text.to_owned(),
1387            _ => format!("%{text}%"),
1388        })
1389    }
1390
1391    fn apply<M: Model>(
1392        &self,
1393        mut query: Query<M>,
1394        column: &Column,
1395        zone: &crate::timezone::Zone,
1396        money: f64,
1397    ) -> Query<M> {
1398        let key = column.key.as_str();
1399        let target = column.target::<M>();
1400        match column.kind {
1401            Kind::Text | Kind::Color => {
1402                if let Some(pattern) = self.pattern() {
1403                    query = target.like(query, pattern);
1404                }
1405            }
1406            Kind::Number | Kind::Money => {
1407                for (value, op) in [(self.min, ">="), (self.max, "<=")] {
1408                    let Some(value) = value else { continue };
1409                    // Money is typed in whole units and stored in the smallest.
1410                    let value = if column.kind == Kind::Money {
1411                        smallest_unit(value, money)
1412                    } else {
1413                        value
1414                    };
1415                    query = if value.fract() == 0.0 && value.abs() < 9e15 {
1416                        target.compare(query, op, value as i64)
1417                    } else {
1418                        target.compare(query, op, value)
1419                    };
1420                }
1421            }
1422            Kind::Date => {
1423                if let Some(from) = self.from {
1424                    query = target.compare(query, ">=", from);
1425                }
1426                if let Some(to) = self
1427                    .to
1428                    .and_then(|d| d.checked_add_signed(Duration::days(1)))
1429                {
1430                    query = target.compare(query, "<", to);
1431                }
1432            }
1433            Kind::DateTime => {
1434                let at = |d: NaiveDate| day_start(zone, d);
1435                if let Some(from) = self.from.and_then(at) {
1436                    query = target.compare(query, ">=", from);
1437                }
1438                if let Some(to) = self
1439                    .to
1440                    .and_then(|d| d.checked_add_signed(Duration::days(1)))
1441                    .and_then(at)
1442                {
1443                    query = target.compare(query, "<", to);
1444                }
1445            }
1446            Kind::Bool => {
1447                let picked: Vec<bool> = self
1448                    .any_of
1449                    .iter()
1450                    .filter_map(|v| match v.as_str() {
1451                        "1" | "true" => Some(true),
1452                        "0" | "false" => Some(false),
1453                        _ => None,
1454                    })
1455                    .collect();
1456                if !picked.is_empty() {
1457                    query = target.among(query, picked);
1458                }
1459            }
1460            Kind::Select => {
1461                let picked: Vec<&String> = self
1462                    .any_of
1463                    .iter()
1464                    .filter(|v| column.options.iter().any(|(o, _)| o == *v))
1465                    .collect();
1466                if !picked.is_empty() {
1467                    let values: Vec<String> = picked.into_iter().cloned().collect();
1468                    // Options that are numbers belong to an integer column,
1469                    // which PostgreSQL won't compare with text: compare the
1470                    // column's text instead.
1471                    let numeric = column.options.iter().all(|(o, _)| o.parse::<i64>().is_ok());
1472                    query = match (&target, numeric) {
1473                        (Target::Column(key), true) if M::COLUMNS.contains(&key.as_str()) => {
1474                            let marks = vec!["?"; values.len()].join(", ");
1475                            query
1476                                .where_raw(&format!("CAST(\"{key}\" AS TEXT) IN ({marks})"), values)
1477                        }
1478                        (Target::Expr(expr), true) => {
1479                            let marks = vec!["?"; values.len()].join(", ");
1480                            query.where_raw(&format!("CAST({expr} AS TEXT) IN ({marks})"), values)
1481                        }
1482                        _ => target.among(query, values),
1483                    };
1484                }
1485            }
1486            Kind::Tags => {
1487                let picked: Vec<String> = self
1488                    .any_of
1489                    .iter()
1490                    .filter(|v| column.options.iter().any(|(o, _)| o == *v))
1491                    .map(|v| format!("%{}%", serde_json::to_string(v).unwrap_or_default()))
1492                    .collect();
1493                if !picked.is_empty() && M::COLUMNS.contains(&key) {
1494                    // The JSON text holds `"value"` for each picked one.
1495                    let sql =
1496                        vec![format!("CAST(\"{key}\" AS TEXT) LIKE ?"); picked.len()].join(" OR ");
1497                    query = query.where_raw(&sql, picked);
1498                }
1499            }
1500            Kind::Custom | Kind::Image => {}
1501        }
1502        query
1503    }
1504
1505    fn to_value(&self) -> Value {
1506        json!({
1507            "text": self.text,
1508            "mode": self.mode.clone().unwrap_or_else(|| "contains".into()),
1509            "min": self.min,
1510            "max": self.max,
1511            "from": self.from.map(|d| d.to_string()),
1512            "to": self.to.map(|d| d.to_string()),
1513            "any_of": self.any_of,
1514        })
1515    }
1516}
1517
1518/// The request's filters, sort and page, checked against the grid.
1519#[derive(Debug, Clone)]
1520struct State_ {
1521    filters: BTreeMap<String, Filter>,
1522    /// The toolbar's search.
1523    search: Option<String>,
1524    /// The advanced filter's rules, and whether any (not all) must hold.
1525    rules: Vec<Rule>,
1526    any: bool,
1527    /// The column rows are grouped by.
1528    group: Option<String>,
1529    /// The grid's own default group (the query string says when it differs).
1530    grid_group: Option<String>,
1531    sort: Option<(String, bool)>,
1532    /// `sort` is the grid's default, not in the query string.
1533    defaulted: bool,
1534    page: u32,
1535    per_page: u32,
1536}
1537
1538impl State_ {
1539    fn parse(grid: &Grid, params: &[(String, String)]) -> Self {
1540        let mut filters: BTreeMap<String, Filter> = BTreeMap::new();
1541        let mut sort = None;
1542        let mut search = None;
1543        let mut group = grid.group.clone();
1544        let mut rules: BTreeMap<u32, Rule> = BTreeMap::new();
1545        let mut any = false;
1546        let mut page = 1;
1547        let mut per_page = grid.per_page;
1548        for (name, value) in params {
1549            let value = value.trim();
1550            match name.as_str() {
1551                "page" => page = value.parse().unwrap_or(1).max(1),
1552                "group" => {
1553                    group = grid.groups.iter().find(|g| *g == value).cloned();
1554                }
1555                "match" => any = value == "any",
1556                _ if name.starts_with("r.") => {
1557                    // r.<n>.c (column), r.<n>.o (operator), r.<n>.v (value).
1558                    let mut parts = name.splitn(3, '.').skip(1);
1559                    let (Some(n), Some(field)) = (parts.next(), parts.next()) else {
1560                        continue;
1561                    };
1562                    let Ok(n) = n.parse::<u32>() else { continue };
1563                    if n >= 20 {
1564                        continue;
1565                    }
1566                    let rule = rules.entry(n).or_default();
1567                    match field {
1568                        "c" => rule.column = value.to_owned(),
1569                        "o" => rule.op = value.to_owned(),
1570                        "v" => rule.value = value.chars().take(200).collect(),
1571                        _ => {}
1572                    }
1573                }
1574                "search" if !value.is_empty() && grid.columns.iter().any(|c| c.searchable) => {
1575                    search = Some(value.chars().take(200).collect());
1576                }
1577                "per_page" => {
1578                    if let Ok(n) = value.parse::<u32>()
1579                        && grid.per_page_options.contains(&n)
1580                    {
1581                        per_page = n;
1582                    }
1583                }
1584                "sort" => {
1585                    let (key, desc) = match value.strip_prefix('-') {
1586                        Some(key) => (key, true),
1587                        None => (value, false),
1588                    };
1589                    if grid.find(key).is_some_and(|c| c.sortable) || key == "id" {
1590                        sort = Some((key.to_owned(), desc));
1591                    }
1592                }
1593                _ => {
1594                    let Some((part, key)) = name.split_once('.') else {
1595                        continue;
1596                    };
1597                    let Some(column) = grid.find(key).filter(|c| c.filterable) else {
1598                        continue;
1599                    };
1600                    if value.is_empty() {
1601                        continue;
1602                    }
1603                    let filter = filters.entry(column.key.clone()).or_default();
1604                    match part {
1605                        "q" => filter.text = Some(value.chars().take(200).collect()),
1606                        "m" if ["contains", "starts", "ends", "equals"].contains(&value) => {
1607                            filter.mode = Some(value.to_owned())
1608                        }
1609                        "min" => filter.min = value.parse().ok().filter(|v: &f64| v.is_finite()),
1610                        "max" => filter.max = value.parse().ok().filter(|v: &f64| v.is_finite()),
1611                        "from" => filter.from = value.parse().ok(),
1612                        "to" => filter.to = value.parse().ok(),
1613                        "in" if filter.any_of.len() < 100 => filter.any_of.push(value.to_owned()),
1614                        _ => {}
1615                    }
1616                }
1617            }
1618        }
1619        // A mode alone filters nothing.
1620        filters.retain(|_, f| {
1621            !Filter {
1622                mode: None,
1623                ..f.clone()
1624            }
1625            .is_empty()
1626        });
1627        // Without a sort of the user's, the grid's (its first key marks the
1628        // heading).
1629        let defaulted = sort.is_none();
1630        if defaulted {
1631            sort = grid.default_sort().into_iter().next();
1632        }
1633        let rules: Vec<Rule> = rules
1634            .into_values()
1635            .filter(|r| {
1636                grid.find(&r.column)
1637                    .is_some_and(|c| c.filterable && ops_for(c.kind).contains(&r.op.as_str()))
1638                    && (!op_takes_value(&r.op) || !r.value.is_empty())
1639            })
1640            .collect();
1641        Self {
1642            filters,
1643            search,
1644            rules,
1645            any,
1646            grid_group: grid.group.clone(),
1647            group,
1648            sort,
1649            defaulted,
1650            page,
1651            per_page,
1652        }
1653    }
1654
1655    /// The query string for this state, without `page` (links set it).
1656    fn query_string(&self) -> Vec<(String, String)> {
1657        let mut out = Vec::new();
1658        if let Some(search) = &self.search {
1659            out.push(("search".into(), search.clone()));
1660        }
1661        if !self.rules.is_empty() {
1662            out.push(("match".into(), if self.any { "any" } else { "all" }.into()));
1663            for (n, rule) in self.rules.iter().enumerate() {
1664                out.push((format!("r.{n}.c"), rule.column.clone()));
1665                out.push((format!("r.{n}.o"), rule.op.clone()));
1666                if op_takes_value(&rule.op) {
1667                    out.push((format!("r.{n}.v"), rule.value.clone()));
1668                }
1669            }
1670        }
1671        if self.group != self.grid_group {
1672            out.push(("group".into(), self.group.clone().unwrap_or_default()));
1673        }
1674        for (key, f) in &self.filters {
1675            if let Some(text) = &f.text {
1676                out.push((format!("q.{key}"), text.clone()));
1677            }
1678            if let Some(mode) = &f.mode {
1679                out.push((format!("m.{key}"), mode.clone()));
1680            }
1681            for (name, value) in [("min", f.min), ("max", f.max)] {
1682                if let Some(v) = value {
1683                    out.push((format!("{name}.{key}"), v.to_string()));
1684                }
1685            }
1686            for (name, value) in [("from", f.from), ("to", f.to)] {
1687                if let Some(v) = value {
1688                    out.push((format!("{name}.{key}"), v.to_string()));
1689                }
1690            }
1691            for v in &f.any_of {
1692                out.push((format!("in.{key}"), v.clone()));
1693            }
1694        }
1695        if let Some((key, desc)) = self.sort.as_ref().filter(|_| !self.defaulted) {
1696            out.push((
1697                "sort".into(),
1698                format!("{}{key}", if *desc { "-" } else { "" }),
1699            ));
1700        }
1701        out.push(("per_page".into(), self.per_page.to_string()));
1702        out
1703    }
1704}
1705
1706/// An amount typed in whole units (`40.5`) in the smallest unit (`4050`
1707/// for a currency with 2 decimals), rounded so `40.1 * 100` stays `4010`.
1708fn smallest_unit(units: f64, scale: f64) -> f64 {
1709    (units * scale * 1e6).round() / 1e6
1710}
1711
1712/// The moment the day `day` starts in `zone`: date-time filters take whole
1713/// days of `APP_TIMEZONE`, the zone their cells are shown in. When a clock
1714/// change skips midnight, the day starts at its first wall-clock time.
1715fn day_start(
1716    zone: &crate::timezone::Zone,
1717    day: NaiveDate,
1718) -> Option<chrono::DateTime<chrono::Utc>> {
1719    let midnight = day.and_hms_opt(0, 0, 0)?;
1720    let at = (0..=2).find_map(|h| zone.resolve(midnight + Duration::hours(h)))?;
1721    chrono::DateTime::from_timestamp(at, 0)
1722}
1723
1724/// One rule of the advanced filter.
1725#[derive(Debug, Clone, Default, PartialEq)]
1726struct Rule {
1727    column: String,
1728    op: String,
1729    value: String,
1730}
1731
1732/// The advanced filter's operators for a kind of column.
1733fn ops_for(kind: Kind) -> &'static [&'static str] {
1734    match kind {
1735        Kind::Text | Kind::Color | Kind::Tags => &[
1736            "contains",
1737            "not_contains",
1738            "equals",
1739            "not_equals",
1740            "starts",
1741            "ends",
1742            "empty",
1743            "not_empty",
1744        ],
1745        Kind::Number | Kind::Money => &["eq", "ne", "gt", "gte", "lt", "lte", "empty", "not_empty"],
1746        Kind::Date | Kind::DateTime => &["on", "before", "after", "empty", "not_empty"],
1747        Kind::Bool => &["is_true", "is_false"],
1748        Kind::Select => &["is", "is_not", "empty", "not_empty"],
1749        Kind::Custom | Kind::Image => &[],
1750    }
1751}
1752
1753fn op_takes_value(op: &str) -> bool {
1754    !matches!(op, "empty" | "not_empty" | "is_true" | "is_false")
1755}
1756
1757impl Rule {
1758    /// The rule as SQL on `target` (a quoted column or a related value),
1759    /// with its values; `None` when the value doesn't fit the column.
1760    fn sql(
1761        &self,
1762        target: &str,
1763        kind: Kind,
1764        zone: &crate::timezone::Zone,
1765        money: f64,
1766    ) -> Option<(String, Vec<DbValue>)> {
1767        let text = format!("LOWER(CAST({target} AS TEXT))");
1768        let lower = self.value.to_lowercase();
1769        let like = |pattern: String| (format!("{text} LIKE ?"), vec![pattern.to_db_value()]);
1770        Some(match self.op.as_str() {
1771            "empty" => (
1772                format!("{target} IS NULL OR CAST({target} AS TEXT) = ''"),
1773                vec![],
1774            ),
1775            "not_empty" => (
1776                format!("{target} IS NOT NULL AND CAST({target} AS TEXT) <> ''"),
1777                vec![],
1778            ),
1779            "is_true" => (format!("{target} = ?"), vec![true.to_db_value()]),
1780            "is_false" => (
1781                format!("{target} = ? OR {target} IS NULL"),
1782                vec![false.to_db_value()],
1783            ),
1784            "contains" => like(format!("%{lower}%")),
1785            "not_contains" => (
1786                format!("{target} IS NULL OR {text} NOT LIKE ?"),
1787                vec![format!("%{lower}%").to_db_value()],
1788            ),
1789            "equals" => like(lower),
1790            "not_equals" => (
1791                format!("{target} IS NULL OR {text} <> ?"),
1792                vec![lower.to_db_value()],
1793            ),
1794            "starts" => like(format!("{lower}%")),
1795            "ends" => like(format!("%{lower}")),
1796            "is" => (format!("{target} = ?"), vec![self.value.to_db_value()]),
1797            "is_not" => (
1798                format!("{target} IS NULL OR {target} <> ?"),
1799                vec![self.value.to_db_value()],
1800            ),
1801            "eq" | "ne" | "gt" | "gte" | "lt" | "lte" => {
1802                let n: f64 = self
1803                    .value
1804                    .trim()
1805                    .parse()
1806                    .ok()
1807                    .filter(|n: &f64| n.is_finite())?;
1808                let n = if kind == Kind::Money {
1809                    smallest_unit(n, money)
1810                } else {
1811                    n
1812                };
1813                let op = match self.op.as_str() {
1814                    "eq" => "=",
1815                    "ne" => "<>",
1816                    "gt" => ">",
1817                    "gte" => ">=",
1818                    "lt" => "<",
1819                    _ => "<=",
1820                };
1821                let value = if n.fract() == 0.0 && n.abs() < 9e15 {
1822                    (n as i64).to_db_value()
1823                } else {
1824                    n.to_db_value()
1825                };
1826                (format!("{target} {op} ?"), vec![value])
1827            }
1828            "on" | "before" | "after" => {
1829                let day: NaiveDate = self.value.trim().parse().ok()?;
1830                let next = day.checked_add_signed(Duration::days(1))?;
1831                let bound = |d: NaiveDate| -> DbValue {
1832                    if kind == Kind::DateTime {
1833                        day_start(zone, d).to_db_value()
1834                    } else {
1835                        d.to_db_value()
1836                    }
1837                };
1838                match self.op.as_str() {
1839                    "on" => (
1840                        format!("{target} >= ? AND {target} < ?"),
1841                        vec![bound(day), bound(next)],
1842                    ),
1843                    "before" => (format!("{target} < ?"), vec![bound(day)]),
1844                    _ => (format!("{target} >= ?"), vec![bound(next)]),
1845                }
1846            }
1847            _ => return None,
1848        })
1849    }
1850}
1851
1852/// What a user chose in a grid's column menu. `None` means "the grid's
1853/// default".
1854#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1855#[non_exhaustive]
1856pub struct GridPrefs {
1857    /// Column keys in the order they're shown.
1858    #[serde(default)]
1859    pub order: Option<Vec<String>>,
1860    /// Columns frozen at the left edge.
1861    #[serde(default)]
1862    pub left: Option<Vec<String>>,
1863    /// Columns frozen at the right edge.
1864    #[serde(default)]
1865    pub right: Option<Vec<String>>,
1866    /// Columns shown on small screens (under 768 px).
1867    #[serde(default)]
1868    pub compact: Option<Vec<String>>,
1869    /// Columns shown on wide screens.
1870    #[serde(default)]
1871    pub wide: Option<Vec<String>>,
1872    /// Widths the user gave columns, in CSS pixels (40 to 2000).
1873    #[serde(default)]
1874    pub widths: Option<BTreeMap<String, u32>>,
1875}
1876
1877impl GridPrefs {
1878    fn is_valid(&self) -> bool {
1879        [
1880            &self.order,
1881            &self.left,
1882            &self.right,
1883            &self.compact,
1884            &self.wide,
1885        ]
1886        .into_iter()
1887        .flatten()
1888        .all(|keys| keys.len() <= 200 && keys.iter().all(|k| valid_key(k)))
1889            && self.widths.as_ref().is_none_or(|widths| {
1890                widths.len() <= 200
1891                    && widths
1892                        .iter()
1893                        .all(|(k, w)| valid_key(k) && (40..=2000).contains(w))
1894            })
1895    }
1896}
1897
1898fn session_key(grid: &str) -> String {
1899    format!("_grid.{grid}")
1900}
1901
1902async fn load_prefs(request: &GridRequest, grid: &str) -> GridPrefs {
1903    match request.user_id {
1904        Some(user_id) => {
1905            let data: Option<String> =
1906                crate::db::sql("SELECT data FROM grid_preferences WHERE user_id = ? AND grid = ?")
1907                    .bind(user_id)
1908                    .bind(grid)
1909                    .scalar_optional(&request.db)
1910                    .await
1911                    .ok()
1912                    .flatten();
1913            data.and_then(|d| serde_json::from_str(&d).ok())
1914                .unwrap_or_default()
1915        }
1916        None => request
1917            .session
1918            .as_ref()
1919            .and_then(|s| s.get(&session_key(grid)))
1920            .unwrap_or_default(),
1921    }
1922}
1923
1924/// Saves a user's choices for `grid` (what the column menu does).
1925pub async fn save_prefs(db: &Db, user_id: i64, grid: &str, prefs: &GridPrefs) -> Result {
1926    let data = serde_json::to_string(prefs)?;
1927    crate::db::sql(
1928        "INSERT INTO grid_preferences (user_id, grid, data, updated_at) VALUES (?, ?, ?, ?)
1929         ON CONFLICT (user_id, grid) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
1930    )
1931    .bind(user_id)
1932    .bind(grid)
1933    .bind(data)
1934    .bind(crate::db::now())
1935    .execute(db)
1936    .await?;
1937    Ok(())
1938}
1939
1940/// The query string, the session and the user a grid needs, as an
1941/// extractor: `async fn index(request: GridRequest) -> Result<View>`.
1942#[derive(Clone)]
1943pub struct GridRequest {
1944    params: Vec<(String, String)>,
1945    path: String,
1946    db: Db,
1947    session: Option<Session>,
1948    user_id: Option<i64>,
1949    lang: Option<crate::Lang>,
1950    zone: crate::timezone::Zone,
1951    /// `APP_CURRENCY`'s usual decimals: money is stored in its smallest unit.
1952    money_decimals: u32,
1953}
1954
1955impl GridRequest {
1956    /// A request for tests and commands: `params` as a query string would
1957    /// give them, for a guest.
1958    pub fn new(db: &Db, path: &str, params: &[(&str, &str)]) -> Self {
1959        Self {
1960            params: params
1961                .iter()
1962                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1963                .collect(),
1964            path: path.to_owned(),
1965            db: db.clone(),
1966            session: None,
1967            user_id: None,
1968            lang: None,
1969            zone: crate::timezone::Zone::UTC,
1970            money_decimals: crate::view_filters::currency_decimals(
1971                &crate::Config::default().currency,
1972            ),
1973        }
1974    }
1975
1976    /// The query string's value for `name`, if any.
1977    pub fn param(&self, name: &str) -> Option<&str> {
1978        self.params
1979            .iter()
1980            .find(|(k, _)| k == name)
1981            .map(|(_, v)| v.as_str())
1982    }
1983}
1984
1985impl<S: Send + Sync> FromRequestParts<S> for GridRequest {
1986    type Rejection = Response;
1987
1988    async fn from_request_parts(
1989        parts: &mut Parts,
1990        state: &S,
1991    ) -> std::result::Result<Self, Response> {
1992        let Some(app) = parts.extensions.get::<AppState>().cloned() else {
1993            return Err(
1994                Error::Internal(anyhow::anyhow!("GridRequest needs Renox's middleware"))
1995                    .into_response(),
1996            );
1997        };
1998        let params: Vec<(String, String)> = parts
1999            .uri
2000            .query()
2001            .and_then(|q| serde_html_form::from_str(q).ok())
2002            .unwrap_or_default();
2003        let session = Session::from_request_parts(parts, state).await.ok();
2004        let user = <AuthUser as axum::extract::OptionalFromRequestParts<S>>::from_request_parts(
2005            parts, state,
2006        )
2007        .await
2008        .ok()
2009        .flatten();
2010        let lang = crate::Lang::from_request_parts(parts, state).await.ok();
2011        // The whole path: inside a `Routes::group` the router sees it
2012        // without the group's prefix, and the grid's links need it all.
2013        let path = parts
2014            .extensions
2015            .get::<axum::extract::OriginalUri>()
2016            .map_or_else(|| parts.uri.path(), |original| original.0.path())
2017            .to_owned();
2018        Ok(Self {
2019            params,
2020            path,
2021            db: app.db.clone(),
2022            session,
2023            user_id: user.map(|u| u.id),
2024            lang,
2025            zone: app.config.timezone,
2026            money_decimals: crate::view_filters::currency_decimals(&app.config.currency),
2027        })
2028    }
2029}
2030
2031/// One page of a grid, as the `grid` macro of `renox/grid.html` draws it.
2032/// Pass it to the view as is; [`GridPage::extend`] adds values that aren't
2033/// columns of the model (a chart's points, a link).
2034pub struct GridPage<M> {
2035    grid: Grid,
2036    prefs: GridPrefs,
2037    state: State_,
2038    path: String,
2039    /// Query string values that aren't this grid's, kept in its links.
2040    keep: Vec<(String, String)>,
2041    rows: Paginated<M>,
2042    extra: Vec<Map<String, Value>>,
2043    /// The related columns' values, per row.
2044    related: Vec<Map<String, Value>>,
2045    summaries: BTreeMap<String, Map<String, Value>>,
2046    group_summaries: BTreeMap<String, Map<String, Value>>,
2047    /// `APP_CURRENCY`'s usual decimals, for money columns.
2048    money_decimals: u32,
2049}
2050
2051impl<M: Serialize> GridPage<M> {
2052    /// Adds values to each row, from the row's model: `page.extend(|order|
2053    /// json!({ "trend": trends.get(&order.id) }))`. The values must be an
2054    /// object; its keys join the row's (the `custom` columns read them).
2055    pub fn extend(mut self, mut values: impl FnMut(&M) -> Value) -> Self {
2056        self.extra = self
2057            .rows
2058            .items
2059            .iter()
2060            .map(|item| match values(item) {
2061                Value::Object(map) => map,
2062                _ => Map::new(),
2063            })
2064            .collect();
2065        self
2066    }
2067
2068    /// The models on this page.
2069    pub fn items(&self) -> &[M] {
2070        &self.rows.items
2071    }
2072
2073    /// Rows matching the filters, on all pages.
2074    pub fn total(&self) -> u64 {
2075        self.rows.total
2076    }
2077
2078    fn to_value(&self) -> Value {
2079        let ordered = self.grid.ordered(&self.prefs);
2080        let compact = self
2081            .prefs
2082            .compact
2083            .clone()
2084            .unwrap_or_else(|| self.grid.default_visible(true));
2085        let wide = self
2086            .prefs
2087            .wide
2088            .clone()
2089            .unwrap_or_else(|| self.grid.default_visible(false));
2090        let sort = self.state.sort.clone();
2091        // Rows can be dragged while sorted by the order column, ascending.
2092        let grouped = self
2093            .state
2094            .group
2095            .clone()
2096            .filter(|g| self.grid.find(g).is_some());
2097        let dragging = grouped.is_none()
2098            && matches!((&self.grid.reorder, &sort), (Some((column, _)), Some((key, false))) if column == key);
2099        let columns: Vec<Value> = ordered
2100            .iter()
2101            .map(|(c, pin)| {
2102                let filter = self.state.filters.get(&c.key);
2103                json!({
2104                    "key": c.key,
2105                    "label": c.label,
2106                    "kind": c.kind,
2107                    "pin": pin,
2108                    "sortable": c.sortable,
2109                    "filterable": c.filterable,
2110                    "decimals": match c.kind {
2111                        Kind::Money => Some(c.decimals.map_or(self.money_decimals, u32::from)),
2112                        _ => c.decimals.map(u32::from),
2113                    },
2114                    // Money cells are divided by this: stored in the smallest unit.
2115                    "scale": match c.kind {
2116                        Kind::Money => 10u64.pow(self.money_decimals),
2117                        _ => 1,
2118                    },
2119                    "width": c.width,
2120                    "numeric": matches!(c.kind, Kind::Number | Kind::Money),
2121                    "options": c.options.iter().map(|(v, l)| json!({"value": v, "label": l})).collect::<Vec<_>>(),
2122                    "labels": c.options.iter().map(|(v, l)| (v.clone(), Value::from(l.clone()))).collect::<Map<_, _>>(),
2123                    "filter": filter.map(Filter::to_value).unwrap_or_else(|| Filter::default().to_value()),
2124                    "filtered": filter.is_some(),
2125                    "sort": match &sort {
2126                        Some((key, desc)) if *key == c.key => Value::from(if *desc { "desc" } else { "asc" }),
2127                        _ => Value::Null,
2128                    },
2129                    "compact": compact.contains(&c.key),
2130                    "wide": wide.contains(&c.key),
2131                    "editable": c.editable && self.grid.edit_url.is_some(),
2132                    "merge": c.merge && !dragging && grouped.is_none(),
2133                    "summaries": c.summaries,
2134                    "badges": c.badges.as_ref().map(|b| b.iter().map(|(v, t)| (v.clone(), Value::from(t.clone()))).collect::<Map<_, _>>()),
2135                    "icons": c.icons,
2136                    "description": c.description,
2137                    "tooltip": c.tooltip,
2138                    "wrap": c.wrap,
2139                    "limit": c.limit,
2140                    "link": c.link,
2141                    "copyable": c.copyable,
2142                    "round": c.round,
2143                })
2144            })
2145            .collect();
2146        let rows: Vec<Value> = self
2147            .rows
2148            .items
2149            .iter()
2150            .enumerate()
2151            .map(|(i, item)| {
2152                let mut row = match serde_json::to_value(item) {
2153                    Ok(Value::Object(map)) => map,
2154                    _ => Map::new(),
2155                };
2156                if let Some(related) = self.related.get(i) {
2157                    row.extend(related.clone());
2158                }
2159                if let Some(extra) = self.extra.get(i) {
2160                    row.extend(extra.clone());
2161                }
2162                Value::Object(row)
2163            })
2164            .collect();
2165        let merged: Vec<&str> = if dragging || grouped.is_some() {
2166            Vec::new()
2167        } else {
2168            ordered
2169                .iter()
2170                .filter(|(c, _)| c.merge)
2171                .map(|(c, _)| c.key.as_str())
2172                .collect()
2173        };
2174        let spans = merge_spans(&rows, &merged);
2175        // Where groups start and end on this page.
2176        let group_text = |row: &Value| -> Option<String> {
2177            let key = grouped.as_ref()?;
2178            Some(match row.get(key) {
2179                Some(Value::String(s)) => s.clone(),
2180                Some(Value::Null) | None => String::new(),
2181                Some(Value::Bool(b)) => {
2182                    if *b {
2183                        "1".into()
2184                    } else {
2185                        "0".into()
2186                    }
2187                }
2188                Some(other) => other.to_string(),
2189            })
2190        };
2191        let texts: Vec<Option<String>> = rows.iter().map(group_text).collect();
2192        // The row each group's run on this page starts at.
2193        let mut run_start = vec![0; texts.len()];
2194        for i in 1..texts.len() {
2195            run_start[i] = if texts[i] == texts[i - 1] {
2196                run_start[i - 1]
2197            } else {
2198                i
2199            };
2200        }
2201        let group_figures = |text: &str| -> Option<&Map<String, Value>> {
2202            self.group_summaries.get(text).or_else(|| {
2203                // Booleans read back as `true`/`false` on PostgreSQL.
2204                let alt = match text {
2205                    "1" => "true",
2206                    "0" => "false",
2207                    _ => return None,
2208                };
2209                self.group_summaries.get(alt)
2210            })
2211        };
2212        let rows: Vec<Value> = rows
2213            .into_iter()
2214            .zip(spans)
2215            .enumerate()
2216            .map(|(i, (mut row, merge))| {
2217                let text = texts[i].clone();
2218                let starts = text.is_some() && (i == 0 || texts[i - 1] != text);
2219                let ends = text.is_some() && texts.get(i + 1).is_none_or(|next| *next != text);
2220                let group = text.as_ref().map(|t| {
2221                    json!({
2222                        "id": format!("g{}", run_start[i]),
2223                        "value": row.get(grouped.as_deref().unwrap_or_default()).cloned(),
2224                        "rows": group_figures(t).and_then(|f| f.get("_rows").cloned()),
2225                        "figures": group_figures(t),
2226                        "starts": starts,
2227                        "ends": ends,
2228                    })
2229                });
2230                let id = row.get("id").map(|id| match id {
2231                    Value::String(s) => s.clone(),
2232                    other => other.to_string(),
2233                });
2234                if let Value::Object(map) = &mut row {
2235                    map.insert(
2236                        "_rx".into(),
2237                        json!({
2238                            "id": id,
2239                            "spans": merge.spans,
2240                            "covered": merge.covered,
2241                            "groups": merge.groups,
2242                            "edit": self.grid.edit_url.as_ref().zip(id.as_ref()).map(|(url, id)| url.replace("{id}", id)),
2243                            "href": self.grid.row_url.as_ref().zip(id.as_ref()).map(|(url, id)| url.replace("{id}", id)),
2244                            "actions": self.grid.row_actions.iter().map(|a| a.to_value(id.as_deref())).collect::<Vec<_>>(),
2245                            "group": group,
2246                        }),
2247                    );
2248                }
2249                row
2250            })
2251            .collect();
2252        let editable = self.grid.edit_url.is_some() && self.grid.columns.iter().any(|c| c.editable);
2253        // This grid's values (prefixed) after the others the page had.
2254        let own: Vec<(String, String)> = self
2255            .state
2256            .query_string()
2257            .into_iter()
2258            .map(|(k, v)| (self.grid.name(&k), v))
2259            .collect();
2260        let query: Vec<(String, String)> = self.keep.iter().cloned().chain(own).collect();
2261        let query = &query;
2262        let config = json!({
2263            "id": self.grid.id,
2264            "prefix": self.grid.prefix.as_ref().map(|p| format!("{p}.")).unwrap_or_default(),
2265            "poll": self.grid.poll,
2266            "prefs": format!("/_renox/grid/{}/prefs", self.grid.id),
2267            "order": ordered.iter().map(|(c, _)| c.key.clone()).collect::<Vec<_>>(),
2268            "left": ordered.iter().filter(|(_, p)| *p == Some(Pin::Left)).map(|(c, _)| c.key.clone()).collect::<Vec<_>>(),
2269            "right": ordered.iter().filter(|(_, p)| *p == Some(Pin::Right)).map(|(c, _)| c.key.clone()).collect::<Vec<_>>(),
2270            "compact": compact,
2271            "wide": wide,
2272            "widths": self.prefs.widths.clone().unwrap_or_default(),
2273            "defaults": {
2274                "compact": self.grid.default_visible(true),
2275                "wide": self.grid.default_visible(false),
2276            },
2277            "columns": ordered.iter().map(|(c, _)| (c.key.clone(), json!({
2278                "kind": c.kind,
2279                "label": c.label,
2280                "options": c.options,
2281                "editable": c.editable && self.grid.edit_url.is_some(),
2282            }))).collect::<Map<_, _>>(),
2283            "reorder": self.grid.reorder.as_ref().filter(|_| dragging).map(|(_, url)| url),
2284            "offset": u64::from(self.rows.page.saturating_sub(1)) * u64::from(self.rows.per_page),
2285        });
2286        json!({
2287            "id": self.grid.id,
2288            "title": self.grid.title,
2289            "path": self.path,
2290            "columns": columns,
2291            "header": header_rows(&ordered),
2292            "rows": rows,
2293            "default_per_page": self.grid.per_page,
2294            "page": {
2295                "page": self.rows.page,
2296                "per_page": self.rows.per_page,
2297                "total": self.rows.total,
2298                "last_page": self.rows.last_page,
2299                "from": self.rows.from,
2300                "to": self.rows.to,
2301                "has_prev": self.rows.has_prev,
2302                "has_next": self.rows.has_next,
2303                "pages": self.rows.pages,
2304            },
2305            "per_page_options": self.grid.per_page_options,
2306            "sort": sort.filter(|_| !self.state.defaulted).map(|(key, desc)| format!("{}{key}", if desc { "-" } else { "" })),
2307            "query": query,
2308            "filtered": self.state.filters.len() + usize::from(!self.state.rules.is_empty()),
2309            "config": config.to_string(),
2310            "audit": self.grid.audit,
2311            "details": self.grid.audit || self.grid.details,
2312            "custom_details": self.grid.details,
2313            "editable": editable,
2314            "reorder": self.grid.reorder.as_ref().map(|(column, _)| json!({
2315                "column": column,
2316                "active": dragging,
2317            })),
2318            "tools": self.grid.audit || self.grid.details || editable || self.grid.reorder.is_some()
2319                || !self.grid.bulk.is_empty() || !self.grid.row_actions.is_empty(),
2320            "bulk": self.grid.bulk.iter().map(|a| a.to_value(None)).collect::<Vec<_>>(),
2321            "row_actions": !self.grid.row_actions.is_empty(),
2322            "cards": self.grid.cards,
2323            "remember": self.grid.remember,
2324            "advanced": {
2325                "on": self.grid.advanced,
2326                "any": self.state.any,
2327                "rules": self.state.rules.iter().map(|r| json!({"column": r.column, "op": r.op, "value": r.value})).collect::<Vec<_>>(),
2328                "columns": ordered.iter().filter(|(c, _)| c.filterable && !ops_for(c.kind).is_empty()).map(|(c, _)| json!({
2329                    "key": c.key,
2330                    "label": c.label,
2331                    "kind": c.kind,
2332                    "ops": ops_for(c.kind),
2333                    "options": c.options.iter().map(|(v, l)| json!({"value": v, "label": l})).collect::<Vec<_>>(),
2334                })).collect::<Vec<_>>(),
2335            },
2336            "summary": self.summaries.get("").filter(|_| self.grid.columns.iter().any(|c| !c.summaries.is_empty())),
2337            "grouping": (!self.grid.groups.is_empty()).then(|| json!({
2338                "options": self.grid.groups.iter().filter_map(|g| self.grid.find(g)).map(|c| json!({"key": c.key, "label": c.label})).collect::<Vec<_>>(),
2339                "current": grouped,
2340                "column": grouped.as_ref().and_then(|g| ordered.iter().position(|(c, _)| c.key == *g)),
2341            })),
2342            "exports": self.grid.exports.then(|| export::urls(&self.path, query, &self.grid.name("per_page"), &self.grid.name("export"))),
2343            // Field names: `p` before each (`orders.` with a prefix).
2344            "p": self.grid.prefix.as_ref().map(|p| format!("{p}.")).unwrap_or_default(),
2345            "keep": self.keep,
2346            "search": {
2347                "on": self.grid.columns.iter().any(|c| c.searchable),
2348                "value": self.state.search,
2349            },
2350            "empty": self.grid.empty.as_ref().map(|(heading, description)| json!({
2351                "heading": heading,
2352                "description": description,
2353            })),
2354            "row_links": self.grid.row_url.is_some(),
2355        })
2356    }
2357}
2358
2359impl<M: Serialize> Serialize for GridPage<M> {
2360    fn serialize<S: serde::Serializer>(
2361        &self,
2362        serializer: S,
2363    ) -> std::result::Result<S::Ok, S::Error> {
2364        self.to_value().serialize(serializer)
2365    }
2366}
2367
2368/// A button that sends a request: in each row's menu ([`Grid::row_action`])
2369/// or over the selected rows ([`Grid::bulk_action`]).
2370#[derive(Debug, Clone)]
2371#[non_exhaustive]
2372pub struct Action {
2373    label: String,
2374    url: String,
2375    method: String,
2376    confirm: Option<String>,
2377    danger: bool,
2378    link: bool,
2379    sheet: Option<String>,
2380}
2381
2382impl Action {
2383    /// An action that `POST`s to `url` (`{id}` is the row's id in row
2384    /// actions).
2385    pub fn new(label: &str, url: &str) -> Self {
2386        Self {
2387            label: label.to_owned(),
2388            url: url.to_owned(),
2389            method: "POST".into(),
2390            confirm: None,
2391            danger: false,
2392            link: false,
2393            sheet: None,
2394        }
2395    }
2396
2397    /// A link to `url` instead of a request (row actions: `Edit`, `Open`).
2398    pub fn link(label: &str, url: &str) -> Self {
2399        Self {
2400            link: true,
2401            ..Self::new(label, url)
2402        }
2403    }
2404
2405    /// The request's method: `POST` (the default), `PATCH`, `PUT` or
2406    /// `DELETE`.
2407    pub fn method(mut self, method: &str) -> Self {
2408        self.method = method.to_ascii_uppercase();
2409        self
2410    }
2411
2412    /// Asks first, in a dialog with this question.
2413    pub fn confirm(mut self, question: &str) -> Self {
2414        self.confirm = Some(question.to_owned());
2415        self
2416    }
2417
2418    /// Shown in red, for actions that delete or can't be undone.
2419    pub fn danger(mut self) -> Self {
2420        self.danger = true;
2421        self
2422    }
2423
2424    /// Opens the dialog `id` (a kit `action_sheet`, elsewhere on the page)
2425    /// instead of sending the request at once: the grid points the sheet's
2426    /// form at this action's `url`, adds the selection (`ids`, `all`) and
2427    /// shows it, so the action can ask for input first. Use it with
2428    /// `action_sheet(id, …, button=false)`.
2429    pub fn sheet(mut self, id: &str) -> Self {
2430        self.sheet = Some(id.to_owned());
2431        self
2432    }
2433
2434    fn to_value(&self, id: Option<&str>) -> Value {
2435        json!({
2436            "sheet": self.sheet,
2437            "label": self.label,
2438            "url": id.map_or_else(|| self.url.clone(), |id| self.url.replace("{id}", id)),
2439            "method": self.method,
2440            "confirm": self.confirm,
2441            "danger": self.danger,
2442            "link": self.link,
2443        })
2444    }
2445}
2446
2447/// What a bulk action was sent for (read with axum's `Form`): the selected
2448/// rows' `ids` (separated by commas), or `all=true` for every row the
2449/// grid's filters match. See [`Grid::selected`].
2450#[derive(Debug, Clone, Default, Deserialize)]
2451#[non_exhaustive]
2452pub struct Selection {
2453    /// The selected rows' ids.
2454    #[serde(default, deserialize_with = "comma_list")]
2455    pub ids: Vec<String>,
2456    /// Every row the filters match, not just the selected ones.
2457    #[serde(default)]
2458    pub all: bool,
2459}
2460
2461/// Where a row sits in the merged columns' groups.
2462#[derive(Debug, Default, Clone, PartialEq)]
2463struct MergeInfo {
2464    /// For the rows starting a group: how many rows the cell spans.
2465    spans: Map<String, Value>,
2466    /// The merged columns whose cell an earlier row draws.
2467    covered: Vec<String>,
2468    /// The group of each merged column, e.g. `city-3` (its first row).
2469    groups: Map<String, Value>,
2470}
2471
2472/// Groups neighbouring rows with equal values in the `merged` columns,
2473/// each nested in the groups of the columns before it.
2474fn merge_spans(rows: &[Value], merged: &[&str]) -> Vec<MergeInfo> {
2475    let mut info = vec![MergeInfo::default(); rows.len()];
2476    // Where each row's group starts, per column.
2477    let mut starts: Vec<Vec<usize>> = vec![vec![0; merged.len()]; rows.len()];
2478    for i in 0..rows.len() {
2479        let mut outer_break = i == 0;
2480        for (k, key) in merged.iter().enumerate() {
2481            let same = !outer_break && rows[i].get(*key) == rows[i - 1].get(*key);
2482            if same {
2483                starts[i][k] = starts[i - 1][k];
2484            } else {
2485                starts[i][k] = i;
2486                outer_break = true;
2487            }
2488        }
2489    }
2490    for (k, key) in merged.iter().enumerate() {
2491        for i in 0..rows.len() {
2492            let start = starts[i][k];
2493            info[i]
2494                .groups
2495                .insert((*key).to_owned(), format!("{key}-{start}").into());
2496            if start == i {
2497                let len = (i..rows.len()).take_while(|&j| starts[j][k] == i).count();
2498                info[i].spans.insert((*key).to_owned(), len.into());
2499            } else {
2500                info[i].covered.push((*key).to_owned());
2501            }
2502        }
2503    }
2504    info
2505}
2506
2507/// The new order of a page of rows, as a reorderable grid sends it
2508/// (`ids=4,2,9`: the ids in their new order, `offset` the position of the
2509/// first), read with axum's `Form`:
2510///
2511/// ```
2512/// # use renox::prelude::*;
2513/// use renox::grid::RowOrder;
2514/// # #[derive(Model, serde::Serialize, Default)] struct Task { id: i64, position: i64 }
2515/// async fn reorder(State(db): State<Db>, Form(order): Form<RowOrder>) -> Result<StatusCode> {
2516///     order.save::<Task>(&db, "position").await?;
2517///     Ok(StatusCode::NO_CONTENT)
2518/// }
2519/// ```
2520#[derive(Debug, Clone, Default, Deserialize)]
2521#[non_exhaustive]
2522pub struct RowOrder {
2523    /// The rows' ids, in their new order (sent separated by commas).
2524    #[serde(default, deserialize_with = "comma_list")]
2525    pub ids: Vec<String>,
2526    /// The position of the first row (the rows before this page).
2527    #[serde(default)]
2528    pub offset: u64,
2529}
2530
2531fn comma_list<'de, D: serde::Deserializer<'de>>(
2532    d: D,
2533) -> std::result::Result<Vec<String>, D::Error> {
2534    let text = String::deserialize(d)?;
2535    Ok(text
2536        .split(',')
2537        .map(str::trim)
2538        .filter(|id| !id.is_empty())
2539        .map(str::to_owned)
2540        .collect())
2541}
2542
2543impl RowOrder {
2544    /// Writes `offset + n` into `column` of the n-th row, in one
2545    /// transaction. Ids that don't parse as the model's key are an error;
2546    /// `updated_at` is left alone.
2547    pub async fn save<M: Model>(&self, db: &Db, column: &str) -> Result<u64> {
2548        if !M::COLUMNS.contains(&column) || column == "id" {
2549            return Err(Error::Internal(anyhow::anyhow!(
2550                "`{}` has no column `{column}` to order by",
2551                M::TABLE
2552            )));
2553        }
2554        if self.ids.len() > 1000 {
2555            return Err(Error::BadRequest("too many rows".into()));
2556        }
2557        let mut keys = Vec::with_capacity(self.ids.len());
2558        for id in &self.ids {
2559            let key: M::Key = id
2560                .parse()
2561                .map_err(|_| Error::BadRequest(format!("`{id}` is not an id")))?;
2562            keys.push(key);
2563        }
2564        let sql = format!(
2565            "UPDATE \"{}\" SET \"{column}\" = ? WHERE \"id\" = ?",
2566            M::TABLE
2567        );
2568        let mut tx = db.begin().await?;
2569        let mut changed = 0;
2570        for (n, key) in keys.into_iter().enumerate() {
2571            let position = i64::try_from(self.offset + n as u64).unwrap_or(i64::MAX);
2572            changed += crate::db::sql(&sql)
2573                .bind(position)
2574                .bind(key)
2575                .execute(&mut tx)
2576                .await?;
2577        }
2578        tx.commit().await?;
2579        Ok(changed)
2580    }
2581}
2582
2583/// The heading rows: grouped headings (`Column::under`) span the columns
2584/// under them, leaf headings span down to the last row. Groups don't cross
2585/// a frozen edge.
2586fn header_rows(columns: &[(&Column, Option<Pin>)]) -> Vec<Vec<Value>> {
2587    let depth = columns
2588        .iter()
2589        .map(|(c, _)| c.under.len() + 1)
2590        .max()
2591        .unwrap_or(1);
2592    let mut rows: Vec<Vec<Value>> = vec![Vec::new(); depth];
2593    for (level, row) in rows.iter_mut().enumerate() {
2594        let mut previous: Option<(Vec<String>, Option<Pin>)> = None;
2595        for (index, (column, pin)) in columns.iter().enumerate() {
2596            if level < column.under.len() {
2597                let path = column.under[..=level].to_vec();
2598                let same = previous
2599                    .as_ref()
2600                    .is_some_and(|(p, side)| *p == path && side == pin);
2601                if same && let Some(Value::Object(cell)) = row.last_mut() {
2602                    let span = cell["colspan"].as_u64().unwrap_or(1) + 1;
2603                    cell.insert("colspan".into(), span.into());
2604                    if let Some(Value::Array(keys)) = cell.get_mut("keys") {
2605                        keys.push(column.key.clone().into());
2606                    }
2607                } else {
2608                    row.push(json!({
2609                        "group": true,
2610                        "label": column.under[level],
2611                        "colspan": 1,
2612                        "rowspan": 1,
2613                        "keys": [column.key],
2614                        "pin": pin,
2615                    }));
2616                }
2617                previous = Some((path, *pin));
2618            } else {
2619                if level == column.under.len() {
2620                    row.push(json!({
2621                        "group": false,
2622                        "key": column.key,
2623                        "index": index,
2624                        "colspan": 1,
2625                        "rowspan": depth - level,
2626                        "keys": [column.key],
2627                        "pin": pin,
2628                    }));
2629                }
2630                previous = None;
2631            }
2632        }
2633    }
2634    rows
2635}
2636
2637/// `POST /_renox/grid/{id}/prefs` (JSON [`GridPrefs`]) saves a user's
2638/// choices; `DELETE` forgets them.
2639pub(crate) fn router() -> Router<AppState> {
2640    Router::new().route(
2641        "/_renox/grid/{grid}/prefs",
2642        post(save_route).delete(reset_route),
2643    )
2644}
2645
2646async fn save_route(
2647    State(state): State<AppState>,
2648    axum::extract::Path(grid): axum::extract::Path<String>,
2649    session: Session,
2650    user: Option<AuthUser>,
2651    axum::Json(prefs): axum::Json<GridPrefs>,
2652) -> Result<Response> {
2653    if !valid_key(&grid) || !prefs.is_valid() {
2654        return Ok(StatusCode::UNPROCESSABLE_ENTITY.into_response());
2655    }
2656    match user {
2657        Some(user) => save_prefs(&state.db, user.id, &grid, &prefs).await?,
2658        None => session.put(&session_key(&grid), &prefs)?,
2659    }
2660    Ok(StatusCode::NO_CONTENT.into_response())
2661}
2662
2663async fn reset_route(
2664    State(state): State<AppState>,
2665    axum::extract::Path(grid): axum::extract::Path<String>,
2666    session: Session,
2667    user: Option<AuthUser>,
2668) -> Result<Response> {
2669    match user {
2670        Some(user) => {
2671            crate::db::sql("DELETE FROM grid_preferences WHERE user_id = ? AND grid = ?")
2672                .bind(user.id)
2673                .bind(&grid)
2674                .execute(&state.db)
2675                .await?;
2676        }
2677        None => {
2678            session.remove(&session_key(&grid));
2679        }
2680    }
2681    Ok(StatusCode::NO_CONTENT.into_response())
2682}
2683
2684#[cfg(test)]
2685mod tests {
2686    use super::*;
2687
2688    fn grid() -> Grid {
2689        Grid::new("orders")
2690            .column(Column::text("number", "Order").frozen())
2691            .column(Column::text("name", "Name").under(["Customer"]))
2692            .column(Column::text("email", "Email").under(["Customer"]).hidden())
2693            .column(Column::money("total", "Total").under(["Amounts", "Sum"]))
2694            .column(Column::number("tax", "Tax").under(["Amounts", "Sum"]))
2695            .column(Column::select("status", "Status", [("new", "New"), ("paid", "Paid")]).mobile())
2696            .column(Column::custom("trend", "Trend"))
2697            .column(Column::custom("actions", "").frozen_right())
2698    }
2699
2700    fn params(list: &[(&str, &str)]) -> Vec<(String, String)> {
2701        list.iter()
2702            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
2703            .collect()
2704    }
2705
2706    #[test]
2707    fn the_query_string_is_checked_against_the_grid() {
2708        let grid = grid().sort_by("-total");
2709        let state = State_::parse(
2710            &grid,
2711            &params(&[
2712                ("q.name", "coffee"),
2713                ("m.name", "starts"),
2714                ("min.total", "1000"),
2715                ("max.total", "x"),
2716                ("in.status", "paid"),
2717                ("q.trend", "custom columns don't filter"),
2718                ("q.missing", "nor unknown ones"),
2719                ("m.email", "starts"),
2720                ("page", "3"),
2721                ("per_page", "1000"),
2722                ("sort", "trend"),
2723            ]),
2724        );
2725        assert_eq!(state.filters.len(), 3, "{:?}", state.filters);
2726        assert_eq!(state.filters["name"].pattern().as_deref(), Some("coffee%"));
2727        assert_eq!(state.filters["total"].min, Some(1000.0));
2728        assert_eq!(state.filters["total"].max, None);
2729        assert_eq!(state.page, 3);
2730        assert_eq!(state.per_page, 25, "only the offered sizes");
2731        assert_eq!(state.sort, Some(("total".into(), true)), "the default");
2732
2733        let sorted = State_::parse(&grid, &params(&[("sort", "name"), ("per_page", "50")]));
2734        assert_eq!(sorted.sort, Some(("name".into(), false)));
2735        assert_eq!(sorted.per_page, 50);
2736        assert!(
2737            sorted
2738                .query_string()
2739                .contains(&("sort".into(), "name".into()))
2740        );
2741    }
2742
2743    #[test]
2744    fn text_filters_make_like_patterns() {
2745        let pattern = |text: &str, mode: Option<&str>| {
2746            Filter {
2747                text: Some(text.into()),
2748                mode: mode.map(Into::into),
2749                ..Default::default()
2750            }
2751            .pattern()
2752        };
2753        assert_eq!(pattern("coffee", None).as_deref(), Some("%coffee%"));
2754        assert_eq!(pattern("coffee", Some("ends")).as_deref(), Some("%coffee"));
2755        assert_eq!(pattern("coffee", Some("equals")).as_deref(), Some("coffee"));
2756        assert_eq!(
2757            pattern("ko%pi", Some("equals")).as_deref(),
2758            Some("ko%pi"),
2759            "% wins"
2760        );
2761        assert_eq!(pattern("  ", None), None);
2762    }
2763
2764    #[test]
2765    fn headings_group_and_span() {
2766        let grid = grid();
2767        let ordered = grid.ordered(&GridPrefs::default());
2768        let keys: Vec<&str> = ordered.iter().map(|(c, _)| c.key.as_str()).collect();
2769        assert_eq!(
2770            keys,
2771            [
2772                "number", "name", "email", "total", "tax", "status", "trend", "actions"
2773            ]
2774        );
2775        let rows = header_rows(&ordered);
2776        assert_eq!(rows.len(), 3);
2777        let labels: Vec<(String, u64, u64)> = rows[0]
2778            .iter()
2779            .map(|c| {
2780                (
2781                    c["label"]
2782                        .as_str()
2783                        .or(c["key"].as_str())
2784                        .unwrap()
2785                        .to_owned(),
2786                    c["colspan"].as_u64().unwrap(),
2787                    c["rowspan"].as_u64().unwrap(),
2788                )
2789            })
2790            .collect();
2791        assert_eq!(
2792            labels,
2793            [
2794                ("number".into(), 1, 3),
2795                ("Customer".into(), 2, 1),
2796                ("Amounts".into(), 2, 1),
2797                ("status".into(), 1, 3),
2798                ("trend".into(), 1, 3),
2799                ("actions".into(), 1, 3),
2800            ]
2801        );
2802        // Row 2: Customer's leaves span down; Amounts → Sum.
2803        let second: Vec<&str> = rows[1]
2804            .iter()
2805            .map(|c| c["label"].as_str().or(c["key"].as_str()).unwrap())
2806            .collect();
2807        assert_eq!(second, ["name", "email", "Sum"]);
2808        assert_eq!(rows[1][0]["rowspan"], 2);
2809        let third: Vec<&str> = rows[2].iter().map(|c| c["key"].as_str().unwrap()).collect();
2810        assert_eq!(third, ["total", "tax"]);
2811    }
2812
2813    #[test]
2814    fn preferences_reorder_and_freeze() {
2815        let grid = grid();
2816        let prefs = GridPrefs {
2817            order: Some(vec!["status".into(), "total".into(), "number".into()]),
2818            left: Some(vec!["total".into()]),
2819            right: Some(vec![]),
2820            ..Default::default()
2821        };
2822        let ordered = grid.ordered(&prefs);
2823        let keys: Vec<(&str, Option<Pin>)> =
2824            ordered.iter().map(|(c, p)| (c.key.as_str(), *p)).collect();
2825        assert_eq!(keys[0], ("total", Some(Pin::Left)));
2826        assert_eq!(keys[1], ("status", None));
2827        assert_eq!(keys[2], ("number", None), "unfrozen by the user");
2828        assert!(keys.iter().all(|(_, p)| *p != Some(Pin::Right)));
2829        // A group split by the frozen edge is two groups.
2830        let rows = header_rows(&ordered);
2831        let amounts = rows[0].iter().filter(|c| c["label"] == "Amounts").count();
2832        assert_eq!(amounts, 2);
2833    }
2834
2835    #[test]
2836    fn small_screens_start_with_a_few_columns() {
2837        let grid = grid();
2838        assert_eq!(grid.default_visible(true), ["status"]);
2839        assert!(!grid.default_visible(false).contains(&"email".to_string()));
2840        let plain = Grid::new("x")
2841            .column(Column::text("a", "A"))
2842            .column(Column::text("b", "B").hidden())
2843            .column(Column::text("c", "C"))
2844            .column(Column::text("d", "D"))
2845            .column(Column::text("e", "E"));
2846        assert_eq!(plain.default_visible(true), ["a", "c", "d"]);
2847    }
2848
2849    #[test]
2850    fn merged_columns_nest() {
2851        let rows: Vec<Value> = [
2852            ("java", "Bandung"),
2853            ("java", "Bandung"),
2854            ("java", "Jakarta"),
2855            ("bali", "Jakarta"),
2856            ("bali", "Denpasar"),
2857            ("java", "Denpasar"),
2858        ]
2859        .iter()
2860        .map(|(r, c)| json!({ "region": r, "city": c }))
2861        .collect();
2862        let info = merge_spans(&rows, &["region", "city"]);
2863        let spans = |i: usize| {
2864            (
2865                info[i].spans.get("region").cloned(),
2866                info[i].spans.get("city").cloned(),
2867            )
2868        };
2869        assert_eq!(spans(0), (Some(json!(3)), Some(json!(2))));
2870        assert_eq!(info[1].covered, ["region", "city"]);
2871        assert_eq!(spans(2), (None, Some(json!(1))));
2872        assert_eq!(info[2].covered, ["region"]);
2873        // Jakarta again, but under another region: a new group.
2874        assert_eq!(spans(3), (Some(json!(2)), Some(json!(1))));
2875        assert_eq!(spans(4), (None, Some(json!(1))));
2876        assert_eq!(spans(5), (Some(json!(1)), Some(json!(1))));
2877        assert_eq!(info[1].groups["city"], "city-0");
2878        assert!(merge_spans(&rows, &[]).iter().all(|m| m.spans.is_empty()));
2879    }
2880
2881    #[test]
2882    fn preferences_are_checked() {
2883        assert!(GridPrefs::default().is_valid());
2884        let widths = |w: u32| GridPrefs {
2885            widths: Some(BTreeMap::from([("total".to_owned(), w)])),
2886            ..Default::default()
2887        };
2888        assert!(widths(180).is_valid());
2889        assert!(!widths(10).is_valid() && !widths(5000).is_valid());
2890        let bad = GridPrefs {
2891            order: Some(vec!["a; DROP".into()]),
2892            ..Default::default()
2893        };
2894        assert!(!bad.is_valid());
2895        assert!(valid_key("orders-2026"));
2896        assert!(!valid_key(""));
2897    }
2898}