Skip to main content

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}