Skip to main content

tablo_core/column/
text.rs

1//! [`TextColumn`]: one field rendered as text, searchable on a string field, and the escaped
2//! `LIKE` pattern its search builds.
3
4use std::sync::Arc;
5
6use derive_where::derive_where;
7use toasty::stmt::{Expr, OrderByExpr};
8use topcoat::{context::Cx, icon::IconData, view::*};
9
10use super::{
11    Column, ColumnBase, ColumnWidth, ReadFn,
12    cell::{TextCell, text_cell},
13    sort_order,
14};
15use crate::{
16    Lens, Text,
17    display::{Alignment, Color, Currency, FontWeight, Numeric},
18    values::FormScalar,
19};
20
21/// A column of one field, rendered as text and bound through a [`Lens`] for sorting and search.
22///
23/// ```rust
24/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
25/// # struct User {
26/// #     #[key] #[auto] id: uuid::Uuid,
27/// #     name: String,
28/// #     email: String,
29/// #     age: i64,
30/// # }
31/// tablo_core::TextColumn::new(tablo_core::lens!(User.name))
32///     .searchable()
33///     .sortable();
34/// tablo_core::TextColumn::new(tablo_core::lens!(User.age))
35///     .sortable()
36///     .format(|age| format!("{age} years"));
37/// ```
38///
39/// The cell is the value's [`FormScalar::to_label`], an [`Options`](crate::Options) enum's label
40/// or any other type's form spelling, unless [`format`](Self::format) says otherwise. Only a
41/// string field is [`searchable`](Self::searchable).
42///
43/// [`sortable`](Self::sortable) orders by the stored column, so a type a driver stores as text
44/// sorts by its spelling: a decimal column on SQLite, MySQL or Turso lists `10.00`, `100`, `9.50`.
45/// The [`ModelColumns`](derive@crate::ModelColumns) derive leaves a type that is not
46/// [`SORTABLE`](crate::extend::TypedValue::SORTABLE) unsortable.
47///
48/// A column presents its value as a [`badge`](Self::badge), in a [`color`](Self::color), with a
49/// muted [`description`](Self::description) line under it, or as a right-aligned
50/// [`numeric`](Self::numeric) or [`money`](Self::money) amount:
51///
52/// ```rust
53/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
54/// # struct Order {
55/// #     #[key] #[auto] id: uuid::Uuid,
56/// #     number: String,
57/// #     customer: String,
58/// #     status: Status,
59/// #     total_cents: i64,
60/// # }
61/// # #[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, tablo_core::Options)]
62/// # enum Status { #[option(color = "warning")] Pending, #[option(color = "success")] Paid }
63/// use tablo_core::{Currency, TextColumn, lens};
64///
65/// TextColumn::new(lens!(Order.number)).description(|order: &Order| order.customer.clone());
66/// TextColumn::new(lens!(Order.status)).badge();
67/// TextColumn::new(lens!(Order.total_cents)).money(Currency::USD.minor_units());
68/// ```
69///
70/// A cell also links to a [`url`](Self::url), shows a [`tooltip`](Self::tooltip), cuts a long
71/// value to a [`limit`](Self::limit), offers a [`copyable`](Self::copyable) button, shows an
72/// [`icon`](Self::icon) before the value, and a [`placeholder`](Self::placeholder) for an empty
73/// one:
74///
75/// ```rust
76/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
77/// # struct User { #[key] #[auto] id: uuid::Uuid, email: String, bio: String, phone: String }
78/// use tablo_core::{Reads, TextColumn, lens};
79///
80/// TextColumn::new(lens!(User.email))
81///     .url(|user: &User| Some(format!("mailto:{}", user.email)))
82///     .reading(Reads::nothing())
83///     .copyable();
84/// TextColumn::new(lens!(User.bio)).limit(40);
85/// TextColumn::new(lens!(User.phone)).placeholder("No phone");
86/// ```
87///
88/// A closure over the row, a [`description`](Self::description), a [`url`](Self::url), a
89/// [`tooltip`](Self::tooltip) or a [`money_by`](Self::money_by), may read any field, so a
90/// resource that [restricts a field](crate::ResourceDef::field_access) refuses the column until
91/// [`reading`](Self::reading) declares the ones they read.
92#[derive_where(Clone, Debug)]
93pub struct TextColumn<M, T> {
94    lens: Lens<M, T>,
95    base: ColumnBase,
96    #[derive_where(skip(Debug))]
97    format: FormatFn<M, T>,
98    /// The `LIKE` predicate for a search pattern, when [`searchable`](Self::searchable).
99    #[derive_where(skip(Debug))]
100    search: Option<SearchFn>,
101    sortable: bool,
102    /// The value's color: its own, an `Options` enum's, unless [`color`](Self::color) says
103    /// otherwise.
104    #[derive_where(skip(Debug))]
105    color: ColorFn<T>,
106    /// Whether the color tints the text, which only a declared [`color`](Self::color) does.
107    tinted: bool,
108    badge: bool,
109    /// Whether the digits take equal widths, so a column of amounts lines up.
110    tabular: bool,
111    weight: FontWeight,
112    alignment: Alignment,
113    #[derive_where(skip(Debug))]
114    description: Option<RowFn<M, String>>,
115    /// The value's icon: its own, an `Options` enum's, unless [`icon`](Self::icon) says
116    /// otherwise.
117    #[derive_where(skip(Debug))]
118    icon: ReadFn<T, Option<IconData>>,
119    #[derive_where(skip(Debug))]
120    url: Option<RowFn<M, Option<String>>>,
121    new_tab: bool,
122    #[derive_where(skip(Debug))]
123    tooltip: Option<RowFn<M, Option<String>>>,
124    limit: Option<usize>,
125    copyable: bool,
126    placeholder: Option<Text>,
127    /// Whether a closure over the row reads beyond the column's field.
128    reads_row: bool,
129    /// The fields the closures over the row read, when declared.
130    row_reads: Option<crate::lens::Reads<M>>,
131}
132
133/// A searchable column's predicate for an escaped `LIKE` pattern.
134type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
135
136/// A column's color for a value.
137type ColorFn<T> = ReadFn<T, Option<Color>>;
138
139/// A closure over the row.
140type RowFn<M, V> = ReadFn<M, V>;
141
142/// A column's spelling of its value, which may read the rest of the row.
143type FormatFn<M, T> = Arc<dyn Fn(&Cx, &M, &T) -> Text + Send + Sync>;
144
145/// The escape character a search or filter pattern declares to `LIKE`.
146pub(crate) const LIKE_ESCAPE: char = '\\';
147
148/// `term` with `LIKE`'s wildcards and the escape character escaped, so it matches only itself.
149pub(crate) fn escape_like(term: &str) -> String {
150    let mut escaped = String::with_capacity(term.len());
151    for c in term.chars() {
152        if c == LIKE_ESCAPE || c == '%' || c == '_' {
153            escaped.push(LIKE_ESCAPE);
154        }
155        escaped.push(c);
156    }
157    escaped
158}
159
160/// Wrap `term` as a `LIKE` pattern matching it anywhere in the column.
161pub(super) fn escape_like_pattern(term: &str) -> String {
162    format!("%{}%", escape_like(term))
163}
164
165/// Escaped substring predicate for `lens` matching `term`.
166pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
167where
168    M: toasty::schema::Model,
169    T: toasty::schema::Field<Inner = String>,
170{
171    let trimmed = term.trim();
172    (!trimmed.is_empty()).then(|| {
173        lens.path()
174            .clone()
175            .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
176    })
177}
178
179impl<M, T> TextColumn<M, T>
180where
181    M: toasty::schema::Model,
182{
183    /// Bind a column to the field `lens` reads, which must be one field of the model.
184    pub fn new(lens: Lens<M, T>) -> Self
185    where
186        T: FormScalar + Send + Sync + 'static,
187    {
188        Self {
189            base: ColumnBase::new(lens.path(), ColumnWidth::Grow),
190            lens,
191            format: Arc::new(|_, _, value: &T| value.to_label()),
192            search: None,
193            sortable: false,
194            color: Arc::new(T::color),
195            tinted: false,
196            badge: false,
197            tabular: false,
198            weight: FontWeight::Normal,
199            alignment: Alignment::Start,
200            description: None,
201            icon: Arc::new(T::icon),
202            url: None,
203            new_tab: false,
204            tooltip: None,
205            limit: None,
206            copyable: false,
207            placeholder: None,
208            reads_row: false,
209            row_reads: None,
210        }
211    }
212
213    /// Render the field's value through `format`: a `String`, or a [`Text`] the request's locale
214    /// spells.
215    pub fn format<L: Into<Text>>(
216        mut self,
217        format: impl Fn(&T) -> L + Send + Sync + 'static,
218    ) -> Self {
219        self.format = Arc::new(move |_, _, value| format(value).into());
220        self
221    }
222
223    /// Show the value as a badge, with a dot in its color: an `Options` enum's
224    /// `#[option(color = "…")]`, or the [`color`](Self::color) declared here.
225    pub fn badge(mut self) -> Self {
226        self.badge = true;
227        self
228    }
229
230    /// Color the value: its badge's dot, or its text when it is no badge.
231    pub fn color(mut self, color: impl Fn(&T) -> Option<Color> + Send + Sync + 'static) -> Self {
232        self.color = Arc::new(color);
233        self.tinted = true;
234        self
235    }
236
237    /// Show `description(row)` as a muted line under the value, such as a customer's email under
238    /// their name. Search, sort and export read the value alone.
239    pub fn description(
240        mut self,
241        description: impl Fn(&M) -> String + Send + Sync + 'static,
242    ) -> Self {
243        self.description = Some(Arc::new(description));
244        self.reads_row = true;
245        self
246    }
247
248    /// Link the value to `url(row)`, or show it unlinked when that is `None`. The link takes a
249    /// relative URL or an `http`, `https`, `mailto` or `tel` one; any other scheme, such as
250    /// `javascript:`, leaves the value unlinked.
251    pub fn url(mut self, url: impl Fn(&M) -> Option<String> + Send + Sync + 'static) -> Self {
252        self.url = Some(Arc::new(url));
253        self.reads_row = true;
254        self
255    }
256
257    /// Open the [`url`](Self::url) in a new tab.
258    pub fn open_url_in_new_tab(mut self) -> Self {
259        self.new_tab = true;
260        self
261    }
262
263    /// Show `tooltip(row)` when the pointer rests on the value.
264    pub fn tooltip(
265        mut self,
266        tooltip: impl Fn(&M) -> Option<String> + Send + Sync + 'static,
267    ) -> Self {
268        self.tooltip = Some(Arc::new(tooltip));
269        self.reads_row = true;
270        self
271    }
272
273    /// Show at most `chars` characters of the value, then an ellipsis; the whole value shows
274    /// when the pointer rests on it, unless a [`tooltip`](Self::tooltip) says otherwise. Search,
275    /// sort and export read the whole value.
276    pub fn limit(mut self, chars: usize) -> Self {
277        self.limit = Some(chars);
278        self
279    }
280
281    /// Follow the value with a button copying it to the clipboard, which needs JavaScript.
282    pub fn copyable(mut self) -> Self {
283        self.copyable = true;
284        self
285    }
286
287    /// Show `placeholder` for an empty value, in place of a dash. An export writes the empty
288    /// value.
289    pub fn placeholder(mut self, placeholder: impl Into<Text>) -> Self {
290        self.placeholder = Some(placeholder.into());
291        self
292    }
293
294    /// Show `icon(value)` before the value, in its color, replacing a badge's dot.
295    pub fn icon(mut self, icon: impl Fn(&T) -> Option<IconData> + Send + Sync + 'static) -> Self {
296        self.icon = Arc::new(icon);
297        self
298    }
299
300    /// Declare the fields the closures over the row read, beside the column's own: the
301    /// [`description`](Self::description), [`url`](Self::url), [`tooltip`](Self::tooltip) and
302    /// [`money_by`](Self::money_by).
303    pub fn reading(mut self, reads: crate::lens::Reads<M>) -> Self {
304        self.row_reads = Some(reads);
305        self
306    }
307
308    /// Render the value in `weight`, such as a product's name in [`FontWeight::Medium`]. A
309    /// [`badge`](Self::badge) keeps its own weight.
310    pub fn weight(mut self, weight: FontWeight) -> Self {
311        self.weight = weight;
312        self
313    }
314
315    /// Align the header and the cells.
316    pub fn align(mut self, alignment: Alignment) -> Self {
317        self.alignment = alignment;
318        self
319    }
320
321    base_builders!(sortable);
322}
323
324impl<M, T> TextColumn<M, T>
325where
326    M: toasty::schema::Model,
327    T: Numeric + 'static,
328{
329    /// Spell the value as a number with `decimals` digits after the point and its thousands
330    /// grouped in the request's locale, `12,345.60` in English and `12 345,60` in French, aligned
331    /// to the end with equal-width digits.
332    pub fn numeric(mut self, decimals: u8) -> Self {
333        self.format = Arc::new(move |cx, _, value: &T| {
334            value
335                .number()
336                .map(|number| crate::display::NumberFormat::of(cx).spell(number, decimals))
337                .unwrap_or_default()
338                .into()
339        });
340        self.amount()
341    }
342
343    /// Spell the value as an amount of `currency` in the request's locale, `$1,234.50` in English
344    /// and `1 234,50 $` in French, aligned to the end with equal-width digits.
345    pub fn money(mut self, currency: Currency) -> Self {
346        self.format = Arc::new(move |cx, _, value: &T| currency.format_in(cx, value).into());
347        self.amount()
348    }
349
350    /// Spell the value as an amount of the currency `currency(row)` names, such as an order's
351    /// own, aligned as [`money`](Self::money) aligns it.
352    pub fn money_by(mut self, currency: impl Fn(&M) -> Currency + Send + Sync + 'static) -> Self {
353        self.format = Arc::new(move |cx, row, value: &T| currency(row).format_in(cx, value).into());
354        self.reads_row = true;
355        self.amount()
356    }
357
358    /// Align the column as an amount.
359    fn amount(mut self) -> Self {
360        self.alignment = Alignment::End;
361        self.tabular = true;
362        self.base.width = ColumnWidth::Auto;
363        self
364    }
365}
366
367impl<M, T> TextColumn<M, T>
368where
369    M: toasty::schema::Model + Send + Sync + 'static,
370    T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
371{
372    /// Join the table's search with a substring match on this string field.
373    pub fn searchable(mut self) -> Self {
374        let path = self.lens.path().clone();
375        self.search = Some(Arc::new(move |pattern| {
376            path.clone().like_with_escape(pattern, LIKE_ESCAPE)
377        }));
378        self
379    }
380}
381
382impl<M, T> Column<M> for TextColumn<M, T>
383where
384    M: toasty::schema::Model + Send + Sync + 'static,
385    T: Send + Sync + 'static,
386{
387    base_column_methods!(reads);
388
389    fn reads(&self) -> Option<crate::lens::Reads<M>> {
390        let own = self.base.reads();
391        match (self.reads_row, &self.row_reads) {
392            (false, _) => Some(own),
393            (true, None) => None,
394            (true, Some(declared)) => Some(own.union(declared)),
395        }
396    }
397
398    fn text(&self, cx: &Cx, row: &M) -> String {
399        (self.format)(cx, row, self.lens.read(row)).localize(cx)
400    }
401
402    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
403        let value = self.lens.read(row);
404        let text = (self.format)(cx, row, value).localize(cx);
405        let color = (!text.trim().is_empty())
406            .then(|| (self.color)(value))
407            .flatten();
408        // An icon takes the value's color, as a badge's dot does; only a declared color tints the
409        // text.
410        let icon = (self.icon)(value).map(|icon| (icon, color));
411        let color = color.filter(|_| self.badge || self.tinted);
412        text_cell(
413            cx,
414            TextCell {
415                text,
416                color,
417                badge: self.badge,
418                tabular: self.tabular,
419                weight: self.weight,
420                icon,
421                description: self.description.as_ref().map(|describe| describe(row)),
422                url: self.url.as_ref().and_then(|url| url(row)),
423                new_tab: self.new_tab,
424                tooltip: self.tooltip.as_ref().and_then(|tooltip| tooltip(row)),
425                limit: self.limit,
426                copyable: self.copyable,
427                placeholder: self.placeholder.clone(),
428            },
429        )
430    }
431
432    fn alignment(&self) -> Alignment {
433        self.alignment
434    }
435
436    /// A portable, escaped substring match.
437    fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
438        let t = term.trim();
439        let search = self.search.as_ref()?;
440        (!t.is_empty()).then(|| search(escape_like_pattern(t)))
441    }
442
443    fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
444        self.sortable
445            .then(|| sort_order(self.lens.path(), descending))
446    }
447}
448
449impl<M, T> crate::column::ColumnInternals<M> for TextColumn<M, T>
450where
451    M: toasty::schema::Model + Send + Sync + 'static,
452    T: Send + Sync + 'static,
453{
454    base_internals!(binding, related);
455
456    /// A closure over the row may reach any relation the page loads, as a computed column's does.
457    fn related(&self) -> crate::column::Related {
458        if self.reads_row {
459            crate::column::Related::Row
460        } else {
461            crate::column::Related::None
462        }
463    }
464}