Skip to main content

tablo_core/resource/
def.rs

1//! [`ResourceDef`]: everything a resource declares, as one value.
2
3use std::sync::Arc;
4
5use toasty::stmt::{Expr, Path};
6use topcoat::{context::Cx, icon::IconData};
7
8use super::{Action, Actions, HeaderAction, HeaderActions, Relation, Resource};
9use crate::{
10    DeclarationErrorKind, Lens,
11    detail::Detail,
12    form::FormScalar,
13    navigation::NavigationItem,
14    policy::{Ability, Deny, Policy},
15    schema::Schema,
16    table::{Table, contains_expr},
17    tenancy::Tenancy,
18    toasty_compat::model::{self, ModelPath},
19};
20
21/// What a [`Resource`] declares: its names, navigation, policy, tenancy, table, form, detail page,
22/// relations and actions.
23///
24/// [`Resource::declare`] returns one, and [`Panel::resource_with`](crate::Panel::resource_with)
25/// adjusts it for one panel. Every setting has a default, so a resource sets only what differs:
26///
27/// ```rust
28/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
29/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, featured: bool }
30/// # #[derive(Debug, Clone, tablo_core::RecordForm)]
31/// # #[form(model = Post)]
32/// # struct PostForm { title: String, featured: bool }
33/// # struct PostResource;
34/// use tablo_core::{ModelColumns, ReadOnly, Resource, ResourceDef, TernaryFilter, lens};
35///
36/// impl Resource for PostResource {
37///     type Model = Post;
38///     type Form = PostForm;
39///
40///     fn declare() -> ResourceDef<Self> {
41///         ResourceDef::new()
42///             .label("Blog post")
43///             .policy(ReadOnly)
44///             .table(Post::table().filters(TernaryFilter::new(lens!(Post.featured))))
45///     }
46/// }
47/// ```
48///
49/// The panel builds the def once when it mounts, binding the paths it names to the database
50/// schema, and serves the result to every request.
51pub struct ResourceDef<R: Resource> {
52    pub(crate) slug: Option<String>,
53    pub(crate) label: Option<String>,
54    pub(crate) plural_label: Option<String>,
55    pub(crate) icon: Option<IconData>,
56    pub(crate) navigation_order: Option<i32>,
57    pub(crate) navigation_group: Option<String>,
58    pub(crate) navigation: Option<NavigationItem>,
59    pub(crate) policy: Arc<dyn Policy<R::Model>>,
60    pub(crate) tenancy: Tenancy<R::Model>,
61    pub(crate) table: Option<Table<R::Model>>,
62    pub(crate) form: Option<Schema<R::Form>>,
63    pub(crate) detail: Option<Detail<R::Model>>,
64    pub(crate) record_title: Option<RecordTitle<R::Model>>,
65    pub(crate) public_link: Option<PublicLinkFn<R::Model>>,
66    pub(crate) relations: Vec<Relation<R::Model>>,
67    pub(crate) actions: Actions<R>,
68    pub(crate) header_actions: HeaderActions,
69    /// The columns [`Self::create_column`] names, or why a path names no column.
70    pub(crate) create_columns: Vec<Result<String, DeclarationErrorKind>>,
71}
72
73impl<R: Resource> Default for ResourceDef<R> {
74    fn default() -> Self {
75        Self {
76            slug: None,
77            label: None,
78            plural_label: None,
79            icon: None,
80            navigation_order: None,
81            navigation_group: None,
82            navigation: None,
83            policy: Arc::new(Deny),
84            tenancy: Tenancy::none(),
85            table: None,
86            form: None,
87            detail: None,
88            record_title: None,
89            public_link: None,
90            relations: Vec::new(),
91            actions: Actions::default(),
92            header_actions: HeaderActions::new(),
93            create_columns: Vec::new(),
94        }
95    }
96}
97
98impl<R: Resource> ResourceDef<R> {
99    /// A def with every default: the policy denies all, rows belong to no tenant, the model's
100    /// [`ModelColumns`](crate::ModelColumns) give the table and the detail page, and the record
101    /// form gives the form.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// The URL slug for the resource's pages: `"users"` mounts the list at `{panel prefix}/users`.
107    ///
108    /// Defaults to the resource type's name without a trailing `Resource`, pluralized and
109    /// kebab-cased: `UserResource` → `users`, `BlogPostResource` → `blog-posts`.
110    #[must_use]
111    pub fn slug(mut self, slug: impl Into<String>) -> Self {
112        self.slug = Some(slug.into());
113        self
114    }
115
116    /// One record's name, the noun in the "Create {label}" and "Edit {label}" titles.
117    ///
118    /// Defaults to the model's type name in sentence case: `BlogPost` → `Blog post`, `APIKey` →
119    /// `API key`.
120    #[must_use]
121    pub fn label(mut self, label: impl Into<String>) -> Self {
122        self.label = Some(label.into());
123        self
124    }
125
126    /// The list title and sidebar label.
127    ///
128    /// Defaults to the pluralized [`label`](Self::label): `Category` → `Categories`, `Person` →
129    /// `People`.
130    #[must_use]
131    pub fn plural_label(mut self, label: impl Into<String>) -> Self {
132        self.plural_label = Some(label.into());
133        self
134    }
135
136    /// The icon of the resource's sidebar entry, replacing the icon of a
137    /// [`navigation`](Self::navigation) item.
138    #[must_use]
139    pub fn icon(mut self, icon: IconData) -> Self {
140        self.icon = Some(icon);
141        self
142    }
143
144    /// Where the sidebar entry sorts among the panel's entries, lowest first; defaults to `0`, and
145    /// entries of equal order keep their registration order. It replaces the order of a
146    /// [`navigation`](Self::navigation) item.
147    #[must_use]
148    pub fn navigation_order(mut self, order: i32) -> Self {
149        self.navigation_order = Some(order);
150        self
151    }
152
153    /// The labelled sidebar group the entry renders in, such as `"Content"`; defaults to none,
154    /// above every group. It replaces the group of a [`navigation`](Self::navigation) item.
155    #[must_use]
156    pub fn navigation_group(mut self, group: impl Into<String>) -> Self {
157        self.navigation_group = Some(group.into());
158        self
159    }
160
161    /// Replaces the sidebar entry, for one that links somewhere other than the list page:
162    /// `NavigationItem::at("Drafts", "/admin/posts?f.status=draft")`. [`icon`](Self::icon),
163    /// [`navigation_order`](Self::navigation_order) and
164    /// [`navigation_group`](Self::navigation_group) still apply to it.
165    #[must_use]
166    pub fn navigation(mut self, item: NavigationItem) -> Self {
167        self.navigation = Some(item);
168        self
169    }
170
171    /// Authorizes every handler; defaults to [`Deny`]. Row scoping belongs in
172    /// [`Resource::query`], not the policy.
173    #[must_use]
174    pub fn policy(mut self, policy: impl Policy<R::Model>) -> Self {
175        self.policy = Arc::new(policy);
176        self
177    }
178
179    /// Authorizes every handler with a closure, as [`policy`](Self::policy) does, inferring the
180    /// closure's argument types from the def's model:
181    ///
182    /// ```rust
183    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
184    /// # struct Post { #[key] #[auto] id: uuid::Uuid, locked: bool }
185    /// # struct PostResource;
186    /// # impl tablo_core::Resource for PostResource {
187    /// #     type Model = Post;
188    /// #     type Form = tablo_core::NoForm<Post>;
189    /// # }
190    /// use tablo_core::{Ability, Panel};
191    ///
192    /// Panel::new("portal").resource_with::<PostResource>(|def| {
193    ///     def.policy_fn(|_cx, ability| match ability {
194    ///         Ability::Update(post) | Ability::Delete(post) => !post.locked,
195    ///         _ => true,
196    ///     })
197    /// });
198    /// ```
199    ///
200    /// In [`Resource::declare`], the chain on `ResourceDef::new()` learns its resource from the
201    /// return type, after the closure is checked, so a closure that reads the record names the
202    /// ability's type, `|_cx, ability: Ability<'_, Post>|`, or the chain starts from
203    /// `ResourceDef::<Self>::new()`.
204    ///
205    /// [`policy`](Self::policy) takes a closure too, but infers neither argument type: Rust infers
206    /// a closure's signature from an `Fn` bound, not from [`Policy`]'s blanket impl.
207    #[must_use]
208    pub fn policy_fn(
209        self,
210        policy: impl Fn(&Cx, Ability<'_, R::Model>) -> bool + Send + Sync + 'static,
211    ) -> Self {
212        self.policy(policy)
213    }
214
215    /// How rows belong to a tenant; defaults to [`Tenancy::none`].
216    #[must_use]
217    pub fn tenancy(mut self, tenancy: Tenancy<R::Model>) -> Self {
218        self.tenancy = tenancy;
219        self
220    }
221
222    /// The list table; defaults to the model's ([`ModelColumns::table`]).
223    ///
224    /// [`ModelColumns::table`]: crate::ModelColumns::table
225    #[must_use]
226    pub fn table(mut self, table: Table<R::Model>) -> Self {
227        self.table = Some(table);
228        self
229    }
230
231    /// Arranges the create and edit forms: the record form's own controls, from its
232    /// `controls()`, in layout blocks.
233    ///
234    /// A control the schema does not place follows the ones it does, in the record form's
235    /// declaration order and outside every layout block, so the default form renders one control
236    /// per field, and a form that adjusts one control places only that one:
237    ///
238    /// ```rust
239    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
240    /// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String }
241    /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
242    /// # #[form(model = User)]
243    /// # struct UserForm { name: String, email: String }
244    /// # struct UserResource;
245    /// use tablo_core::{Resource, ResourceDef, Schema};
246    ///
247    /// impl Resource for UserResource {
248    ///     type Model = User;
249    ///     type Form = UserForm;
250    ///
251    ///     fn declare() -> ResourceDef<Self> {
252    ///         // `email`, then `name`.
253    ///         ResourceDef::new().form(Schema::new(UserForm::controls().email.email()))
254    ///     }
255    /// }
256    /// ```
257    #[must_use]
258    pub fn form(mut self, form: Schema<R::Form>) -> Self {
259        self.form = Some(form);
260        self
261    }
262
263    /// The detail page: columns in layout blocks; defaults to the model's
264    /// ([`ModelColumns::detail`]), and [`Detail::empty`] turns the page off.
265    ///
266    /// The page loads the relations its columns declare.
267    ///
268    /// [`ModelColumns::detail`]: crate::ModelColumns::detail
269    #[must_use]
270    pub fn detail(mut self, detail: Detail<R::Model>) -> Self {
271        self.detail = Some(detail);
272        self
273    }
274
275    /// Titles each record with the text column `lens` reads, as in
276    /// `.record_title(lens!(Post.title))`: the detail page's heading, each option of a
277    /// [`relationship`](crate::ChoiceField::relationship) choice over this resource, and each cell
278    /// of a [`RelationColumn::of`](crate::RelationColumn::of) it.
279    ///
280    /// A record whose column is empty, and every record by default, is titled with the
281    /// [`label`](Self::label) and its key, as in `Blog Post 3f2a…`. A relationship choice over this
282    /// resource also searches the column, besides the table's searchable columns.
283    ///
284    /// A title is display text, not a key: two records can share one, so it never replaces the
285    /// primary key that keys the table's rows and the action routes. A lens names one of the
286    /// model's own columns, never a relation, so the title reads on every record however it was
287    /// loaded; a title built from several columns is a column the model stores.
288    #[must_use]
289    pub fn record_title<T>(mut self, lens: Lens<R::Model, T>) -> Self
290    where
291        T: FormScalar + toasty::schema::Field<Inner = String> + Send + Sync + 'static,
292    {
293        self.record_title = Some(RecordTitle::new(lens));
294        self
295    }
296
297    /// Links a record's public page from its detail and edit pages, for each record `link`
298    /// returns one for; defaults to no link.
299    ///
300    /// The edit page loads the record without relations, so a link that reads one renders on the
301    /// detail page only when its relation is loaded there.
302    #[must_use]
303    pub fn public_link(
304        mut self,
305        link: impl Fn(&Cx, &R::Model) -> Option<PublicLink> + Send + Sync + 'static,
306    ) -> Self {
307        self.public_link = Some(Arc::new(link));
308        self
309    }
310
311    /// Adds a related resource, rendered as its table narrowed to the record on the detail and
312    /// edit pages.
313    #[must_use]
314    pub fn relation(mut self, relation: Relation<R::Model>) -> Self {
315        self.relations.push(relation);
316        self
317    }
318
319    /// Adds the custom [`Action`] `A` after the ones already declared.
320    ///
321    /// The policy decides who may run it, through
322    /// [`Ability::RunAny`](crate::Ability::RunAny) and [`Ability::Run`](crate::Ability::Run). An
323    /// `A::NAME` that is not a single URL path segment does not compile.
324    #[must_use]
325    pub fn action<A: Action<R>>(mut self) -> Self {
326        self.actions = self.actions.add::<A>();
327        self
328    }
329
330    /// Adds the [`HeaderAction`] `A`, which acts on no record, to the list page's header, after
331    /// the ones already declared.
332    ///
333    /// The policy decides who may run it, through [`Ability::ViewAny`](crate::Ability::ViewAny)
334    /// and [`Ability::RunHeader`](crate::Ability::RunHeader) with its name, which no other action
335    /// of the resource may share. An `A::NAME` that is not
336    /// a single URL path segment does not compile.
337    ///
338    /// ```rust,compile_fail,E0080
339    /// # use tablo_core::{HeaderAction, NoForm, Resource, ResourceDef};
340    /// # use topcoat::{Result, context::Cx};
341    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
342    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
343    /// # struct PostResource;
344    /// # impl Resource for PostResource {
345    /// #     type Model = Post;
346    /// #     type Form = NoForm<Post>;
347    /// # }
348    /// struct RecountTags;
349    ///
350    /// impl HeaderAction for RecountTags {
351    ///     type Input = ();
352    ///     const NAME: &'static str = "recount/tags";
353    ///
354    ///     async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<()> {
355    ///         Ok(())
356    ///     }
357    /// }
358    ///
359    /// let _def = ResourceDef::<PostResource>::new().header_action::<RecountTags>();
360    /// ```
361    #[must_use]
362    pub fn header_action<A: HeaderAction>(mut self) -> Self {
363        self.header_actions = self.header_actions.add::<A>();
364        self
365    }
366
367    /// Declares a column an overriding [`Resource::create_record`] sets itself, beyond the form's
368    /// fields: `.create_column(lens!(Post.slug))`. Call it once per column.
369    ///
370    /// Mounting the panel refuses a path that names no one field of the model, and the tenant
371    /// column, which the framework stamps.
372    #[must_use]
373    pub fn create_column<T>(mut self, path: impl Into<Path<R::Model, T>>) -> Self {
374        let path = ModelPath::of(&path.into());
375        self.create_columns
376            .push(model::field::<R::Model>(&path).map(|field| field.name));
377        self
378    }
379}
380
381/// A record's title column, as [`ResourceDef::record_title`] declares it.
382pub(crate) struct RecordTitle<M> {
383    read: Box<dyn Fn(&M) -> String + Send + Sync>,
384    search: TitleSearch,
385}
386
387/// The substring match on a title column for a search term.
388type TitleSearch = Box<dyn Fn(&str) -> Option<Expr<bool>> + Send + Sync>;
389
390impl<M: toasty::schema::Model + Send + Sync + 'static> RecordTitle<M> {
391    fn new<T>(lens: Lens<M, T>) -> Self
392    where
393        T: FormScalar + toasty::schema::Field<Inner = String> + Send + Sync + 'static,
394    {
395        let path = lens.clone();
396        Self {
397            read: Box::new(move |record| lens.read(record).to_label()),
398            search: Box::new(move |term| contains_expr(&path, term)),
399        }
400    }
401
402    /// The column's value on `record`, or `None` when it is empty.
403    pub(crate) fn read(&self, record: &M) -> Option<String> {
404        Some((self.read)(record)).filter(|title| !title.trim().is_empty())
405    }
406
407    /// The substring match on the column for `term`, or `None` for a blank term.
408    pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
409        (self.search)(term)
410    }
411}
412
413/// A record's public page, linked from its detail and edit pages.
414#[derive(Debug, Clone)]
415pub struct PublicLink {
416    /// The public page's URL.
417    pub url: String,
418    /// The link's text.
419    pub label: &'static str,
420}
421
422/// A record's public page, as [`ResourceDef::public_link`] declares it.
423pub(crate) type PublicLinkFn<M> = Arc<dyn Fn(&Cx, &M) -> Option<PublicLink> + Send + Sync>;
424
425impl<R: Resource> std::fmt::Debug for ResourceDef<R> {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        f.debug_struct("ResourceDef")
428            .field("slug", &self.slug)
429            .field("label", &self.label)
430            .field("plural_label", &self.plural_label)
431            .field("relations", &self.relations)
432            .field("actions", &self.actions)
433            .field("header_actions", &self.header_actions)
434            .finish_non_exhaustive()
435    }
436}