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