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