Skip to main content

tablo_core/table/render/
core.rs

1//! Static and live entry points and the table chrome they share.
2
3use tablo_ui::{table, table_body};
4use topcoat::{Result, context::Cx, runtime::Event, view::*};
5
6use super::{
7    super::Table,
8    BAR_CLASS, TABLE_BARE_CLASS, TABLE_CARD_CLASS,
9    rows::{RowChrome, render_rows},
10    widths::ColumnWidths,
11};
12use crate::table::{
13    page::TablePage,
14    state::{TableSignals, TableState},
15};
16
17impl<M> Table<M> {
18    /// Render the table for the given loaded page.
19    ///
20    /// # Errors
21    ///
22    /// A misdeclared table ([`Table::declaration_errors`]) fails with its
23    /// errors rather than render.
24    pub async fn render<'a>(&self, cx: &'a Cx, page: TablePage<M>) -> Result<BoxView<'a>>
25    where
26        M: toasty::schema::Model + Send + Sync + 'static,
27    {
28        let state = TableState::from_cx(cx);
29        let path = topcoat::context::try_request_context::<http::request::Parts>(cx)
30            .map(|parts| parts.uri.path().to_string())
31            .unwrap_or_default();
32        self.render_with_state(cx, page, &state, &path).await
33    }
34
35    /// Render with explicit list state and path, normalizing the state before rendering links.
36    pub async fn render_with_state<'a>(
37        &self,
38        cx: &'a Cx,
39        page: TablePage<M>,
40        state: &TableState,
41        path: &str,
42    ) -> Result<BoxView<'a>>
43    where
44        M: toasty::schema::Model + Send + Sync + 'static,
45    {
46        self.render_inner(cx, page, &self.normalize_state(state), path, None)
47            .await
48    }
49
50    /// Render the interactive body for a live table, excluding the row-delete dialog the caller
51    /// renders separately.
52    pub(crate) async fn render_live<'a>(
53        &self,
54        cx: &'a Cx,
55        page: TablePage<M>,
56        state: &TableState,
57        path: &str,
58        signals: TableSignals,
59    ) -> Result<BoxView<'a>>
60    where
61        M: toasty::schema::Model + Send + Sync + 'static,
62    {
63        self.render_inner(cx, page, state, path, Some(signals))
64            .await
65    }
66
67    async fn render_inner<'a>(
68        &self,
69        cx: &'a Cx,
70        page: TablePage<M>,
71        state: &TableState,
72        path: &str,
73        signals: Option<TableSignals>,
74    ) -> Result<BoxView<'a>>
75    where
76        M: toasty::schema::Model + Send + Sync + 'static,
77    {
78        let errors = self.declaration_errors();
79        if !errors.is_empty() {
80            return Err(crate::error::misdeclared(&errors));
81        }
82        let delete_prefix = self.delete_prefix.clone();
83        let with_actions = self.with_actions();
84        let with_bulk = self.bulk_enabled();
85        let head = self
86            .render_thead(cx, state, path, with_actions, with_bulk, signals.as_ref())
87            .await?;
88        let show_search = self.search_enabled();
89        let search_bar = if show_search {
90            Some(self.render_search_bar(cx, state, path).await?)
91        } else {
92            None
93        };
94        let show_filters = self.filter_bar_enabled();
95        let filter_bar = if show_filters {
96            Some(
97                self.render_filter_bar(cx, state, path, signals.as_ref())
98                    .await?,
99            )
100        } else {
101            None
102        };
103        let bulk_bar_view = self.render_bulk_bar(cx, signals.as_ref());
104        let pager = self
105            .render_pager(cx, state, path, &page, signals.as_ref())
106            .await?;
107        let filter_warning = self.render_filter_warning(cx, state, path);
108        let group_key = self.effective_group_key(state);
109        let row_data = self.row_views(cx, state, path, &page, group_key.as_ref());
110        let delete_dialog = if signals.is_none() {
111            self.render_delete_dialog(cx, state).await?
112        } else {
113            None
114        };
115
116        let ColumnWidths {
117            cells: cell_widths,
118            actions_min,
119            table_min_width,
120            ..
121        } = self.column_widths();
122        let mut pager_views: Vec<BoxView<'_>> = Vec::new();
123        let body: BoxView<'_> = if page.rows.is_empty() {
124            let empty_cell = self
125                .render_empty_cell(cx, state, path, with_actions, with_bulk, signals.as_ref())
126                .await?;
127            view! {
128                cx =>
129                table(
130                    attrs: attributes! { class="table-fixed" style=(table_min_width.as_deref()) },
131                    (head)
132                    (empty_cell)
133                )
134            }
135            .boxed()
136        } else {
137            pager_views = pager;
138            let chrome = RowChrome {
139                with_bulk,
140                with_actions,
141                header_colspan: self.columns.len()
142                    + usize::from(with_bulk)
143                    + usize::from(with_actions),
144                cell_widths,
145                actions_min,
146                delete_dialog_id: delete_prefix
147                    .as_deref()
148                    .map(Self::delete_dialog_dom_id)
149                    .unwrap_or_default(),
150            };
151            let rows = render_rows(cx, row_data, &chrome);
152            view! {
153                cx =>
154                table(
155                    attrs: attributes! { class="table-fixed" style=(table_min_width.as_deref()) },
156                    (head)
157                    table_body(
158                        #[key(row.key.as_str())]
159                        for row in rows {
160                            (row.view)
161                        }
162                    )
163                )
164            }
165            .boxed()
166        };
167
168        let revision_attrs = signals.as_ref().map(|_| {
169            let revision = topcoat::runtime::signal(&cx.keyed(path), || "0".to_string());
170            attributes! {
171                cx =>
172                type="hidden"
173                value=(revision.get())
174                :value=$(revision.get())
175                @change=$(|e: Event| revision.set(e.target.value))
176                data-table-revision=""
177            }
178        });
179
180        let toolbar_row = show_search || with_bulk;
181        let content = view! {
182            cx =>
183            if toolbar_row {
184                <div class=(BAR_CLASS)>
185                    if show_search {
186                        (search_bar.expect("search bar built when enabled"))
187                    }
188                    (bulk_bar_view)
189                </div>
190            } else {
191                (bulk_bar_view)
192            }
193            if show_filters {
194                (filter_bar.expect("filter bar built when enabled"))
195            }
196            if let Some(attrs) = revision_attrs {
197                <input (attrs)>
198            }
199            if let Some(warning) = filter_warning {
200                (warning)
201            }
202            (body)
203            for p in pager_views {
204                (p)
205            }
206            if let Some(dialog) = delete_dialog {
207                (dialog)
208            }
209        };
210        Ok(table_frame(
211            cx,
212            false,
213            self.framed,
214            self.delete_prefix.as_deref(),
215            content.boxed(),
216        ))
217    }
218}
219
220/// Render the table's two wrappers shared by the loaded table and its skeleton, naming the region
221/// from the table's delete prefix.
222pub(super) fn table_frame<'a>(
223    cx: &'a Cx,
224    busy: bool,
225    framed: bool,
226    name: Option<&str>,
227    content: BoxView<'a>,
228) -> BoxView<'a> {
229    let busy = busy.then_some("true");
230    let name = name.map(str::to_string);
231    let root_class = if framed {
232        TABLE_CARD_CLASS
233    } else {
234        TABLE_BARE_CLASS
235    };
236    view! {
237        cx =>
238        <div data-boundary="table" data-table=(name) aria-busy=(busy)>
239            <div class=(root_class) data-table-root="" aria-busy=(busy)>(content)</div>
240        </div>
241    }
242    .boxed()
243}
244
245#[cfg(test)]
246pub(super) mod tests;