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, view::*};
9
10use super::{Column, ColumnBase, ColumnWidth, sort_order};
11use crate::{
12    Lens, Text,
13    display::{Alignment, Color, Currency, Numeric},
14    values::FormScalar,
15};
16
17/// A column of one field, rendered as text and bound through a [`Lens`] for sorting and search.
18///
19/// ```rust
20/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
21/// # struct User {
22/// #     #[key] #[auto] id: uuid::Uuid,
23/// #     name: String,
24/// #     email: String,
25/// #     age: i64,
26/// # }
27/// tablo_core::TextColumn::new(tablo_core::lens!(User.name))
28///     .searchable()
29///     .sortable();
30/// tablo_core::TextColumn::new(tablo_core::lens!(User.age))
31///     .sortable()
32///     .format(|age| format!("{age} years"));
33/// ```
34///
35/// The cell is the value's [`FormScalar::to_label`], an [`Options`](crate::Options) enum's label
36/// or any other type's form spelling, unless [`format`](Self::format) says otherwise. Only a
37/// string field is [`searchable`](Self::searchable).
38///
39/// A column presents its value as a [`badge`](Self::badge), in a [`color`](Self::color), with a
40/// muted [`description`](Self::description) line under it, or as a right-aligned
41/// [`numeric`](Self::numeric) or [`money`](Self::money) amount:
42///
43/// ```rust
44/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
45/// # struct Order {
46/// #     #[key] #[auto] id: uuid::Uuid,
47/// #     number: String,
48/// #     customer: String,
49/// #     status: Status,
50/// #     total_cents: i64,
51/// # }
52/// # #[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, tablo_core::Options)]
53/// # enum Status { #[option(color = "warning")] Pending, #[option(color = "success")] Paid }
54/// use tablo_core::{Currency, TextColumn, lens};
55///
56/// TextColumn::new(lens!(Order.number)).description(|order: &Order| order.customer.clone());
57/// TextColumn::new(lens!(Order.status)).badge();
58/// TextColumn::new(lens!(Order.total_cents)).money(Currency::USD.minor_units());
59/// ```
60#[derive_where(Clone, Debug)]
61pub struct TextColumn<M, T> {
62    lens: Lens<M, T>,
63    base: ColumnBase,
64    #[derive_where(skip(Debug))]
65    format: Arc<dyn Fn(&T) -> Text + Send + Sync>,
66    /// The `LIKE` predicate for a search pattern, when [`searchable`](Self::searchable).
67    #[derive_where(skip(Debug))]
68    search: Option<SearchFn>,
69    sortable: bool,
70    /// The value's color: its own, an `Options` enum's, unless [`color`](Self::color) says
71    /// otherwise.
72    #[derive_where(skip(Debug))]
73    color: ColorFn<T>,
74    /// Whether the color tints the text, which only a declared [`color`](Self::color) does.
75    tinted: bool,
76    badge: bool,
77    /// Whether the digits take equal widths, so a column of amounts lines up.
78    tabular: bool,
79    alignment: Alignment,
80    #[derive_where(skip(Debug))]
81    description: Option<DescriptionFn<M>>,
82    /// The fields the description reads, when declared.
83    description_reads: Option<crate::lens::Reads<M>>,
84}
85
86/// A searchable column's predicate for an escaped `LIKE` pattern.
87type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
88
89/// A column's color for a value.
90type ColorFn<T> = Arc<dyn Fn(&T) -> Option<Color> + Send + Sync>;
91
92/// A column's second line for a row.
93type DescriptionFn<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
94
95/// The escape character the search pattern declares to `LIKE`.
96const LIKE_ESCAPE: char = '\\';
97
98/// Wrap `term` as a `LIKE` pattern matching it anywhere in the column.
99pub(super) fn escape_like_pattern(term: &str) -> String {
100    let mut pattern = String::with_capacity(term.len() + 2);
101    pattern.push('%');
102    for c in term.chars() {
103        if c == LIKE_ESCAPE || c == '%' || c == '_' {
104            pattern.push(LIKE_ESCAPE);
105        }
106        pattern.push(c);
107    }
108    pattern.push('%');
109    pattern
110}
111
112/// Escaped substring predicate for `lens` matching `term`.
113pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
114where
115    M: toasty::schema::Model,
116    T: toasty::schema::Field<Inner = String>,
117{
118    let trimmed = term.trim();
119    (!trimmed.is_empty()).then(|| {
120        lens.path()
121            .clone()
122            .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
123    })
124}
125
126impl<M, T> TextColumn<M, T>
127where
128    M: toasty::schema::Model,
129{
130    /// Bind a column to the field `lens` reads, which must be one field of the model.
131    pub fn new(lens: Lens<M, T>) -> Self
132    where
133        T: FormScalar + Send + Sync + 'static,
134    {
135        Self {
136            base: ColumnBase::new(lens.path(), ColumnWidth::Grow),
137            lens,
138            format: Arc::new(T::to_label),
139            search: None,
140            sortable: false,
141            color: Arc::new(T::color),
142            tinted: false,
143            badge: false,
144            tabular: false,
145            alignment: Alignment::Start,
146            description: None,
147            description_reads: None,
148        }
149    }
150
151    /// Render the field's value through `format`: a `String`, or a [`Text`] the request's locale
152    /// spells.
153    pub fn format<L: Into<Text>>(
154        mut self,
155        format: impl Fn(&T) -> L + Send + Sync + 'static,
156    ) -> Self {
157        self.format = Arc::new(move |value| format(value).into());
158        self
159    }
160
161    /// Show the value as a badge, with a dot in its color: an `Options` enum's
162    /// `#[option(color = "…")]`, or the [`color`](Self::color) declared here.
163    pub fn badge(mut self) -> Self {
164        self.badge = true;
165        self
166    }
167
168    /// Color the value: its badge's dot, or its text when it is no badge.
169    pub fn color(mut self, color: impl Fn(&T) -> Option<Color> + Send + Sync + 'static) -> Self {
170        self.color = Arc::new(color);
171        self.tinted = true;
172        self
173    }
174
175    /// Show `description(row)` as a muted line under the value, such as a customer's email under
176    /// their name. Search, sort and export read the value alone.
177    ///
178    /// The description may read any field, so a resource that
179    /// [restricts a field](crate::ResourceDef::field_access) refuses the column until
180    /// [`reading`](Self::reading) declares the ones it reads.
181    pub fn description(
182        mut self,
183        description: impl Fn(&M) -> String + Send + Sync + 'static,
184    ) -> Self {
185        self.description = Some(Arc::new(description));
186        self
187    }
188
189    /// Declare the fields the [`description`](Self::description) reads, beside the column's own.
190    pub fn reading(mut self, reads: crate::lens::Reads<M>) -> Self {
191        self.description_reads = Some(reads);
192        self
193    }
194
195    /// Align the header and the cells.
196    pub fn align(mut self, alignment: Alignment) -> Self {
197        self.alignment = alignment;
198        self
199    }
200
201    base_builders!(sortable);
202}
203
204impl<M, T> TextColumn<M, T>
205where
206    M: toasty::schema::Model,
207    T: Numeric + 'static,
208{
209    /// Spell the value as a number with `decimals` digits after the point and its thousands
210    /// grouped, `12,345.60`, aligned to the end with equal-width digits.
211    pub fn numeric(mut self, decimals: u8) -> Self {
212        self.format = Arc::new(move |value: &T| {
213            value
214                .number()
215                .map(|number| crate::display::format_number(number, decimals))
216                .unwrap_or_default()
217                .into()
218        });
219        self.amount()
220    }
221
222    /// Spell the value as an amount of `currency`, `$1,234.50`, aligned to the end with
223    /// equal-width digits.
224    pub fn money(mut self, currency: Currency) -> Self {
225        self.format = Arc::new(move |value: &T| currency.format(value).into());
226        self.amount()
227    }
228
229    /// Align the column as an amount.
230    fn amount(mut self) -> Self {
231        self.alignment = Alignment::End;
232        self.tabular = true;
233        self.base.width = ColumnWidth::Auto;
234        self
235    }
236}
237
238impl<M, T> TextColumn<M, T>
239where
240    M: toasty::schema::Model + Send + Sync + 'static,
241    T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
242{
243    /// Join the table's search with a substring match on this string field.
244    pub fn searchable(mut self) -> Self {
245        let path = self.lens.path().clone();
246        self.search = Some(Arc::new(move |pattern| {
247            path.clone().like_with_escape(pattern, LIKE_ESCAPE)
248        }));
249        self
250    }
251}
252
253impl<M, T> Column<M> for TextColumn<M, T>
254where
255    M: toasty::schema::Model + Send + Sync + 'static,
256    T: Send + Sync + 'static,
257{
258    base_column_methods!(binding, reads);
259
260    fn reads(&self) -> Option<crate::lens::Reads<M>> {
261        let own = self.base.reads();
262        match (&self.description, &self.description_reads) {
263            (None, _) => Some(own),
264            (Some(_), None) => None,
265            (Some(_), Some(declared)) => Some(own.union(declared)),
266        }
267    }
268
269    fn text(&self, cx: &Cx, row: &M) -> String {
270        (self.format)(self.lens.read(row)).localize(cx)
271    }
272
273    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
274        let value = self.lens.read(row);
275        let text = (self.format)(value).localize(cx);
276        let color = (!text.trim().is_empty() && (self.badge || self.tinted))
277            .then(|| (self.color)(value))
278            .flatten();
279        let description = self.description.as_ref().map(|describe| describe(row));
280        text_cell(cx, text, color, self.badge, self.tabular, description)
281    }
282
283    fn alignment(&self) -> Alignment {
284        self.alignment
285    }
286
287    fn is_searchable(&self) -> bool {
288        self.search.is_some()
289    }
290
291    /// A portable, escaped substring match.
292    fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
293        let t = term.trim();
294        let search = self.search.as_ref()?;
295        (!t.is_empty()).then(|| search(escape_like_pattern(t)))
296    }
297
298    fn is_sortable(&self) -> bool {
299        self.sortable
300    }
301
302    fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
303        self.sortable
304            .then(|| sort_order(self.lens.path(), descending))
305    }
306}
307
308/// A text cell: `text`, as a badge or tinted by `color`, over its `description`. A free function,
309/// so tablo-core compiles the view once rather than once for each model and field type.
310fn text_cell<'a>(
311    cx: &'a Cx,
312    text: String,
313    color: Option<Color>,
314    badge: bool,
315    tabular: bool,
316    description: Option<String>,
317) -> BoxView<'a> {
318    let value: BoxView<'a> = if text.trim().is_empty() {
319        crate::schema::value_cell(cx, &text)
320    } else if badge {
321        badge_cell(cx, text, color)
322    } else {
323        let class = [
324            color.map(Color::text_class),
325            tabular.then_some("tabular-nums"),
326        ]
327        .into_iter()
328        .flatten()
329        .collect::<Vec<_>>()
330        .join(" ");
331        if class.is_empty() {
332            view! { cx => (text) }.boxed()
333        } else {
334            view! { cx => <span class=(class)>(text)</span> }.boxed()
335        }
336    };
337    match description.filter(|description| !description.trim().is_empty()) {
338        None => value,
339        Some(description) => view! {
340            cx =>
341            <div class="flex min-w-0 flex-col">
342                <span class="truncate">(value)</span>
343                <span class="truncate text-xs text-muted-foreground">
344                    (description)
345                </span>
346            </div>
347        }
348        .boxed(),
349    }
350}
351
352/// `text` as an outline badge, led by a dot in `color` when it has one.
353fn badge_cell<'a>(cx: &'a Cx, text: String, color: Option<Color>) -> BoxView<'a> {
354    view! {
355        cx =>
356        tablo_ui::badge(
357            variant: tablo_ui::BadgeVariant::Outline,
358            if let Some(color) = color {
359                <span class=(color.dot_class()) aria-hidden="true"></span>
360            }
361            (text)
362        )
363    }
364    .boxed()
365}