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}