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