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