Skip to main content

tablo_core/
column.rs

1//! Columns, which a [`Table`](crate::Table) lists and a [`Detail`](crate::Detail) shows: the
2//! [`Column`] trait, the built-in [`TextColumn`], [`DateColumn`], [`ComputedColumn`],
3//! [`RelationColumn`], [`CountColumn`], [`BooleanColumn`], [`FileColumn`], [`ColorColumn`],
4//! [`ImageColumn`], [`EmbeddedColumn`], [`RepeaterColumn`] and [`RichTextColumn`], and the
5//! [`IntoColumns`] seam.
6//!
7//! The module sits below both: `table` re-exports it, and `detail` implements
8//! [`IntoDetail`](crate::IntoDetail) for each built-in column.
9
10use std::sync::Arc;
11
12use derive_where::derive_where;
13use toasty::stmt::{Expr, List, OrderByExpr, Path, Query};
14use topcoat::{context::Cx, view::*};
15
16use crate::{display::Alignment, schema::Binding};
17
18/// The `label` and `width` builders of a column over a [`ColumnBase`], inside its inherent impl.
19macro_rules! base_builders {
20    () => {
21        /// Replace the label the field's name gives it.
22        pub fn label(mut self, label: impl Into<$crate::Text>) -> Self {
23            self.base.label = Some(label.into());
24            self
25        }
26
27        /// Declare this column's width.
28        pub fn width(mut self, width: $crate::column::ColumnWidth) -> Self {
29            self.base.width = width;
30            self
31        }
32
33        toggle_builders!(base.toggle);
34    };
35    (sortable) => {
36        base_builders!();
37
38        /// Make the header a sort link.
39        pub fn sortable(mut self) -> Self {
40            self.sortable = true;
41            self
42        }
43    };
44}
45
46/// The `toggleable` and `hidden_by_default` builders of a column holding its [`ColumnToggle`] at
47/// the field path given, inside its inherent impl.
48macro_rules! toggle_builders {
49    ($($field:ident).+) => {
50        /// Let each reader hide this column from their list; it starts shown.
51        pub fn toggleable(mut self) -> Self {
52            self.$($field).+ = $crate::column::ColumnToggle::ShownByDefault;
53            self
54        }
55
56        /// Let each reader show this column in their list; it starts hidden.
57        pub fn hidden_by_default(mut self) -> Self {
58            self.$($field).+ = $crate::column::ColumnToggle::HiddenByDefault;
59            self
60        }
61    };
62}
63
64/// The [`Column`] methods a column over a [`ColumnBase`] answers from it, inside its `Column`
65/// impl; `binding` adds the binding's `misdeclared`.
66macro_rules! base_column_methods {
67    () => {
68        base_column_methods!(@shared);
69
70        fn reads(&self) -> Option<$crate::lens::Reads<M>> {
71            Some(self.base.reads())
72        }
73    };
74    (binding) => {
75        base_column_methods!();
76        base_column_methods!(@misdeclared);
77    };
78    // A column that answers `reads` itself.
79    (binding, reads) => {
80        base_column_methods!(@shared);
81        base_column_methods!(@misdeclared);
82    };
83    (@misdeclared) => {
84        fn misdeclared(&self) -> Option<$crate::DeclarationErrorKind> {
85            self.base.binding.misdeclared()
86        }
87    };
88    (@shared) => {
89        fn related(&self) -> $crate::column::Related {
90            $crate::column::Related::None
91        }
92
93        fn name(&self) -> &str {
94            self.base.name()
95        }
96
97        fn label(&self) -> $crate::Text {
98            self.base.label()
99        }
100
101        fn column_width(&self) -> $crate::column::ColumnWidth {
102            self.base.width
103        }
104
105        fn toggle(&self) -> $crate::column::ColumnToggle {
106            self.base.toggle
107        }
108    };
109}
110
111fn sort_order<M, T>(path: &Path<M, T>, descending: bool) -> OrderByExpr {
112    if descending {
113        path.clone().desc()
114    } else {
115        path.clone().asc()
116    }
117}
118
119mod related;
120pub use related::Related;
121mod boolean;
122mod color;
123mod computed;
124mod date;
125mod embedded;
126mod file;
127mod image;
128mod into_columns;
129mod relation;
130mod repeater;
131#[cfg(feature = "rich-text")]
132mod rich_text;
133mod text;
134
135pub use boolean::BooleanColumn;
136pub use color::ColorColumn;
137pub use computed::ComputedColumn;
138pub use date::{DateColumn, DateValue};
139pub use embedded::EmbeddedColumn;
140pub use file::FileColumn;
141pub use image::ImageColumn;
142pub use into_columns::IntoColumns;
143pub use relation::{CountColumn, RelationColumn, RelationLens, ToOneRelation};
144pub use repeater::RepeaterColumn;
145#[cfg(feature = "rich-text")]
146pub use rich_text::RichTextColumn;
147pub use text::{TextColumn, contains_expr};
148#[cfg(test)]
149use {color::is_css_color, text::escape_like_pattern};
150
151/// One column of a table or a [`Detail`](crate::Detail) declares its label, its value read off
152/// the record, and its query predicates.
153///
154/// ```rust
155/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
156/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
157/// # use tablo_core::{Text, extend::Column};
158/// # use topcoat::context::Cx;
159/// struct Initials;
160///
161/// impl Column<User> for Initials {
162///     fn name(&self) -> &str {
163///         "initials"
164///     }
165///     fn label(&self) -> Text {
166///         "Initials".into()
167///     }
168///     fn text(&self, _cx: &Cx, row: &User) -> String {
169///         row.name
170///             .split_whitespace()
171///             .filter_map(|w| w.chars().next())
172///             .collect()
173///     }
174/// }
175/// ```
176///
177/// Only [`name`](Self::name), [`label`](Self::label) and
178/// [`text`](Self::text) are required.
179pub trait Column<M>: Send + Sync {
180    /// The column's identifier, distinct within its table.
181    fn name(&self) -> &str;
182
183    /// The header text.
184    fn label(&self) -> crate::Text;
185
186    /// The row's value as plain text.
187    fn text(&self, cx: &Cx, row: &M) -> String;
188
189    /// The row's table cell.
190    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
191        crate::schema::value_cell(cx, &self.text(cx, row))
192    }
193
194    /// The record's entry on a detail page: the [`label`](Self::label) over the
195    /// [`cell`](Self::cell).
196    fn entry<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
197        crate::schema::read_only(cx, &self.label(), self.cell(cx, row))
198    }
199
200    /// The width the column claims.
201    fn column_width(&self) -> ColumnWidth {
202        ColumnWidth::Auto
203    }
204
205    /// Whether each reader may hide or show the column in their list, and how it starts. A
206    /// detail page shows every column.
207    fn toggle(&self) -> ColumnToggle {
208        ColumnToggle::Fixed
209    }
210
211    /// Where the header and the cells align their content.
212    fn alignment(&self) -> Alignment {
213        Alignment::Start
214    }
215
216    /// Whether the column joins the table's search.
217    fn is_searchable(&self) -> bool {
218        false
219    }
220
221    /// The predicate a search for `term` adds.
222    fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
223        None
224    }
225
226    /// Whether the header links to a sort on this column.
227    fn is_sortable(&self) -> bool {
228        false
229    }
230
231    /// The ordering a sort on this column applies.
232    fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
233        None
234    }
235
236    /// The relations [`text`](Self::text), [`cell`](Self::cell) and [`entry`](Self::entry) read.
237    fn includes(&self) -> Includes<M> {
238        Includes::new()
239    }
240
241    /// Check which related records [`includes`](Self::includes) loaded into `rows` the related
242    /// resource shows the user, before any cell renders: a cell shows only those. Every loader
243    /// that applies the includes runs it through `ex`, the executor that loaded the rows.
244    #[doc(hidden)]
245    fn restrict<'a>(
246        &'a self,
247        _cx: &'a Cx,
248        _rows: &'a [M],
249        _ex: &'a mut dyn toasty::Executor,
250    ) -> crate::BoxFuture<'a, topcoat::Result<()>> {
251        Box::pin(async { Ok(()) })
252    }
253
254    /// The fields of the row [`text`](Self::text), [`cell`](Self::cell),
255    /// [`entry`](Self::entry), [`search_expr`](Self::search_expr) and
256    /// [`order_by`](Self::order_by) read, or `None` when the column does not say.
257    ///
258    /// A panel drops a column reading a field
259    /// [`field_access`](crate::ResourceDef::field_access) hides from the user, and refuses to
260    /// mount a panel restricting a field whose columns answer `None`. The built-in columns
261    /// answer their own field.
262    fn reads(&self) -> Option<crate::lens::Reads<M>> {
263        None
264    }
265
266    /// Whether the column shows a field of a related record that the related resource's
267    /// [`field_access`](crate::ResourceDef::field_access) hides from `cx`: a panel drops it then,
268    /// as it drops one reading a hidden field of the row.
269    #[doc(hidden)]
270    fn masked(&self, _cx: &Cx) -> bool {
271        false
272    }
273
274    /// What the column reads of the related records it loads: see [`Related`]. A column answers
275    /// [`Related::Row`] unless it says otherwise, since a closure reading its row may reach any
276    /// relation the page loads.
277    #[doc(hidden)]
278    fn related(&self) -> Related {
279        Related::Row
280    }
281
282    /// What is wrong with this column's declaration.
283    #[doc(hidden)]
284    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
285        None
286    }
287
288    /// The source the column labels its records by, when the context's panel cannot load from it.
289    #[doc(hidden)]
290    fn unavailable_source(&self, _cx: &Cx) -> Option<&'static str> {
291        None
292    }
293}
294
295/// The relations a [`Column`] reads off its row.
296#[derive_where(Clone, Debug, Default)]
297pub struct Includes<M>(
298    Vec<crate::toasty_compat::UntypedInclude>,
299    #[derive_where(skip(Debug))] std::marker::PhantomData<fn() -> M>,
300);
301
302impl<M> Includes<M> {
303    /// No relation.
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Add `relation`.
309    pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
310        let include: crate::toasty_compat::UntypedInclude = relation.into().into();
311        if !self.0.contains(&include) {
312            self.0.push(include);
313        }
314        self
315    }
316
317    /// The relations, in the order they were added.
318    pub(crate) fn into_vec(self) -> Vec<crate::toasty_compat::UntypedInclude> {
319        self.0
320    }
321
322    pub fn len(&self) -> usize {
323        self.0.len()
324    }
325
326    pub fn is_empty(&self) -> bool {
327        self.0.is_empty()
328    }
329}
330
331/// One [`UnregisteredLabelSource`](DeclarationErrorKind::UnregisteredLabelSource) per column
332/// labelling its records by a source `cx`'s panel cannot load from.
333pub(crate) fn unavailable_sources<'c, M: 'c>(
334    cx: &Cx,
335    columns: impl IntoIterator<Item = &'c dyn Column<M>>,
336) -> Vec<crate::DeclarationErrorKind> {
337    columns
338        .into_iter()
339        .filter_map(|column| {
340            column.unavailable_source(cx).map(|source| {
341                crate::DeclarationErrorKind::UnregisteredLabelSource {
342                    column: column.name().to_string(),
343                    source,
344                }
345            })
346        })
347        .collect()
348}
349
350pub(crate) fn include_relations<'c, M>(
351    mut query: Query<List<M>>,
352    columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
353) -> Query<List<M>>
354where
355    M: toasty::schema::Model + 'c,
356{
357    let mut seen: Vec<crate::toasty_compat::UntypedInclude> = Vec::new();
358    for include in columns.into_iter().flat_map(|c| c.includes().into_vec()) {
359        if !seen.contains(&include) {
360            query = query.include(include.clone());
361            seen.push(include);
362        }
363    }
364    query
365}
366
367/// Run each column's [`restrict`](Column::restrict) over `rows`, which a query through
368/// [`include_relations`] loaded.
369pub(crate) async fn restrict_relations<'c, M>(
370    cx: &Cx,
371    columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
372    rows: &[M],
373    ex: &mut dyn toasty::Executor,
374) -> topcoat::Result<()>
375where
376    M: 'c,
377{
378    for column in columns {
379        column.restrict(cx, rows, &mut *ex).await?;
380    }
381    Ok(())
382}
383
384/// What a column over one field declares beside its lens: the field's binding, the declared label
385/// and the width.
386#[derive(Clone, Debug)]
387struct ColumnBase {
388    binding: Binding,
389    /// The declared label, over the binding's.
390    label: Option<crate::Text>,
391    width: ColumnWidth,
392    toggle: ColumnToggle,
393}
394
395impl ColumnBase {
396    /// Bind `path`, with no declared label.
397    fn new<M, T>(path: &Path<M, T>, width: ColumnWidth) -> Self
398    where
399        M: toasty::schema::Model,
400    {
401        Self {
402            binding: Binding::of(path),
403            label: None,
404            width,
405            toggle: ColumnToggle::Fixed,
406        }
407    }
408
409    fn name(&self) -> &str {
410        self.binding.name()
411    }
412
413    fn label(&self) -> crate::Text {
414        self.label.clone().unwrap_or_else(|| self.binding.label())
415    }
416
417    /// The field the column reads.
418    fn reads<M>(&self) -> crate::lens::Reads<M> {
419        self.binding.reads()
420    }
421}
422
423/// How a [`Column`] takes the table's width.
424///
425/// The table lays out automatically: each column is as wide as its content, and the first column
426/// that [`Grow`](Self::Grow)s takes the width left over. A table wider than its card scrolls
427/// sideways.
428#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
429pub enum ColumnWidth {
430    /// As wide as its content, on one line: a status, date, number or boolean.
431    #[default]
432    Auto,
433    /// As wide as its content up to 20rem, past which a value truncates; the first growing column
434    /// also takes the table's spare width. The default for text and relation columns.
435    Grow,
436    /// Wrap the content onto several lines, between 12rem and 28rem wide.
437    Wrap,
438    /// Exactly this many rem; a value past that truncates.
439    Rem(u8),
440}
441
442/// Whether each reader may hide a [`Column`] from their list, and whether it starts shown.
443///
444/// A list with a toggleable column renders a Columns menu in its toolbar, whose Apply keeps the
445/// checked columns and Reset restores the defaults. A panel keeps each signed-in user's choice per
446/// resource, in the [`ColumnPreference`](crate::auth::ColumnPreference) model, so the relation
447/// tables over a resource show what its list shows; a column added later, or whose default changes,
448/// takes its default until the reader picks otherwise. Search, sort and export still read every
449/// column. A panel without auth, and a table a page renders with
450/// [`wired_table`](crate::wired_table), render no menu and show the defaults.
451///
452/// ```rust
453/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
454/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, slug: String }
455/// use tablo_core::{TextColumn, lens};
456///
457/// TextColumn::new(lens!(Post.title)).toggleable();
458/// TextColumn::new(lens!(Post.slug)).hidden_by_default();
459/// ```
460#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
461pub enum ColumnToggle {
462    /// Always shown.
463    #[default]
464    Fixed,
465    /// Shown until the reader hides it: `toggleable()`.
466    ShownByDefault,
467    /// Hidden until the reader shows it: `hidden_by_default()`.
468    HiddenByDefault,
469}
470
471impl ColumnToggle {
472    /// Whether the reader may hide or show the column.
473    pub(crate) fn is_toggleable(self) -> bool {
474        self != Self::Fixed
475    }
476}
477
478impl ColumnWidth {
479    /// The classes of the box a body cell wraps its content in, if any.
480    pub(crate) fn content_class(self) -> Option<&'static str> {
481        match self {
482            Self::Auto => None,
483            Self::Grow => Some("max-w-xs truncate"),
484            Self::Wrap => Some("min-w-48 max-w-md whitespace-normal"),
485            Self::Rem(_) => Some("truncate"),
486        }
487    }
488
489    /// The `style` of a cell of this width, header and body alike.
490    pub(crate) fn style(self) -> Option<String> {
491        match self {
492            Self::Rem(rem) => Some(format!("width: {rem}rem; max-width: {rem}rem")),
493            Self::Auto | Self::Grow | Self::Wrap => None,
494        }
495    }
496}
497
498/// A table's columns, as the table stores them.
499pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
500
501/// Whether `column` shows to `cx`, which may not view the row's `hidden` fields: it reads none of
502/// them, and no field of a related record its resource hides from `cx`.
503pub(crate) fn shows<M>(cx: &Cx, column: &dyn Column<M>, hidden: &[String]) -> bool {
504    crate::lens::shows(column.reads(), hidden) && !column.masked(cx)
505}
506
507#[cfg(test)]
508mod tests;