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