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