Skip to main content

tablo_core/
widget.rs

1//! [`Widget`] — one block of a page's widget grid, such as a row of [`Stat`]s — and the
2//! [`WidgetDef`] it declares.
3
4use std::{future::Future, marker::PhantomData, sync::Arc};
5
6use derive_where::derive_where;
7use toasty::stmt::Path;
8use topcoat::{
9    Result,
10    context::Cx,
11    icon::IconData,
12    view::{BoxView, View, ViewExt},
13};
14
15use crate::{Color, naming::type_stem};
16
17/// One block of a page's widget grid: a row of stats, a chart, a list of recent records.
18///
19/// A resource adds widgets above its list with
20/// [`ResourceDef::list_widget`](crate::ResourceDef::list_widget). A page adds them with
21/// [`PageDef::widget`](crate::PageDef::widget), and the page's default
22/// [`render`](crate::Page::render) shows them under its header:
23///
24/// ```rust
25/// # use tablo_core::{Page, PageDef, Stat, Widget, WidgetDef};
26/// # use topcoat::{Result, context::Cx, view::View};
27/// struct Overview;
28///
29/// impl Widget for Overview {
30///     fn declare() -> WidgetDef {
31///         WidgetDef::new().full_width()
32///     }
33///
34///     async fn render(cx: &Cx) -> Result<impl View> {
35///         Ok(tablo_core::stats(
36///             cx,
37///             [Stat::new("Orders", "1,204").detail("12 today")],
38///         ))
39///     }
40/// }
41///
42/// struct Dashboard;
43///
44/// impl Page for Dashboard {
45///     fn declare() -> PageDef {
46///         PageDef::new().widget::<Overview>()
47///     }
48/// }
49/// ```
50///
51/// A widget reads what it shows itself. One that reads a resource's records asks
52/// [`can_list`](crate::can_list) before it reads, shows or links anything, and reads them through
53/// [`scoped_query`](crate::scoped_query), which keeps the caller's tenant and the resource's
54/// [`Query`](crate::Resource) but not its policy.
55pub trait Widget: Sized + Send + Sync + 'static {
56    /// The widget's span and access. Defaults to [`WidgetDef::new`]: one column, for every user
57    /// who may open the page.
58    fn declare() -> WidgetDef {
59        WidgetDef::new()
60    }
61
62    /// Render the widget's body. The grid checks [`WidgetDef::access`] before calling it. An
63    /// error fails the whole page's response, as a page's own error does: the page answers with
64    /// the error's status, and none of its other widgets render.
65    fn render(cx: &Cx) -> impl Future<Output = Result<impl View>> + Send;
66}
67
68/// Whether the request's user may see a widget.
69type AccessFn = dyn Fn(&Cx) -> bool + Send + Sync;
70
71/// How many columns of the page's widget grid a widget spans.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub(crate) enum Span {
74    /// That many columns, at most the grid's.
75    Columns(u8),
76    /// Every column.
77    Full,
78}
79
80/// What a [`Widget`] declares: how many columns of the grid it spans, and who sees it.
81pub struct WidgetDef {
82    name: Option<&'static str>,
83    span: Span,
84    access: Arc<AccessFn>,
85}
86
87impl WidgetDef {
88    /// A def with every default: one column, for every user who may open the page.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// The widget's name, its `data-widget` hook and its key on the page; defaults to its type's
94    /// name in kebab case, without `Widget` and generic arguments: `RecentOrdersWidget` →
95    /// `recent-orders`, `Count<Book>` → `count`. Each widget of a page needs a distinct name, so a
96    /// generic widget mounted twice names each mount.
97    #[must_use]
98    pub fn name(mut self, name: &'static str) -> Self {
99        self.name = Some(name);
100        self
101    }
102
103    /// Spans `columns` columns of the grid, at most as many as it has, and at least one. On a
104    /// narrow screen the grid stacks every widget in one column.
105    #[must_use]
106    pub fn span(mut self, columns: u8) -> Self {
107        self.span = Span::Columns(columns.max(1));
108        self
109    }
110
111    /// Spans every column of the grid.
112    #[must_use]
113    pub fn full_width(mut self) -> Self {
114        self.span = Span::Full;
115        self
116    }
117
118    /// Whether the request's user may see the widget. The grid leaves out a widget it refuses
119    /// and never renders it. Defaults to every user who may open the page.
120    #[must_use]
121    pub fn access(mut self, access: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
122        self.access = Arc::new(access);
123        self
124    }
125}
126
127impl Default for WidgetDef {
128    fn default() -> Self {
129        Self {
130            name: None,
131            span: Span::Columns(1),
132            access: Arc::new(|_| true),
133        }
134    }
135}
136
137impl std::fmt::Debug for WidgetDef {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("WidgetDef")
140            .field("name", &self.name)
141            .field("span", &self.span)
142            .finish_non_exhaustive()
143    }
144}
145
146/// A [`WidgetDef`] for the widget `W`, as a page holds it.
147pub(crate) struct MountedWidget {
148    /// The widget's [`WidgetDef::name`].
149    pub(crate) name: String,
150    pub(crate) span: Span,
151    access: Arc<AccessFn>,
152    pub(crate) render: for<'a> fn(&'a Cx) -> crate::BoxFuture<'a, Result<BoxView<'a>>>,
153}
154
155impl MountedWidget {
156    pub(crate) fn new<W: Widget>() -> Self {
157        let def = W::declare();
158        Self {
159            name: def.name.map_or_else(
160                || crate::naming::kebab_case(type_stem::<W>("Widget")),
161                str::to_string,
162            ),
163            span: def.span,
164            access: def.access,
165            render: render::<W>,
166        }
167    }
168
169    /// Whether the request's user may see the widget.
170    pub(crate) fn admits(&self, cx: &Cx) -> bool {
171        (self.access)(cx)
172    }
173}
174
175impl std::fmt::Debug for MountedWidget {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("MountedWidget")
178            .field("name", &self.name)
179            .field("span", &self.span)
180            .finish_non_exhaustive()
181    }
182}
183
184/// The widgets a page or a resource's list declares, in order, and how many columns their grid
185/// has on a wide screen.
186#[derive(Debug)]
187pub(crate) struct WidgetGrid {
188    pub(crate) widgets: Vec<MountedWidget>,
189    pub(crate) columns: u8,
190}
191
192impl WidgetGrid {
193    /// Adds `W` after the widgets already declared.
194    pub(crate) fn push<W: Widget>(&mut self) {
195        self.widgets.push(MountedWidget::new::<W>());
196    }
197
198    /// Sets the grid's columns, from 1 to 4.
199    pub(crate) fn set_columns(&mut self, columns: u8) {
200        self.columns = columns.clamp(1, 4);
201    }
202
203    /// The widgets the request may see, in order.
204    pub(crate) fn admitted<'a>(&'a self, cx: &'a Cx) -> impl Iterator<Item = &'a MountedWidget> {
205        self.widgets.iter().filter(|widget| widget.admits(cx))
206    }
207
208    /// Refuses two widgets of `T`'s grid sharing a name: each keys its cell.
209    pub(crate) fn check<T>(&self, errors: &mut Vec<crate::DeclarationError>) {
210        let mut seen = std::collections::HashSet::new();
211        for widget in &self.widgets {
212            if !seen.insert(widget.name.as_str()) {
213                errors.push(crate::DeclarationError::of::<T>(
214                    crate::declaration::Site::Registration,
215                    crate::DeclarationErrorKind::DuplicateWidget {
216                        name: widget.name.clone(),
217                    },
218                ));
219            }
220        }
221    }
222}
223
224impl Default for WidgetGrid {
225    fn default() -> Self {
226        Self {
227            widgets: Vec::new(),
228            columns: 2,
229        }
230    }
231}
232
233fn render<W: Widget>(cx: &Cx) -> crate::BoxFuture<'_, Result<BoxView<'_>>> {
234    Box::pin(async move { Ok(W::render(cx).await?.boxed()) })
235}
236
237/// Which way a [`Stat`]'s figure moved, shown as an arrow before its detail line.
238#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum Trend {
240    /// The figure grew.
241    Up,
242    /// The figure shrank.
243    Down,
244    /// The figure held.
245    Flat,
246}
247
248/// One figure of a [`stats`](crate::stats) row: what it counts, the value, and optionally a line
249/// under it, an icon, a link, a trend and a sparkline.
250///
251/// ```rust
252/// # use tablo_core::{Color, Stat, Trend};
253/// Stat::new("Revenue", "$12,400")
254///     .detail("8% more than last month")
255///     .trend(Trend::Up)
256///     .color(Color::Success)
257///     .chart([9.1, 10.2, 11.0, 12.4]);
258/// ```
259#[derive(Clone, Debug)]
260pub struct Stat {
261    pub(crate) label: crate::Text,
262    pub(crate) value: String,
263    pub(crate) detail: Option<crate::Text>,
264    pub(crate) icon: Option<IconData>,
265    pub(crate) href: Option<String>,
266    pub(crate) trend: Option<Trend>,
267    pub(crate) color: Color,
268    pub(crate) chart: Vec<f64>,
269}
270
271impl Stat {
272    /// A figure labelled `label`, with `value` already formatted: `"1,204"`, `"98%"`.
273    pub fn new(label: impl Into<crate::Text>, value: impl Into<String>) -> Self {
274        Self {
275            label: label.into(),
276            value: value.into(),
277            detail: None,
278            icon: None,
279            href: None,
280            trend: None,
281            color: Color::Gray,
282            chart: Vec::new(),
283        }
284    }
285
286    /// The line under the value, such as `"12 this week"`.
287    #[must_use]
288    pub fn detail(mut self, detail: impl Into<crate::Text>) -> Self {
289        self.detail = Some(detail.into());
290        self
291    }
292
293    /// The icon before the label.
294    #[must_use]
295    pub fn icon(mut self, icon: IconData) -> Self {
296        self.icon = Some(icon);
297        self
298    }
299
300    /// The URL the whole tile links to, such as the list the figure counts.
301    #[must_use]
302    pub fn href(mut self, href: impl Into<String>) -> Self {
303        self.href = Some(href.into());
304        self
305    }
306
307    /// Which way the figure moved, as an arrow before the detail line; a stat with no detail line
308    /// shows none.
309    #[must_use]
310    pub fn trend(mut self, trend: Trend) -> Self {
311        self.trend = Some(trend);
312        self
313    }
314
315    /// The color of the detail line and the sparkline; defaults to [`Color::Gray`], muted. A
316    /// trend is good or bad only for the figure it moves, so the color is the caller's to pick.
317    #[must_use]
318    pub fn color(mut self, color: Color) -> Self {
319        self.color = color;
320        self
321    }
322
323    /// The values the tile draws as a sparkline under the value, oldest first. Fewer than two
324    /// finite values draw none.
325    #[must_use]
326    pub fn chart(mut self, values: impl IntoIterator<Item = f64>) -> Self {
327        self.chart = values.into_iter().collect();
328        self
329    }
330}
331
332/// How many records a [`RecordTable`] lists unless it says otherwise.
333pub const DEFAULT_RECORD_ROWS: usize = 5;
334
335/// The most records a [`RecordTable`] lists: it never pages, so its full list is a link away.
336pub const MAX_RECORD_ROWS: usize = 50;
337
338/// A few of a resource's records as a table for a [`Widget`]'s body, under the resource's own
339/// columns: the newest posts, the orders awaiting review. [`record_table`](crate::record_table)
340/// loads and renders it.
341///
342/// ```rust
343/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
344/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, created_at: jiff::Timestamp }
345/// use tablo_core::{RecordTable, lens};
346///
347/// RecordTable::new()
348///     .heading("Latest posts")
349///     .column(lens!(Post.title))
350///     .column(lens!(Post.created_at))
351///     .order_by(Post::fields().created_at().desc())
352///     .limit(5);
353/// ```
354#[derive_where(Clone, Debug)]
355pub struct RecordTable<M> {
356    pub(crate) heading: Option<crate::Text>,
357    pub(crate) description: Option<crate::Text>,
358    pub(crate) columns: Option<Vec<String>>,
359    pub(crate) filter: Option<toasty::stmt::Expr<bool>>,
360    pub(crate) order_by: Vec<toasty::stmt::OrderBy>,
361    pub(crate) limit: usize,
362    #[derive_where(skip(Debug))]
363    _model: PhantomData<fn() -> M>,
364}
365
366impl<M> RecordTable<M> {
367    /// A table of the resource's first [`DEFAULT_RECORD_ROWS`] records in the database's order,
368    /// under the columns its list shows by default and its plural label.
369    pub fn new() -> Self {
370        Self {
371            heading: None,
372            description: None,
373            columns: None,
374            filter: None,
375            order_by: Vec::new(),
376            limit: DEFAULT_RECORD_ROWS,
377            _model: PhantomData,
378        }
379    }
380
381    /// The heading over the table, in place of the resource's plural label.
382    #[must_use]
383    pub fn heading(mut self, heading: impl Into<crate::Text>) -> Self {
384        self.heading = Some(heading.into());
385        self
386    }
387
388    /// The line under the heading.
389    #[must_use]
390    pub fn description(mut self, description: impl Into<crate::Text>) -> Self {
391        self.description = Some(description.into());
392        self
393    }
394
395    /// Show the list's column of the field `path` names, after the columns given before: once a
396    /// table names one, it shows only the columns it names, in that order, a hidden-by-default
397    /// one included. `path` is a [`Lens`](crate::Lens), a field path, or a relation's path,
398    /// `Post::fields().author()`. A field the list declares no column for fails the render; a
399    /// column whose field the request may not see is left out.
400    #[must_use]
401    pub fn column<T>(self, path: impl Into<Path<M, T>>) -> Self
402    where
403        M: toasty::schema::Model,
404    {
405        let name = crate::schema::form_key(path);
406        self.column_named(name)
407    }
408
409    /// Show the list's column named `name`, as [`column`](Self::column) does: a column of your
410    /// own, whose [name](crate::extend::Column::name) no field path spells. A field's column
411    /// takes [`column`](Self::column), whose path a rename fails to compile.
412    #[must_use]
413    pub fn column_named(mut self, name: impl Into<String>) -> Self {
414        self.columns.get_or_insert_with(Vec::new).push(name.into());
415        self
416    }
417
418    /// Only the records `filter` admits, among those the request may see.
419    #[must_use]
420    pub fn filter(mut self, filter: toasty::stmt::Expr<bool>) -> Self {
421        self.filter = Some(match self.filter.take() {
422            Some(previous) => previous.and(filter),
423            None => filter,
424        });
425        self
426    }
427
428    /// Order the records by `order`, after any order given before: `Post::fields().created_at()
429    /// .desc()` lists the newest first.
430    #[must_use]
431    pub fn order_by(mut self, order: impl Into<toasty::stmt::OrderBy>) -> Self {
432        self.order_by.push(order.into());
433        self
434    }
435
436    /// List at most `rows` records, at least one and at most [`MAX_RECORD_ROWS`].
437    #[must_use]
438    pub fn limit(mut self, rows: usize) -> Self {
439        self.limit = rows.clamp(1, MAX_RECORD_ROWS);
440        self
441    }
442}
443
444impl<M> Default for RecordTable<M> {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450#[cfg(test)]
451mod tests;