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, PublicLink, Relation, Resource};
9use crate::{
10 DeclarationErrorKind, Lens,
11 detail::Detail,
12 form::FormScalar,
13 navigation::NavigationItem,
14 policy::{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)]
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::{ReadOnly, RecordForm, 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(PostForm::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: Option<NavigationItem>,
58 pub(crate) policy: Arc<dyn Policy<R::Model>>,
59 pub(crate) tenancy: Tenancy<R::Model>,
60 pub(crate) table: Option<Table<R::Model>>,
61 pub(crate) form: Option<Schema<R::Form>>,
62 pub(crate) view: Option<Detail<R::Model>>,
63 pub(crate) record_title: Option<RecordTitle<R::Model>>,
64 pub(crate) public_link: Option<PublicLinkFn<R::Model>>,
65 pub(crate) relations: Vec<Relation<R::Model>>,
66 pub(crate) actions: Actions<R>,
67 pub(crate) header_actions: HeaderActions,
68 /// The columns [`Self::create_column`] names, or why a path names no column.
69 pub(crate) create_columns: Vec<Result<String, DeclarationErrorKind>>,
70}
71
72impl<R: Resource> Default for ResourceDef<R> {
73 fn default() -> Self {
74 Self {
75 slug: None,
76 label: None,
77 plural_label: None,
78 icon: None,
79 navigation_order: None,
80 navigation: None,
81 policy: Arc::new(Deny),
82 tenancy: Tenancy::none(),
83 table: None,
84 form: None,
85 view: None,
86 record_title: None,
87 public_link: None,
88 relations: Vec::new(),
89 actions: Actions::default(),
90 header_actions: HeaderActions::new(),
91 create_columns: Vec::new(),
92 }
93 }
94}
95
96impl<R: Resource> ResourceDef<R> {
97 /// A def with every default: the policy denies all, rows belong to no tenant, and the record
98 /// form derives the table, the form and the detail page.
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 /// The URL slug for the resource's pages: `"users"` mounts the list at `{panel prefix}/users`.
104 ///
105 /// Defaults to the resource type's name without a trailing `Resource`, pluralized and
106 /// kebab-cased: `UserResource` → `users`, `BlogPostResource` → `blog-posts`.
107 #[must_use]
108 pub fn slug(mut self, slug: impl Into<String>) -> Self {
109 self.slug = Some(slug.into());
110 self
111 }
112
113 /// One record's name, the noun in the "Create {label}" and "Edit {label}" titles.
114 ///
115 /// Defaults to the model's type name in sentence case: `BlogPost` → `Blog post`, `APIKey` →
116 /// `API key`.
117 #[must_use]
118 pub fn label(mut self, label: impl Into<String>) -> Self {
119 self.label = Some(label.into());
120 self
121 }
122
123 /// The list title and sidebar label.
124 ///
125 /// Defaults to the pluralized [`label`](Self::label): `Category` → `Categories`, `Person` →
126 /// `People`.
127 #[must_use]
128 pub fn plural_label(mut self, label: impl Into<String>) -> Self {
129 self.plural_label = Some(label.into());
130 self
131 }
132
133 /// The icon of the resource's sidebar entry, replacing the icon of a
134 /// [`navigation`](Self::navigation) item.
135 #[must_use]
136 pub fn icon(mut self, icon: IconData) -> Self {
137 self.icon = Some(icon);
138 self
139 }
140
141 /// Where the sidebar entry sorts among the panel's entries, lowest first; defaults to `0`, and
142 /// entries of equal order keep their registration order. It replaces the order of a
143 /// [`navigation`](Self::navigation) item.
144 #[must_use]
145 pub fn navigation_order(mut self, order: i32) -> Self {
146 self.navigation_order = Some(order);
147 self
148 }
149
150 /// Replaces the sidebar entry, for one that links somewhere other than the list page:
151 /// `NavigationItem::at("Drafts", "/admin/posts?f.status=draft")`. [`icon`](Self::icon) and
152 /// [`navigation_order`](Self::navigation_order) still apply to it.
153 #[must_use]
154 pub fn navigation(mut self, item: NavigationItem) -> Self {
155 self.navigation = Some(item);
156 self
157 }
158
159 /// Gates every handler; defaults to [`Deny`]. Row scoping belongs in
160 /// [`Resource::query`], not the policy.
161 #[must_use]
162 pub fn policy(mut self, policy: impl Policy<R::Model>) -> Self {
163 self.policy = Arc::new(policy);
164 self
165 }
166
167 /// How rows belong to a tenant; defaults to [`Tenancy::none`].
168 #[must_use]
169 pub fn tenancy(mut self, tenancy: Tenancy<R::Model>) -> Self {
170 self.tenancy = tenancy;
171 self
172 }
173
174 /// The list table; defaults to the record form's derived table ([`RecordForm::table`]).
175 ///
176 /// [`RecordForm::table`]: crate::RecordForm::table
177 #[must_use]
178 pub fn table(mut self, table: Table<R::Model>) -> Self {
179 self.table = Some(table);
180 self
181 }
182
183 /// Arranges the create and edit forms: the record form's own controls, from its
184 /// `controls()`, in layout blocks.
185 ///
186 /// A control the schema does not place follows the ones it does, in the record form's
187 /// declaration order and outside every layout block, so the default form renders one control
188 /// per field, and a form that adjusts one control places only that one:
189 ///
190 /// ```rust
191 /// # #[derive(Debug, Clone, toasty::Model)]
192 /// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String }
193 /// # #[derive(Debug, Clone, tablo_core::RecordForm)]
194 /// # #[form(model = User)]
195 /// # struct UserForm { name: String, email: String }
196 /// # struct UserResource;
197 /// use tablo_core::{Resource, ResourceDef, Schema};
198 ///
199 /// impl Resource for UserResource {
200 /// type Model = User;
201 /// type Form = UserForm;
202 ///
203 /// fn declare() -> ResourceDef<Self> {
204 /// // `email`, then `name`.
205 /// ResourceDef::new().form(Schema::new(UserForm::controls().email.email()))
206 /// }
207 /// }
208 /// ```
209 #[must_use]
210 pub fn form(mut self, form: Schema<R::Form>) -> Self {
211 self.form = Some(form);
212 self
213 }
214
215 /// The detail page: columns in layout blocks; defaults to the record form's derived detail
216 /// page ([`RecordForm::detail`]), and [`Detail::empty`] turns the page off.
217 ///
218 /// The page loads the relations its columns declare.
219 ///
220 /// [`RecordForm::detail`]: crate::RecordForm::detail
221 #[must_use]
222 pub fn view(mut self, view: Detail<R::Model>) -> Self {
223 self.view = Some(view);
224 self
225 }
226
227 /// Titles each record with the text column `lens` reads, as in
228 /// `.record_title(lens!(Post.title))`: the detail page's heading, each option of a
229 /// [`relationship`](crate::ChoiceField::relationship) choice over this resource, and each cell
230 /// of a [`RelationColumn::of`](crate::RelationColumn::of) it.
231 ///
232 /// A record whose column is empty, and every record by default, is titled with the
233 /// [`label`](Self::label) and its key, as in `Blog Post 3f2a…`. A relationship choice over this
234 /// resource also searches the column, besides the table's searchable columns.
235 ///
236 /// A title is display text, not a key: two records can share one, so it never replaces the
237 /// primary key that keys the table's rows and the action routes. A lens names one of the
238 /// model's own columns, never a relation, so the title reads on every record however it was
239 /// loaded; a title built from several columns is a column the model stores.
240 #[must_use]
241 pub fn record_title<T>(mut self, lens: Lens<R::Model, T>) -> Self
242 where
243 T: FormScalar + toasty::schema::Field<Inner = String> + Send + Sync + 'static,
244 {
245 self.record_title = Some(RecordTitle::new(lens));
246 self
247 }
248
249 /// Links a record's public page from its detail and edit pages, for each record `link`
250 /// returns one for; defaults to no link.
251 ///
252 /// The edit page loads the record without relations, so a link that reads one renders on the
253 /// detail page only when its relation is loaded there.
254 #[must_use]
255 pub fn public_link(
256 mut self,
257 link: impl Fn(&Cx, &R::Model) -> Option<PublicLink> + Send + Sync + 'static,
258 ) -> Self {
259 self.public_link = Some(Arc::new(link));
260 self
261 }
262
263 /// Adds a related resource, rendered as its table narrowed to the record on the detail and
264 /// edit pages.
265 #[must_use]
266 pub fn relation(mut self, relation: Relation<R::Model>) -> Self {
267 self.relations.push(relation);
268 self
269 }
270
271 /// Adds the custom [`Action`] `A` after the ones already declared.
272 ///
273 /// The policy decides who may run it, through
274 /// [`Ability::RunAny`](crate::Ability::RunAny) and [`Ability::Run`](crate::Ability::Run). An
275 /// `A::NAME` that is not a single URL path segment does not compile.
276 #[must_use]
277 pub fn action<A: Action<R>>(mut self) -> Self {
278 self.actions = self.actions.add::<A>();
279 self
280 }
281
282 /// Adds the [`HeaderAction`] `A`, which acts on no record, to the list page's header, after
283 /// the ones already declared.
284 ///
285 /// The policy decides who may run it, through [`Ability::ViewAny`](crate::Ability::ViewAny)
286 /// and [`Ability::RunHeader`](crate::Ability::RunHeader) with its name, which no other action
287 /// of the resource may share. An `A::NAME` that is not
288 /// a single URL path segment does not compile.
289 #[must_use]
290 pub fn header_action<A: HeaderAction>(mut self) -> Self {
291 self.header_actions = self.header_actions.add::<A>();
292 self
293 }
294
295 /// Declares a column an overriding [`Resource::create_record`] sets itself, beyond the form's
296 /// fields: `.create_column(lens!(Post.slug))`. Call it once per column.
297 ///
298 /// Mounting the panel refuses a path that names no one field of the model, and the tenant
299 /// column, which the framework stamps.
300 #[must_use]
301 pub fn create_column<T>(mut self, path: impl Into<Path<R::Model, T>>) -> Self {
302 let path = ModelPath::of(&path.into());
303 self.create_columns
304 .push(model::field::<R::Model>(&path).map(|field| field.name));
305 self
306 }
307}
308
309/// A record's title column, as [`ResourceDef::record_title`] declares it.
310pub(crate) struct RecordTitle<M> {
311 read: Box<dyn Fn(&M) -> String + Send + Sync>,
312 search: TitleSearch,
313}
314
315/// The substring match on a title column for a search term.
316type TitleSearch = Box<dyn Fn(&str) -> Option<Expr<bool>> + Send + Sync>;
317
318impl<M: toasty::schema::Model + Send + Sync + 'static> RecordTitle<M> {
319 fn new<T>(lens: Lens<M, T>) -> Self
320 where
321 T: FormScalar + toasty::schema::Field<Inner = String> + Send + Sync + 'static,
322 {
323 let path = lens.clone();
324 Self {
325 read: Box::new(move |record| lens.read(record).to_label()),
326 search: Box::new(move |term| contains_expr(&path, term)),
327 }
328 }
329
330 /// The column's value on `record`, or `None` when it is empty.
331 pub(crate) fn read(&self, record: &M) -> Option<String> {
332 Some((self.read)(record)).filter(|title| !title.trim().is_empty())
333 }
334
335 /// The substring match on the column for `term`, or `None` for a blank term.
336 pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
337 (self.search)(term)
338 }
339}
340
341/// A record's public page, as [`ResourceDef::public_link`] declares it.
342pub(crate) type PublicLinkFn<M> = Arc<dyn Fn(&Cx, &M) -> Option<PublicLink> + Send + Sync>;
343
344impl<R: Resource> std::fmt::Debug for ResourceDef<R> {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346 f.debug_struct("ResourceDef")
347 .field("slug", &self.slug)
348 .field("label", &self.label)
349 .field("plural_label", &self.plural_label)
350 .field("relations", &self.relations)
351 .field("actions", &self.actions)
352 .field("header_actions", &self.header_actions)
353 .finish_non_exhaustive()
354 }
355}