Skip to main content

tablo_core/
page.rs

1//! [`Page`] — a panel page that is not a resource: a dashboard, a report, a
2//! settings screen, a media library — and the [`PageDef`] it declares.
3
4use std::{any::TypeId, future::Future, sync::Arc};
5
6use topcoat::{
7    Result,
8    context::Cx,
9    icon::IconData,
10    view::{BoxView, View, ViewExt},
11};
12
13pub use self::filters::{PageFilter, PageFilters};
14use crate::{
15    HeaderAction, HeaderActions, Widget,
16    naming::{kebab_case, sentence_case, type_stem},
17    navigation::NavigationItem,
18    widget::WidgetGrid,
19};
20
21/// A panel page that is not a resource, mounted at `{prefix}/{slug}` with a
22/// sidebar entry; the panel owns the route and entry while the page owns its
23/// markup.
24///
25/// ```rust
26/// # use tablo_core::{Page, Panel};
27/// struct ReportsPage;
28///
29/// impl Page for ReportsPage {}
30///
31/// Panel::new("admin").page::<ReportsPage>(); // GET /admin/reports
32/// ```
33///
34/// A page serves one `GET` with a sidebar entry, which [`PageDef::access`] limits
35/// to the users it admits. A form it renders posts to an app-declared route the
36/// auth gate covers; that route asks [`can_open`](crate::can_open) itself.
37pub trait Page: Sized + Send + Sync + 'static {
38    /// The page's slug, sidebar entry, access and header actions. Defaults to
39    /// [`PageDef::new`]: every default.
40    ///
41    /// [`Panel::page_with`](crate::Panel::page_with) adjusts it for one panel.
42    fn declare() -> PageDef {
43        PageDef::new()
44    }
45
46    /// Render the page body. The panel checks for a resolved user and
47    /// [`PageDef::access`] before calling it.
48    ///
49    /// Defaults to the page its [`PageDef`] declares: the [`title`](PageDef::title) and
50    /// [`description`](PageDef::description), the buttons of its
51    /// [`header_action`](PageDef::header_action)s, and the grid of its
52    /// [`widget`](PageDef::widget)s. A page that renders more places those parts itself, through
53    /// [`header_action_buttons`](crate::header_action_buttons) and [`widgets`](crate::widgets):
54    ///
55    /// ```rust
56    /// # use tablo_core::Page;
57    /// # use topcoat::{Result, context::Cx, view::{View, view}};
58    /// struct ReportsPage;
59    ///
60    /// impl Page for ReportsPage {
61    ///     async fn render(cx: &Cx) -> Result<impl View> {
62    ///         Ok(view! {
63    ///             tablo_ui::page(
64    ///                 tablo_ui::page_header(tablo_ui::page_title("Reports"))
65    ///                 tablo_ui::page_content((tablo_core::widgets::<Self>(cx)) "…")
66    ///             )
67    ///         })
68    ///     }
69    /// }
70    /// ```
71    fn render(cx: &Cx) -> impl Future<Output = Result<impl View>> + Send {
72        async move { Ok(declared_body(cx, TypeId::of::<Self>())) }
73    }
74}
75
76/// Renders the page of type `page` as its [`PageDef`] declares it. The panel installs it: a page's
77/// header and grid are the serving layer's markup.
78pub(crate) struct PageBody(pub(crate) for<'a> fn(&'a Cx, TypeId) -> BoxView<'a>);
79
80/// The body [`Page::render`] defaults to; nothing on a router that mounts no panel.
81fn declared_body(cx: &Cx, page: TypeId) -> BoxView<'_> {
82    match topcoat::context::try_context::<PageBody>(cx) {
83        Some(PageBody(render)) => render(cx, page),
84        None => ().boxed(),
85    }
86}
87
88/// Whether the request's user may open a page.
89type AccessFn = dyn Fn(&Cx) -> bool + Send + Sync;
90
91/// What a [`Page`] declares: its slug, sidebar entry, title, access, header actions and widgets.
92///
93/// [`Page::declare`] returns one, and [`Panel::page_with`](crate::Panel::page_with) adjusts it for
94/// one panel. Every setting has a default, so a page sets only what differs:
95///
96/// ```rust
97/// # use tablo_core::{Page, PageDef};
98/// # use topcoat::{Result, context::Cx, view::View};
99/// struct MediaLibraryPage;
100///
101/// impl Page for MediaLibraryPage {
102///     fn declare() -> PageDef {
103///         PageDef::new().slug("media").icon(tablo_ui::icons::IMAGE)
104///     }
105///
106///     async fn render(_cx: &Cx) -> Result<impl View> {
107///         Ok(())
108///     }
109/// }
110/// ```
111///
112/// The panel builds the def once when it mounts and serves the result to every request.
113pub struct PageDef {
114    slug: Option<String>,
115    label: Option<crate::Text>,
116    icon: Option<IconData>,
117    navigation_order: Option<i32>,
118    navigation_group: Option<crate::Text>,
119    navigation: Option<NavigationItem>,
120    navigation_hidden: bool,
121    title: Option<crate::Text>,
122    description: Option<crate::Text>,
123    access: Arc<AccessFn>,
124    access_declared: bool,
125    header_actions: HeaderActions,
126    widgets: WidgetGrid,
127    filters: Vec<PageFilter>,
128}
129
130impl PageDef {
131    /// A def with every default: the slug and label from the page's type name, and access for
132    /// every signed-in user.
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// The URL segment under the panel prefix: `"media"` mounts the page at
138    /// `{panel prefix}/media`.
139    ///
140    /// Defaults to the page type's name without a trailing `Page`, kebab-cased:
141    /// `MediaLibraryPage` → `media-library`. [`Panel::home`](crate::Panel::home) mounts the page
142    /// at the prefix and does not read it.
143    #[must_use]
144    pub fn slug(mut self, slug: impl Into<String>) -> Self {
145        self.slug = Some(slug.into());
146        self
147    }
148
149    /// The sidebar label.
150    ///
151    /// Defaults to the message `page-{name}` of the panel's catalogs, else the page type's name
152    /// without a trailing `Page`, in sentence case: `MediaLibraryPage` reads `page-media-library`,
153    /// else `Media library`.
154    #[must_use]
155    pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
156        self.label = Some(label.into());
157        self
158    }
159
160    /// The icon of the page's sidebar entry, replacing the icon of a
161    /// [`navigation`](Self::navigation) item.
162    #[must_use]
163    pub fn icon(mut self, icon: IconData) -> Self {
164        self.icon = Some(icon);
165        self
166    }
167
168    /// Where the sidebar entry sorts among the panel's entries, lowest first; defaults to `0`, and
169    /// entries of equal order keep their registration order. It replaces the order of a
170    /// [`navigation`](Self::navigation) item.
171    #[must_use]
172    pub fn navigation_order(mut self, order: i32) -> Self {
173        self.navigation_order = Some(order);
174        self
175    }
176
177    /// The labelled sidebar group the entry renders in, such as `"Reports"`; defaults to none,
178    /// above every group. It replaces the group of a [`navigation`](Self::navigation) item.
179    #[must_use]
180    pub fn navigation_group(mut self, group: impl Into<crate::Text>) -> Self {
181        self.navigation_group = Some(group.into());
182        self
183    }
184
185    /// Replaces the sidebar entry, for one that links somewhere other than the page:
186    /// `NavigationItem::at("Exports", "/admin/reports?tab=exports")`. [`icon`](Self::icon),
187    /// [`navigation_order`](Self::navigation_order) and
188    /// [`navigation_group`](Self::navigation_group) still apply to it.
189    #[must_use]
190    pub fn navigation(mut self, item: NavigationItem) -> Self {
191        self.navigation = Some(item);
192        self
193    }
194
195    /// Keeps the page out of the sidebar. It stays mounted at its URL, and
196    /// [`access`](Self::access) still decides who may open it: link it from a
197    /// [user menu item](crate::Panel::user_menu_item), another page or a notification.
198    #[must_use]
199    pub fn without_navigation(mut self) -> Self {
200        self.navigation_hidden = true;
201        self
202    }
203
204    /// The heading the default [`render`](Page::render) shows. Defaults to the
205    /// [`label`](Self::label) when one is given, else the label of the
206    /// [`navigation`](Self::navigation) item, else the label's default.
207    #[must_use]
208    pub fn title(mut self, title: impl Into<crate::Text>) -> Self {
209        self.title = Some(title.into());
210        self
211    }
212
213    /// The line the default [`render`](Page::render) shows under the title; defaults to none.
214    #[must_use]
215    pub fn description(mut self, description: impl Into<crate::Text>) -> Self {
216        self.description = Some(description.into());
217        self
218    }
219
220    /// Whether the request's user may open the page. The panel answers 403 and leaves the page
221    /// out of the sidebar when it answers `false`. Defaults to every signed-in user.
222    ///
223    /// ```rust
224    /// # use tablo_core::{Page, PageDef, auth};
225    /// # use topcoat::{Result, context::Cx, view::{View, view}};
226    /// # #[derive(Clone)]
227    /// # struct Staff { admin: bool }
228    /// # impl tablo_core::PanelUser for Staff {
229    /// #     fn user_id(&self) -> String { String::new() }
230    /// #     fn display_name(&self) -> &str { "" }
231    /// #     fn can_access_panel(&self, _panel: &str) -> bool { true }
232    /// # }
233    /// struct BillingPage;
234    ///
235    /// impl Page for BillingPage {
236    ///     fn declare() -> PageDef {
237    ///         PageDef::new().access(|cx| auth::user::<Staff>(cx).is_some_and(|staff| staff.admin))
238    ///     }
239    ///
240    ///     async fn render(cx: &Cx) -> Result<impl View> {
241    ///         Ok(view! { tablo_ui::page(tablo_ui::page_title("Billing")) })
242    ///     }
243    /// }
244    /// ```
245    #[must_use]
246    pub fn access(mut self, access: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
247        self.access = Arc::new(access);
248        self.access_declared = true;
249        self
250    }
251
252    /// Adds the [`HeaderAction`] `A` after the ones already declared, served at
253    /// `{url}/-/actions/{NAME}`. The default [`render`](Page::render) shows their buttons in the
254    /// page's header; a page that renders itself places
255    /// [`header_action_buttons`](crate::header_action_buttons). An `A::NAME` that is not a
256    /// single URL path segment does not compile.
257    ///
258    /// The actions run for whoever may open the page: a page has no policy. An action that writes
259    /// a resource's records asks that resource's policy in its own
260    /// [`can_run`](crate::HeaderAction::can_run).
261    #[must_use]
262    pub fn header_action<A: HeaderAction>(mut self) -> Self {
263        self.header_actions = self.header_actions.add::<A>();
264        self
265    }
266
267    /// Adds the [`Widget`] `W` after the ones already declared, to the grid the default
268    /// [`render`](Page::render) shows under the header; a page that renders itself places
269    /// [`widgets`](crate::widgets).
270    #[must_use]
271    pub fn widget<W: Widget>(mut self) -> Self {
272        self.widgets.push::<W>();
273        self
274    }
275
276    /// Offers `filter` above the widgets, after the filters already declared: a value the page
277    /// keeps in its query string, which its widgets read through
278    /// [`page_filters`](crate::page_filters). The default [`render`](Page::render) shows the
279    /// filters' bar under the header; a page that renders itself places
280    /// [`page_filter_bar`](crate::page_filter_bar). Mounting refuses two filters sharing a key,
281    /// and a key that is not ASCII letters, digits, `-` and `_`.
282    #[must_use]
283    pub fn filter(mut self, filter: PageFilter) -> Self {
284        self.filters.push(filter);
285        self
286    }
287
288    /// How many columns the widget grid has on a wide screen, from 1 to 4; defaults to 2. A
289    /// medium screen shows at most two, a narrow one one.
290    #[must_use]
291    pub fn widget_columns(mut self, columns: u8) -> Self {
292        self.widgets.set_columns(columns);
293        self
294    }
295}
296
297impl Default for PageDef {
298    fn default() -> Self {
299        Self {
300            slug: None,
301            label: None,
302            icon: None,
303            navigation_order: None,
304            navigation_group: None,
305            navigation: None,
306            navigation_hidden: false,
307            title: None,
308            description: None,
309            access: Arc::new(|_| true),
310            access_declared: false,
311            header_actions: HeaderActions::new(),
312            widgets: WidgetGrid::default(),
313            filters: Vec::new(),
314        }
315    }
316}
317
318impl std::fmt::Debug for PageDef {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        f.debug_struct("PageDef")
321            .field("slug", &self.slug)
322            .field("label", &self.label)
323            .field("navigation_order", &self.navigation_order)
324            .field("navigation_group", &self.navigation_group)
325            .field("navigation", &self.navigation)
326            .field("navigation_hidden", &self.navigation_hidden)
327            .field("title", &self.title)
328            .field("description", &self.description)
329            .field("header_actions", &self.header_actions)
330            .field("widgets", &self.widgets)
331            .finish_non_exhaustive()
332    }
333}
334
335/// A [`PageDef`] with its defaults filled in for the page `P`, as a panel mounted it.
336pub(crate) struct MountedPage {
337    pub(crate) slug: String,
338    /// The sidebar entry, its target not yet resolved to the page's URL.
339    pub(crate) navigation: NavigationItem,
340    /// Whether the sidebar leaves the page out: [`PageDef::without_navigation`].
341    pub(crate) navigation_hidden: bool,
342    /// The heading the default [`Page::render`] shows.
343    pub(crate) title: crate::Text,
344    pub(crate) description: Option<crate::Text>,
345    access: Arc<AccessFn>,
346    /// Whether the def declared [`PageDef::access`] rather than keep the default.
347    pub(crate) access_declared: bool,
348    pub(crate) header_actions: HeaderActions,
349    pub(crate) widgets: WidgetGrid,
350    /// The [`PageDef::filter`]s, in declaration order.
351    pub(crate) filters: Vec<PageFilter>,
352}
353
354impl MountedPage {
355    pub(crate) fn new<P: Page>(def: PageDef) -> Self {
356        let stem = type_stem::<P>("Page");
357        let noun = sentence_case(stem);
358        let label = crate::Text::derived(
359            crate::naming::derived_id("page", &crate::naming::slug_case(&noun)),
360            noun,
361        );
362        Self::declared(def, kebab_case(stem), label)
363    }
364
365    /// `def` with its defaults filled in: `slug` and `label` where it names none.
366    pub(crate) fn declared(def: PageDef, slug: String, label: crate::Text) -> Self {
367        let title = def
368            .title
369            .or_else(|| def.label.clone())
370            .or_else(|| def.navigation.as_ref().map(|item| item.label.clone()));
371        let label = def.label.unwrap_or(label);
372        let title = title.unwrap_or_else(|| label.clone());
373        let mut navigation = def.navigation.unwrap_or_else(|| NavigationItem {
374            label,
375            ..NavigationItem::default()
376        });
377        if let Some(order) = def.navigation_order {
378            navigation.order = order;
379        }
380        if let Some(icon) = def.icon {
381            navigation.icon = Some(icon);
382        }
383        if let Some(group) = def.navigation_group {
384            navigation.group = Some(group);
385        }
386        Self {
387            slug: def.slug.unwrap_or(slug),
388            navigation,
389            navigation_hidden: def.navigation_hidden,
390            title,
391            description: def.description,
392            access: def.access,
393            access_declared: def.access_declared,
394            header_actions: def.header_actions,
395            widgets: def.widgets,
396            filters: def.filters,
397        }
398    }
399
400    /// Whether the request's user may open the page.
401    pub(crate) fn admits(&self, cx: &Cx) -> bool {
402        (self.access)(cx)
403    }
404
405    /// The page behind `access` in place of its def's: an auth page placed in an area, which
406    /// declares none, opens to whom auth offers it.
407    pub(crate) fn with_access(
408        mut self,
409        access: impl Fn(&Cx) -> bool + Send + Sync + 'static,
410    ) -> Self {
411        self.access = Arc::new(access);
412        self
413    }
414
415    /// The page's access check, shared with its sidebar entry.
416    pub(crate) fn access(&self) -> Arc<AccessFn> {
417        Arc::clone(&self.access)
418    }
419}
420
421/// A page's declaration check, run by
422/// [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) as a resource's is.
423pub(crate) type PageCheck = Box<dyn Fn(&Cx, &mut Vec<crate::DeclarationError>) + Send>;
424
425/// Checks the header actions of `page`, the page `P` as the panel mounted it, as a resource's are,
426/// that no two of its widgets share a name, and that its filters' keys are distinct query words.
427pub(crate) fn check_page<P: Page>(page: Arc<MountedPage>) -> PageCheck {
428    Box::new(move |cx, errors| {
429        page.widgets.check::<P>(errors);
430        let mut keys = std::collections::HashSet::new();
431        for filter in &page.filters {
432            if !filter.has_valid_key() || !keys.insert(filter.key()) {
433                errors.push(crate::DeclarationError::of::<P>(
434                    crate::declaration::Site::Registration,
435                    crate::DeclarationErrorKind::MisdeclaredPageFilter {
436                        key: filter.key().to_string(),
437                    },
438                ));
439            }
440        }
441        crate::resource::check_inputs::<P>(
442            cx,
443            page.header_actions
444                .entries()
445                .iter()
446                .map(|action| (action.name, action.input)),
447            errors,
448        );
449    })
450}
451
452pub(crate) mod filters;
453#[cfg(test)]
454mod tests;