tablo_core/column.rs
1//! Columns, which a [`Table`](crate::Table) lists and a [`Detail`](crate::Detail) shows: the
2//! [`Column`] trait, the built-in [`TextColumn`], [`DateColumn`], [`ComputedColumn`],
3//! [`RelationColumn`], [`CountColumn`], [`BooleanColumn`], [`FileColumn`], [`ColorColumn`],
4//! [`ImageColumn`], [`IconColumn`], [`EmbeddedColumn`], [`ListColumn`], [`RepeaterColumn`] and
5//! [`RichTextColumn`], the [`ToggleColumn`], [`SelectColumn`] and [`TextInputColumn`] that edit
6//! their field in a table's cell, and the [`IntoColumns`] seam.
7//!
8//! The module sits below both: `table` re-exports it, and `detail` implements
9//! [`IntoDetail`](crate::IntoDetail) for each built-in column.
10
11use std::sync::Arc;
12
13use derive_where::derive_where;
14use toasty::stmt::{Expr, List, OrderByExpr, Path, Query};
15use topcoat::{context::Cx, view::*};
16
17use crate::{display::Alignment, schema::Binding};
18
19/// The `label` and `width` builders of a column over a [`ColumnBase`], inside its inherent impl.
20macro_rules! base_builders {
21 () => {
22 /// Replace the label the field's name gives it.
23 pub fn label(mut self, label: impl Into<$crate::Text>) -> Self {
24 self.base.label = Some(label.into());
25 self
26 }
27
28 /// Declare this column's width.
29 pub fn width(mut self, width: $crate::column::ColumnWidth) -> Self {
30 self.base.width = width;
31 self
32 }
33
34 toggle_builders!(base.toggle);
35 };
36 (sortable) => {
37 base_builders!();
38
39 /// Make the header a sort link.
40 pub fn sortable(mut self) -> Self {
41 self.sortable = true;
42 self
43 }
44 };
45}
46
47/// The `toggleable` and `hidden_by_default` builders of a column holding its [`ColumnToggle`] at
48/// the field path given, inside its inherent impl.
49macro_rules! toggle_builders {
50 ($($field:ident).+) => {
51 /// Let each reader hide this column from their list; it starts shown.
52 pub fn toggleable(mut self) -> Self {
53 self.$($field).+ = $crate::column::ColumnToggle::ShownByDefault;
54 self
55 }
56
57 /// Let each reader show this column in their list; it starts hidden.
58 pub fn hidden_by_default(mut self) -> Self {
59 self.$($field).+ = $crate::column::ColumnToggle::HiddenByDefault;
60 self
61 }
62 };
63}
64
65/// The [`Column`] methods a column over a [`ColumnBase`] answers from it, inside its `Column`
66/// impl, and the accessor to its [`ColumnInternals`] impl; `reads` leaves `reads` to the column.
67macro_rules! base_column_methods {
68 () => {
69 base_column_methods!(reads);
70
71 fn reads(&self) -> Option<$crate::lens::Reads<M>> {
72 Some(self.base.reads())
73 }
74 };
75 (reads) => {
76 fn name(&self) -> &str {
77 self.base.name()
78 }
79
80 fn label(&self) -> $crate::Text {
81 self.base.label()
82 }
83
84 fn column_width(&self) -> $crate::column::ColumnWidth {
85 self.base.width
86 }
87
88 fn toggle(&self) -> $crate::column::ColumnToggle {
89 self.base.toggle
90 }
91
92 fn internals(&self) -> &dyn $crate::column::ColumnInternals<M> {
93 self
94 }
95 };
96}
97
98/// The [`ColumnInternals`] methods a column over a [`ColumnBase`] answers from it, inside its
99/// `ColumnInternals` impl: it reads no related record; `binding` adds the binding's
100/// `misdeclared`, and `binding, related` leaves `related` to the column.
101macro_rules! base_internals {
102 () => {
103 fn related(&self) -> $crate::column::Related {
104 $crate::column::Related::None
105 }
106 };
107 (binding) => {
108 base_internals!();
109 base_internals!(binding, related);
110 };
111 (binding, related) => {
112 fn misdeclared(&self) -> Option<$crate::DeclarationErrorKind> {
113 self.base.binding.misdeclared()
114 }
115 };
116}
117
118/// A closure reading a value off a row or a field: a cell's color, icon, link or tooltip.
119pub(crate) type ReadFn<T, V> = Arc<dyn Fn(&T) -> V + Send + Sync>;
120
121fn sort_order<M, T>(path: &Path<M, T>, descending: bool) -> OrderByExpr {
122 if descending {
123 path.clone().desc()
124 } else {
125 path.clone().asc()
126 }
127}
128
129mod related;
130pub use related::Related;
131mod bare;
132mod boolean;
133mod cell;
134mod color;
135mod computed;
136mod date;
137mod editable;
138mod embedded;
139mod file;
140mod icon;
141mod image;
142mod into_columns;
143mod list;
144mod relation;
145mod repeater;
146#[cfg(feature = "rich-text")]
147mod rich_text;
148mod text;
149
150pub(crate) use bare::{bare_column, reads_own_fields};
151pub use boolean::BooleanColumn;
152pub(crate) use cell::link_href;
153pub use color::ColorColumn;
154pub(crate) use color::is_css_color;
155pub use computed::ComputedColumn;
156pub use date::{DateColumn, DateValue};
157pub(crate) use editable::Editor;
158pub use editable::{SelectColumn, TextInputColumn, ToggleColumn};
159pub use embedded::EmbeddedColumn;
160pub use file::FileColumn;
161pub use icon::IconColumn;
162pub use image::ImageColumn;
163pub use into_columns::IntoColumns;
164pub use list::ListColumn;
165pub use relation::{CountColumn, RelationColumn, RelationLens, ToOneRelation};
166pub use repeater::RepeaterColumn;
167#[cfg(feature = "rich-text")]
168pub use rich_text::RichTextColumn;
169pub(crate) use sealed::ColumnInternals;
170#[cfg(test)]
171use text::escape_like_pattern;
172pub(crate) use text::{LIKE_ESCAPE, escape_like};
173pub use text::{TextColumn, contains_expr};
174
175mod sealed {
176 use topcoat::{context::Cx, view::BoxView};
177
178 use super::{Editor, Related};
179
180 /// The [`Column`](super::Column) methods only the framework answers, through
181 /// [`Column::internals`](super::Column::internals). It lives in a private module, so a column
182 /// outside tablo cannot implement it and answers the [`Defaults`].
183 pub trait ColumnInternals<M>: Send + Sync {
184 /// Check which related records the column's includes loaded into `rows` the related
185 /// resource shows the user, before any cell renders: a cell shows only those. Every
186 /// loader that applies the includes runs it through `ex`, the executor that loaded the
187 /// rows.
188 fn restrict<'a>(
189 &'a self,
190 _cx: &'a Cx,
191 _rows: &'a [M],
192 _ex: &'a mut dyn toasty::Executor,
193 ) -> crate::BoxFuture<'a, topcoat::Result<()>> {
194 Box::pin(async { Ok(()) })
195 }
196
197 /// Whether the column shows a field of a related record that the related resource's
198 /// [`field_access`](crate::ResourceDef::field_access) hides from `cx`: a panel drops it
199 /// then, as it drops one reading a hidden field of the row.
200 fn masked(&self, _cx: &Cx) -> bool {
201 false
202 }
203
204 /// What the column reads of the related records it loads: see [`Related`]. A column
205 /// answers [`Related::Row`] unless it says otherwise, since a closure reading its row may
206 /// reach any relation the page loads.
207 fn related(&self) -> Related {
208 Related::Row
209 }
210
211 /// What is wrong with this column's declaration.
212 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
213 None
214 }
215
216 /// The source the column labels its records by, when the context's panel cannot load
217 /// from it.
218 fn unavailable_source(&self, _cx: &Cx) -> Option<&'static str> {
219 None
220 }
221
222 /// The control the column edits its field with in a table's cell, if it does: the mount
223 /// checks the resource's form edits the field the column names with it.
224 fn edits(&self) -> Option<Editor> {
225 None
226 }
227
228 /// The options a [`Editor::Select`] offers, each a value and its label: the mount checks
229 /// the form's choice offers the same.
230 fn edit_options(&self) -> Vec<(String, crate::Text)> {
231 Vec::new()
232 }
233
234 /// The control editing `row`'s field, posting the field's form key, which a table renders
235 /// in place of the cell where the user may change the field. `None` renders the cell.
236 fn editor<'a>(&self, _cx: &'a Cx, _row: &M) -> Option<BoxView<'a>> {
237 None
238 }
239 }
240
241 /// What a column outside tablo answers.
242 pub struct Defaults;
243
244 impl<M> ColumnInternals<M> for Defaults {}
245}
246
247/// One column of a table or a [`Detail`](crate::Detail) declares its label, its value read off
248/// the record, and its query predicates.
249///
250/// ```rust
251/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
252/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
253/// # use tablo_core::{Text, extend::Column};
254/// # use topcoat::context::Cx;
255/// struct Initials;
256///
257/// impl Column<User> for Initials {
258/// fn name(&self) -> &str {
259/// "initials"
260/// }
261/// fn label(&self) -> Text {
262/// "Initials".into()
263/// }
264/// fn text(&self, _cx: &Cx, row: &User) -> String {
265/// row.name
266/// .split_whitespace()
267/// .filter_map(|w| w.chars().next())
268/// .collect()
269/// }
270/// }
271/// ```
272///
273/// Only [`name`](Self::name), [`label`](Self::label) and
274/// [`text`](Self::text) are required.
275pub trait Column<M>: Send + Sync {
276 /// The column's identifier, distinct within its table.
277 fn name(&self) -> &str;
278
279 /// The header text.
280 fn label(&self) -> crate::Text;
281
282 /// The row's value as plain text.
283 fn text(&self, cx: &Cx, row: &M) -> String;
284
285 /// The row's table cell.
286 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
287 crate::schema::value_cell(cx, &self.text(cx, row))
288 }
289
290 /// The record's entry on a detail page: the [`label`](Self::label) over the
291 /// [`cell`](Self::cell).
292 fn entry<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
293 crate::schema::read_only(cx, &self.label(), self.cell(cx, row))
294 }
295
296 /// The width the column claims.
297 fn column_width(&self) -> ColumnWidth {
298 ColumnWidth::Auto
299 }
300
301 /// Whether each reader may hide or show the column in their list, and how it starts. A
302 /// detail page shows every column.
303 fn toggle(&self) -> ColumnToggle {
304 ColumnToggle::Fixed
305 }
306
307 /// Where the header and the cells align their content.
308 fn alignment(&self) -> Alignment {
309 Alignment::Start
310 }
311
312 /// The predicate a search for `term` adds, or `None` when the column does not join the table's
313 /// search. A column that joins it answers `Some` for every non-empty, trimmed `term`.
314 fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
315 None
316 }
317
318 /// The ordering a sort on this column applies, or `None` when the header does not link to a
319 /// sort on it. A sortable column answers `Some` either way.
320 fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
321 None
322 }
323
324 /// The relations [`text`](Self::text), [`cell`](Self::cell) and [`entry`](Self::entry) read.
325 fn includes(&self) -> Includes<M> {
326 Includes::new()
327 }
328
329 /// The fields of the row [`text`](Self::text), [`cell`](Self::cell),
330 /// [`entry`](Self::entry), [`search_expr`](Self::search_expr) and
331 /// [`order_by`](Self::order_by) read, or `None` when the column does not say.
332 ///
333 /// A panel drops a column reading a field
334 /// [`field_access`](crate::ResourceDef::field_access) hides from the user, and refuses to
335 /// mount a panel restricting a field whose columns answer `None`. The built-in columns
336 /// answer their own field.
337 fn reads(&self) -> Option<crate::lens::Reads<M>> {
338 None
339 }
340
341 /// The framework's own column methods. The trait is sealed: a column outside tablo answers
342 /// their defaults.
343 #[doc(hidden)]
344 fn internals(&self) -> &dyn ColumnInternals<M> {
345 &sealed::Defaults
346 }
347}
348
349/// The relations a [`Column`] reads off its row.
350#[derive_where(Clone, Debug, Default)]
351pub struct Includes<M>(
352 Vec<crate::toasty_compat::UntypedInclude>,
353 #[derive_where(skip(Debug))] std::marker::PhantomData<fn() -> M>,
354);
355
356impl<M> Includes<M> {
357 /// No relation.
358 pub fn new() -> Self {
359 Self::default()
360 }
361
362 /// Add `relation`.
363 pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
364 let include: crate::toasty_compat::UntypedInclude = relation.into().into();
365 if !self.0.contains(&include) {
366 self.0.push(include);
367 }
368 self
369 }
370
371 /// The relations, in the order they were added.
372 pub(crate) fn into_vec(self) -> Vec<crate::toasty_compat::UntypedInclude> {
373 self.0
374 }
375}
376
377/// One [`UnregisteredLabelSource`](DeclarationErrorKind::UnregisteredLabelSource) per column
378/// labelling its records by a source `cx`'s panel cannot load from.
379pub(crate) fn unavailable_sources<'c, M: 'c>(
380 cx: &Cx,
381 columns: impl IntoIterator<Item = &'c dyn Column<M>>,
382) -> Vec<crate::DeclarationErrorKind> {
383 columns
384 .into_iter()
385 .filter_map(|column| {
386 column.internals().unavailable_source(cx).map(|source| {
387 crate::DeclarationErrorKind::UnregisteredLabelSource {
388 column: column.name().to_string(),
389 source,
390 }
391 })
392 })
393 .collect()
394}
395
396pub(crate) fn include_relations<'c, M>(
397 mut query: Query<List<M>>,
398 columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
399) -> Query<List<M>>
400where
401 M: toasty::schema::Model + 'c,
402{
403 let mut seen: Vec<crate::toasty_compat::UntypedInclude> = Vec::new();
404 for include in columns.into_iter().flat_map(|c| c.includes().into_vec()) {
405 if !seen.contains(&include) {
406 query = query.include(include.clone());
407 seen.push(include);
408 }
409 }
410 query
411}
412
413/// Run each column's [`restrict`](ColumnInternals::restrict) over `rows`, which a query through
414/// [`include_relations`] loaded.
415pub(crate) async fn restrict_relations<'c, M>(
416 cx: &Cx,
417 columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
418 rows: &[M],
419 ex: &mut dyn toasty::Executor,
420) -> topcoat::Result<()>
421where
422 M: 'c,
423{
424 for column in columns {
425 column.internals().restrict(cx, rows, &mut *ex).await?;
426 }
427 Ok(())
428}
429
430/// What a column over one field declares beside its lens: the field's binding, the declared label
431/// and the width.
432#[derive(Clone, Debug)]
433struct ColumnBase {
434 binding: Binding,
435 /// The declared label, over the binding's.
436 label: Option<crate::Text>,
437 width: ColumnWidth,
438 toggle: ColumnToggle,
439}
440
441impl ColumnBase {
442 /// Bind `path`, with no declared label.
443 fn new<M, T>(path: &Path<M, T>, width: ColumnWidth) -> Self
444 where
445 M: toasty::schema::Model,
446 {
447 Self {
448 binding: Binding::of(path),
449 label: None,
450 width,
451 toggle: ColumnToggle::Fixed,
452 }
453 }
454
455 fn name(&self) -> &str {
456 self.binding.name()
457 }
458
459 fn label(&self) -> crate::Text {
460 self.label.clone().unwrap_or_else(|| self.binding.label())
461 }
462
463 /// The field the column reads.
464 fn reads<M>(&self) -> crate::lens::Reads<M> {
465 self.binding.reads()
466 }
467}
468
469/// How a [`Column`] takes the table's width.
470///
471/// The table lays out automatically: each column is as wide as its content, and the first column
472/// that [`Grow`](Self::Grow)s takes the width left over. A table wider than its card scrolls
473/// sideways.
474#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
475#[non_exhaustive]
476pub enum ColumnWidth {
477 /// As wide as its content, on one line: a status, date, number or boolean.
478 #[default]
479 Auto,
480 /// As wide as its content up to 20rem, past which a value truncates; the first growing column
481 /// also takes the table's spare width. The default for text and relation columns.
482 Grow,
483 /// Wrap the content onto several lines, between 12rem and 28rem wide.
484 Wrap,
485 /// Exactly this many rem; a value past that truncates.
486 Rem(u8),
487}
488
489/// Whether each reader may hide a [`Column`] from their list, and whether it starts shown.
490///
491/// A list with a toggleable column renders a Columns menu in its toolbar, whose Apply keeps the
492/// checked columns and Reset restores the defaults. A panel keeps each signed-in user's choice per
493/// resource, in the [`ColumnPreference`](crate::auth::ColumnPreference) model, so the relation
494/// tables over a resource show what its list shows; a column added later, or whose default changes,
495/// takes its default until the reader picks otherwise. Search, sort and export still read every
496/// column. A panel without auth, and a table a page renders with
497/// [`wired_table`](crate::wired_table), render no menu and show the defaults.
498///
499/// ```rust
500/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
501/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, slug: String }
502/// use tablo_core::{TextColumn, lens};
503///
504/// TextColumn::new(lens!(Post.title)).toggleable();
505/// TextColumn::new(lens!(Post.slug)).hidden_by_default();
506/// ```
507#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
508pub enum ColumnToggle {
509 /// Always shown.
510 #[default]
511 Fixed,
512 /// Shown until the reader hides it: `toggleable()`.
513 ShownByDefault,
514 /// Hidden until the reader shows it: `hidden_by_default()`.
515 HiddenByDefault,
516}
517
518impl ColumnToggle {
519 /// Whether the reader may hide or show the column.
520 pub(crate) fn is_toggleable(self) -> bool {
521 self != Self::Fixed
522 }
523}
524
525impl ColumnWidth {
526 /// The classes of the box a body cell wraps its content in, if any.
527 pub(crate) fn content_class(self) -> Option<&'static str> {
528 match self {
529 Self::Auto => None,
530 Self::Grow => Some("max-w-xs truncate"),
531 Self::Wrap => Some("min-w-48 max-w-md whitespace-normal"),
532 Self::Rem(_) => Some("truncate"),
533 }
534 }
535
536 /// The `style` of a cell of this width, header and body alike.
537 pub(crate) fn style(self) -> Option<String> {
538 match self {
539 Self::Rem(rem) => Some(format!("width: {rem}rem; max-width: {rem}rem")),
540 Self::Auto | Self::Grow | Self::Wrap => None,
541 }
542 }
543}
544
545/// Whether `column` joins a table's search: it answers a term, as it answers every one then.
546pub(crate) fn searches<M>(column: &dyn Column<M>) -> bool {
547 column.search_expr("a").is_some()
548}
549
550/// Whether a table's header links to a sort on `column`.
551pub(crate) fn sorts<M>(column: &dyn Column<M>) -> bool {
552 column.order_by(false).is_some()
553}
554
555/// A table's columns, as the table stores them.
556pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
557
558/// Whether `column` shows to `cx`, which may not view the row's `hidden` fields: it reads none of
559/// them, and no field of a related record its resource hides from `cx`.
560pub(crate) fn shows<M>(cx: &Cx, column: &dyn Column<M>, hidden: &[String]) -> bool {
561 crate::lens::shows(column.reads(), hidden) && !column.internals().masked(cx)
562}
563
564#[cfg(test)]
565mod tests;