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::{future::Future, sync::Arc};
5
6use topcoat::{Result, context::Cx, icon::IconData, view::View};
7
8use crate::{
9    HeaderAction, HeaderActions,
10    naming::{kebab_case, sentence_case, type_stem},
11    navigation::NavigationItem,
12};
13
14/// A panel page that is not a resource, mounted at `{prefix}/{slug}` with a
15/// sidebar entry; the panel owns the route and entry while the page owns its
16/// markup.
17///
18/// ```rust
19/// # use tablo_core::{Page, Panel};
20/// # use topcoat::{Result, context::Cx, view::{View, view}};
21/// struct ReportsPage;
22///
23/// impl Page for ReportsPage {
24///     async fn render(cx: &Cx) -> Result<impl View> {
25///         Ok(view! { cx => tablo_ui::page(tablo_ui::page_title("Reports")) })
26///     }
27/// }
28///
29/// Panel::new("admin").page::<ReportsPage>(); // GET /admin/reports
30/// ```
31///
32/// A page serves one `GET` with a sidebar entry, which [`PageDef::access`] limits
33/// to the users it admits. A form it renders posts to an app-declared route the
34/// auth gate covers; that route asks [`can_open`](crate::can_open) itself.
35pub trait Page: Sized + Send + Sync + 'static {
36    /// The page's slug, sidebar entry, access and header actions. Defaults to
37    /// [`PageDef::new`]: every default.
38    ///
39    /// [`Panel::page_with`](crate::Panel::page_with) adjusts it for one panel.
40    fn declare() -> PageDef {
41        PageDef::new()
42    }
43
44    /// Render the page body. The panel checks for a resolved user and
45    /// [`PageDef::access`] before calling it.
46    fn render(cx: &Cx) -> impl Future<Output = Result<impl View>> + Send;
47}
48
49/// Whether the request's user may open a page.
50type AccessFn = dyn Fn(&Cx) -> bool + Send + Sync;
51
52/// What a [`Page`] declares: its slug, sidebar entry, access and header actions.
53///
54/// [`Page::declare`] returns one, and [`Panel::page_with`](crate::Panel::page_with) adjusts it for
55/// one panel. Every setting has a default, so a page sets only what differs:
56///
57/// ```rust
58/// # use tablo_core::{Page, PageDef};
59/// # use topcoat::{Result, context::Cx, view::View};
60/// struct MediaLibraryPage;
61///
62/// impl Page for MediaLibraryPage {
63///     fn declare() -> PageDef {
64///         PageDef::new().slug("media").icon(tablo_ui::icons::IMAGE)
65///     }
66///
67///     async fn render(_cx: &Cx) -> Result<impl View> {
68///         Ok(())
69///     }
70/// }
71/// ```
72///
73/// The panel builds the def once when it mounts and serves the result to every request.
74pub struct PageDef {
75    slug: Option<String>,
76    label: Option<crate::Text>,
77    icon: Option<IconData>,
78    navigation_order: Option<i32>,
79    navigation_group: Option<crate::Text>,
80    navigation: Option<NavigationItem>,
81    access: Arc<AccessFn>,
82    header_actions: HeaderActions,
83}
84
85impl PageDef {
86    /// A def with every default: the slug and label from the page's type name, and access for
87    /// every signed-in user.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// The URL segment under the panel prefix: `"media"` mounts the page at
93    /// `{panel prefix}/media`.
94    ///
95    /// Defaults to the page type's name without a trailing `Page`, kebab-cased:
96    /// `MediaLibraryPage` → `media-library`. [`Panel::home`](crate::Panel::home) mounts the page
97    /// at the prefix and does not read it.
98    #[must_use]
99    pub fn slug(mut self, slug: impl Into<String>) -> Self {
100        self.slug = Some(slug.into());
101        self
102    }
103
104    /// The sidebar label.
105    ///
106    /// Defaults to the message `page-{name}` of the panel's catalogs, else the page type's name
107    /// without a trailing `Page`, in sentence case: `MediaLibraryPage` reads `page-media-library`,
108    /// else `Media library`.
109    #[must_use]
110    pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
111        self.label = Some(label.into());
112        self
113    }
114
115    /// The icon of the page's sidebar entry, replacing the icon of a
116    /// [`navigation`](Self::navigation) item.
117    #[must_use]
118    pub fn icon(mut self, icon: IconData) -> Self {
119        self.icon = Some(icon);
120        self
121    }
122
123    /// Where the sidebar entry sorts among the panel's entries, lowest first; defaults to `0`, and
124    /// entries of equal order keep their registration order. It replaces the order of a
125    /// [`navigation`](Self::navigation) item.
126    #[must_use]
127    pub fn navigation_order(mut self, order: i32) -> Self {
128        self.navigation_order = Some(order);
129        self
130    }
131
132    /// The labelled sidebar group the entry renders in, such as `"Reports"`; defaults to none,
133    /// above every group. It replaces the group of a [`navigation`](Self::navigation) item.
134    #[must_use]
135    pub fn navigation_group(mut self, group: impl Into<crate::Text>) -> Self {
136        self.navigation_group = Some(group.into());
137        self
138    }
139
140    /// Replaces the sidebar entry, for one that links somewhere other than the page:
141    /// `NavigationItem::at("Exports", "/admin/reports?tab=exports")`. [`icon`](Self::icon),
142    /// [`navigation_order`](Self::navigation_order) and
143    /// [`navigation_group`](Self::navigation_group) still apply to it.
144    #[must_use]
145    pub fn navigation(mut self, item: NavigationItem) -> Self {
146        self.navigation = Some(item);
147        self
148    }
149
150    /// Whether the request's user may open the page. The panel answers 403 and leaves the page
151    /// out of the sidebar when it answers `false`. Defaults to every signed-in user.
152    ///
153    /// ```rust
154    /// # use tablo_core::{Page, PageDef, auth};
155    /// # use topcoat::{Result, context::Cx, view::{View, view}};
156    /// # #[derive(Clone)]
157    /// # struct Staff { admin: bool }
158    /// # impl tablo_core::PanelUser for Staff {
159    /// #     fn user_id(&self) -> String { String::new() }
160    /// #     fn display_name(&self) -> &str { "" }
161    /// # }
162    /// struct BillingPage;
163    ///
164    /// impl Page for BillingPage {
165    ///     fn declare() -> PageDef {
166    ///         PageDef::new().access(|cx| auth::user::<Staff>(cx).is_some_and(|staff| staff.admin))
167    ///     }
168    ///
169    ///     async fn render(cx: &Cx) -> Result<impl View> {
170    ///         Ok(view! { cx => tablo_ui::page(tablo_ui::page_title("Billing")) })
171    ///     }
172    /// }
173    /// ```
174    #[must_use]
175    pub fn access(mut self, access: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
176        self.access = Arc::new(access);
177        self
178    }
179
180    /// Adds the [`HeaderAction`] `A` after the ones already declared, served at
181    /// `{url}/-/actions/{NAME}`. [`header_action_buttons`](crate::header_action_buttons) renders
182    /// their buttons where [`render`](Page::render) places it. An `A::NAME` that is not a
183    /// single URL path segment does not compile.
184    ///
185    /// The actions run for whoever may open the page: a page has no policy. An action that writes
186    /// a resource's records asks that resource's policy in its own
187    /// [`can_run`](crate::HeaderAction::can_run).
188    #[must_use]
189    pub fn header_action<A: HeaderAction>(mut self) -> Self {
190        self.header_actions = self.header_actions.add::<A>();
191        self
192    }
193}
194
195impl Default for PageDef {
196    fn default() -> Self {
197        Self {
198            slug: None,
199            label: None,
200            icon: None,
201            navigation_order: None,
202            navigation_group: None,
203            navigation: None,
204            access: Arc::new(|_| true),
205            header_actions: HeaderActions::new(),
206        }
207    }
208}
209
210impl std::fmt::Debug for PageDef {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct("PageDef")
213            .field("slug", &self.slug)
214            .field("label", &self.label)
215            .field("navigation_order", &self.navigation_order)
216            .field("navigation_group", &self.navigation_group)
217            .field("navigation", &self.navigation)
218            .field("header_actions", &self.header_actions)
219            .finish_non_exhaustive()
220    }
221}
222
223/// A [`PageDef`] with its defaults filled in for the page `P`, as a panel mounted it.
224pub(crate) struct MountedPage {
225    pub(crate) slug: String,
226    /// The sidebar entry, its target not yet resolved to the page's URL.
227    pub(crate) navigation: NavigationItem,
228    access: Arc<AccessFn>,
229    pub(crate) header_actions: HeaderActions,
230}
231
232impl MountedPage {
233    pub(crate) fn new<P: Page>(def: PageDef) -> Self {
234        let stem = type_stem::<P>("Page");
235        let mut navigation = def.navigation.unwrap_or_else(|| NavigationItem {
236            label: def.label.unwrap_or_else(|| {
237                let noun = sentence_case(stem);
238                crate::Text::derived(
239                    crate::naming::derived_id("page", &crate::naming::slug_case(&noun)),
240                    noun,
241                )
242            }),
243            ..NavigationItem::default()
244        });
245        if let Some(order) = def.navigation_order {
246            navigation.order = order;
247        }
248        if let Some(icon) = def.icon {
249            navigation.icon = Some(icon);
250        }
251        if let Some(group) = def.navigation_group {
252            navigation.group = Some(group);
253        }
254        Self {
255            slug: def.slug.unwrap_or_else(|| kebab_case(stem)),
256            navigation,
257            access: def.access,
258            header_actions: def.header_actions,
259        }
260    }
261
262    /// Whether the request's user may open the page.
263    pub(crate) fn admits(&self, cx: &Cx) -> bool {
264        (self.access)(cx)
265    }
266
267    /// The page's access check, shared with its sidebar entry.
268    pub(crate) fn access(&self) -> Arc<AccessFn> {
269        Arc::clone(&self.access)
270    }
271}
272
273/// A page's declaration check, run by
274/// [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) as a resource's is.
275pub(crate) type PageCheck = Box<dyn Fn(&Cx, &mut Vec<crate::DeclarationError>) + Send>;
276
277/// Checks the header actions of `page`, the page `P` as the panel mounted it, as a resource's are.
278pub(crate) fn check_page<P: Page>(page: Arc<MountedPage>) -> PageCheck {
279    Box::new(move |cx, errors| {
280        crate::resource::check_inputs::<P>(
281            cx,
282            page.header_actions
283                .entries()
284                .iter()
285                .map(|action| (action.name, action.input)),
286            errors,
287        );
288    })
289}
290
291#[cfg(test)]
292mod tests;