Skip to main content

tablo_core/column/
rich_text.rs

1//! [`RichTextColumn`]: a rich text field's HTML, cleaned again as it renders (ADR-0032).
2
3use derive_where::derive_where;
4use topcoat::{context::Cx, view::*};
5
6use super::{Column, ColumnBase, ColumnWidth};
7use crate::{
8    Lens,
9    rich_text::{PROSE, clean, plain_text},
10    values::FormScalar,
11};
12
13/// A column of a `String` field holding a [rich text](crate::Field::rich_text) field's HTML. A
14/// detail page shows the formatted HTML; a table cell shows its text, cut to
15/// [`excerpt`](Self::excerpt) characters; an export holds the text.
16///
17/// ```rust
18/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
19/// # struct Post { #[key] #[auto] id: uuid::Uuid, body: String }
20/// tablo_core::RichTextColumn::new(tablo_core::lens!(Post.body));
21/// ```
22///
23/// The HTML is cleaned again as it renders, keeping what any [`Tool`](crate::Tool) produces, since
24/// a seed, a migration or another app can write the column without the field's cleaning.
25#[derive_where(Clone, Debug)]
26pub struct RichTextColumn<M, T> {
27    lens: Lens<M, T>,
28    base: ColumnBase,
29    excerpt: usize,
30}
31
32impl<M, T> RichTextColumn<M, T>
33where
34    M: toasty::schema::Model,
35{
36    /// Bind the column to the `String` or `Option<String>` field `lens` reads.
37    pub fn new(lens: Lens<M, T>) -> Self
38    where
39        T: toasty::schema::Field<Inner = String>,
40    {
41        Self {
42            base: ColumnBase::new(lens.path(), ColumnWidth::Grow),
43            lens,
44            excerpt: 120,
45        }
46    }
47
48    /// The most characters of text a table cell shows, followed by `…` when the text runs on;
49    /// 120 by default.
50    pub fn excerpt(mut self, chars: usize) -> Self {
51        self.excerpt = chars;
52        self
53    }
54
55    base_builders!();
56}
57
58impl<M, T> RichTextColumn<M, T>
59where
60    T: FormScalar,
61{
62    fn html(&self, row: &M) -> String {
63        clean(&self.lens.read(row).to_form())
64    }
65}
66
67impl<M, T> Column<M> for RichTextColumn<M, T>
68where
69    M: toasty::schema::Model + Send + Sync + 'static,
70    T: FormScalar + Send + Sync + 'static,
71{
72    base_column_methods!(binding);
73
74    fn text(&self, _cx: &Cx, row: &M) -> String {
75        plain_text(&self.html(row))
76    }
77
78    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
79        excerpt_cell(cx, &self.text(cx, row), self.excerpt)
80    }
81
82    fn entry<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
83        entry(cx, &self.label(), self.html(row))
84    }
85}
86
87/// A table cell of `text` on one line, cut to `chars` characters with `…` when it runs on.
88fn excerpt_cell<'a>(cx: &'a Cx, text: &str, chars: usize) -> BoxView<'a> {
89    let text = text.replace('\n', " ");
90    let cut = match text.char_indices().nth(chars) {
91        Some((at, _)) => format!("{}…", text[..at].trim_end()),
92        None => text,
93    };
94    crate::schema::value_cell(cx, &cut)
95}
96
97/// A detail page's entry: `label` over `html`, the output of [`clean`].
98fn entry<'a>(cx: &'a Cx, label: &crate::Text, html: String) -> BoxView<'a> {
99    let value = if html.is_empty() {
100        crate::schema::value_cell(cx, "")
101    } else {
102        // `clean` kept only the elements and attributes a tool produces.
103        let html = Unescaped::new_unchecked(html);
104        view! { cx => <div class=(PROSE) data-rich-text-value="">(html)</div> }.boxed()
105    };
106    crate::schema::read_only(cx, label, value)
107}