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, 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}