Skip to main content

tablo_core/
lib.rs

1//! Tablo: a server-rendered admin toolkit on [Topcoat](topcoat) and [Toasty](toasty).
2//!
3//! A [`Panel`] serves one [`Resource`] per Toasty model. A resource names the model, whose
4//! [`ModelColumns`](derive@ModelColumns) give its default table and detail page, and the record
5//! form its create and edit submissions parse into, which [`ModelForm`](derive@ModelForm) derives
6//! from the model or [`RecordForm`](derive@RecordForm) from a struct of its own. It declares the
7//! rest as one [`ResourceDef`] value: its [`Policy`], which denies by default, and any [`Table`],
8//! [`Schema`] or [`Detail`] that replaces or extends the defaults.
9//! The app mounts the panel on its own Topcoat router with [`RouterBuilderPanelExt::panel`], which
10//! builds each def once, binds it to the database schema and checks it first; one router mounts any
11//! number of panels at distinct prefixes.
12//!
13//! ```rust,no_run
14//! # use tablo_core::{Allow, Panel, Resource, ResourceDef, RouterBuilderPanelExt};
15//! # use toasty::Db;
16//! # use topcoat::router::{Router, RouterBuilderDiscoverExt};
17//! # fn main() -> topcoat::Result<()> {
18//! #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns, tablo_core::ModelForm)]
19//! pub struct Book {
20//!     #[key]
21//!     #[auto]
22//!     pub id: uuid::Uuid,
23//!     pub title: String,
24//! }
25//!
26//! pub struct BookResource;
27//!
28//! impl Resource for BookResource {
29//!     type Model = Book;
30//!     type Form = BookForm;
31//!
32//!     fn declare() -> ResourceDef<Self> {
33//!         ResourceDef::new().policy(Allow)
34//!     }
35//! }
36//!
37//! # let db: Db = todo!();
38//! let router = Router::builder()
39//!     .discover()
40//!     .app_context(db)
41//!     .panel(Panel::new("admin").resource::<BookResource>())?
42//!     .build();
43//! # let _ = router;
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! The [user guide](https://y-l.fr/tablo/nightly/guide/) walks through a complete panel and each
49//! part of it. [`ResourceDef`] lists everything a resource declares, [`Resource`] the hooks it
50//! overrides, and [`Panel`] every builder call.
51
52// The derives emit `tablo_core::` paths; this lets them expand inside this
53// crate's own tests too.
54extern crate self as tablo_core;
55
56#[doc(hidden)]
57pub mod __macro {
58    pub use toasty::{
59        Executor, Result as DbResult,
60        schema::{Embed, Model},
61        stmt,
62        stmt::{List, Path},
63    };
64    pub use topcoat::context::Cx;
65
66    pub use crate::{
67        Lens,
68        detail::Detail,
69        extend::{Includes, OptionSource},
70        form::{
71            FieldError, FormField, FormScalar, FormValues, NullableScalar, RecordForm,
72            assert_form_scalar, links_include, links_key, parse_list, parse_password, parse_scalar,
73            write_links,
74        },
75        resource::{ActionInput, required_input},
76        schema::{
77            ChoiceField, CustomField, EmbeddedForm, Field, FieldResolver, FileField, IntoSchema,
78            Options, RepeaterField, RepeaterItem, Schema, TextField,
79            embedded::{
80                Embedded, EmbeddedBuilder, embedded_field, embedded_form, take_leaf, take_value,
81            },
82            form_key, parse_items,
83            tree::Retype,
84            write_items,
85        },
86        table::{ModelColumns, Table, column::model::pick},
87        toasty_compat::VariantId,
88    };
89}
90// The toolkit surface: Panel, Resource, Table, Schema, Notification,
91// Tenancy, CSRF, and the `Db` glue.
92pub mod auth;
93pub mod csrf;
94pub mod db;
95mod declaration;
96pub mod detail;
97mod error;
98pub mod extend;
99pub mod form;
100mod lens;
101mod naming;
102pub mod navigation;
103pub mod notification;
104mod page;
105pub mod panel;
106pub mod policy;
107pub mod resource;
108pub mod schema;
109pub mod table;
110pub mod tenancy;
111#[cfg(test)]
112mod test_support;
113mod toasty_compat;
114mod topcoat_compat;
115pub mod upload;
116
117/// The future a type-erased, dyn-compatible method returns.
118pub(crate) type BoxFuture<'a, T> = std::pin::Pin<Box<dyn Future<Output = T> + Send + 'a>>;
119
120pub use auth::{
121    Auth, Authenticator, LoginThrottle, PanelUser, PasswordAuth, Registrar, SignUp, membership,
122};
123pub use declaration::{
124    ActionInputFault, DeclarationError, DeclarationErrorKind, ManyToManyFault, MountError,
125    SegmentFault, SignUpFault, Site,
126};
127pub use detail::{Detail, IntoDetail};
128pub use form::{
129    FieldError, FieldErrorKind, FieldErrors, FormField, FormScalar, FormValue, FormValues, NoForm,
130    Posted, RecordForm,
131};
132pub use lens::Lens;
133pub use navigation::{NavTarget, NavigationItem};
134pub use notification::{Notification, NotificationStatus};
135pub use page::{Page, PageDef, header_action_buttons};
136pub use panel::{Brand, Panel, PanelHandle, RouterBuilderPanelExt, can_list, can_open, url};
137pub use policy::{Ability, Allow, Deny, Policy, ReadOnly, when};
138pub use resource::{
139    Action, ActionInput, Committed, ForeignKey, HeaderAction, HeaderActions, Mutation, Places,
140    PublicLink, Relation, Resource, ResourceDef, can, scoped_query, write_create, write_update,
141};
142pub use schema::{
143    ChoiceField, CustomField, EmbeddedForm, Field, FieldResolver, FileField, Grid, Group,
144    IntoFormValue, IntoOptions, IntoSchema, Options, RepeaterField, RepeaterItem, Schema, Section,
145    Source, TextField, Toggle, Watched,
146};
147pub use table::{
148    BooleanColumn, ColumnWidth, ComputedColumn, CountColumn, Cursor, DateColumn, DateFilter,
149    DateValue, EmbeddedColumn, FileColumn, FilterValue, IntoColumns, IntoFilters, ModelColumns,
150    QueryFilter, RelationColumn, RelationLens, RepeaterColumn, SelectFilter, Sort, Table,
151    TablePage, TableState, TernaryFilter, TextColumn, ToOneRelation, WiredTable, contains_expr,
152};
153/// Derives [`ActionInput`](trait@ActionInput) for the typed value an [`Action`] asks for
154/// before it runs.
155///
156/// ```rust
157/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
158/// # struct Post { #[key] #[auto] id: uuid::Uuid, status: String }
159/// # use tablo_core::{Action, ActionInput, NoForm, Resource};
160/// # use topcoat::{Result, context::Cx};
161/// # struct PostResource;
162/// # impl Resource for PostResource {
163/// #     type Model = Post;
164/// #     type Form = NoForm<Post>;
165/// # }
166/// #[derive(tablo_core::ActionInput)]
167/// pub struct Rejection {
168///     #[form(multiline = 4)]
169///     pub reason: String,
170///     pub notify_author: bool,
171/// }
172///
173/// struct Reject;
174///
175/// impl Action<PostResource> for Reject {
176///     type Input = Rejection;
177///     const NAME: &'static str = "reject";
178///
179///     async fn run(
180///         _cx: &Cx,
181///         posts: &[Post],
182///         rejection: Rejection,
183///         ex: &mut dyn toasty::Executor,
184///     ) -> Result<()> {
185///         // Store `rejection.reason`, notify when `rejection.notify_author` holds…
186///         # let _ = (posts, rejection.reason, rejection.notify_author, ex);
187///         Ok(())
188///     }
189/// }
190/// ```
191///
192/// Each field posts its own name, which no column binds, and renders the control its type
193/// picks: a `bool` a checkbox, `#[form(options)]` a choice over the field type's
194/// [`Options`](derive@Options), `#[form(options = T)]` one over `T`'s, and any other
195/// [`FormScalar`] a text input of the type's input type.
196///
197/// A field's **blank answer** is its `#[form(blank = ..)]`, `""` for an `#[form(optional)]`
198/// `String`, `None` for an `Option`, or `false` for a `bool`. A field with none is required:
199/// its control renders required and the parse refuses an empty submission.
200///
201/// # Per-field attributes
202///
203/// - `#[form(label = "Reason")]` — the control's label (default: the field name in sentence
204///   case).
205/// - `#[form(multiline = 4)]` — a `<textarea>` of 4 rows.
206/// - `#[form(placeholder = "rust, async")]` — a text input's placeholder.
207/// - `#[form(email)]` — a text input that refuses a malformed email address.
208/// - `#[form(password)]` — a masked text input on a `String`, read untrimmed, that a refused
209///   form renders empty.
210/// - `#[form(blank = ..)]`, `#[form(optional)]` — the blank answer.
211/// - `#[form(options)]`, `#[form(options = T)]` — a choice. An `Option<T>` field names its
212///   options type: `#[form(options = T)]`.
213///
214/// A generic struct, a tuple struct, an empty struct (name `()` instead), an unknown key, a
215/// text input's key with `options` or on a `bool`, `password` with `multiline`, and a field
216/// that is not a `FormScalar` are compile errors.
217/// [`RouterBuilderPanelExt::panel`] refuses a field named `csrf_token`, `confirm` or `ids`,
218/// the keys an action's POST carries besides its input.
219///
220/// A hand-written impl builds its fields with [`Field::text_input`], [`Field::choice_input`]
221/// and [`Field::toggle_input`]; its controls render without the `required` mark, though its
222/// `parse` still refuses what it requires.
223pub use tablo_macros::ActionInput;
224/// Derives `EmbeddedForm` for an embedded struct or enum.
225///
226/// Builds the schema node and converts the value through that node's keys.
227///
228/// ```rust,no_run
229/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
230/// # struct Post {
231/// #     #[key] #[auto] id: uuid::Uuid,
232/// #     publication: Publication,
233/// # }
234/// # use tablo_core::Section;
235/// #[derive(Debug, Clone, toasty::Embed, tablo_core::EmbeddedForm)]
236/// pub enum Publication {
237///     #[column(variant = 1)]
238///     Scheduled {
239///         #[shared(timestamp)]
240///         #[form(label = "Publication timestamp")]
241///         scheduled_at: String,
242///         scheduled_for: String,
243///     },
244///     #[column(variant = 2)]
245///     Published {
246///         #[shared(timestamp)]
247///         published_at: String,
248///         canonical_url: String,
249///     },
250/// }
251///
252/// // form declaration — no field bindings written by hand
253/// Section::new("Publication").schema(Publication::form(Post::fields().publication()));
254/// ```
255///
256/// # How a field is classified
257///
258/// A field marked `#[form(embed)]` is another **embedded value**, delegated to
259/// its own `EmbeddedForm`. Every other field is a **scalar**: one column, read
260/// and written through `FormScalar` (`String`, a `TypedValue` type, an
261/// `Options` enum, or an `Option` of one). A scalar of another type fails to compile at the
262/// field, naming the trait. An empty scalar is its blank answer — its declared
263/// `#[form(blank = ..)]`, `None` for an `Option`, `false` for a `bool`, `""`
264/// for an `#[form(optional)]` `String` — and a scalar with none is required:
265/// its control renders required and the parse refuses its key.
266///
267/// # Which variant an enum reads
268///
269/// A named discriminant always wins, and an undeclared one is refused;
270/// otherwise the first variant, in declaration order, with a **payload of its
271/// own** submitted — a `#[shared(..)]` column belongs to several variants and
272/// never selects one; otherwise the first variant.
273///
274/// A `#[shared(..)]` column renders once, and a variant that does not declare it
275/// neither shows nor submits it: it is a conditional field there, so a variant
276/// that omits it needs the column to have a blank answer.
277///
278/// # Per-field attributes
279///
280/// - `#[form(embed)]` — a nested `EmbeddedForm` value.
281/// - `#[form(label = "Canonical URL")]` — the control's label (default: the field name in
282///   sentence case, `canonical_url` → "Canonical url").
283/// - `#[form(multiline = 3)]` — a `<textarea>` of 3 rows.
284/// - `#[form(blank = ..)]` — what an empty submission reads as.
285/// - `#[form(optional)]` on a `String` — an empty submission reads as `""`.
286///
287/// Anything else in `#[form(..)]` is a compile error, as are `label`, `multiline`, `blank` and
288/// `optional` on an embedded value, `blank` or `optional` on an `Option`, and `optional` on a
289/// type other than `String`.
290pub use tablo_macros::EmbeddedForm;
291/// Derive a model's [`ModelColumns`](trait@ModelColumns): the table a resource lists it in and
292/// the detail page it shows it on, when the resource declares neither.
293///
294/// ```rust
295/// # #[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, tablo_core::Options)]
296/// # pub enum Role { Admin, Member }
297/// #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
298/// pub struct User {
299///     #[key]
300///     #[auto]
301///     pub id: uuid::Uuid,
302///     pub name: String,
303///     pub role: Role,
304///     pub active: bool,
305///     #[columns(skip)]
306///     pub password_hash: String,
307///     #[default(jiff::Timestamp::now())]
308///     pub created_at: jiff::Timestamp,
309/// }
310///
311/// // Lists `name`, `role`, `active` and `created_at`, and shows them on the detail page.
312/// let table = <User as tablo_core::ModelColumns>::table();
313/// # let _ = table;
314/// ```
315///
316/// Each field shows whoever writes it, the person through a form or Toasty through a
317/// `#[default]` or an `#[update]`, in a column its type picks:
318///
319/// - a `bool`: a sortable [`BooleanColumn`], yes or no;
320/// - a `String` or `Option<String>`: a sortable, searchable [`TextColumn`];
321/// - any other [`FormScalar`]: a sortable [`TextColumn`], an [`Options`](derive@Options) enum
322///   by its label;
323/// - an [`EmbeddedForm`](derive@EmbeddedForm) value: an [`EmbeddedColumn`], leaf by leaf, on
324///   the detail page only;
325/// - a list of [`RepeaterItem`](derive@RepeaterItem) values: a [`RepeaterColumn`], on the
326///   detail page only;
327/// - any other type: no column.
328///
329/// No column shows a key Toasty generates, a `#[version]` field, a relation or its foreign
330/// key, which only a declared [`RelationColumn`] shows, by the related record's title, or a
331/// `TenantId` or `Option<TenantId>` field, which belongs to the resource's [`Tenancy`]. An
332/// `#[auto]` `created_at` or `updated_at` shows like any timestamp.
333///
334/// Every other field shows, a secret such as a password hash or an API token included, to
335/// anyone the policy lets view the list: mark it `#[columns(skip)]`, which leaves it out of
336/// the table, its search and export, and the detail page. On a field already left out it is a
337/// compile error, as are an unknown key and a generic model.
338///
339/// The derive reads the model, not a form: a file path shows as text and a `String` an
340/// `#[form(options = ..)]` key labels shows its stored value. Declare a [`FileColumn`] or a
341/// formatted [`TextColumn`] for them.
342///
343/// A resource replaces either default with [`ResourceDef::table`] or [`ResourceDef::detail`],
344/// and extends one by starting from it: `User::table().filters(..)`, with
345/// [`ModelColumns`](trait@ModelColumns) in scope.
346pub use tablo_macros::ModelColumns;
347/// Derive a model's [`RecordForm`](derive@RecordForm) struct from the model: the fields a
348/// person fills, named and typed as the model declares them.
349///
350/// ```rust
351/// #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns, tablo_core::ModelForm)]
352/// pub struct Book {
353///     #[key]
354///     #[auto]
355///     pub id: uuid::Uuid,
356///     pub title: String,
357///     #[form(optional)]
358///     pub subtitle: String,
359///     #[default(jiff::Timestamp::now())]
360///     pub created_at: jiff::Timestamp,
361/// }
362///
363/// // The derive wrote `BookForm { title, subtitle }`, with `BookFormField` and
364/// // `BookFormControls`, as `#[derive(RecordForm)]` would.
365/// let form = BookForm {
366///     title: "Dune".into(),
367///     subtitle: String::new(),
368/// };
369/// # let _ = form;
370/// ```
371///
372/// The form leaves out what no person fills: a key, so an edit never rewrites it, an
373/// `#[auto]`, `#[update]` or `#[version]` field, a relation, and a `TenantId` or
374/// `Option<TenantId>` field, which belongs to the resource's [`Tenancy`]. A form key on one of
375/// those is a compile error naming the fix. A `#[default]` field and a `#[belongs_to]` foreign
376/// key are left out unless a form key opts them in, such as `#[form(repeat)]` on a defaulted
377/// list or `#[form(relationship = AuthorResource)]` on a foreign key. `#[form(skip)]` leaves
378/// out any other field. When the policy allows `Create`, mounting refuses a non-nullable
379/// column left out, such as a key a person picks, unless an overridden `create_record` names
380/// it with `create_column`.
381///
382/// Each field keeps the model field's visibility and docs. A field's `#[form(..)]` keys are
383/// [`RecordForm`](derive@RecordForm)'s, as are the form's checks and defaults.
384/// `#[form(name = BookEditor)]` on the model names the form, `BookForm` otherwise. A form
385/// whose fields differ from the model's, such as a many-to-many field's keys, is a
386/// `#[derive(RecordForm)]` struct.
387pub use tablo_macros::ModelForm;
388/// Derive `Options` for a unit-variant enum: the `(value, label)` list a
389/// choice field, a select filter and a column share, and the `FormScalar`
390/// that posts a variant as its value and reads it as its label.
391///
392/// ```rust
393/// # use tablo_core::Options;
394/// #[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, tablo_core::Options)]
395/// pub enum Status {
396///     Draft,
397///     #[option(label = "Live")]
398///     Published,
399/// }
400///
401/// assert_eq!(Status::Published.value(), "published");
402/// assert_eq!(Status::Published.label(), "Live");
403/// assert_eq!(Status::from_value("draft"), Some(Status::Draft));
404/// ```
405///
406/// Each variant's value is its `snake_case` name and its label that name in
407/// sentence case. `#[option(value = "..")]` and `#[option(label = "..")]`
408/// override either. A generic enum, a variant with fields, two variants
409/// sharing one value or one label, and an unknown key are compile errors.
410///
411/// The enum, and an `Option` of it, is a form scalar through this derive, so
412/// it cannot also implement `TypedValue` or `FormScalar` itself.
413pub use tablo_macros::Options;
414/// Derive `RecordForm` for the typed value a resource's form writes.
415///
416/// One field per model column the form writes, named and typed like the
417/// model's field. A scalar (`String`, a `TypedValue` type, an `Options` enum,
418/// or an `Option` of one) binds the key its control posts; a `#[form(embed)]` field binds
419/// every key of an `EmbeddedForm` value and is written whole.
420///
421/// ```rust
422/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
423/// # pub struct User {
424/// #     #[key] #[auto] id: uuid::Uuid,
425/// #     name: String,
426/// #     role: String,
427/// #     age: i64,
428/// # }
429/// #[derive(tablo_core::RecordForm)]
430/// #[form(model = User)]
431/// pub struct UserForm {
432///     pub name: String,
433///     #[form(blank = "member")]
434///     pub role: String,
435///     #[form(blank = 0)]
436///     pub age: i64,
437/// }
438/// ```
439///
440/// The derive also emits `UserFormField`, one variant per field, which
441/// `Posted` and `Resource::validate_record`'s `FieldErrors` key on and
442/// `RecordForm::fields` answers with each variant's keys.
443/// It emits `UserFormControls`, one control per field chosen from the field —
444/// a `bool` is a toggle, `#[form(options = T)]` a choice over `T`'s options,
445/// `#[form(options)]` a choice over the field type's options,
446/// `#[form(relationship = R)]` a choice over `R`'s records (a multiple choice over a `Vec` of
447/// their keys), `#[form(file)]` a file field, `#[form(embed)]` the embedded value's schema,
448/// `#[form(repeat)]` a repeater, and any
449/// other field a text field — with `controls()` handing them over, typed by the form so only a
450/// `Schema<UserForm>` places them, and `RecordForm::control` answering one
451/// field's. A resource's `ResourceDef` defaults its form to them; `ResourceDef::form` arranges
452/// or extends them instead, and a form renders the controls it does not place after the ones
453/// it does. The table and detail page read the model, not the form: see
454/// [`ModelColumns`](derive@ModelColumns).
455///
456/// # Attributes
457///
458/// - `#[form(model = User)]` on the struct: the model the form writes.
459/// - `#[form(blank = <expr>)]` on a scalar: the value an empty submission reads as.
460/// - `#[form(optional)]` on a `String`: an empty submission reads as `""`.
461/// - `#[form(options)]` on an `Options` enum or an `Option` of one: a choice over its options.
462///   Without it, the field is a text field.
463/// - `#[form(options = Status)]`: a choice over `Status::options()`.
464/// - `#[form(relationship = AuthorResource)]` on a foreign key: a choice over the source's
465///   records, each labelled by its `record_title`. The source is any `OptionSource`, which
466///   every `Resource` is.
467/// - `#[form(relationship = TagResource)]` on a `Vec` of the source's keys, named like the
468///   model's `#[has_many(via = joins.target)]` field: a many-to-many field, a multiple choice
469///   whose records the write links through the join model, one row each. No record is its
470///   blank answer; an edit that does not post it keeps the record's links.
471/// - `#[form(file)]` on a `String`: a file field.
472/// - `#[form(embed)]` on an `EmbeddedForm` value.
473/// - `#[form(repeat)]` on a `#[document]` list of [`RepeaterItem`](derive@RepeaterItem)
474///   values: a repeater, a row per item, which the browser adds, removes and moves. No rows is
475///   its blank answer.
476///
477/// A scalar's **blank answer** is its `blank`, `""` for an `optional` `String`, `None` for an
478/// `Option`, or `false` for a `bool`. A field with none is required: the panel renders its
479/// control required and the parse refuses an empty submission. No control declares presence.
480///
481/// A generic struct, a tuple struct, an empty struct, a `Deferred<_>` field,
482/// `blank` or `optional` on an `Option`, an embedded value or a many-to-many field, `optional`
483/// on a type other than `String`, and an unknown key are compile errors. So are a field the
484/// model lacks, a type the model's field does not have, a scalar that is not a `FormScalar`,
485/// and a many-to-many field whose keys are not its source's primary key.
486pub use tablo_macros::RecordForm;
487/// Derives [`RepeaterItem`](trait@RepeaterItem) for one item of a repeater: an embedded struct
488/// a `#[document]` list stores.
489///
490/// ```rust
491/// #[derive(Debug, Clone, toasty::Embed, tablo_core::RepeaterItem)]
492/// pub struct Link {
493///     pub label: String,
494///     #[form(label = "URL")]
495///     pub url: String,
496/// }
497///
498/// #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
499/// pub struct Post {
500///     #[key]
501///     #[auto]
502///     id: uuid::Uuid,
503///     #[document]
504///     #[default(Vec::<Link>::new())]
505///     links: Vec<Link>,
506/// }
507///
508/// #[derive(tablo_core::RecordForm)]
509/// #[form(model = Post)]
510/// pub struct PostForm {
511///     #[form(repeat)]
512///     pub links: Vec<Link>,
513/// }
514/// ```
515///
516/// Its fields, controls and attributes are an [`ActionInput`](derive@ActionInput)'s: each row
517/// posts them under its own prefix (`links.0.url`). An item's blank answers and required
518/// fields work the same way, and each row answers to its controls' own rules, such as a choice
519/// offering only its options. A repeater takes at most [`MAX_ROWS`](schema::MAX_ROWS) rows.
520pub use tablo_macros::RepeaterItem;
521pub use tenancy::{Membership, Tenancy, Tenant, TenantColumn, TenantId, require_tenant, tenant_id};
522pub use upload::{DirUploader, Uploader};