Resources
A resource is the admin for one Toasty model: which rows it lists, how its table and form look,
who may see and change a record, and how a write runs. You implement the Resource trait on a
unit struct, return what it declares from declare() as one ResourceDef value, and register it
with Panel::resource.
The smallest resource lists rows and nothing else:
#![allow(unused)]
fn main() {
pub struct AuditResource;
impl Resource for AuditResource {
type Model = Audit;
type Form = NoForm<Audit>; // list-only: no create or edit pages
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.policy(ReadOnly)
.table(Table::new(TextColumn::new(lens!(Audit.action))))
}
}
}
A resource with create and edit pages names a #[derive(RecordForm)] struct as its Form. The
derive lays out the rest from the struct’s fields: a table column per field a column can show,
one form control per field, and a detail page showing the form read-only. Set the def’s table,
form or view to arrange or extend one; see Tables and Forms.
The definition
declare() returns a ResourceDef, and every setting has a default:
ResourceDef method | Default | Purpose |
|---|---|---|
table(..) | the record form’s derived table | the list’s columns, filters and options: Tables |
form(..) | the record form’s derived schema | the create and edit form’s controls: Forms |
view(..) | the form | the detail page’s fields; an empty schema turns the page off: Detail pages |
relation(..) | none | a related resource shown as a table on the detail and edit pages |
action::<A>() | none | a custom action: Tables |
policy(..) | Deny | what the user may do: Policy, auth, tenancy |
tenancy(..) | Tenancy::none() | how rows belong to a tenant: Policy, auth, tenancy |
create_columns(..) | none | columns an overridden create_record sets itself |
slug(..), label(..), plural_label(..) | from the type names | URLs and titles: Naming |
icon(..), navigation_order(..), navigation(..) | the default entry | the sidebar entry: Sidebar |
The panel builds the def once when it mounts and serves that copy to every request.
Panel::resource_with adjusts it for one panel, so the same resource can mount read-only in a
second panel:
#![allow(unused)]
fn main() {
Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly))
}
Trait methods
Model and Form are required. The methods, each with a default, receive the request:
| Method | Default | Purpose |
|---|---|---|
query(cx) | every row | the base query every loader starts from: Scoping |
view_query(cx) | query(cx) | the detail page’s query, with the relations it reads |
validate_record(cx, form) | no errors | rules that need the whole parsed form |
view_values(cx, record), view_content(cx, record) | none | what the detail page shows beyond the form’s fields |
record_label(cx, record) | None | the detail page’s heading |
public_url(cx, record) | None | a link to the record’s public page on its detail and edit pages |
create_record, update_record | the derived write | the create and update writes: Writes |
delete_record, bulk_delete_records | delete by primary key | the delete writes |
after_commit(cx, committed) | nothing | side effects after a write commits |
Naming
The names default from the type names, following Filament’s conventions:
ResourceDef method | Default | BlogPostResource over BlogPost |
|---|---|---|
slug(..) | resource name without Resource, pluralized, kebab-cased | blog-posts |
label(..) | the model’s type name; used in “Create …” and “Edit …” | BlogPost |
plural_label(..) | the label pluralized; the sidebar entry and list title | BlogPosts |
Set label to rename a record, and plural_label only when the plural rules guess wrong. Name
resources in the singular: UsersResource pluralizes to userses.
Scoping the query
query(cx) is the base query of every loader: the list, the export, the edit and delete
handlers, relationship options and the detail page. Use it for the resource’s own row scoping,
such as hiding soft-deleted rows:
#![allow(unused)]
fn main() {
pub fn query(_cx: &Cx) -> Query<List<Post>> {
Query::<List<Post>>::all().filter(Post::fields().deleted_at().is_none())
}
}
Two things do not belong in query:
- The tenant filter. For a tenant-owned resource the framework adds it to
queryat every loader. See Tenancy. - Relations. The list and the export load the relations their columns declare with
ComputedColumn::include, and the detail page loadsview_query. Include a relation inqueryonly when every loader reads it, for example because the policy does.
In your own code, load a resource’s rows with scoped_query::<R>(cx)?, not R::query(cx):
scoped_query is query with the tenant filter of the def the request’s panel mounted, and
returns an error rather than an unscoped query when the request has no tenant or the panel does
not mount R. A context with no panel, such as a test’s or a background job’s, uses the def
R::declare() returns.
The unique-value check on forms probes through the same scoped query, so a #[unique] index
wider than the scope is invisible to it: the check misses the collision and the database refuses
the write with a 500. Scope such indexes to match, as in #[unique(tenant_id, email)].
Writes
Every create, update and delete runs in a transaction the framework opens. For an update or a
delete, the handler first loads the target record through the scoped query inside that transaction
and checks policy on it. It then calls the resource’s record function with the open transaction as
ex.
create_record and update_record default to writing the record form’s fields
(write_create and write_update), so most resources declare neither. To check something inside
the transaction, override the function and delegate:
#![allow(unused)]
fn main() {
impl Resource for CommentResource {
type Model = Comment;
// …
async fn update_record(
cx: &Cx,
record: Comment,
posted: Posted<CommentForm>,
ex: &mut dyn toasty::Executor,
) -> Result<Comment> {
tablo_core::write_update::<Self>(cx, record, posted, ex).await
}
}
}
Run every statement through ex, and use the record you are given rather than loading it
again: it is the row the policy check passed. Both functions return the written row. An error
rolls the transaction back and nothing is written.
delete_record deletes the row by its primary key, and bulk_delete_records calls
delete_record once per record in one transaction, so overriding delete_record — for a soft
delete, say — covers both. A bulk delete is all-or-nothing.
After the commit
after_commit runs once per committed write, after the transaction and before the response. Put
side effects there — email, webhooks, audit rows, cache invalidation — so a rolled-back write never
triggers them:
#![allow(unused)]
fn main() {
pub async fn after_commit(cx: &Cx, committed: Committed<Post>) -> Result<()> {
for post in committed.records() {
notify_subscribers(cx, post).await?;
}
Ok(())
}
}
Committed names the mutation (Mutation::Create, Update, Delete, or Action(NAME) for a
custom action) and the rows written: the created or updated row,
every deleted row in one call for a bulk delete, or the rows an action ran on. Mutation is
#[non_exhaustive], so a match on it ends with a _ arm. The hook is not called
when nothing committed. An error it returns is logged; the write stays committed.
Startup checks
Mounting the panel builds each resource’s def once, calling declare() with the database schema
in scope, so a path through an embedded value binds its flattened column wherever a declaration
names one. It refuses the resource when:
- its table, form or view is malformed: a duplicate column, filter or field name, a zero page
size, an empty column set (a resource whose derived table lists nothing declares its own
table), or a lens that binds no column. Rendering such a table throughTable::render(orrender_with_state) or such a schema throughSchema::renderfails with the same errors; - the record form and the form schema disagree: a control no form field binds, a form field with
no control, an optional control whose field has no blank value, a
unique()field with no unique index, or a tenant-owned resource’s form claiming its tenant column; - a relationship field takes its options from a resource the panel does not register;
- the policy allows
Createand a non-nullable column is set by nothing: not the form, not a Toasty default, not the tenant stamp, and not listed increate_columns; - a
NoFormresource declares a form or its policy allowsCreate; - a
Tenancy::columnlens is not one field of the model, aTenancy::vialens is, or the form of aTenancy::viaresource writes the parent’s foreign key other than through a relationship field over a tenant-scoped resource; - two actions share a
NAME; - a relation names a resource the panel does not register, or names one twice;
- the panel registers the resource twice.
The refusal lists every mistake the panel found, not only the first. .panel(..) returns it as a
MountError: each of its DeclarationErrors names the resource, the Site of the declaration
it is in (Registration, Table, Form, View, Tenancy, a Relation) and a
DeclarationErrorKind, whose Display is the message.
Testing shows a test matching on the kind.
An action’s NAME is checked when the app compiles: ResourceDef::action does not compile an
action whose name is not one URL segment.
A modifier on the wrong kind of field does not compile: each Field constructor returns its
control’s builder (TextField, ChoiceField, FileField, CustomField), which offers only
that control’s modifiers.
The panel serves the checked def to every request, so declare() must not depend on the request:
it takes no user, tenant or query string. Request-dependent decisions belong to the policy and to
the trait methods, which receive cx.