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, HeaderActions, Relation, Resource, SoftDelete};
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,
17    tenancy::Tenancy,
18    toasty_compat::model::{self, ModelPath},
19};
20
21mod header_actions;
22
23/// What a [`Resource`] declares: its names, navigation, policy, tenancy, table, list widgets, form,
24/// detail page, record pages, relations and actions.
25///
26/// [`Resource::declare`] returns one, and [`Panel::resource_with`](crate::Panel::resource_with)
27/// adjusts it for one panel. Every setting has a default, so a resource sets only what differs:
28///
29/// ```rust
30/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
31/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, featured: bool }
32/// # #[derive(Debug, Clone, tablo_core::RecordForm)]
33/// # #[form(model = Post)]
34/// # struct PostForm { title: String, featured: bool }
35/// # struct PostResource;
36/// use tablo_core::{ModelColumns, ReadOnly, Resource, ResourceDef, TernaryFilter, lens};
37///
38/// impl Resource for PostResource {
39///     type Model = Post;
40///     type Form = PostForm;
41///
42///     fn declare() -> ResourceDef<Self> {
43///         ResourceDef::new()
44///             .label("Blog post")
45///             .policy(ReadOnly)
46///             .table(Post::table().filters(TernaryFilter::new(lens!(Post.featured))))
47///     }
48/// }
49/// ```
50///
51/// The panel builds the def once when it mounts, binding the paths it names to the database
52/// schema, and serves the result to every request.
53pub struct ResourceDef<R: Resource> {
54    pub(crate) slug: Option<String>,
55    pub(crate) label: Option<crate::Text>,
56    pub(crate) plural_label: Option<crate::Text>,
57    pub(crate) icon: Option<IconData>,
58    pub(crate) navigation_order: Option<i32>,
59    pub(crate) navigation_group: Option<crate::Text>,
60    pub(crate) navigation: Option<NavigationItem>,
61    pub(crate) navigation_badge: Option<Expr<bool>>,
62    pub(crate) navigation_hidden: bool,
63    pub(crate) policy: Arc<dyn Policy<R::Model>>,
64    pub(crate) queries: Vec<super::QueryFn<R::Model>>,
65    pub(crate) tenancy: Tenancy<R::Model>,
66    pub(crate) table: Option<Table<R::Model>>,
67    pub(crate) form: Option<Schema<R::Form>>,
68    pub(crate) detail: Option<Detail<R::Model>>,
69    pub(crate) record_title: Option<super::RecordTitle<R::Model>>,
70    /// `None` once [`Self::without_global_search`] leaves the resource out.
71    pub(crate) global_search: Option<super::GlobalSearch<R::Model>>,
72    pub(crate) public_link: Option<PublicLinkFn<R::Model>>,
73    pub(crate) relations: Vec<Relation<R::Model>>,
74    pub(crate) record_pages: Vec<super::RecordPageEntry<R>>,
75    pub(crate) actions: Actions<R>,
76    pub(crate) header_actions: HeaderActions,
77    /// The header actions' badges, each action's name with the filter its count matches.
78    pub(crate) header_badges: Vec<(&'static str, Expr<bool>)>,
79    pub(crate) list_widgets: crate::widget::WidgetGrid,
80    /// The columns [`Self::create_column`] names, or why a path names no column.
81    pub(crate) create_columns: Vec<Result<String, DeclarationErrorKind>>,
82    /// The pages [`Self::without_page`] has not turned off.
83    pub(crate) pages: super::Pages,
84    /// Whether [`Self::importable`] turned the CSV import on.
85    pub(crate) importable: bool,
86    pub(crate) soft_delete: Option<SoftDelete<R::Model>>,
87    pub(crate) field_rules: Vec<super::FieldRule>,
88}
89
90impl<R: Resource> Default for ResourceDef<R> {
91    fn default() -> Self {
92        Self {
93            slug: None,
94            label: None,
95            plural_label: None,
96            icon: None,
97            navigation_order: None,
98            navigation_group: None,
99            navigation: None,
100            navigation_badge: None,
101            navigation_hidden: false,
102            policy: Arc::new(Deny),
103            queries: Vec::new(),
104            tenancy: Tenancy::undeclared(),
105            table: None,
106            form: None,
107            detail: None,
108            record_title: None,
109            global_search: Some(super::GlobalSearch::default()),
110            public_link: None,
111            relations: Vec::new(),
112            record_pages: Vec::new(),
113            actions: Actions::default(),
114            header_actions: HeaderActions::new(),
115            header_badges: Vec::new(),
116            list_widgets: crate::widget::WidgetGrid::default(),
117            create_columns: Vec::new(),
118            pages: super::Pages::default(),
119            importable: false,
120            soft_delete: None,
121            field_rules: Vec::new(),
122        }
123    }
124}
125
126impl<R: Resource> ResourceDef<R> {
127    /// A def with every default: the policy denies all, rows belong to no tenant, the model's
128    /// [`ModelColumns`](crate::ModelColumns) give the table and the detail page, and the record
129    /// form gives the form.
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// The URL slug for the resource's pages: `"users"` mounts the list at `{panel prefix}/users`.
135    ///
136    /// Defaults to the resource type's name without a trailing `Resource`, pluralized and
137    /// kebab-cased: `UserResource` → `users`, `BlogPostResource` → `blog-posts`. Renaming the type
138    /// then moves the resource's URLs and drops the column choices its users saved, which are
139    /// stored under the slug; set it to keep both.
140    #[must_use]
141    pub fn slug(mut self, slug: impl Into<String>) -> Self {
142        self.slug = Some(slug.into());
143        self
144    }
145
146    /// One record's name, the noun in the "Create {label}" and "Edit {label}" titles.
147    ///
148    /// Defaults to the message `resource-{model}` of the panel's catalogs, else the model's type
149    /// name in sentence case: `BlogPost` reads `resource-blog-post`, else `Blog post`, and
150    /// `APIKey` reads `resource-api-key`, else `API key`.
151    #[must_use]
152    pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
153        self.label = Some(label.into());
154        self
155    }
156
157    /// The list title and sidebar label.
158    ///
159    /// Defaults to the plural of the [`label`](Self::label): a plain label pluralized in English,
160    /// `Category` reading `Categories` and `Person` `People`, and a message's attribute `plural`.
161    /// The default label's message is `resource-{model}`, so its plural is
162    /// `resource-{model}.plural`, else the derived English noun pluralized.
163    ///
164    /// ```ftl
165    /// resource-blog-post = Article
166    ///     .plural = Articles
167    /// ```
168    #[must_use]
169    pub fn plural_label(mut self, label: impl Into<crate::Text>) -> Self {
170        self.plural_label = Some(label.into());
171        self
172    }
173
174    /// The icon of the resource's sidebar entry, replacing the icon of a
175    /// [`navigation`](Self::navigation) item.
176    #[must_use]
177    pub fn icon(mut self, icon: IconData) -> Self {
178        self.icon = Some(icon);
179        self
180    }
181
182    /// Where the sidebar entry sorts among the panel's entries, lowest first; defaults to `0`, and
183    /// entries of equal order keep their registration order. It replaces the order of a
184    /// [`navigation`](Self::navigation) item.
185    #[must_use]
186    pub fn navigation_order(mut self, order: i32) -> Self {
187        self.navigation_order = Some(order);
188        self
189    }
190
191    /// The labelled sidebar group the entry renders in, such as `"Content"`; defaults to none,
192    /// above every group. It replaces the group of a [`navigation`](Self::navigation) item.
193    #[must_use]
194    pub fn navigation_group(mut self, group: impl Into<crate::Text>) -> Self {
195        self.navigation_group = Some(group.into());
196        self
197    }
198
199    /// Replaces the sidebar entry, for one that links somewhere other than the list page:
200    /// `NavigationItem::at("Drafts", "/admin/posts?f.status=draft")`. [`icon`](Self::icon),
201    /// [`navigation_order`](Self::navigation_order), [`navigation_group`](Self::navigation_group)
202    /// and [`navigation_badge`](Self::navigation_badge) still apply to it.
203    #[must_use]
204    pub fn navigation(mut self, item: NavigationItem) -> Self {
205        self.navigation = Some(item);
206        self
207    }
208
209    /// Badge the sidebar entry with the number of rows of [`scoped_query`](crate::scoped_query)
210    /// that `filter` matches, so a tenant counts its own rows. The panel counts them each time its
211    /// sidebar renders, on a page load and on each runtime rerun such as a sort or a page step,
212    /// one query per badge run together, and shows no badge for zero. It replaces the
213    /// badge of a [`navigation`](Self::navigation) item.
214    ///
215    /// ```rust
216    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
217    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, draft: bool }
218    /// # struct PostResource;
219    /// # impl tablo_core::Resource for PostResource {
220    /// #     type Model = Post;
221    /// #     type Form = tablo_core::NoForm<Post>;
222    /// fn declare() -> tablo_core::ResourceDef<Self> {
223    ///     // "Posts 3" while three drafts wait.
224    ///     tablo_core::ResourceDef::new().navigation_badge(Post::fields().draft().eq(true))
225    /// }
226    /// # }
227    /// ```
228    #[must_use]
229    pub fn navigation_badge(mut self, filter: Expr<bool>) -> Self {
230        self.navigation_badge = Some(filter);
231        self
232    }
233
234    /// Keeps the resource out of the sidebar and the root's redirect: hiding every resource needs
235    /// a [`Panel::home`](crate::Panel::home) ([`NoRoot`](crate::DeclarationErrorKind::NoRoot)).
236    /// Its pages stay mounted at their URLs, and its policy still decides who may open them.
237    #[must_use]
238    pub fn without_navigation(mut self) -> Self {
239        self.navigation_hidden = true;
240        self
241    }
242
243    /// Authorizes every handler; defaults to [`Deny`]. Row scoping belongs in
244    /// [`query`](Self::query), not the policy.
245    #[must_use]
246    pub fn policy(mut self, policy: impl Policy<R::Model>) -> Self {
247        self.policy = Arc::new(policy);
248        self
249    }
250
251    /// Authorizes every handler with a closure, as [`policy`](Self::policy) does, inferring the
252    /// closure's argument types from the def's model:
253    ///
254    /// ```rust
255    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
256    /// # struct Post { #[key] #[auto] id: uuid::Uuid, locked: bool }
257    /// # struct PostResource;
258    /// # impl tablo_core::Resource for PostResource {
259    /// #     type Model = Post;
260    /// #     type Form = tablo_core::NoForm<Post>;
261    /// # }
262    /// use tablo_core::{Ability, Panel};
263    ///
264    /// Panel::new("portal").resource_with::<PostResource>(|def| {
265    ///     // No update, delete, restore or record action on a locked post.
266    ///     def.policy_fn(|_cx, ability| ability.written_record().is_none_or(|post| !post.locked))
267    /// });
268    /// ```
269    ///
270    /// In [`Resource::declare`], the chain on `ResourceDef::new()` learns its resource from the
271    /// return type, after the closure is checked, so a closure that reads the record names the
272    /// ability's type, `|_cx, ability: Ability<'_, Post>|`, or the chain starts from
273    /// `ResourceDef::<Self>::new()`.
274    ///
275    /// [`policy`](Self::policy) takes a closure too, but infers neither argument type: Rust infers
276    /// a closure's signature from an `Fn` bound, not from [`Policy`]'s blanket impl.
277    #[must_use]
278    pub fn policy_fn(
279        self,
280        policy: impl Fn(&Cx, Ability<'_, R::Model>) -> bool + Send + Sync + 'static,
281    ) -> Self {
282        self.policy(policy)
283    }
284
285    /// Restricts who views and who changes the field `path` names, as in
286    /// `.field_access(lens!(Employee.salary), FieldAccess::new().view(is_hr))`: see
287    /// [`FieldAccess`](super::FieldAccess).
288    ///
289    /// The rule restricts the field of its model within the panel: every resource the panel mounts
290    /// over the model drops what reads a field the request may not view, the table's and the detail
291    /// page's columns, the filters and the grouping, the export's columns, the searches of the list
292    /// and of the relationship choices over those resources, and the relation columns showing it.
293    /// Each form renders nothing for the field, or a disabled control for one it may view but not
294    /// change, and refuses a submission posting either with 400. A create the field needs a value
295    /// for, with no default, is refused.
296    ///
297    /// Hiding a `belongs_to` relation hides its foreign keys, and hiding a key hides the relation.
298    /// Mounting the panel refuses a path that is not one field of the model, a second declaration
299    /// over one field, a primary key with a `view` rule, a record title or a form condition reading
300    /// a restricted field, and a column or a filter that does not say which fields it
301    /// [`reads`](crate::Reads), and the declarations [`FieldAccessFault`](crate::FieldAccessFault)
302    /// lists that would show a restricted record no panel can mask.
303    ///
304    /// A validation error on a field the request may not view shows as an alert that a hidden
305    /// field refuses the save, without its message.
306    #[must_use]
307    pub fn field_access<T>(
308        mut self,
309        path: impl Into<toasty::stmt::Path<R::Model, T>>,
310        access: super::FieldAccess,
311    ) -> Self {
312        self.field_rules
313            .push(super::FieldRule::new(&path.into(), access));
314        self
315    }
316
317    /// How rows belong to a tenant. A resource that declares none is served to every tenant;
318    /// [`Tenancy::none`] says when mounting the panel refuses it instead.
319    #[must_use]
320    pub fn tenancy(mut self, tenancy: Tenancy<R::Model>) -> Self {
321        self.tenancy = tenancy;
322        self
323    }
324
325    /// The list table; defaults to the model's ([`ModelColumns::table`]). Its columns are also
326    /// the [`detail`](Self::detail) page's, unless the resource declares one.
327    ///
328    /// [`ModelColumns::table`]: crate::ModelColumns::table
329    #[must_use]
330    pub fn table(mut self, table: Table<R::Model>) -> Self {
331        self.table = Some(table);
332        self
333    }
334
335    /// Arranges the create and edit forms: the record form's own controls, from its
336    /// `controls()`, in layout blocks.
337    ///
338    /// A control the schema does not place follows the ones it does, in the record form's
339    /// declaration order and outside every layout block, so the default form renders one control
340    /// per field, and a form that adjusts one control places only that one:
341    ///
342    /// ```rust
343    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
344    /// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String }
345    /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
346    /// # #[form(model = User)]
347    /// # struct UserForm { name: String, email: String }
348    /// # struct UserResource;
349    /// use tablo_core::{Resource, ResourceDef, Schema};
350    ///
351    /// impl Resource for UserResource {
352    ///     type Model = User;
353    ///     type Form = UserForm;
354    ///
355    ///     fn declare() -> ResourceDef<Self> {
356    ///         // `email`, then `name`.
357    ///         ResourceDef::new().form(Schema::new(UserForm::controls().email.email()))
358    ///     }
359    /// }
360    /// ```
361    #[must_use]
362    pub fn form(mut self, form: Schema<R::Form>) -> Self {
363        self.form = Some(form);
364        self
365    }
366
367    /// The detail page: columns in layout blocks; defaults to the [`table`](Self::table)'s columns,
368    /// else to the model's ([`ModelColumns::detail`]). The page adds a View action to each
369    /// row; [`without_page(ResourcePage::View)`](Self::without_page) turns it off, and mounting
370    /// refuses a detail listing no column on a resource that serves it.
371    ///
372    /// The page, at `{list}/{key}`, carries in its header the record's title, a link back to the
373    /// list, the actions placed on [`Places::DETAIL`](crate::Places::DETAIL) and Delete as the
374    /// policy allows them on the record, and Edit when the resource has a form and the policy
375    /// allows `Update`. The [related tables](Self::relation) follow its columns.
376    ///
377    /// The record loads through the resource's tenant-scoped [`query`](Self::query) with every
378    /// relation the columns declare, once each, wherever its block sits; no other relation
379    /// loads. An unknown key and a key outside the request's tenant are the same 404, and a
380    /// record the policy refuses `View` is a 403. The [`form`](Self::form) does not shape the
381    /// page: a resource that arranges its form declares a detail to show the same arrangement.
382    ///
383    /// [`ModelColumns::detail`]: crate::ModelColumns::detail
384    #[must_use]
385    pub fn detail(mut self, detail: Detail<R::Model>) -> Self {
386        self.detail = Some(detail);
387        self
388    }
389
390    /// Titles each record with the text column `lens` reads, as in
391    /// `.record_title(lens!(Post.title))`: the detail page's heading, each option of a
392    /// [`relationship`](crate::ChoiceField::relationship) choice over this resource, and each cell
393    /// of a [`RelationColumn::of`](crate::RelationColumn::of) it.
394    ///
395    /// A record whose column is empty, and every record by default, is titled with the
396    /// [`label`](Self::label) and its key, as in `Blog Post 3f2a…`. A relationship choice over this
397    /// resource also searches the column, besides the table's searchable columns.
398    ///
399    /// A title is display text, not a key: two records can share one, so it never replaces the
400    /// primary key that keys the table's rows and the action routes. A lens names one of the
401    /// model's own columns, never a relation, so the title reads on every record however it was
402    /// loaded; a title built from several columns is a column the model stores.
403    #[must_use]
404    pub fn record_title<T>(mut self, lens: Lens<R::Model, T>) -> Self
405    where
406        T: FormScalar + toasty::schema::Field<Inner = String> + Send + Sync + 'static,
407    {
408        self.record_title = Some(super::RecordTitle::new(lens));
409        self
410    }
411
412    /// Links a record's public page from its detail and edit pages, for each record `link` returns
413    /// one for; defaults to no link.
414    ///
415    /// The edit page loads the record without relations, so a link that reads one renders on the
416    /// detail page only when its relation is loaded there.
417    #[must_use]
418    pub fn public_link(
419        mut self,
420        link: impl Fn(&Cx, &R::Model) -> Option<PublicLink> + Send + Sync + 'static,
421    ) -> Self {
422        self.public_link = Some(Arc::new(link));
423        self
424    }
425
426    /// Adds a related resource: its table, narrowed to the record, on the detail and edit pages.
427    #[must_use]
428    pub fn relation(mut self, relation: Relation<R::Model>) -> Self {
429        self.relations.push(relation);
430        self
431    }
432
433    /// Adds the custom [`Action`] `A` after the ones already declared.
434    ///
435    /// The policy decides who may run it, through
436    /// [`Ability::RunAny`](crate::Ability::RunAny) and [`Ability::Run`](crate::Ability::Run). An
437    /// `A::NAME` that is not a single URL path segment does not compile.
438    #[must_use]
439    pub fn action<A: Action<R>>(mut self) -> Self {
440        self.actions = self.actions.add::<A>();
441        self
442    }
443
444    /// Adds the [`Widget`](crate::Widget) `W` to the grid above the list page's table, after the
445    /// ones already declared: a banner, a row of [`Stat`](crate::Stat)s, a chart.
446    ///
447    /// The grid renders with the list, behind its sign-in and
448    /// [`Ability::ViewAny`](crate::Ability::ViewAny), and leaves out each widget whose
449    /// [`WidgetDef::access`](crate::WidgetDef::access) refuses the request. Its widgets render
450    /// before the rows load, and an error from one fails the list's response. A sort, filter or
451    /// page change reruns the list and its widgets with it. Mounting refuses two widgets sharing a
452    /// name.
453    #[must_use]
454    pub fn list_widget<W: crate::Widget>(mut self) -> Self {
455        self.list_widgets.push::<W>();
456        self
457    }
458
459    /// How many columns the list page's widget grid has on a wide screen, from 1 to 4; defaults
460    /// to 2, as a page's does.
461    #[must_use]
462    pub fn list_widget_columns(mut self, columns: u8) -> Self {
463        self.list_widgets.set_columns(columns);
464        self
465    }
466
467    /// Declares a column an overriding [`Resource::create_record`] sets itself, beyond the form's
468    /// fields: `.create_column(lens!(Post.slug))`. Call it once per column.
469    ///
470    /// Mounting the panel refuses a path that names no one field of the model, and the tenant
471    /// column, which the framework stamps.
472    #[must_use]
473    pub fn create_column<T>(mut self, path: impl Into<Path<R::Model, T>>) -> Self {
474        let path = ModelPath::of(&path.into());
475        self.create_columns
476            .push(model::field::<R::Model>(&path).map(|field| field.name));
477        self
478    }
479
480    /// Serves a CSV import beside the create page: the list links **Import CSV** to a page that
481    /// takes a file whose header names the form's keys, and creates one record per row through
482    /// the create form, all in one transaction or none.
483    ///
484    /// Each row is checked as a create submission is, under the same policy, field access and
485    /// tenant. Mounting refuses an importable resource that serves no create page.
486    ///
487    /// - The first row names the columns, each by its form key or its field's label in any case:
488    ///   the controls the request may change, but file fields, repeaters and controls disabled on
489    ///   the create page. A header naming any other column, or a label two fields carry, refuses
490    ///   the file. **Download template** at `{list}/-/import/template` returns the header row.
491    /// - Each cell posts what the create form's control would: an option's value, `true` or `false`
492    ///   for a checkbox, a date and time in the request's time zone. A blank cell posts a blank
493    ///   value, and a column the header leaves out takes the field's `default`. A multiple choice
494    ///   takes one column per value, its key repeated; no other column may repeat.
495    /// - The file is UTF-8 text with commas or semicolons between cells, of at most
496    ///   [`Limits::import_rows`](crate::Limits::import_rows) rows, 500 cells a row and
497    ///   [`Limits::body_bytes`](crate::Limits::body_bytes). A row whose every cell is blank is
498    ///   skipped.
499    /// - [`Resource::before_commit`] receives each row's [`Change::Create`](crate::Change::Create)
500    ///   inside the transaction. A row [`Resource::create_record`] refuses rolls back to a
501    ///   savepoint taken before it, so the rows after it are checked without its writes. When any
502    ///   row is refused nothing is written, and the page lists each refusal by row number, as a
503    ///   spreadsheet numbers it, and column. Once every row passes, the import lands on the list
504    ///   and [`Resource::after_commit`] receives each row's change, in file order.
505    ///
506    /// The transaction lasts the whole file: on SQLite it holds the write lock that long. The
507    /// import reads what the form posts, not what the export writes: an exported `$1,234.50`,
508    /// `Yes` or option label does not import back.
509    #[must_use]
510    pub fn importable(mut self) -> Self {
511        self.importable = true;
512        self
513    }
514}
515
516/// A record's public page, linked from its detail and edit pages.
517#[derive(Debug, Clone)]
518pub struct PublicLink {
519    pub(crate) url: String,
520    pub(crate) label: crate::Text,
521}
522
523impl PublicLink {
524    /// A link to `url` that reads `label`.
525    pub fn new(url: impl Into<String>, label: impl Into<crate::Text>) -> Self {
526        Self {
527            url: url.into(),
528            label: label.into(),
529        }
530    }
531}
532
533/// A record's public page, as [`ResourceDef::public_link`] declares it.
534pub(crate) type PublicLinkFn<M> = Arc<dyn Fn(&Cx, &M) -> Option<PublicLink> + Send + Sync>;
535
536impl<R: Resource> std::fmt::Debug for ResourceDef<R> {
537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538        f.debug_struct("ResourceDef")
539            .field("slug", &self.slug)
540            .field("label", &self.label)
541            .field("plural_label", &self.plural_label)
542            .field("relations", &self.relations)
543            .field("actions", &self.actions)
544            .field("header_actions", &self.header_actions)
545            .finish_non_exhaustive()
546    }
547}