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;