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        let action_dialog = if signals.is_none() {
116            self.render_action_confirm_dialog(cx)
117        } else {
118            None
119        };
120
121        let ColumnWidths {
122            cells: cell_widths,
123            actions_min,
124            table_min_width,
125            ..
126        } = self.column_widths();
127        let mut pager_views: Vec<BoxView<'_>> = Vec::new();
128        let body: BoxView<'_> = if page.rows.is_empty() {
129            let empty_cell = self
130                .render_empty_cell(cx, state, path, with_actions, with_bulk, signals.as_ref())
131                .await?;
132            view! {
133                cx =>
134                table(
135                    attrs: attributes! { class="table-fixed" style=(table_min_width.as_deref()) },
136                    (head)
137                    (empty_cell)
138                )
139            }
140            .boxed()
141        } else {
142            pager_views = pager;
143            let chrome = RowChrome {
144                with_bulk,
145                with_actions,
146                header_colspan: self.columns.len()
147                    + usize::from(with_bulk)
148                    + usize::from(with_actions),
149                cell_widths,
150                actions_min,
151                delete_dialog_id: delete_prefix
152                    .as_deref()
153                    .map(Self::delete_dialog_dom_id)
154                    .unwrap_or_default(),
155                action_dialog_id: self
156                    .actions_prefix
157                    .as_deref()
158                    .map(Self::action_confirm_dialog_dom_id)
159                    .unwrap_or_default(),
160            };
161            let rows = render_rows(cx, row_data, &chrome);
162            view! {
163                cx =>
164                table(
165                    attrs: attributes! { class="table-fixed" style=(table_min_width.as_deref()) },
166                    (head)
167                    table_body(
168                        #[key(row.key.as_str())]
169                        for row in rows {
170                            (row.view)
171                        }
172                    )
173                )
174            }
175            .boxed()
176        };
177
178        let revision_attrs = signals.as_ref().map(|_| {
179            let revision = topcoat::runtime::signal(&cx.keyed(path), || "0".to_string());
180            attributes! {
181                cx =>
182                type="hidden"
183                value=(revision.get())
184                :value=$(revision.get())
185                @change=$(|e: Event| revision.set(e.target.value))
186                data-table-revision=""
187            }
188        });
189
190        let toolbar_row = show_search || with_bulk;
191        let content = view! {
192            cx =>
193            if toolbar_row {
194                <div class=(BAR_CLASS)>
195                    if show_search {
196                        (search_bar.expect("search bar built when enabled"))
197                    }
198                    (bulk_bar_view)
199                </div>
200            } else {
201                (bulk_bar_view)
202            }
203            if show_filters {
204                (filter_bar.expect("filter bar built when enabled"))
205            }
206            if let Some(attrs) = revision_attrs {
207                <input (attrs)>
208            }
209            if let Some(warning) = filter_warning {
210                (warning)
211            }
212            (body)
213            for p in pager_views {
214                (p)
215            }
216            if let Some(dialog) = delete_dialog {
217                (dialog)
218            }
219            if let Some(dialog) = action_dialog {
220                (dialog)
221            }
222        };
223        Ok(table_frame(
224            cx,
225            false,
226            self.framed,
227            self.delete_prefix.as_deref(),
228            content.boxed(),
229        ))
230    }
231}
232
233/// Render the table's two wrappers shared by the loaded table and its skeleton, naming the region
234/// from the table's delete prefix.
235pub(super) fn table_frame<'a>(
236    cx: &'a Cx,
237    busy: bool,
238    framed: bool,
239    name: Option<&str>,
240    content: BoxView<'a>,
241) -> BoxView<'a> {
242    let busy = busy.then_some("true");
243    let name = name.map(str::to_string);
244    let root_class = if framed {
245        TABLE_CARD_CLASS
246    } else {
247        TABLE_BARE_CLASS
248    };
249    view! {
250        cx =>
251        <div data-boundary="table" data-table=(name) aria-busy=(busy)>
252            <div class=(root_class) data-table-root="" aria-busy=(busy)>(content)</div>
253        </div>
254    }
255    .boxed()
256}
257
258#[cfg(test)]
259pub(super) mod tests;