Skip to main content

tablo_core/
table.rs

1//! A resource's list view: the [`Table`] builder and its columns and filters, the URL state a list
2//! reads, query planning (`filter_expr`/`search_expr`/`order_bys_for`), the page a table loads, and
3//! CSV export.
4//!
5//! A column renders its own cell and a filter its own control. What a request wires onto a table
6//! and the list page around it belong to the panel: [`WiredTable`](crate::panel::WiredTable).
7
8use std::{num::NonZeroUsize, sync::Arc};
9
10use derive_where::derive_where;
11use toasty::stmt::List;
12use topcoat::context::Cx;
13
14use self::filter::BoxFilter;
15pub(crate) use self::{applied::ignored_reason, choice::ColumnChoice};
16use crate::DeclarationErrorKind;
17pub(crate) use crate::column::{BoxColumn, Column, include_relations, restrict_relations};
18
19mod applied;
20mod choice;
21mod export;
22pub(crate) mod filter;
23mod group;
24mod mask;
25pub(crate) mod model;
26mod page;
27mod row_color;
28mod row_link;
29mod search;
30mod sort;
31mod state;
32mod summary;
33mod tab;
34
35use self::sort::FieldOrder;
36pub use self::{
37    filter::{
38        DateFilter, FilterValue, IntoFilters, NumberFilter, PresenceFilter, QueryFilter,
39        SelectFilter, TernaryFilter, TextFilter, TextMatch,
40    },
41    model::ModelColumns,
42    page::{DEFAULT_PAGE_SIZE, DEFAULT_PER_PAGE_OPTIONS, PagePosition, Pagination, TablePage},
43    row_link::RowLink,
44    state::{Cursor, Sort, TableState},
45    summary::Summary,
46    tab::ListTab,
47};
48pub(crate) use self::{
49    group::GroupDef,
50    state::{bulk_key, bulk_token, group_header_dom_id, query_of, request_query, row_dom_id},
51};
52pub(crate) use crate::{
53    column::{ColumnToggle, ColumnWidth, IntoColumns, contains_expr},
54    display::Alignment,
55};
56
57/// Where a table puts a row's actions: its custom actions, View, Edit and Delete.
58///
59/// ```rust
60/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
61/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
62/// use tablo_core::{ActionLayout, Table, TextColumn, lens};
63///
64/// Table::new(TextColumn::new(lens!(Post.title))).action_layout(ActionLayout::Menu);
65/// ```
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum ActionLayout {
69    /// Side by side at the end of the row: View, Edit and Delete as icons, a custom action by its
70    /// label.
71    #[default]
72    Inline,
73    /// In a menu behind one button at the end of the row, Delete last.
74    Menu,
75}
76
77/// Table description of a `Resource`'s list view declaring columns and how they map to queries.
78///
79/// A clone shares every column, filter and projection.
80///
81/// The list keeps its whole state in the URL: `?tab=` picks a [tab](ListTab), `?q=` searches,
82/// `?sort=` and `?dir=` sort, `?page=`, `?per_page=`, `?after=` and `?before=` page,
83/// `?f.<name>=` filters and `?group_by=` groups. Each row is keyed by its record's primary key,
84/// which the action URLs and bulk checkboxes carry; a composite key has no URL form, so its rows
85/// render without row actions or bulk selection.
86///
87/// - Search matches a row any searchable column contains the term in. It escapes `%` and `_`, trims
88///   the term and caps it at 128 characters, and matches case-insensitively: PostgreSQL runs it as
89///   `ILIKE`, SQLite (for ASCII) and MySQL (under its default collations) as `LIKE`.
90/// - Without a `?sort=` naming a sortable column, the rows sort by the
91///   [`default_sort`](Self::default_sort), else by the first sortable column, ascending. The
92///   primary key breaks ties, so a sort over duplicate values pages deterministically, and a page
93///   number past the last page reads as the last.
94/// - A filter that cannot apply, an unknown name, a value the filter rejects or one past the
95///   limits, is never dropped silently: the list shows a warning banner naming it, and the export
96///   answers 400. At most 32 filters apply, each with at most 64 values and parts, each name, part
97///   and value at most 256 bytes. Active filters combine with AND.
98/// - `?group_by=` groups the rows by one of the table's [groupings](Self::group_by), which orders
99///   them by its field ahead of the sort, so a group runs on across pages; one the table does not
100///   declare is ignored.
101/// - The list updates in place: a search, filter, sort or page reruns the page through the runtime,
102///   keeping focus and scroll, and without JavaScript its links and its search form load the page.
103///   A table that fails to load shows an error state with a retry link in place of the rows.
104///
105/// When the policy allows `DeleteAny`, or `RunAny` for an action placed on the bulk bar, the list
106/// adds a checkbox column and a bulk bar; a row that no delete or bulk action allows gets no
107/// checkbox. A bulk delete takes at most [`Limits::bulk_records`](crate::Limits::bulk_records), 400
108/// by default, runs in one transaction and skips the
109/// records the policy refuses `Delete`. Both deletes ask first through a confirmation dialog,
110/// which needs JavaScript; a POST not confirmed through it answers 400. Each lands back on the
111/// list as it was left.
112///
113/// # Export
114///
115/// `GET {list}/-/export` returns the list as a CSV file named `{slug}.csv`, with the current
116/// search, filters and sort applied and the relations the columns include loaded: the rows the
117/// list shows, each of them, whatever the policy answers `View` of it. It delivers at most
118/// [`Limits::export_rows`](crate::Limits::export_rows), 10,000 by default, and answers 413 rather
119/// than a truncated file when more match. It writes each column's formatted value, a date column's
120/// stored value, and escapes a cell a spreadsheet would read as a formula; `?bom=1` prefixes the
121/// file with a byte-order mark for Excel.
122#[derive_where(Clone)]
123pub struct Table<M> {
124    columns: Vec<BoxColumn<M>>,
125    /// Misdeclarations a builder recorded ([`Self::declaration_errors`]).
126    misdeclared: Vec<DeclarationErrorKind>,
127    filters: Vec<BoxFilter<M>>,
128    groupings: Vec<GroupDef<M>>,
129    row_color: row_color::RowColor<M>,
130    row_links: Vec<RowLink<M>>,
131    tabs: Vec<ListTab<M>>,
132    summaries: Vec<Summary<M>>,
133    /// A row's key: its record's primary key, as its action URLs carry it.
134    key: fn(&M) -> String,
135    /// Whether a row's key resolves as a URL id; a composite key does not, so its rows carry no
136    /// action.
137    addressable: bool,
138    page_size: NonZeroUsize,
139    pagination: Pagination,
140    action_layout: ActionLayout,
141    /// The page sizes `?per_page=` may ask for, sorted; empty offers no choice.
142    per_page_options: Vec<usize>,
143    /// The sort a request naming no sortable column reads.
144    default_sort: Option<Sort>,
145    /// The field a [`default_sort`](Self::default_sort) orders by, which no sortable column
146    /// need show.
147    default_field: Option<FieldOrder>,
148    hide_search: bool,
149    hide_filter_menu: bool,
150    /// Whether the filter menu applies its controls only on its Apply button.
151    deferred_filters: bool,
152}
153
154impl<M> std::fmt::Debug for Table<M> {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("Table")
157            .field(
158                "columns",
159                &self.columns.iter().map(|c| c.name()).collect::<Vec<_>>(),
160            )
161            .field("filters", &self.filters.len())
162            .field(
163                "groupings",
164                &self
165                    .groupings
166                    .iter()
167                    .map(GroupDef::name)
168                    .collect::<Vec<_>>(),
169            )
170            .field(
171                "tabs",
172                &self.tabs.iter().map(ListTab::key).collect::<Vec<_>>(),
173            )
174            .field("page_size", &self.page_size)
175            .field("pagination", &self.pagination)
176            .field("default_sort", &self.default_sort)
177            .field("hide_search", &self.hide_search)
178            .field("hide_filter_menu", &self.hide_filter_menu)
179            .field("deferred_filters", &self.deferred_filters)
180            .finish()
181    }
182}
183
184impl<M> Table<M> {
185    /// Declare a table of `cols`.
186    ///
187    /// Each row is keyed by its record's primary key, which the row's action URLs carry.
188    pub fn new(cols: impl IntoColumns<M>) -> Self
189    where
190        M: toasty::schema::Model + toasty::stmt::IntoExpr<M>,
191    {
192        Self {
193            columns: cols.into_columns(),
194            misdeclared: Vec::new(),
195            filters: Vec::new(),
196            groupings: Vec::new(),
197            row_color: row_color::RowColor::default(),
198            row_links: Vec::new(),
199            tabs: Vec::new(),
200            summaries: Vec::new(),
201            key: crate::toasty_compat::pk::pk_text::<M>,
202            addressable: !crate::toasty_compat::pk::pk_is_composite::<M>(),
203            page_size: DEFAULT_PAGE_SIZE,
204            pagination: Pagination::default(),
205            action_layout: ActionLayout::default(),
206            per_page_options: DEFAULT_PER_PAGE_OPTIONS.to_vec(),
207            default_sort: None,
208            default_field: None,
209            hide_search: false,
210            hide_filter_menu: false,
211            deferred_filters: false,
212        }
213    }
214
215    /// Append `column` after the declared ones.
216    pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
217        self.columns.push(Arc::new(column));
218        self
219    }
220
221    /// Append `filters` after the declared ones, such as those a field's `#[columns(filter)]`
222    /// declares.
223    pub fn filters(mut self, filters: impl IntoFilters<M>) -> Self
224    where
225        M: toasty::schema::Model,
226    {
227        self.filters.extend(filters.into_filters());
228        self
229    }
230
231    /// Whether a reader may hide or show any of the columns.
232    pub(crate) fn has_toggleable_columns(&self) -> bool {
233        self.columns.iter().any(|c| c.toggle().is_toggleable())
234    }
235
236    /// The declared columns, in order.
237    pub(crate) fn declared_columns(&self) -> &[BoxColumn<M>] {
238        &self.columns
239    }
240
241    /// This table with only the columns `names` names, in that order, and no default sort on a
242    /// column it drops. A name it declares no column for picks none.
243    pub(crate) fn only(&self, names: &[String]) -> Self {
244        let mut table = self.clone();
245        table.columns = names
246            .iter()
247            .filter_map(|name| self.columns.iter().find(|column| column.name() == name))
248            .cloned()
249            .collect();
250        if table
251            .default_sort
252            .as_ref()
253            .is_some_and(|sort| !table.orders_by(sort))
254        {
255            table.default_sort = None;
256        }
257        table
258    }
259
260    /// Appends `filter` after the declared ones.
261    pub(crate) fn push_filter(&mut self, filter: BoxFilter<M>) {
262        self.filters.push(filter);
263    }
264
265    /// The declared filters, in order.
266    pub(crate) fn declared_filters(&self) -> &[BoxFilter<M>] {
267        &self.filters
268    }
269
270    /// Include every relation this table's columns declare, once each.
271    pub(crate) fn include_relations(
272        &self,
273        query: toasty::stmt::Query<List<M>>,
274    ) -> toasty::stmt::Query<List<M>>
275    where
276        M: toasty::schema::Model,
277    {
278        include_relations(query, &self.columns)
279    }
280
281    /// Whether [`Self::include_relations`] adds an include.
282    pub(crate) fn includes_relations(&self) -> bool {
283        self.columns
284            .iter()
285            .any(|column| !column.includes().into_vec().is_empty())
286    }
287
288    /// Have each closure over the row that declares it reads none of the row's relations, its row
289    /// color, its row links and its columns, read `bare(row)`, a copy of the row without them, when
290    /// a column loads one: the page's loads then cannot reach the closure.
291    pub(crate) fn read_bare(&mut self, bare: fn(&M) -> M)
292    where
293        M: toasty::schema::Model + 'static,
294    {
295        if !self.includes_relations() {
296            return;
297        }
298        self.bare_row_color(bare);
299        self.bare_row_links(bare);
300        self.columns = std::mem::take(&mut self.columns)
301            .into_iter()
302            .map(|column| crate::column::bare_column(column, bare))
303            .collect();
304    }
305
306    /// Check which related records `rows`, loaded through [`Self::include_relations`], hold that
307    /// the columns' sources show the user.
308    pub(crate) async fn restrict_relations(
309        &self,
310        cx: &topcoat::context::Cx,
311        rows: &[M],
312        ex: &mut dyn toasty::Executor,
313    ) -> topcoat::Result<()> {
314        restrict_relations(cx, &self.columns, rows, ex).await
315    }
316
317    /// Page through the rows as `pagination` says; [`Pagination::Pages`] by default.
318    pub fn pagination(mut self, pagination: Pagination) -> Self {
319        self.pagination = pagination;
320        self
321    }
322
323    /// Put a row's actions where `layout` says; [`ActionLayout::Inline`] by default.
324    pub fn action_layout(mut self, layout: ActionLayout) -> Self {
325        self.action_layout = layout;
326        self
327    }
328
329    /// The page sizes a reader may pick, beside the table's own; none offers no choice.
330    /// [`DEFAULT_PER_PAGE_OPTIONS`] by default. A cursor table offers none.
331    pub fn per_page_options(mut self, options: impl IntoIterator<Item = usize>) -> Self {
332        self.per_page_options = options.into_iter().filter(|size| *size > 0).collect();
333        self
334    }
335
336    /// Show `size` rows a page; [`DEFAULT_PAGE_SIZE`] by default.
337    pub fn page_size(mut self, size: usize) -> Self {
338        match NonZeroUsize::new(size) {
339            Some(size) => self.page_size = size,
340            None => self.misdeclared.push(DeclarationErrorKind::ZeroPageSize),
341        }
342        self
343    }
344
345    /// The columns and filters reading a source the context's panel cannot load from.
346    pub(crate) fn unavailable_sources(
347        &self,
348        cx: &topcoat::context::Cx,
349    ) -> Vec<DeclarationErrorKind> {
350        let mut errors = crate::column::unavailable_sources(cx, self.columns.iter().map(|c| &**c));
351        errors.extend(self.filters.iter().filter_map(|filter| {
352            filter.internals().unavailable_source(cx).map(|source| {
353                DeclarationErrorKind::UnregisteredOptionSource {
354                    field: filter.name().to_string(),
355                    source,
356                }
357            })
358        }));
359        errors
360    }
361
362    /// What is wrong with this declaration.
363    pub fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
364        let mut errors = self.misdeclared.clone();
365        errors.extend(self.grouping_errors());
366        errors.extend(self.tab_errors());
367        if self.columns.is_empty() {
368            errors.push(DeclarationErrorKind::NoColumns);
369        }
370        if let Some(sort) = &self.default_sort
371            && !self.orders_by(sort)
372        {
373            errors.push(DeclarationErrorKind::UnsortableDefaultSort {
374                column: sort.column.clone(),
375            });
376        }
377        let mut seen = std::collections::HashSet::with_capacity(self.columns.len());
378        for column in &self.columns {
379            match column.internals().misdeclared() {
380                Some(error) => errors.push(error),
381                None if !seen.insert(column.name()) => {
382                    errors.push(DeclarationErrorKind::DuplicateColumn {
383                        name: column.name().to_string(),
384                    });
385                }
386                None => {}
387            }
388        }
389        let mut seen = std::collections::HashSet::with_capacity(self.filters.len());
390        for filter in &self.filters {
391            match filter.internals().misdeclared() {
392                Some(error) => errors.push(error),
393                None if filter.name().contains('.') => {
394                    errors.push(DeclarationErrorKind::DottedFilterName {
395                        name: filter.name().to_string(),
396                    });
397                }
398                None if !seen.insert(filter.name()) => {
399                    errors.push(DeclarationErrorKind::DuplicateFilter {
400                        name: filter.name().to_string(),
401                    });
402                }
403                None => {}
404            }
405        }
406        errors
407    }
408
409    /// The rows a page shows, as [`Self::page_size`] set them.
410    pub fn per_page(&self) -> usize {
411        self.page_size.get()
412    }
413
414    /// How the table pages.
415    pub(crate) fn pagination_mode(&self) -> Pagination {
416        self.pagination
417    }
418
419    /// Where the table puts a row's actions.
420    pub(crate) fn row_action_layout(&self) -> ActionLayout {
421        self.action_layout
422    }
423
424    /// The page sizes the reader may pick, sorted, the table's own among them; empty when it
425    /// offers no choice.
426    pub(crate) fn page_sizes(&self) -> Vec<usize> {
427        if self.per_page_options.is_empty() || self.pagination == Pagination::Cursor {
428            return Vec::new();
429        }
430        let mut sizes = self.per_page_options.clone();
431        sizes.push(self.per_page());
432        sizes.sort_unstable();
433        sizes.dedup();
434        sizes
435    }
436
437    /// The page size `state` reads at: its `?per_page=` when the table offers it, else the
438    /// table's own.
439    pub(crate) fn page_size_for(&self, state: &TableState) -> usize {
440        state
441            .per_page
442            .and_then(|asked| usize::try_from(asked).ok())
443            .filter(|asked| self.page_sizes().contains(asked))
444            .unwrap_or_else(|| self.per_page())
445    }
446
447    /// The key of `record`'s row: its primary key's URL id.
448    pub(crate) fn key_of(&self, record: &M) -> String {
449        (self.key)(record)
450    }
451
452    /// Each column's name, label and text for `record`, as the export spells them.
453    pub(crate) fn spell(&self, cx: &Cx, record: &M) -> Vec<(String, crate::Text, String)> {
454        self.columns
455            .iter()
456            .map(|column| {
457                (
458                    column.name().to_string(),
459                    column.label(),
460                    column.text(cx, record),
461                )
462            })
463            .collect()
464    }
465
466    /// Whether a row's key resolves as a URL id, so its record's actions have a route.
467    pub(crate) fn is_addressable(&self) -> bool {
468        self.addressable
469    }
470
471    /// Render no search toolbar in the table.
472    pub fn hide_search(mut self) -> Self {
473        self.hide_search = true;
474        self
475    }
476
477    /// Render no filter menu in the table's toolbar.
478    pub fn hide_filter_menu(mut self) -> Self {
479        self.hide_filter_menu = true;
480        self
481    }
482
483    /// Apply the filter menu's controls only when its Apply button is pressed, rather than as each
484    /// one changes, so a reader sets several filters before the list reruns once. A search, a
485    /// sort or a tab applies the controls as they stand too, since the toolbar's form carries
486    /// them.
487    pub fn deferred_filters(mut self) -> Self {
488        self.deferred_filters = true;
489        self
490    }
491
492    /// Whether the filter menu applies its controls only on its Apply button.
493    pub(crate) fn filters_deferred(&self) -> bool {
494        self.deferred_filters
495    }
496
497    /// Apply this table's declaration to `query`: [`Self::narrow`], then the ordering.
498    pub(crate) fn apply_declaration(
499        &self,
500        cx: &Cx,
501        db: &toasty::Db,
502        query: toasty::stmt::Query<List<M>>,
503        state: &TableState,
504        refused: &[String],
505    ) -> toasty::stmt::Query<List<M>>
506    where
507        M: toasty::schema::Model,
508    {
509        self.order(self.narrow(cx, db, query, state, refused), state)
510    }
511
512    /// Narrow `query` to the rows `state`'s tab, search and filters select, but the `refused`
513    /// filters, unordered: what a count reads.
514    pub(crate) fn narrow(
515        &self,
516        cx: &Cx,
517        db: &toasty::Db,
518        query: toasty::stmt::Query<List<M>>,
519        state: &TableState,
520        refused: &[String],
521    ) -> toasty::stmt::Query<List<M>>
522    where
523        M: toasty::schema::Model,
524    {
525        self.narrow_tab(self.narrow_untabbed(cx, db, query, state, refused), state)
526    }
527
528    /// [`Self::narrow`] but the tab: what a tab's badge counts within.
529    fn narrow_untabbed(
530        &self,
531        cx: &Cx,
532        db: &toasty::Db,
533        mut query: toasty::stmt::Query<List<M>>,
534        state: &TableState,
535        refused: &[String],
536    ) -> toasty::stmt::Query<List<M>>
537    where
538        M: toasty::schema::Model,
539    {
540        if let Some(term) = &state.search
541            && let Some(expr) = self.search_expr(term)
542        {
543            query = query.filter(crate::toasty_compat::case_insensitive_search(db, expr));
544        }
545        if let Some(expr) = self.filter_expr(cx, state, refused) {
546            query = query.filter(expr);
547        }
548        query
549    }
550
551    /// Whether the declaration enables a filter menu.
552    pub(crate) fn filter_menu_enabled(&self) -> bool {
553        !self.hide_filter_menu && !self.filters.is_empty()
554    }
555}
556
557#[cfg(test)]
558mod tests;