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;