tablo_core/resource/record_page.rs
1//! [`RecordPage`]: a page of the app's own about one record of a resource, served under the
2//! record's URL, and the [`RecordPageDef`] 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 super::{Resource, ResourceDef};
14use crate::{
15 DeclarationError, DeclarationErrorKind, Site, Text,
16 naming::{derived_id, kebab_case, sentence_case, slug_case, type_stem},
17};
18
19/// A page about one record of the resource `R`, served at `{list}/{key}/{slug}` and linked from
20/// the record's navigation: the tab bar its detail page, edit page, record pages and relation tabs
21/// share. The panel loads the record and checks the request may open the page;
22/// the page renders its body.
23///
24/// ```rust
25/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
26/// # struct Order { #[key] #[auto] id: uuid::Uuid, number: String }
27/// # struct OrderResource;
28/// # impl tablo_core::Resource for OrderResource {
29/// # type Model = Order;
30/// # type Form = tablo_core::NoForm<Order>;
31/// # fn declare() -> tablo_core::ResourceDef<Self> {
32/// # tablo_core::ResourceDef::new().record_page::<OrderHistoryPage>()
33/// # }
34/// # }
35/// use tablo_core::RecordPage;
36/// use topcoat::{
37/// Result,
38/// context::Cx,
39/// view::{View, view},
40/// };
41///
42/// struct OrderHistoryPage; // GET /admin/orders/{key}/order-history
43///
44/// impl RecordPage<OrderResource> for OrderHistoryPage {
45/// async fn render(cx: &Cx, order: Order) -> Result<impl View> {
46/// Ok(view! { <p>"Order " (order.number)</p> })
47/// }
48/// }
49/// ```
50///
51/// A request reaches [`render`](Self::render) only through the checks the detail page makes:
52/// sign-in, the request's tenant, the resource's [`query`](crate::ResourceDef::query), and
53/// [`Ability::View`](crate::Ability::View) of the record; then [`RecordPageDef::access`]. A
54/// trashed record opens too, as it does on its detail page.
55///
56/// A form the page renders posts to an app route, which none of those checks cover: the route
57/// loads the record through [`scoped_query`](crate::scoped_query) and asks the policy itself.
58pub trait RecordPage<R: Resource>: Sized + Send + Sync + 'static {
59 /// The page's slug, label, icon and access. Defaults to [`RecordPageDef::new`]: every
60 /// default.
61 fn declare() -> RecordPageDef<R::Model> {
62 RecordPageDef::new()
63 }
64
65 /// Renders the page's body under the record's header, from the record as the resource's
66 /// [`query`](crate::ResourceDef::query) loads it, with no relation included.
67 ///
68 /// The record arrives whole: a field a [`ResourceDef::field_access`] rule hides from the
69 /// request is still on it, so a page that shows such a field asks
70 /// [`can_view_field`](crate::can_view_field) first.
71 fn render(cx: &Cx, record: R::Model) -> impl Future<Output = Result<impl View>> + Send;
72}
73
74/// Whether the request's user may open a record page for one record.
75type AccessFn<M> = dyn Fn(&Cx, &M) -> bool + Send + Sync;
76
77/// What a [`RecordPage`] declares: its slug, label, icon and access. Every setting has a default.
78pub struct RecordPageDef<M> {
79 slug: Option<String>,
80 label: Option<Text>,
81 icon: Option<IconData>,
82 access: Arc<AccessFn<M>>,
83}
84
85impl<M> RecordPageDef<M> {
86 /// A def with every default: the slug and label from the page's type name, and access for
87 /// whoever may view the record.
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 /// The URL segment after the record's key: `"history"` serves the page at
93 /// `{list}/{key}/history`.
94 ///
95 /// Defaults to the page type's name without a trailing `Page`, kebab-cased:
96 /// `OrderHistoryPage` → `order-history`. Mounting refuses a slug that is not one URL path
97 /// segment, `edit`, `delete`, `-`, or one another record page of the resource takes.
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 label of the page's tab in the record's navigation, and of its breadcrumb.
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: `OrderHistoryPage` reads `page-order-history`,
108 /// else `Order history`.
109 #[must_use]
110 pub fn label(mut self, label: impl Into<Text>) -> Self {
111 self.label = Some(label.into());
112 self
113 }
114
115 /// The icon of the page's tab in the record's navigation.
116 #[must_use]
117 pub fn icon(mut self, icon: IconData) -> Self {
118 self.icon = Some(icon);
119 self
120 }
121
122 /// Whether the request's user may open the page for `record`, asked once the policy allows
123 /// [`Ability::View`](crate::Ability::View) of it. The panel answers 403 and links no page it
124 /// answers `false` for. Defaults to `true`.
125 ///
126 /// Read no relation of `record`: each page asking loads the record with relations of its own,
127 /// the record page with none.
128 #[must_use]
129 pub fn access(mut self, access: impl Fn(&Cx, &M) -> bool + Send + Sync + 'static) -> Self {
130 self.access = Arc::new(access);
131 self
132 }
133}
134
135impl<M> Default for RecordPageDef<M> {
136 fn default() -> Self {
137 Self {
138 slug: None,
139 label: None,
140 icon: None,
141 access: Arc::new(|_, _| true),
142 }
143 }
144}
145
146impl<M> std::fmt::Debug for RecordPageDef<M> {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("RecordPageDef")
149 .field("slug", &self.slug)
150 .field("label", &self.label)
151 .finish_non_exhaustive()
152 }
153}
154
155impl<R: Resource> ResourceDef<R> {
156 /// Adds the [`RecordPage`] `P` after the ones already declared, served at
157 /// `{list}/{key}/{slug}` for each record and linked from the record's navigation.
158 #[must_use]
159 pub fn record_page<P: RecordPage<R>>(mut self) -> Self {
160 self.record_pages.push(RecordPageEntry::new::<P>());
161 self
162 }
163}
164
165/// Renders a record page's body.
166type RenderFn<M> = for<'a> fn(&'a Cx, M) -> crate::BoxFuture<'a, Result<BoxView<'a>>>;
167
168/// A [`RecordPageDef`] with its defaults filled in for the page `P`, its type erased.
169pub(crate) struct RecordPageEntry<R: Resource> {
170 pub(crate) page: TypeId,
171 pub(crate) name: &'static str,
172 pub(crate) slug: String,
173 pub(crate) label: Text,
174 pub(crate) icon: Option<IconData>,
175 access: Arc<AccessFn<R::Model>>,
176 pub(crate) render: RenderFn<R::Model>,
177}
178
179impl<R: Resource> RecordPageEntry<R> {
180 fn new<P: RecordPage<R>>() -> Self {
181 let def = P::declare();
182 let stem = type_stem::<P>("Page");
183 let label = def.label.unwrap_or_else(|| {
184 let noun = sentence_case(stem);
185 Text::derived(derived_id("page", &slug_case(&noun)), noun)
186 });
187 Self {
188 page: TypeId::of::<P>(),
189 name: std::any::type_name::<P>(),
190 slug: def.slug.unwrap_or_else(|| kebab_case(stem)),
191 label,
192 icon: def.icon,
193 access: def.access,
194 render: render::<R, P>,
195 }
196 }
197
198 /// Whether the request's user may open the page for `record`, once `View` allows it.
199 pub(crate) fn admits(&self, cx: &Cx, record: &R::Model) -> bool {
200 (self.access)(cx, record)
201 }
202}
203
204impl<R: Resource> std::fmt::Debug for RecordPageEntry<R> {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.debug_struct("RecordPageEntry")
207 .field("name", &self.name)
208 .field("slug", &self.slug)
209 .finish_non_exhaustive()
210 }
211}
212
213fn render<R: Resource, P: RecordPage<R>>(
214 cx: &Cx,
215 record: R::Model,
216) -> crate::BoxFuture<'_, Result<BoxView<'_>>> {
217 Box::pin(async move { Ok(P::render(cx, record).await?.boxed()) })
218}
219
220/// The segments a record's own routes take after its key, which no record page may.
221pub(crate) const RESERVED_SEGMENTS: &[&str] = &["edit", "delete", "-"];
222
223/// Refuses a record page of `R` whose slug `validate` refuses as a URL path segment, is one of
224/// the record's own routes, or is another record page's. Answers whether it refused none.
225pub(crate) fn check<R: Resource>(
226 pages: &[RecordPageEntry<R>],
227 validate: impl Fn(&str) -> Option<DeclarationErrorKind>,
228 errors: &mut Vec<DeclarationError>,
229) -> bool {
230 let before = errors.len();
231 for (index, page) in pages.iter().enumerate() {
232 let slug = page.slug.clone();
233 let kind = if let Some(kind) = validate(&slug) {
234 kind
235 } else if RESERVED_SEGMENTS.contains(&slug.as_str()) {
236 DeclarationErrorKind::ReservedSlug { slug: slug.clone() }
237 } else if pages[..index].iter().any(|earlier| earlier.slug == slug) {
238 DeclarationErrorKind::DuplicateSlug { slug: slug.clone() }
239 } else {
240 continue;
241 };
242 errors.push(DeclarationError::of::<R>(Site::RecordPage(slug), kind));
243 }
244 errors.len() == before
245}