Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 methodDefaultPurpose
table(..)the record form’s derived tablethe list’s columns, filters and options: Tables
form(..)the record form’s derived schemathe create and edit form’s controls: Forms
view(..)the formthe detail page’s fields; an empty schema turns the page off: Detail pages
relation(..)nonea related resource shown as a table on the detail and edit pages
action::<A>()nonea custom action: Tables
policy(..)Denywhat the user may do: Policy, auth, tenancy
tenancy(..)Tenancy::none()how rows belong to a tenant: Policy, auth, tenancy
create_columns(..)nonecolumns an overridden create_record sets itself
slug(..), label(..), plural_label(..)from the type namesURLs and titles: Naming
icon(..), navigation_order(..), navigation(..)the default entrythe 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:

MethodDefaultPurpose
query(cx)every rowthe 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 errorsrules that need the whole parsed form
view_values(cx, record), view_content(cx, record)nonewhat the detail page shows beyond the form’s fields
record_label(cx, record)Nonethe detail page’s heading
public_url(cx, record)Nonea link to the record’s public page on its detail and edit pages
create_record, update_recordthe derived writethe create and update writes: Writes
delete_record, bulk_delete_recordsdelete by primary keythe delete writes
after_commit(cx, committed)nothingside effects after a write commits

Naming

The names default from the type names, following Filament’s conventions:

ResourceDef methodDefaultBlogPostResource over BlogPost
slug(..)resource name without Resource, pluralized, kebab-casedblog-posts
label(..)the model’s type name; used in “Create …” and “Edit …”BlogPost
plural_label(..)the label pluralized; the sidebar entry and list titleBlogPosts

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 query at every loader. See Tenancy.
  • Relations. The list and the export load the relations their columns declare with ComputedColumn::include, and the detail page loads view_query. Include a relation in query only 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 through Table::render (or render_with_state) or such a schema through Schema::render fails 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 Create and a non-nullable column is set by nothing: not the form, not a Toasty default, not the tenant stamp, and not listed in create_columns;
  • a NoForm resource declares a form or its policy allows Create;
  • a Tenancy::column lens is not one field of the model, a Tenancy::via lens is, or the form of a Tenancy::via resource 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.