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`), and the page a table loads.
3//!
4//! What a request wires onto a table (row actions, policy) lives in `wiring`, rendering
5//! in `render`, CSV export in `export`.
6
7use std::{marker::PhantomData, num::NonZeroUsize, sync::Arc};
8
9use toasty::stmt::{Expr, List, OrderByExpr};
10
11pub(crate) use self::column::{BoxColumn, Column, include_relations};
12use self::filter::BoxFilter;
13use crate::{
14    DeclarationErrorKind, Lens,
15    form::FormScalar,
16    schema::{Binding, FieldResolver, Schema},
17};
18
19pub(crate) mod column;
20mod export;
21pub(crate) mod filter;
22mod input;
23mod page;
24mod render;
25mod state;
26mod wiring;
27
28pub use self::{
29    column::{
30        BooleanColumn, ColumnWidth, ComputedColumn, CountColumn, EmbeddedColumn, FileColumn,
31        IntoColumns, RelationColumn, RelationLens, RepeaterColumn, TextColumn, ToOneRelation,
32        contains_expr,
33    },
34    filter::{DateFilter, IntoFilters, QueryFilter, SelectFilter, TernaryFilter},
35    page::TablePage,
36    state::{Cursor, Sort, TableState},
37    wiring::WiredTable,
38};
39pub(crate) use self::{
40    input::{InputDialog, Posts, TableTarget, open_dialog, selected_count},
41    page::{Past, row_exists_past},
42    state::{
43        ACTION_ROUTE_PARAM, ACTIONS_ROUTE_SEGMENT, BULK_DELETE_ROUTE_SEGMENT, CREATE_ROUTE_SEGMENT,
44        DASH_ROUTE_SEGMENT, DELETE_ROUTE_SEGMENT, EDIT_ROUTE_SEGMENT, RECORD_ROUTE_PARAM,
45        RELATED_ROUTE_PARAM, RELATION_ROUTE_PARAM, RELATIONS_ROUTE_SEGMENT, RETURN_PARAM,
46        action_options_url, bulk_action_url, create_page_url, delete_action_url, relation_url,
47        row_action_url, with_return,
48    },
49};
50
51/// Group-label projection reads a row's group off one model instance.
52pub(crate) type GroupKey<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
53
54/// Per-record action policy reads which row actions one model instance allows.
55pub(crate) type RowPolicy<M> = Arc<dyn Fn(&M) -> RowActions + Send + Sync>;
56
57/// Which row actions one record may use.
58///
59/// A denied action emits no link, and a row denied `delete` renders no bulk checkbox.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) struct RowActions {
62    /// Whether the row renders its `View` link.
63    pub view: bool,
64    /// Whether the row renders its `Edit` link.
65    pub edit: bool,
66    /// Whether the row renders its `Delete` link and an enabled bulk checkbox.
67    pub delete: bool,
68}
69
70impl RowActions {
71    /// Every action allowed.
72    pub const ALL: Self = Self {
73        view: true,
74        edit: true,
75        delete: true,
76    };
77}
78
79/// A named grouping a `Table` renders.
80pub(crate) struct GroupDef<M> {
81    binding: Binding,
82    key: GroupKey<M>,
83}
84
85impl<M> Clone for GroupDef<M> {
86    fn clone(&self) -> Self {
87        Self {
88            binding: self.binding.clone(),
89            key: Arc::clone(&self.key),
90        }
91    }
92}
93
94/// The action chrome a resource declares.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub(crate) struct TableChrome {
97    /// Whether the row renders a Delete action (which also enables bulk).
98    pub(crate) delete: bool,
99    /// Whether the row renders an Edit action.
100    pub(crate) edit: bool,
101    /// Whether the row renders a View action.
102    pub(crate) view: bool,
103    /// Whether the table renders the resource's custom actions.
104    pub(crate) actions: bool,
105}
106
107/// One custom [`Action`](crate::resource::Action) as a table renders it.
108pub(crate) struct TableAction<M> {
109    pub(crate) name: &'static str,
110    pub(crate) label: String,
111    pub(crate) row: bool,
112    pub(crate) bulk: bool,
113    /// Whether the action confirms: in the confirmation dialog, or in its input dialog.
114    pub(crate) confirm: bool,
115    /// The action's input form, when it asks for one: its buttons open the input's dialog.
116    pub(crate) input: Option<fn() -> Schema>,
117    pub(crate) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
118}
119
120/// The page size of a table that declares none with [`Table::paginate`].
121pub const DEFAULT_PAGE_SIZE: NonZeroUsize = NonZeroUsize::new(25).unwrap();
122
123/// Table description of a `Resource`'s list view declaring columns and how they map to queries.
124pub struct Table<M> {
125    columns: Vec<BoxColumn<M>>,
126    /// Misdeclarations a builder recorded ([`Self::declaration_errors`]).
127    misdeclared: Vec<DeclarationErrorKind>,
128    filters: Vec<BoxFilter<M>>,
129    group_by: Option<GroupDef<M>>,
130    /// A row's key: its record's primary key, as its action URLs carry it.
131    key: fn(&M) -> String,
132    /// Whether a row's key resolves as a URL id; a composite key does not, so its rows carry no
133    /// action.
134    addressable: bool,
135    page_size: NonZeroUsize,
136    hide_search: bool,
137    hide_filter_bar: bool,
138    _marker: PhantomData<M>,
139}
140
141/// A copy sharing every column, filter and projection.
142impl<M> Clone for Table<M> {
143    fn clone(&self) -> Self {
144        Self {
145            columns: self.columns.clone(),
146            misdeclared: self.misdeclared.clone(),
147            filters: self.filters.clone(),
148            group_by: self.group_by.clone(),
149            key: self.key,
150            addressable: self.addressable,
151            page_size: self.page_size,
152            hide_search: self.hide_search,
153            hide_filter_bar: self.hide_filter_bar,
154            _marker: PhantomData,
155        }
156    }
157}
158
159impl<M> std::fmt::Debug for Table<M> {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("Table")
162            .field(
163                "columns",
164                &self.columns.iter().map(|c| c.name()).collect::<Vec<_>>(),
165            )
166            .field("filters", &self.filters.len())
167            .field("group_by", &self.group_by.is_some())
168            .field("page_size", &self.page_size)
169            .field("hide_search", &self.hide_search)
170            .field("hide_filter_bar", &self.hide_filter_bar)
171            .finish()
172    }
173}
174
175impl<M> Table<M> {
176    /// Declare a table of `cols`.
177    ///
178    /// Each row is keyed by its record's primary key, which the row's action URLs carry.
179    pub fn new(cols: impl IntoColumns<M>) -> Self
180    where
181        M: toasty::schema::Model + toasty::stmt::IntoExpr<M>,
182    {
183        Self {
184            columns: cols.into_columns(),
185            misdeclared: Vec::new(),
186            filters: Vec::new(),
187            group_by: None,
188            key: crate::toasty_compat::pk::pk_text::<M>,
189            addressable: !crate::toasty_compat::pk::pk_is_composite::<M>(),
190            page_size: DEFAULT_PAGE_SIZE,
191            hide_search: false,
192            hide_filter_bar: false,
193            _marker: PhantomData,
194        }
195    }
196
197    /// Append `column` after the declared ones.
198    pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
199        self.columns.push(Arc::new(column));
200        self
201    }
202
203    /// Declare filters.
204    pub fn filters(mut self, filters: impl IntoFilters<M>) -> Self
205    where
206        M: toasty::schema::Model,
207    {
208        let filters = filters.into_filters();
209        self.filters = filters;
210        self
211    }
212
213    /// Include every relation this table's columns declare, once each.
214    pub(crate) fn include_relations(
215        &self,
216        query: toasty::stmt::Query<List<M>>,
217    ) -> toasty::stmt::Query<List<M>>
218    where
219        M: toasty::schema::Model,
220    {
221        column::include_relations(query, &self.columns)
222    }
223
224    /// Filter predicate for the current `TableState`.
225    pub(crate) fn filter_expr(&self, state: &TableState) -> Option<Expr<bool>>
226    where
227        M: toasty::schema::Model,
228    {
229        let mut exprs = Vec::new();
230        for f in &self.filters {
231            if let Some(v) = state.filters.get(f.name())
232                && let Some(e) = f.to_expr(v)
233            {
234                exprs.push(e);
235            }
236        }
237        if exprs.is_empty() {
238            None
239        } else {
240            let mut iter = exprs.into_iter();
241            let first = iter.next().unwrap();
242            Some(iter.fold(first, |acc, e| acc.and(e)))
243        }
244    }
245
246    /// Requested filters that produce no predicate.
247    pub(crate) fn unapplied_filters(&self, state: &TableState) -> Vec<(String, String)>
248    where
249        M: toasty::schema::Model,
250    {
251        let mut out = Vec::new();
252        for (key, value) in &state.filters {
253            match self.filters.iter().find(|f| f.name() == key) {
254                None => out.push((format!("{key}:{value}"), "unknown filter".to_string())),
255                Some(f) if f.to_expr(value).is_none() && !f.is_noop_value(value) => {
256                    out.push((format!("{key}:{value}"), "invalid value".to_string()))
257                }
258                Some(_) => {}
259            }
260        }
261        if state.filters_dropped {
262            out.push((
263                "dropped filters".to_string(),
264                format!(
265                    "more than {}, over {} bytes, or the retired filters= form",
266                    state::MAX_FILTERS,
267                    state::MAX_FILTER_LEN
268                ),
269            ));
270        }
271        out.sort();
272        out
273    }
274
275    /// Offer grouping the page's rows by the field `lens` reads, under the field's name.
276    pub fn group_by<T>(mut self, lens: Lens<M, T>) -> Self
277    where
278        M: toasty::schema::Model + Send + Sync + 'static,
279        T: FormScalar + Send + Sync + 'static,
280    {
281        self.group_by = Some(GroupDef {
282            binding: Binding::of(lens.path()),
283            key: Arc::new(move |record| lens.read(record).to_label()),
284        });
285        self
286    }
287
288    /// The declared grouping iff `state.group_by` names it.
289    fn effective_group_key(&self, state: &TableState) -> Option<GroupKey<M>> {
290        match (&self.group_by, &state.group_by) {
291            (Some(def), Some(want)) if def.binding.name() == want => Some(def.key.clone()),
292            _ => None,
293        }
294    }
295
296    /// Normalize `state.group_by` against the declared grouping.
297    pub(crate) fn normalize_state(&self, state: &TableState) -> TableState {
298        let mut out = state.clone();
299        if self.group_by.as_ref().map(|def| def.binding.name()) != out.group_by.as_deref() {
300            out.group_by = None;
301        }
302        out
303    }
304
305    /// Set the page size.
306    pub fn paginate(mut self, per_page: usize) -> Self {
307        match NonZeroUsize::new(per_page) {
308            Some(size) => self.page_size = size,
309            None => self.misdeclared.push(DeclarationErrorKind::ZeroPageSize),
310        }
311        self
312    }
313
314    /// The columns labelling their records by a source the context's panel cannot load from.
315    pub(crate) fn unavailable_sources(
316        &self,
317        cx: &topcoat::context::Cx,
318    ) -> Vec<DeclarationErrorKind> {
319        crate::table::column::unavailable_sources(cx, self.columns.iter().map(|c| &**c))
320    }
321
322    /// What is wrong with this declaration.
323    pub fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
324        let mut errors = self.misdeclared.clone();
325        errors.extend(
326            self.group_by
327                .as_ref()
328                .and_then(|def| def.binding.misdeclared()),
329        );
330        if self.columns.is_empty() {
331            errors.push(DeclarationErrorKind::NoColumns);
332        }
333        let mut seen = std::collections::HashSet::with_capacity(self.columns.len());
334        for column in &self.columns {
335            match column.misdeclared() {
336                Some(error) => errors.push(error),
337                None if !seen.insert(column.name()) => {
338                    errors.push(DeclarationErrorKind::DuplicateColumn {
339                        name: column.name().to_string(),
340                    });
341                }
342                None => {}
343            }
344        }
345        let mut seen = std::collections::HashSet::with_capacity(self.filters.len());
346        for filter in &self.filters {
347            match filter.misdeclared() {
348                Some(error) => errors.push(error),
349                None if !seen.insert(filter.name()) => {
350                    errors.push(DeclarationErrorKind::DuplicateFilter {
351                        name: filter.name().to_string(),
352                    });
353                }
354                None => {}
355            }
356        }
357        errors
358    }
359
360    /// Binds the table's embedded paths to `db`'s app schema.
361    ///
362    /// A panel binds the tables it mounts; bind one built outside a panel so its
363    /// [`declaration_errors`](Self::declaration_errors) reports no unbound path. A table with no
364    /// embedded path is bound from the start.
365    pub fn bind(self, db: &toasty::Db) -> Self {
366        self.bind_with(&FieldResolver::of_db(db));
367        self
368    }
369
370    /// Binds every column, filter, and the grouping through `resolver`.
371    pub(crate) fn bind_with(&self, resolver: &FieldResolver) {
372        for column in &self.columns {
373            column.bind(resolver);
374        }
375        for filter in &self.filters {
376            filter.bind(resolver);
377        }
378        if let Some(def) = &self.group_by {
379            def.binding.bind(resolver);
380        }
381    }
382
383    /// The page size.
384    pub fn page_size(&self) -> usize {
385        self.page_size.get()
386    }
387
388    /// The key of `record`'s row: its primary key's URL id.
389    pub(crate) fn key_of(&self, record: &M) -> String {
390        (self.key)(record)
391    }
392
393    /// Whether a row's key resolves as a URL id, so its record's actions have a route.
394    pub(crate) fn is_addressable(&self) -> bool {
395        self.addressable
396    }
397
398    /// Render no search toolbar in the table.
399    pub fn hide_search(mut self) -> Self {
400        self.hide_search = true;
401        self
402    }
403
404    /// Render no filter bar in the table.
405    pub fn hide_filter_bar(mut self) -> Self {
406        self.hide_filter_bar = true;
407        self
408    }
409
410    /// Global search predicate across searchable columns.
411    pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>>
412    where
413        M: toasty::schema::Model,
414    {
415        let t = term.trim();
416        if t.is_empty() {
417            return None;
418        }
419        let mut exprs = self.columns.iter().filter_map(|c| c.search_expr(t));
420        let first = exprs.next()?;
421        Some(exprs.fold(first, |acc, e| acc.or(e)))
422    }
423
424    /// First sortable column's order_by (tokio-rs/toasty#1142).
425    pub(crate) fn order_by(&self, descending: bool) -> Option<OrderByExpr>
426    where
427        M: toasty::schema::Model,
428    {
429        self.columns.iter().find_map(|c| c.order_by(descending))
430    }
431
432    /// Resolve the full query ordering for a request.
433    pub(crate) fn order_bys_for(&self, state: &TableState) -> Vec<OrderByExpr>
434    where
435        M: toasty::schema::Model,
436    {
437        if let Some(sort) = &state.sort
438            && let Some(col) = self
439                .columns
440                .iter()
441                .find(|c| c.is_sortable() && c.name() == sort.column)
442            && let Some(ord) = col.order_by(sort.descending)
443        {
444            return vec![ord];
445        }
446        let out: Vec<OrderByExpr> = self.order_by(false).into_iter().collect();
447        if out.is_empty() {
448            return crate::toasty_compat::pk::pk_order_bys::<M>();
449        }
450        out
451    }
452
453    /// Apply this table's declaration to `query`.
454    pub(crate) fn apply_declaration(
455        &self,
456        db: &toasty::Db,
457        mut query: toasty::stmt::Query<List<M>>,
458        state: &TableState,
459    ) -> toasty::stmt::Query<List<M>>
460    where
461        M: toasty::schema::Model,
462    {
463        if let Some(term) = &state.search
464            && let Some(expr) = self.search_expr(term)
465        {
466            query = query.filter(crate::toasty_compat::case_insensitive_search(db, expr));
467        }
468        if let Some(expr) = self.filter_expr(state) {
469            query = query.filter(expr);
470        }
471        for ord in self.order_bys_for(state) {
472            query = query.order_by(ord);
473        }
474        query
475    }
476
477    /// Whether the declaration enables a search toolbar.
478    pub(crate) fn search_enabled(&self) -> bool
479    where
480        M: toasty::schema::Model,
481    {
482        !self.hide_search && self.columns.iter().any(|c| c.is_searchable())
483    }
484
485    /// Whether the declaration enables a filter bar.
486    pub(crate) fn filter_bar_enabled(&self) -> bool {
487        !self.hide_filter_bar && !self.filters.is_empty()
488    }
489}
490
491#[cfg(test)]
492mod tests;