Skip to main content

tablo_core/table/
column.rs

1//! Table columns: the [`Column`] trait, the built-in [`TextColumn`], [`ComputedColumn`] and
2//! [`BooleanColumn`], and the [`IntoColumns`] seam.
3
4use std::{borrow::Cow, sync::Arc};
5
6use toasty::stmt::{Expr, OrderByExpr};
7use topcoat::{context::Cx, icon::icon, view::*};
8
9use crate::{Lens, form::FormScalar, schema::ResolvedLens};
10
11/// One table column declares its header, its cell, and its query predicates.
12///
13/// ```rust
14/// # #[derive(Debug, Clone, toasty::Model)]
15/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
16/// # use tablo_core::Column;
17/// struct Initials;
18///
19/// impl Column<User> for Initials {
20///     fn name(&self) -> &str {
21///         "initials"
22///     }
23///     fn label(&self) -> &str {
24///         "Initials"
25///     }
26///     fn text(&self, row: &User) -> String {
27///         row.name
28///             .split_whitespace()
29///             .filter_map(|w| w.chars().next())
30///             .collect()
31///     }
32/// }
33/// ```
34///
35/// Only [`name`](Self::name), [`label`](Self::label) and
36/// [`text`](Self::text) are required.
37pub trait Column<M>: Send + Sync {
38    /// The column's identifier, distinct within its table.
39    fn name(&self) -> &str;
40
41    /// The header text.
42    fn label(&self) -> &str;
43
44    /// The row's value as plain text.
45    fn text(&self, row: &M) -> String;
46
47    /// The row's table cell.
48    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
49        let text = self.text(row);
50        view! { cx => (text) }.boxed()
51    }
52
53    /// The width the column claims.
54    fn column_width(&self) -> ColumnWidth {
55        ColumnWidth::Narrow
56    }
57
58    /// Whether the column joins the table's search.
59    fn is_searchable(&self) -> bool {
60        false
61    }
62
63    /// The predicate a search for `term` adds.
64    fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
65        None
66    }
67
68    /// Whether the header links to a sort on this column.
69    fn is_sortable(&self) -> bool {
70        false
71    }
72
73    /// The ordering a sort on this column applies.
74    fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
75        None
76    }
77
78    /// The relations [`text`](Self::text) and [`cell`](Self::cell) read.
79    fn includes(&self) -> Includes<M> {
80        Includes::new()
81    }
82
83    /// What is wrong with this column's declaration.
84    #[doc(hidden)]
85    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
86        None
87    }
88}
89
90/// The relations a [`Column`] reads off its row.
91pub struct Includes<M>(
92    Vec<toasty_core::stmt::Include>,
93    std::marker::PhantomData<fn() -> M>,
94);
95
96impl<M> Default for Includes<M> {
97    fn default() -> Self {
98        Self(Vec::new(), std::marker::PhantomData)
99    }
100}
101
102impl<M> Clone for Includes<M> {
103    fn clone(&self) -> Self {
104        Self(self.0.clone(), std::marker::PhantomData)
105    }
106}
107
108impl<M> std::fmt::Debug for Includes<M> {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_tuple("Includes").field(&self.0).finish()
111    }
112}
113
114impl<M> Includes<M> {
115    /// No relation.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Add `relation`.
121    pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
122        let include: toasty_core::stmt::Include = relation.into().into();
123        if !self.0.contains(&include) {
124            self.0.push(include);
125        }
126        self
127    }
128
129    /// The relations, in the order they were added.
130    pub(crate) fn into_vec(self) -> Vec<toasty_core::stmt::Include> {
131        self.0
132    }
133
134    pub fn len(&self) -> usize {
135        self.0.len()
136    }
137
138    pub fn is_empty(&self) -> bool {
139        self.0.is_empty()
140    }
141}
142
143/// The share of the table a [`ColumnWidth::Narrow`] column claims, in whole
144/// percent.
145pub(crate) const NARROW_DEFAULT_PERCENT: u8 = 10;
146
147/// The width a [`Column`] claims in the table's fixed layout.
148#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
149pub enum ColumnWidth {
150    /// Take a share of whatever the declared columns leave.
151    #[default]
152    Wide,
153    /// A share of the table for a status, boolean, date or count cell.
154    Narrow,
155    /// An explicit length in whole rem.
156    Rem(u8),
157    /// An explicit share of the table in whole percent.
158    Percent(u8),
159}
160
161impl ColumnWidth {
162    /// The share of the table this column claims as a kind default, in whole percent.
163    pub(crate) fn default_percent(self) -> Option<u8> {
164        match self {
165            Self::Narrow => Some(NARROW_DEFAULT_PERCENT),
166            Self::Wide | Self::Rem(_) | Self::Percent(_) => None,
167        }
168    }
169
170    /// The `style` attribute value an explicit declaration emits.
171    pub(crate) fn explicit_css(self) -> Option<Cow<'static, str>> {
172        match self {
173            Self::Rem(rem) => Some(Cow::Owned(format!("width: {rem}rem"))),
174            Self::Percent(percent) => Some(Cow::Owned(format!("width: {percent}%"))),
175            Self::Wide | Self::Narrow => None,
176        }
177    }
178}
179
180/// A column of one field, rendered as text and bound through a [`Lens`] for sorting and search.
181///
182/// ```rust
183/// # #[derive(Debug, Clone, toasty::Model)]
184/// # struct User {
185/// #     #[key] #[auto] id: uuid::Uuid,
186/// #     name: String,
187/// #     email: String,
188/// #     age: i64,
189/// # }
190/// # #[derive(Debug, Clone, toasty::Model)]
191/// # struct Post { #[key] #[auto] id: uuid::Uuid, status: String }
192/// # struct PostStatus;
193/// # impl PostStatus {
194/// #     fn label_of(_: &String) -> String { String::new() }
195/// # }
196/// tablo_core::TextColumn::new(tablo_core::lens!(User.name))
197///     .searchable()
198///     .sortable();
199/// tablo_core::TextColumn::new(tablo_core::lens!(User.age)).sortable();
200/// tablo_core::TextColumn::new(tablo_core::lens!(Post.status))
201///     .format(|status| PostStatus::label_of(status));
202/// ```
203///
204/// The cell is the value's form spelling ([`FormScalar::to_form`]) unless
205/// [`format`](Self::format) says otherwise. Only a string field is
206/// [`searchable`](Self::searchable):
207///
208/// ```compile_fail
209/// # #[derive(Debug, Clone, toasty::Model)]
210/// # struct User { #[key] #[auto] id: uuid::Uuid, age: i64 }
211/// # fn main() {
212/// tablo_core::TextColumn::new(tablo_core::lens!(User.age)).searchable();
213/// # }
214/// ```
215pub struct TextColumn<M, T> {
216    lens: Lens<M, T>,
217    name: String,
218    label: String,
219    format: Arc<dyn Fn(&T) -> String + Send + Sync>,
220    /// The `LIKE` predicate for a search pattern, when [`searchable`](Self::searchable).
221    search: Option<SearchFn>,
222    sortable: bool,
223    /// The width this column claims in the table's fixed layout.
224    width: ColumnWidth,
225    /// What is wrong with the declaration ([`Column::misdeclared`]).
226    misdeclared: Option<crate::DeclarationErrorKind>,
227}
228
229/// A searchable column's predicate for an escaped `LIKE` pattern.
230type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
231
232/// The escape character the search pattern declares to `LIKE`.
233pub(crate) const LIKE_ESCAPE: char = '\\';
234
235/// Wrap `term` as a `LIKE` pattern matching it anywhere in the column.
236pub(crate) fn escape_like_pattern(term: &str) -> String {
237    let mut pattern = String::with_capacity(term.len() + 2);
238    pattern.push('%');
239    for c in term.chars() {
240        if c == LIKE_ESCAPE || c == '%' || c == '_' {
241            pattern.push(LIKE_ESCAPE);
242        }
243        pattern.push(c);
244    }
245    pattern.push('%');
246    pattern
247}
248
249/// Escaped substring predicate for `lens` matching `term`.
250pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
251where
252    M: toasty::schema::Model,
253    T: toasty::schema::Field<Inner = String>,
254{
255    let trimmed = term.trim();
256    (!trimmed.is_empty()).then(|| {
257        lens.path()
258            .clone()
259            .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
260    })
261}
262
263impl<M, T> TextColumn<M, T>
264where
265    M: toasty::schema::Model,
266{
267    /// Bind a column to the field `lens` reads, which must be one field of the model.
268    pub fn new(lens: Lens<M, T>) -> Self
269    where
270        T: FormScalar + Send + Sync + 'static,
271    {
272        let binding = ResolvedLens::of(lens.path().clone());
273        Self {
274            lens,
275            name: binding.name,
276            label: binding.label,
277            format: Arc::new(T::to_form),
278            search: None,
279            sortable: false,
280            width: ColumnWidth::Wide,
281            misdeclared: binding.misdeclared,
282        }
283    }
284
285    /// Render the field's value through `format`.
286    pub fn format(mut self, format: impl Fn(&T) -> String + Send + Sync + 'static) -> Self {
287        self.format = Arc::new(format);
288        self
289    }
290
291    /// Make the header a sort link.
292    pub fn sortable(mut self) -> Self {
293        self.sortable = true;
294        self
295    }
296
297    /// Declare this column's width.
298    pub fn width(mut self, width: ColumnWidth) -> Self {
299        self.width = width;
300        self
301    }
302}
303
304impl<M, T> TextColumn<M, T>
305where
306    M: toasty::schema::Model + Send + Sync + 'static,
307    T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
308{
309    /// Join the table's search with a substring match on this string field.
310    pub fn searchable(mut self) -> Self {
311        let path = self.lens.path().clone();
312        self.search = Some(Arc::new(move |pattern| {
313            path.clone().like_with_escape(pattern, LIKE_ESCAPE)
314        }));
315        self
316    }
317}
318
319impl<M, T> Column<M> for TextColumn<M, T>
320where
321    M: toasty::schema::Model + Send + Sync + 'static,
322    T: Send + Sync + 'static,
323{
324    fn name(&self) -> &str {
325        &self.name
326    }
327
328    fn label(&self) -> &str {
329        &self.label
330    }
331
332    fn text(&self, row: &M) -> String {
333        (self.format)(self.lens.read(row))
334    }
335
336    fn column_width(&self) -> ColumnWidth {
337        self.width
338    }
339
340    fn is_searchable(&self) -> bool {
341        self.search.is_some()
342    }
343
344    /// A portable, escaped substring match.
345    fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
346        let t = term.trim();
347        let search = self.search.as_ref()?;
348        (!t.is_empty()).then(|| search(escape_like_pattern(t)))
349    }
350
351    fn is_sortable(&self) -> bool {
352        self.sortable
353    }
354
355    fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
356        self.sortable.then(|| {
357            let path = self.lens.path().clone();
358            if descending { path.desc() } else { path.asc() }
359        })
360    }
361
362    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
363        self.misdeclared.clone()
364    }
365}
366
367impl<M, T> Clone for TextColumn<M, T> {
368    fn clone(&self) -> Self {
369        Self {
370            lens: self.lens.clone(),
371            name: self.name.clone(),
372            label: self.label.clone(),
373            format: Arc::clone(&self.format),
374            search: self.search.clone(),
375            sortable: self.sortable,
376            width: self.width,
377            misdeclared: self.misdeclared.clone(),
378        }
379    }
380}
381
382impl<M, T> std::fmt::Debug for TextColumn<M, T> {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        f.debug_struct("TextColumn")
385            .field("name", &self.name)
386            .field("label", &self.label)
387            .field("searchable", &self.search.is_some())
388            .field("sortable", &self.sortable)
389            .field("width", &self.width)
390            .finish_non_exhaustive()
391    }
392}
393
394/// A display-only column rendering any text from the row, with no query predicate.
395///
396/// ```rust
397/// # #[derive(Debug, Clone, toasty::Model)]
398/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
399/// # #[derive(Debug, Clone, toasty::Model)]
400/// # struct Post {
401/// #     #[key] #[auto] id: uuid::Uuid,
402/// #     author_id: uuid::Uuid,
403/// #     #[belongs_to(key = author_id, references = id)]
404/// #     author: toasty::Deferred<Author>,
405/// # }
406/// tablo_core::ComputedColumn::new("Author", |p: &Post| p.author.get().name.clone())
407///     .include(Post::fields().author());
408/// ```
409///
410/// It maps to no column, so it neither searches nor sorts:
411///
412/// ```compile_fail
413/// # #[derive(Debug, Clone, toasty::Model)]
414/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
415/// # fn main() {
416/// tablo_core::ComputedColumn::new("Name", |u: &User| u.name.clone()).sortable();
417/// # }
418/// ```
419pub struct ComputedColumn<M> {
420    name: String,
421    label: String,
422    project: Arc<dyn Fn(&M) -> String + Send + Sync>,
423    width: ColumnWidth,
424    /// Relations the projection reads.
425    includes: Includes<M>,
426}
427
428impl<M> ComputedColumn<M>
429where
430    M: toasty::schema::Model,
431{
432    /// Declare a column headed `label` rendering `project(row)`.
433    pub fn new(
434        label: impl Into<String>,
435        project: impl Fn(&M) -> String + Send + Sync + 'static,
436    ) -> Self {
437        let label = label.into();
438        Self {
439            name: label.to_lowercase(),
440            label,
441            project: Arc::new(project),
442            width: ColumnWidth::Narrow,
443            includes: Includes::new(),
444        }
445    }
446
447    /// Declare a relation the projection reads, so the table loads it.
448    pub fn include<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
449        self.includes = self.includes.with(relation);
450        self
451    }
452
453    /// Declare this column's width.
454    pub fn width(mut self, width: ColumnWidth) -> Self {
455        self.width = width;
456        self
457    }
458}
459
460impl<M> Column<M> for ComputedColumn<M>
461where
462    M: toasty::schema::Model + Send + Sync + 'static,
463{
464    fn name(&self) -> &str {
465        &self.name
466    }
467
468    fn label(&self) -> &str {
469        &self.label
470    }
471
472    fn text(&self, row: &M) -> String {
473        (self.project)(row)
474    }
475
476    fn column_width(&self) -> ColumnWidth {
477        self.width
478    }
479
480    fn includes(&self) -> Includes<M> {
481        self.includes.clone()
482    }
483}
484
485impl<M> Clone for ComputedColumn<M> {
486    fn clone(&self) -> Self {
487        Self {
488            name: self.name.clone(),
489            label: self.label.clone(),
490            project: Arc::clone(&self.project),
491            width: self.width,
492            includes: self.includes.clone(),
493        }
494    }
495}
496
497impl<M> std::fmt::Debug for ComputedColumn<M> {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        f.debug_struct("ComputedColumn")
500            .field("name", &self.name)
501            .field("label", &self.label)
502            .field("width", &self.width)
503            .field("includes", &self.includes.len())
504            .finish_non_exhaustive()
505    }
506}
507
508/// A column of a `bool` field rendered as an icon.
509///
510/// ```rust
511/// # #[derive(Debug, Clone, toasty::Model)]
512/// # struct Post { #[key] #[auto] id: uuid::Uuid, featured: bool }
513/// tablo_core::BooleanColumn::new(tablo_core::lens!(Post.featured)).sortable();
514/// ```
515pub struct BooleanColumn<M> {
516    lens: Lens<M, bool>,
517    name: String,
518    label: String,
519    sortable: bool,
520    labels: (String, String),
521    width: ColumnWidth,
522    misdeclared: Option<crate::DeclarationErrorKind>,
523}
524
525impl<M> BooleanColumn<M>
526where
527    M: toasty::schema::Model,
528{
529    /// Bind the column to the `bool` field `lens` reads.
530    pub fn new(lens: Lens<M, bool>) -> Self {
531        let binding = ResolvedLens::of(lens.path().clone());
532        Self {
533            lens,
534            name: binding.name,
535            label: binding.label,
536            sortable: false,
537            labels: ("Yes".to_string(), "No".to_string()),
538            width: ColumnWidth::Narrow,
539            misdeclared: binding.misdeclared,
540        }
541    }
542
543    /// Make the header a sort link.
544    pub fn sortable(mut self) -> Self {
545        self.sortable = true;
546        self
547    }
548
549    /// Declare this column's width.
550    pub fn width(mut self, width: ColumnWidth) -> Self {
551        self.width = width;
552        self
553    }
554
555    /// The words for `true` and `false`.
556    pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
557        self.labels = (yes.into(), no.into());
558        self
559    }
560}
561
562impl<M> Column<M> for BooleanColumn<M>
563where
564    M: toasty::schema::Model + Send + Sync + 'static,
565{
566    fn name(&self) -> &str {
567        &self.name
568    }
569
570    fn label(&self) -> &str {
571        &self.label
572    }
573
574    fn text(&self, row: &M) -> String {
575        if *self.lens.read(row) {
576            self.labels.0.clone()
577        } else {
578            self.labels.1.clone()
579        }
580    }
581
582    fn column_width(&self) -> ColumnWidth {
583        self.width
584    }
585
586    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
587        let value = *self.lens.read(row);
588        let text = self.text(row);
589        let (data, class) = if value {
590            (tablo_ui::icons::CIRCLE_CHECK, "size-4 text-primary")
591        } else {
592            (tablo_ui::icons::X, "size-4 text-muted-foreground")
593        };
594        view! {
595            cx =>
596            <span class="inline-flex items-center" data-boolean=(value.to_string())>
597                icon(
598                    data: data,
599                    attrs: attributes! { class=(class) aria-hidden="true" }
600                )
601                <span class="sr-only">(text)</span>
602            </span>
603        }
604        .boxed()
605    }
606
607    fn is_sortable(&self) -> bool {
608        self.sortable
609    }
610
611    fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
612        self.sortable.then(|| {
613            let path = self.lens.path().clone();
614            if descending { path.desc() } else { path.asc() }
615        })
616    }
617
618    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
619        self.misdeclared.clone()
620    }
621}
622
623impl<M> Clone for BooleanColumn<M> {
624    fn clone(&self) -> Self {
625        Self {
626            lens: self.lens.clone(),
627            name: self.name.clone(),
628            label: self.label.clone(),
629            sortable: self.sortable,
630            labels: self.labels.clone(),
631            width: self.width,
632            misdeclared: self.misdeclared.clone(),
633        }
634    }
635}
636
637impl<M> std::fmt::Debug for BooleanColumn<M> {
638    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
639        f.debug_struct("BooleanColumn")
640            .field("name", &self.name)
641            .field("label", &self.label)
642            .field("sortable", &self.sortable)
643            .field("width", &self.width)
644            .finish_non_exhaustive()
645    }
646}
647
648/// A table's columns, as the table stores them.
649pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
650
651/// Convert a single built-in column, a tuple of any [`Column`]s, a `Vec` or slice
652/// of boxed columns, or `()` for none yet, into a table's column list.
653///
654/// [`Table::column`](super::Table::column) appends past it.
655pub trait IntoColumns<M> {
656    #[doc(hidden)]
657    fn into_columns(self) -> Vec<BoxColumn<M>>;
658}
659
660impl<M, T> IntoColumns<M> for TextColumn<M, T>
661where
662    M: toasty::schema::Model + Send + Sync + 'static,
663    T: Send + Sync + 'static,
664{
665    fn into_columns(self) -> Vec<BoxColumn<M>> {
666        vec![Arc::new(self)]
667    }
668}
669
670impl<M> IntoColumns<M> for ComputedColumn<M>
671where
672    M: toasty::schema::Model + Send + Sync + 'static,
673{
674    fn into_columns(self) -> Vec<BoxColumn<M>> {
675        vec![Arc::new(self)]
676    }
677}
678
679impl<M> IntoColumns<M> for BooleanColumn<M>
680where
681    M: toasty::schema::Model + Send + Sync + 'static,
682{
683    fn into_columns(self) -> Vec<BoxColumn<M>> {
684        vec![Arc::new(self)]
685    }
686}
687
688impl<M> IntoColumns<M> for () {
689    fn into_columns(self) -> Vec<BoxColumn<M>> {
690        Vec::new()
691    }
692}
693
694impl<M> IntoColumns<M> for Vec<BoxColumn<M>> {
695    fn into_columns(self) -> Vec<BoxColumn<M>> {
696        self
697    }
698}
699
700impl<M> IntoColumns<M> for &[BoxColumn<M>] {
701    fn into_columns(self) -> Vec<BoxColumn<M>> {
702        self.to_vec()
703    }
704}
705
706/// The tuple impls of [`IntoColumns`], one arity per invocation.
707macro_rules! into_columns_tuples {
708    ($($T:ident => $v:ident),+ $(,)?) => {
709        impl<M, $($T),+> IntoColumns<M> for ($($T,)+)
710        where
711            $($T: Column<M> + 'static,)+
712        {
713            fn into_columns(self) -> Vec<BoxColumn<M>> {
714                let ($($v,)+) = self;
715                vec![$(Arc::new($v) as BoxColumn<M>,)+]
716            }
717        }
718    };
719}
720
721into_columns_tuples!(A => a);
722into_columns_tuples!(A => a, B => b);
723into_columns_tuples!(A => a, B => b, C => c);
724into_columns_tuples!(A => a, B => b, C => c, D => d);
725into_columns_tuples!(A => a, B => b, C => c, D => d, E => e);
726into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f);
727into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f, G => g);
728into_columns_tuples!(
729    A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h
730);
731
732#[cfg(test)]
733mod tests;