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;