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}