Skip to main content

tablo_core/panel/
widgets.rs

1//! The widget grid of a [`Page`], and the row of [`Stat`] tiles and the [`RecordTable`] a widget
2//! renders.
3
4use std::{any::TypeId, sync::Arc};
5
6use topcoat::{
7    Result,
8    context::Cx,
9    view::{BoxView, ViewExt, view},
10};
11
12use crate::{
13    Page, RecordTable, Resource, Stat, Trend,
14    navigation::runtime_link,
15    page::MountedPage,
16    panel::list::{TableChrome, declared_chrome, wire_table_over},
17    resource::mounted,
18    table::include_relations,
19    topcoat_compat::async_page,
20    widget::{MountedWidget, Span, WidgetGrid},
21};
22
23/// The widget grid of the page `P`: each [`Widget`](crate::Widget) its
24/// [`PageDef::widget`](crate::PageDef::widget) calls added, in their order, that the request may
25/// see.
26///
27/// It renders nothing outside a request to `P`'s panel, for a request the panel's sign-in or
28/// [`PageDef::access`](crate::PageDef::access) refuses — the check
29/// [`can_open`](crate::can_open) makes — and leaves out each widget whose
30/// [`WidgetDef::access`](crate::WidgetDef::access) refuses the request. The default
31/// [`Page::render`] places it; the [`Page::render`] docs show a page placing it itself.
32pub fn widgets<P: Page>(cx: &Cx) -> BoxView<'_> {
33    match super::pages::mounted_admitted(cx, TypeId::of::<P>()) {
34        Some(page) => page_grid(cx, page),
35        None => ().boxed(),
36    }
37}
38
39/// The widget grid of `page`, which the request may open: each of its widgets the request may
40/// see.
41pub(super) fn page_grid<'a>(cx: &'a Cx, page: &MountedPage) -> BoxView<'a> {
42    let cells = page
43        .widgets
44        .admitted(cx)
45        .map(|widget| (widget, async_page((widget.render)(cx))))
46        .collect();
47    grid(cx, page.widgets.columns, cells)
48}
49
50/// The grid of `widgets` the request may see, each rendered before the grid returns: a list
51/// streams its rows behind a suspense, and a widget rendered lazily beside them would wait for
52/// the connection they hold on a pool of one.
53pub(crate) async fn rendered_grid<'a>(cx: &'a Cx, widgets: &WidgetGrid) -> Result<BoxView<'a>> {
54    let mut cells = Vec::new();
55    for widget in widgets.admitted(cx) {
56        cells.push((widget, (widget.render)(cx).await?));
57    }
58    Ok(grid(cx, widgets.columns, cells))
59}
60
61/// A grid `columns` wide of each widget with its body.
62fn grid<'a>(cx: &'a Cx, columns: u8, cells: Vec<(&MountedWidget, BoxView<'a>)>) -> BoxView<'a> {
63    let cells: Vec<BoxView<'a>> = cells
64        .into_iter()
65        .map(|(widget, body)| {
66            let name = widget.name.clone();
67            let class = span_class(widget.span, columns);
68            view! { <div data-widget=(name) class=(class)>(body)</div> }.boxed()
69        })
70        .collect();
71    if cells.is_empty() {
72        return ().boxed();
73    }
74    view! {
75        <div data-widgets="" class=(grid_class(columns))>
76            for cell in cells {
77                (cell)
78            }
79        </div>
80    }
81    .boxed()
82}
83
84/// The grid's classes: one column on a narrow screen, at most two on a medium one, `columns` on a
85/// wide one.
86const fn grid_class(columns: u8) -> &'static str {
87    match columns {
88        0 | 1 => "grid gap-4",
89        2 => "grid gap-4 md:grid-cols-2",
90        3 => "grid gap-4 md:grid-cols-2 xl:grid-cols-3",
91        _ => "grid gap-4 md:grid-cols-2 xl:grid-cols-4",
92    }
93}
94
95/// A cell's classes for `span` in a grid `columns` wide; Tailwind needs each class spelled whole.
96const fn span_class(span: Span, columns: u8) -> &'static str {
97    let span = match span {
98        Span::Full => return "min-w-0 col-span-full",
99        Span::Columns(span) if span < columns => span,
100        Span::Columns(_) => columns,
101    };
102    match span {
103        0 | 1 => "min-w-0",
104        2 => "min-w-0 md:col-span-2",
105        3 => "min-w-0 md:col-span-2 xl:col-span-3",
106        _ => "min-w-0 md:col-span-2 xl:col-span-4",
107    }
108}
109
110/// A row of [`Stat`] tiles, for a [`Widget`](crate::Widget)'s body: as many to a row as the
111/// widget's width holds, up to three, or four when three would leave one tile alone on the last
112/// row; nothing when there is no tile.
113///
114/// ```rust
115/// # use tablo_core::Stat;
116/// # fn row(cx: &topcoat::context::Cx) -> topcoat::view::BoxView<'_> {
117/// tablo_core::stats(
118///     cx,
119///     [Stat::new("Orders", "1,204"), Stat::new("Refunds", "3")],
120/// )
121/// # }
122/// ```
123pub fn stats(cx: &Cx, stats: impl IntoIterator<Item = Stat>) -> BoxView<'_> {
124    let stats: Vec<Stat> = stats.into_iter().collect();
125    if stats.is_empty() {
126        return ().boxed();
127    }
128    let class = if stats.len() > 3 && stats.len() % 3 == 1 {
129        "grid gap-4 @lg:grid-cols-2 @4xl:grid-cols-4"
130    } else {
131        "grid gap-4 @lg:grid-cols-2 @3xl:grid-cols-3"
132    };
133    let tiles: Vec<BoxView<'_>> = stats.into_iter().map(|stat| tile(cx, stat)).collect();
134    view! {
135        <div data-stats="" class="@container">
136            <div class=(class)>
137                for tile in tiles {
138                    (tile)
139                }
140            </div>
141        </div>
142    }
143    .boxed()
144}
145
146fn tile(cx: &Cx, stat: Stat) -> BoxView<'_> {
147    let Stat {
148        label,
149        value,
150        detail,
151        icon,
152        href,
153        trend,
154        color,
155        chart,
156    } = stat;
157    let detail = detail.map(|detail| detail.localize(cx)).unwrap_or_default();
158    let (detail_icon, detail_icon_label) = match trend {
159        Some(Trend::Up) => (
160            Some(tablo_ui::icons::ARROW_UP),
161            crate::t!(cx, "tablo-stat-trend-up"),
162        ),
163        Some(Trend::Down) => (
164            Some(tablo_ui::icons::ARROW_DOWN),
165            crate::t!(cx, "tablo-stat-trend-down"),
166        ),
167        Some(Trend::Flat) => (
168            Some(tablo_ui::icons::MINUS),
169            crate::t!(cx, "tablo-stat-trend-flat"),
170        ),
171        None => (None, String::new()),
172    };
173    let mut attrs = topcoat::view::attributes! { data-stat="" };
174    if let Some(href) = &href {
175        let mut link = runtime_link(cx, href);
176        link.remove("href");
177        attrs.extend(link);
178    }
179    view! {
180        tablo_ui::stat(
181            label: label.localize(cx),
182            value: value,
183            detail: detail,
184            detail_icon: detail_icon,
185            detail_icon_label: detail_icon_label,
186            accent: color.text_class(),
187            chart: chart,
188            icon: icon,
189            href: href,
190            attrs: attrs
191        )
192    }
193    .boxed()
194}
195
196impl Stat {
197    /// How many records of `R` the request may see, under `R`'s sidebar label and icon, linking to
198    /// its list; `None` when the request may not open that list, so no tile counts records it
199    /// cannot see or links to a list that would answer 403.
200    ///
201    /// The count reads [`scoped_query`](crate::scoped_query): the caller's tenant, `R`'s
202    /// [`query`](crate::ResourceDef::query), and no trashed record.
203    ///
204    /// # Errors
205    ///
206    /// The database's, when the count fails.
207    pub async fn records<R: Resource>(cx: &Cx) -> Result<Option<Self>> {
208        if !super::can_list::<R>(cx) {
209            return Ok(None);
210        }
211        let (Some(item), Some(url)) = (
212            super::navigation_item::<R>(cx),
213            super::url::resource::<R>(cx),
214        ) else {
215            return Ok(None);
216        };
217        let count = crate::db::count(cx, crate::scoped_query::<R>(cx)?).await?;
218        let count = crate::display::NumberFormat::of(cx)
219            .spell(crate::display::Number::Int(count.into()), 0);
220        let mut stat = Self::new(item.label, count).href(url);
221        stat.icon = item.icon;
222        Ok(Some(stat))
223    }
224}
225
226/// `table` over `R`'s records, for a [`Widget`](crate::Widget)'s body: the rows under `R`'s list
227/// columns, each linking its View and Edit pages as `R`'s policy allows, with a link to the full
228/// list. Two record tables of one resource on a page take distinct headings, which keep their
229/// rows' DOM ids apart. It renders nothing when the request may not open that list, the check
230/// [`can_list`](super::can_list) makes, or may see none of its columns' fields.
231///
232/// The rows are the ones the list would show: [`scoped_query`](crate::scoped_query)'s, the
233/// caller's tenant, `R`'s [`query`](crate::ResourceDef::query) and no trashed record, which
234/// `table`'s [`filter`](RecordTable::filter) narrows. Its columns hide what
235/// [`field_access`](crate::ResourceDef::field_access) hides from the request, on `R` and on the
236/// related records they show, as the list does. It never pages, sorts, searches or writes, so it
237/// adds no route: a record's own page and the list do.
238///
239/// ```rust,ignore
240/// impl Widget for LatestPosts {
241///     fn declare() -> WidgetDef {
242///         WidgetDef::new().access(can_list::<PostResource>)
243///     }
244///
245///     async fn render(cx: &Cx) -> Result<impl View> {
246///         let table = RecordTable::new()
247///             .heading("Latest posts")
248///             .order_by(Post::fields().created_at().desc());
249///         tablo::record_table::<PostResource>(cx, table).await
250///     }
251/// }
252/// ```
253///
254/// # Errors
255///
256/// A [column](RecordTable::column) `R`'s list declares no column for, and the database's,
257/// when the load fails.
258pub async fn record_table<R: Resource>(
259    cx: &Cx,
260    table: RecordTable<R::Model>,
261) -> Result<BoxView<'_>> {
262    let Some(resource) = mounted::<R>(cx).filter(|_| super::can_list::<R>(cx)) else {
263        return Ok(().boxed());
264    };
265    let RecordTable {
266        heading,
267        description,
268        columns,
269        filter,
270        order_by,
271        limit,
272        ..
273    } = table;
274    let shown = resource.table_for(cx);
275    let shown = match &columns {
276        Some(names) => {
277            let declared = resource.table.declared_columns();
278            if let Some(name) = names
279                .iter()
280                .find(|name| !declared.iter().any(|column| column.name() == name.as_str()))
281            {
282                return Err(crate::error::declaration(format!(
283                    "a record table of `{}` names `{name}`, a column its list does not declare",
284                    resource.slug
285                )));
286            }
287            Arc::new(shown.only(names))
288        }
289        None => shown,
290    };
291    if shown.declared_columns().is_empty() {
292        return Ok(().boxed());
293    }
294    let mut query = resource.scoped_query(cx)?;
295    if let Some(filter) = filter {
296        query = query.filter(filter);
297    }
298    for order in order_by {
299        query = query.order_by(order);
300    }
301    let query = include_relations(query.limit(limit), shown.declared_columns());
302    let mut db = crate::db::db(cx)?;
303    let rows = query
304        .exec(&mut db)
305        .await
306        .map_err(crate::error::unavailable)?;
307    shown.restrict_relations(cx, &rows, &mut db).await?;
308    let declared = declared_chrome(cx, &resource);
309    let chrome = TableChrome {
310        delete: false,
311        actions: false,
312        ..declared
313    };
314    let back = topcoat::router::request::uri(cx)
315        .path_and_query()
316        .map_or_else(String::new, ToString::to_string);
317    let heading = heading.map_or_else(
318        || resource.plural_label.localize(cx),
319        |heading| heading.localize(cx),
320    );
321    let mut wired =
322        wire_table_over(cx, &resource, shown, chrome).prefixed(dom_scope(&resource.slug, &heading));
323    // An edit begun here lands back here.
324    if !back.is_empty() {
325        wired = wired.returning_to(back);
326    }
327    let rows = wired.render_records(cx, rows).await?;
328    let description = description.map(|description| description.localize(cx));
329    let list = crate::navigation::runtime_link(cx, &resource.url);
330    let view_all = crate::t!(cx, "tablo-record-table-view-all");
331    let slug = resource.slug.clone();
332    Ok(view! {
333        <section
334            data-record-table=(slug)
335            class="relative overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs"
336        >
337            <div class="flex items-start justify-between gap-4 px-5 pt-5 pb-3">
338                <div class="flex min-w-0 flex-col gap-1">
339                    <h2 class="text-base font-semibold">(heading)</h2>
340                    if let Some(description) = description {
341                        <p class="text-sm text-muted-foreground">(description)</p>
342                    }
343                </div>
344                <a
345                    (list)
346                    data-record-table-list=""
347                    class="shrink-0 text-sm text-muted-foreground hover:text-foreground"
348                >
349                    (view_all)
350                </a>
351            </div>
352            (rows)
353        </section>
354    }
355    .boxed())
356}
357
358/// The scope of a record table's DOM ids: its resource's slug and its heading, spelled in the
359/// characters an id takes.
360fn dom_scope(slug: &str, heading: &str) -> String {
361    let heading: String = heading
362        .chars()
363        .map(|c| {
364            if c.is_ascii_alphanumeric() {
365                c.to_ascii_lowercase()
366            } else {
367                '-'
368            }
369        })
370        .collect();
371    format!("{slug}-{heading}")
372}