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}