Introduction
Tablo is an admin toolkit for Rust. You declare, once per database model, its table, its form and who may see and change it; Tablo serves the list, create, edit, detail and delete pages for it, rendered on the server.
Tablo is a layer over two upstream projects:
- Topcoat renders the HTML and owns routing, the request
context
Cx, reactivity, assets, cookies and sessions. The panel’s pages are Topcoat views, and your own pages use the sameview!,#[component]and#[layout]. - Toasty is the ORM. Tablo queries your Toasty models directly, so the model is the single source of truth for columns, relations and nullability.
What you get
- Server-rendered pages, no SPA. There is no client build step and no WASM bundle. Search, sort, filters and pagination work without JavaScript; the scripts add in-place updates.
- Typed declarations. Columns, form fields and filters are built from Toasty field lenses
such as
User::fields().email(), so a renamed or retyped column is a compile error. - Checks at startup. Mounting the panel validates every declaration — a form struct that disagrees with its schema, two resources on one URL, a tenant-owned resource with no tenant column — and returns an error naming the mistake before the server takes a request.
- Safe defaults. Every policy predicate denies until you allow it, authentication is on, every panel POST verifies a CSRF token, and a tenant-owned resource is scoped to the request’s tenant at every query.
What it is not
- Not a client framework. Anything that reads the database renders on the server.
- Not tested across databases. The test suites and benchmarks run on Toasty’s SQLite driver.
How to read this guide
Your first panel builds a complete, runnable admin in one file. Each later chapter covers one part of it:
| Chapter | Covers |
|---|---|
| Panel and routing | the panel builder, the routes it serves, custom and public pages |
| Resources | the Resource trait: naming, query scoping, writes |
| Tables | columns, search, sort, filters, export, live updates, deletes |
| Forms | the record form, controls, validation, uploads, embedded values |
| Detail pages | the read-only record page and related tables |
| Policy, auth, tenancy | who may see and change what |
| Data access | querying Toasty from your own pages |
| Security | the defaults and what your deployment must provide |
| Testing and benchmarks | testing a panel over HTTP |
The runnable reference is
examples/showcase: every
feature in this guide is exercised there. Terms are defined in
CONTEXT.md and design decisions are
recorded in docs/adr/. If this guide
disagrees with the code, the code is right: please open an issue.
Your first panel
This chapter builds a complete admin in one file: a Book model, a resource for it, and the
main that serves it with a login page. examples/quickstart is this app with writes opened and the
stylesheet wired in.
Dependencies
An app depends on the tablo facade, picks its database driver with a tablo feature, and builds
its stylesheet with tablo-build. It names topcoat and toasty directly for their macros, at the
versions Tablo uses:
[dependencies]
tablo = { version = "0.2.0", features = ["sqlite"] }
topcoat = { version = "0.10", default-features = false, features = ["tailwind", "font", "font-fontsource", "asset"] }
toasty = { version = "0.11", default-features = false, features = ["jiff"] }
jiff = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
uuid = "1"
[build-dependencies]
tablo-build = "0.2.0"
The toolkit enables no driver itself: sqlite, postgresql and mysql each turn on Toasty’s
driver of the same name. The [workspace.dependencies] of Tablo’s Cargo.toml hold the pinned
versions. tablo re-exports tablo_core at its root, so tablo_core::X in the other chapters is
tablo::X here, and the derives work with tablo as the only Tablo dependency.
The app
use tablo::auth::{AdminUser, AuthSession, hash_password};
use tablo::prelude::*;
use toasty::Db;
use topcoat::{
Result,
router::{Router, RouterBuilderDiscoverExt},
};
/// The model: every column the panel renders comes from this type.
#[derive(Debug, Clone, toasty::Model)]
pub struct Book {
#[key]
#[auto]
pub id: uuid::Uuid,
pub title: String,
}
/// What the create and edit forms parse into.
#[derive(tablo::RecordForm)]
#[form(model = Book)]
pub struct BookForm {
pub title: String,
}
pub struct BookResource;
impl Resource for BookResource {
type Model = Book;
type Form = BookForm;
// The default policy denies everything; this one opens the list and
// the records, and nothing else.
fn declare() -> ResourceDef<Self> {
ResourceDef::new().policy(ReadOnly)
}
}
#[tokio::main]
pub async fn main() -> Result<()> {
let mut db = Db::builder()
.models(toasty::models!(
Book,
// The built-in password login reads these two tables.
AdminUser,
AuthSession
))
.connect("sqlite::memory:")
.await?;
db.push_schema().await?;
// One account to sign in with.
toasty::create!(AdminUser {
email: "admin@example.com".to_string(),
password_hash: hash_password("secret")?,
display_name: "Admin".to_string(),
active: true,
created_at: jiff::Timestamp::now(),
})
.exec(&mut db)
.await?;
let router = Router::builder()
.discover()
.app_context(db)
.panel(Panel::new("admin").resource::<BookResource>())?
.build();
topcoat::start(router).await?;
Ok(())
}
Run it and sign in as admin@example.com / secret:
cargo run
# open http://127.0.0.1:3000/admin/books
What each part does
#[derive(RecordForm)]declares what a form submission parses into. Each field is named and typed like the model’s field, so a renamed column fails to compile. The derive also lays out the list’s table, the form and the detail page from those fields. See Forms.impl Resourcedeclares the admin for one model. A resource must nameModelandForm; every other item has a default, and the default policy denies everything. See Resources.Db::builder().models(..)lists every model Toasty maps, including the two tables the built-in login uses. With authentication on, mounting the panel returns an error naming the missing models.db.push_schema()creates the tables, which suits a prototype. A production app runstoasty-climigrations instead.Router::builder().discover()starts the app’s own router: the app owns it, and the panel is one part of it.app_context(db)gives it the database every handler reads.Panel::new("admin")is a panel at/admin, framed in the shell — sidebar, topbar, toasts — by a layout it registers itself.resource::<BookResource>()registers the resource’s routes under/admin/books, adds its sidebar entry, and, because the panel has no home page, makes/adminredirect to the book list. Panel and routing lists every route..panel(..)checks every declaration and mounts the panel, or returns aMountErrorlisting every mistake it found.build()returns theRouter.topcoat::startserves the router onHOST:PORT(default127.0.0.1:3000) until Ctrl+C orSIGTERM.
What the example leaves out
- Writes.
ReadOnlyallows only listing and viewing, so the create link is hidden, the create and edit pages answer 403, and rows show no Edit or Delete action. Policy, auth, tenancy opens them. - Styling. The example registers no asset bundle, so pages render unstyled and without the
shell’s scripts, but every page and form works. The router’s
.assets(..)and the panel’sshell_assetsadd the stylesheet, the font and the scripts; see Panel and routing. The stylesheet itself comes from the build script below.
The stylesheet
The panel’s markup carries Tailwind classes, so the app generates a stylesheet that covers them.
The app owns styles.css at its package root: it imports tailwindcss, declares the theme tokens
(examples/quickstart/styles.css is a neutral set to start from), and names its own sources.
@import "tailwindcss";
@source "./src/**/*.rs";
/* the theme tokens: --background, --foreground, --primary, ... */
build.rs runs the build:
#![allow(unused)]
fn main() {
pub fn build_main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-changed=src");
tablo_build::tailwind().expect("the Tailwind build runs");
}
}
tablo_build::tailwind() adds Tablo’s own sources to the build, wherever Cargo unpacked them, and
writes $OUT_DIR/tailwind.css, which tailwind::stylesheet!() hands to Panel::shell_assets. The
stylesheet names no path into Tablo. The build downloads the Tailwind CLI on first run.
Panel and routing
A Panel is an admin panel: resources and pages under one prefix, one shell, one login. You
configure it with builder calls and mount it on your app’s Topcoat router, which serves every
registered resource and page, the login page, and the shell that frames them, beside your own
routes.
#![allow(unused)]
fn main() {
use tablo::prelude::*;
use topcoat::{asset::RouterBuilderAssetExt, router::{Router, RouterBuilderDiscoverExt}};
pub fn admin_router(db: Db) -> topcoat::Result<Router> {
let router = Router::builder()
.discover() // the app's own pages and routes, and Tablo's
.app_context(db) // the toasty::Db every handler uses
.panel(
Panel::new("admin") // mounted at /admin
.brand(Brand::new("Acme"))
.home::<Dashboard>() // GET /admin
.resource::<UserResource>() // /admin/users and its sub-routes
.page::<ReportsPage>(), // GET /admin/reports
)?
.build();
Ok(router)
}
}
Routes
A resource with the slug users, on a panel mounted at /admin, serves:
| Method | Path | Serves |
|---|---|---|
GET | /admin/users | the list |
GET | /admin/users/{id} | the detail page; 404 when the resource’s view is empty |
POST | /admin/users/{id}/delete | delete one record |
POST | /admin/users/bulk-delete | delete the selected records |
GET | /admin/users/export | the list as CSV |
GET, POST | /admin/users/create | the create form ¹ |
GET, POST | /admin/users/{id}/edit | the edit form ¹ |
GET | /admin/users/options | option search for a searchable relationship select ¹ |
¹ Only for a resource whose Form is a record form; a list-only resource names NoForm and gets
none of these. See Forms.
{id} is the record’s primary key. Each route checks the resource’s policy, so a registered
route is not an open one: see Policy, auth, tenancy.
The panel also serves:
GET /admin: the home page, or a redirect to the first registered resource’s list when there is none.GET,POST /admin/loginandPOST /admin/logout, unless authentication is disabled.
Builder reference
| Call | Effect | See |
|---|---|---|
Panel::new(prefix) | the panel at /{prefix} ("" mounts at /admin) | |
resource::<R>() | registers a resource’s routes and sidebar entry | Resources |
page::<P>() | registers a page at /{prefix}/{slug} | Pages |
home::<P>() | registers a page at /{prefix} | Pages |
brand(Brand) | sets the name and optional logo in the sidebar, the topbar and the login card | Branding |
dark_mode(bool) | sets the theme a first-time visitor sees | Branding |
login_hint(text) | adds a line under the login form | Branding |
auth(Auth) | replaces or disables the built-in password login | Policy, auth, tenancy |
uploads(uploader) | sets where file fields store their bytes | Forms |
serve_dir(path, dir) | serves a directory of files, publicly | Forms |
shell_assets(css, font) | adds the stylesheet, font and scripts | Assets |
layout(render) | frames the panel’s pages with your layout instead of the shell | The shell layout |
frame_ancestors(..), without_frame_ancestors() | changes the anti-framing header | Security |
Mounting
Router::builder().panel(panel) mounts a panel; the RouterBuilderPanelExt trait, in the
prelude, provides it. The router is yours: discover your own pages and routes, install the Db
with .app_context(db) and the asset bundle with .assets(..), then mount. The panel reads both
from the router, so they come first.
The first panel mounted also installs what every panel shares: cookies, sessions unless the
router already configures them, the gate over Topcoat’s runtime endpoints with the shard
dispatch, and Topcoat’s runtime layer with link prefetching off unless the router already set
those up. The runtime layer has no
path, so mount panels after your own pathless layers: a page re-run must reach those layers
already rewritten to a GET.
The builder calls never fail; .panel(..) returns a MountError listing every mistake
instead. It refuses a router with no Db, a Db missing the shipped auth models, two resources or pages
with one slug, a slug the panel routes itself
(login, logout), a slug that is not a single URL segment, a second home page, shell_assets
on a router with no asset bundle, a prefix that overlaps another panel’s or Topcoat’s
/_topcoat/runtime, and every resource declaration check described in
Resources.
Several panels
One router mounts any number of panels at distinct prefixes, each with its own resources, pages, sidebar, brand and auth:
#![allow(unused)]
fn main() {
pub fn two_panels(db: Db) -> topcoat::Result<Router> {
let router = Router::builder()
.discover()
.app_context(db)
.panel(
Panel::new("admin")
.resource::<UserResource>()
.resource::<OrderResource>(),
)?
.panel(
Panel::new("portal")
.brand(Brand::new("Customer portal"))
.auth(Auth::custom(Customers))
.resource::<OrderResource>(),
)?
.build();
Ok(router)
}
}
A resource registered by two panels is declared once: both serve the same table, form and policy,
each under its own prefix. A panel’s prefix must not overlap another’s: /admin and /admin/reports
are refused, /admin and /administration are not.
Each panel has its own login page, and a session belongs to the panel that signed the user in. A
user signed in to /admin is anonymous on /portal, and signing in to /portal ends the /admin
session. See Policy, auth, tenancy.
URLs
The tablo::url helpers answer for the request’s panel, so app code never spells a prefix:
#![allow(unused)]
fn main() {
pub fn panel_urls(cx: &Cx) {
let _ = tablo::url::resource::<PostResource>(cx); // Some("/admin/posts")
let _ = tablo::url::page::<ReportsPage>(cx); // Some("/admin/reports")
let _ = tablo::url::panel(cx); // Some("/admin")
}
}
The request’s panel is the one whose prefix the request is under; on a router with a single panel
it is that panel for every request. A resource or page that panel does not register has no URL
there, and the helper returns None. tablo::panel::navigation::<R>(cx) answers the same way
with R’s whole sidebar entry, its label, URL and icon, which suits a dashboard linking to lists.
The shell layout
The panel frames every page under its prefix in the shell — sidebar, topbar, theme toggle and
notification toasts — with a layout it registers itself. Declare no #[layout] of your own at the
prefix: a second layout there would nest a second document inside the first.
To change the frame, pass your own layout function to Panel::layout. Panel::layout_shell is
the shipped one, so a layout that keeps the shell calls it around its own markup; one that does not
call it replaces the shell entirely:
#![allow(unused)]
fn main() {
pub fn admin_layout<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
let page = view! { cx => <div class="acme-admin">(slot)</div> }.boxed();
Panel::layout_shell(cx, page.into())
}
pub fn shell_panel() -> Panel {
Panel::new("admin").layout(admin_layout)
}
}
Sidebar
Every resource and page gets one sidebar entry, labelled with its plural label (a page’s
navigation_label()) and linked to its list or page URL. Entries sort by order (lower first,
default 0); entries with the same order keep registration order, and the home page leads its
order.
A resource sets the order and an icon on its def, and the panel resolves the URL:
#![allow(unused)]
fn main() {
impl Resource for UserResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.navigation_order(-1)
.icon(tablo_ui::icons::USERS)
}
}
}
A page overrides Page::navigation(), starting from NavigationItem::for_page::<Self>().
NavigationItem::at(label, url) links elsewhere, from a resource’s ResourceDef::navigation or a
page’s navigation(); the panel keeps that URL as written. The entry whose URL is the longest match
for the current path is marked active.
Pages
A page that is not a record list — a dashboard, a report, a settings screen — implements Page:
#![allow(unused)]
fn main() {
use tablo_core::{Page, Panel};
use topcoat::{Result, context::Cx, view::{View, view}};
pub struct ReportsPage;
impl Page for ReportsPage {
async fn render(cx: &Cx) -> Result<impl View> {
Ok(view! {
cx =>
tablo_ui::page(
tablo_ui::page_header(tablo_ui::page_title("Reports"))
tablo_ui::page_content(tablo_ui::card(tablo_ui::card_content("…")))
)
})
}
}
pub fn reports_panel() -> Panel {
Panel::new("admin").page::<ReportsPage>() // GET /admin/reports
}
}
- Slug and label default to the type name without its
Pagesuffix:ReportsPagemounts atreportsand is labelled “Reports”;MediaLibraryPagemounts atmedia-libraryand is labelled “Media library”. Overrideslug()andnavigation_label()to change them. Pages and resources share one slug namespace. - The home page.
Panel::home::<P>()mounts a page at the prefix itself instead of the redirect to the first resource. A panel has at most one; a second fails.panel(..). - Access. With authentication on, the page renders only for a signed-in user with panel access, inside the shell layout.
- Forms. A page serves one
GET. A form it renders posts to an app#[route]; put that route under the panel prefix so the auth gate covers it.
The tablo_ui composites give a page the same frame as the panel’s own pages: page sets the
width and padding; page_header holds a page_title, an optional page_description and optional
page_actions; page_content holds the body. Inside it, use card for a panel of content and
empty_state for a region with nothing to show. They are styled by the design tokens in your
styles.css (--card, --border, --primary, …), so one theme restyles the panel’s pages and
yours.
Public pages
A page outside the panel prefix is an ordinary Topcoat #[page] on your router. The auth gate
covers only the panel prefix and /_topcoat/runtime, so such a page is public.
Panel::document renders the same HTML document as the admin shell — head, assets, theme — around
your own markup. Outside any prefix it takes the panel’s shell settings when the router mounts one
panel; with several, it renders the document without the panel’s stylesheet and font.
#![allow(unused)]
fn main() {
// A layout wraps every route under its path: this one wraps /blog and /blog/{id}.
// A layout at "/" would wrap /admin too.
#[layout("/blog")]
pub async fn blog_layout(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
Panel::document(
cx,
"Blog",
view! { <div class="mx-auto max-w-3xl px-6 py-10">(slot)</div> },
)
.await
}
#[page("/blog")]
pub async fn blog() -> Result<impl View> {
Ok(view! { "Posts" })
}
}
A public page loads its rows with a plain Toasty query, not through a resource. The resource’s tenant scope applies only to the panel’s loaders, so in a multi-tenant app the page states its own tenant predicate. See Data access.
Branding and theme
#![allow(unused)]
fn main() {
pub fn branded_panel() -> Panel {
Panel::new("admin")
.brand(Brand::new("Acme").logo("/logo.svg"))
.dark_mode(true)
.login_hint("Demo: admin@example.com / password")
}
}
brandnames the panel in the sidebar header, on the login card, and in the topbar on narrow screens. A brand without a logo shows its first letter.dark_mode(true)makes the panel start dark for a visitor who has not chosen a theme. The theme toggle always renders, and a visitor’s choice, light or dark, is remembered and wins over this default. Without the call the panel starts light.login_hintrenders a muted line under the login form, such as demo credentials.
Assets
Without assets the panel renders unstyled HTML without the shell’s scripts, and every page and form still works. To style it, install the app’s Topcoat asset bundle on the router, then give the panel the Tailwind stylesheet and the font the shell links:
#![allow(unused)]
fn main() {
pub const GEIST: Font = fontsource_font!(GEIST, host: Asset);
pub fn asset_panel(db: Db, bundle: AssetBundle) -> Panel {
let _router = Router::builder().discover().app_context(db).assets(bundle);
Panel::new("admin").shell_assets(tailwind::stylesheet!(), GEIST)
}
}
shell_assets also makes the shell load tablo-ui’s scripts: live search, confirmation dialogs,
searchable selects, toasts and the sidebar and theme toggles. Mounting refuses shell_assets on a
router with no asset bundle. The stylesheet comes from tablo_build::tailwind() in the app’s build.rs
(Your first panel).
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.
Tables
A resource’s Table, set with ResourceDef::table, declares its list page: the columns, and
the search, sort, filter, grouping and pagination the list offers. It depends on no request: the
panel builds it once when it mounts and serves that table to every request. The same declaration
drives the CSV export.
The table defaults to the record form’s derived table, UserForm::table(): a sortable column per
text field, searchable over a String or Option<String>, an #[form(options = ..)] field by
its option’s label, and a bool as yes or no. A bare choice, a file and an embedded value get no
column. Extend the derived table, or declare the columns yourself:
#![allow(unused)]
fn main() {
ResourceDef::new().table(UserForm::table().filters(TernaryFilter::new(User::fields().active())))
}
#![allow(unused)]
fn main() {
impl Resource for UserResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.table(
Table::new((
TextColumn::new(lens!(User.name)).searchable().sortable(),
TextColumn::new(lens!(User.email)).searchable(),
TextColumn::new(lens!(User.age)).sortable(),
BooleanColumn::new(lens!(User.active)),
))
.paginate(20),
)
}
}
}
Each row is keyed by its record’s primary key: the table identifies rows for selection and in-place updates by it, and the action URLs and bulk checkboxes carry it. A composite primary key has no URL form, so its rows render without row actions or bulk selection.
Lenses
lens!(User.name) names a field once and yields both halves a column needs: the path a query
sorts and searches on (User::fields().name()) and the reader that renders the loaded value
(&user.name). The two cannot disagree. A lens names a field of the model or, through an embedded
value, its leaf (lens!(Post.seo.title)), which binds the flattened seo_title column; a lens
through a relation does not compile, since a relation’s records are not part of the row. Builders
that only query, such as the filters and Field constructors, take either a lens or a plain path.
Columns
| Constructor | Cell | Search and sort |
|---|---|---|
TextColumn::new(lens) | the field’s value as text, or .format(|value| ..) of it | .searchable() on a String field, .sortable() |
ComputedColumn::new(label, project) | project(row) | neither: the methods do not exist |
BooleanColumn::new(lens) | a check or a cross icon for a bool field; the export writes Yes/No (.labels(..)) | .sortable() |
#![allow(unused)]
fn main() {
pub fn formatted_columns() {
TextColumn::new(lens!(Post.status)).format(|status| PostStatus::label_of(status));
TextColumn::new(lens!(User.created_at)).format(|at| at.strftime("%Y-%m-%d").to_string());
}
}
- Labels. A field column is labelled from its field name (
created_at→ “Created at”); a computed column uses the label you pass. - Relations. A computed column whose closure reads a relation declares it with
.include(..), and the list and the export load it with the page’s rows in one query. A relation no column includes is not loaded. Guard the read so a missing include renders(unloaded)instead of blank data:
#![allow(unused)]
fn main() {
pub fn author_column() -> ComputedColumn<Post> {
ComputedColumn::new("Author", |p: &Post| {
if p.author.is_unloaded() {
"(unloaded)".into()
} else {
p.author.get().name.clone()
}
})
.include(Post::fields().author())
}
}
- Widths. The table uses a fixed layout: a column’s width is what it declares, not the width of
its widest cell, so paging and filtering never shift the columns. A field column takes an equal
share of the space left over; a computed column defaults to a narrow share of the table (10%,
scaled down when many columns claim one). Override with
.width(ColumnWidth::Percent(30)),Rem(8),NarroworWide. A cell wider than its column is truncated with an ellipsis. On a narrow screen the table keeps a minimum width and scrolls horizontally instead of narrowing its columns.
Two columns with the same name, two filters with the same name, a table with no columns, and a zero page size are misdeclarations: mounting the panel refuses the resource, and rendering the table fails with the same errors.
Your own columns
A column is anything that implements Column<M>. The built-in columns implement it and nothing
more, so a column of your own reaches as far as theirs:
#![allow(unused)]
fn main() {
pub struct Initials;
impl Column<User> for Initials {
fn name(&self) -> &str {
"initials"
}
fn label(&self) -> &str {
"Initials"
}
// The export's cell, and the table's unless `cell` renders a view.
fn text(&self, u: &User) -> String {
u.name
.split_whitespace()
.filter_map(|w| w.chars().next())
.collect()
}
fn cell<'a>(&self, cx: &'a Cx, u: &User) -> BoxView<'a> {
let text = self.text(u);
view! { cx => <span class="font-mono">(text)</span> }.boxed()
}
}
}
Only name, label and text are required. The other methods default to a column that is
narrow, not searchable, not sortable and reads no relation: override column_width,
is_searchable and search_expr, is_sortable and order_by, or includes to change that. Put
the column in the tuple next to the built-in ones, or append it with Table::column(..), which
also takes columns past the tuple’s eight.
Search, sort and pagination
The URL holds the list’s whole state, so every view of a list is a link you can share:
| Parameter | Effect |
|---|---|
?q=ada | search: each searchable column contains ada; any column may match |
?sort=name&dir=desc | sort by a sortable column; dir is asc (default) or desc |
?after=…, ?before=… | the next or previous page, as an opaque cursor |
?f.status=published | a filter: see Filters |
?group_by=status | grouping: see Grouping |
- Search escapes
%and_, so they match literally. Terms are trimmed and capped at 128 characters. Matching follows the database’sLIKE: case-insensitive for ASCII on SQLite, case-sensitive on PostgreSQL. - Pagination is cursor-based, 25 rows per page unless
.paginate(n)sets another size. The primary key breaks ties, so a sort over duplicate values still pages deterministically. - All of it works without JavaScript.
.hide_search()removes the search box, and.hide_filter_bar()the filter controls.
Live updates
#![allow(unused)]
fn main() {
pub fn live(columns: impl tablo_core::IntoColumns<User>) -> Table<User> {
Table::new(columns).live_search()
}
}
With live_search(), typing in the search box, sorting, filtering and paging update the table
in place, without a page load, keeping focus and scroll position. The plain links and forms
remain for visitors without JavaScript.
Filters
#![allow(unused)]
fn main() {
.filters((
SelectFilter::new(Post::fields().status(), PostStatus::options()),
TernaryFilter::new(Post::fields().featured()),
DateFilter::new(Post::fields().created_at()),
))
}
| Filter | Field | Values |
|---|---|---|
SelectFilter::new(lens, options) | String | one of options, matched exactly; the options are Vec<(String, String)> (an Options list), Vec<String>, or [&str; N] |
TernaryFilter::new(lens) | bool | true, false, or all (no filter) |
DateFilter::new(lens) | jiff::Timestamp | a date 2024-01-15 matches that UTC day; an RFC 3339 timestamp matches that instant |
QueryFilter::new(name, label).option(label, predicate) | any | named options, each a Toasty predicate you build |
Each active filter is one parameter, ?f.<name>=<value>, named after the field (a
QueryFilter after its name), and active
filters combine with AND.
A filter is anything that implements Filter<M>: a name, a label, the predicate a value selects,
and the control the filter bar renders. The four filters above implement it and nothing more.
#![allow(unused)]
fn main() {
pub fn promoted_filter() -> QueryFilter<Post> {
QueryFilter::new("promoted", "Promoted")
.option("Promoted", Post::fields().featured().eq(true))
.option("Backlog", Post::fields().featured().eq(false))
}
}
FilterInput carries the parameter the control submits and the current value, and
input.select(cx, options) renders the built-in select:
#![allow(unused)]
fn main() {
pub struct Adults;
impl Filter<User> for Adults {
fn name(&self) -> &str {
"adults"
}
fn label(&self) -> &str {
"Adults"
}
fn to_expr(&self, value: &str) -> Option<Expr<bool>> {
(value == "yes").then(|| User::fields().age().ge(18))
}
fn control<'a>(&self, cx: &'a Cx, input: FilterInput) -> BoxView<'a> {
input.select(cx, vec![("yes".into(), "Adults only".into())])
}
}
}
A filter of your own goes in the filters((..)) tuple; alone, it is a one-element tuple,
.filters((Adults,)). At most 32 filters apply, each name and value at most 256 bytes.
A filter that cannot apply — an unknown name, a value the filter rejects, or one over the limits — is never dropped silently: the list shows a warning banner naming it, and the export refuses the request with 400 rather than export more rows than asked.
Grouping
#![allow(unused)]
fn main() {
.group_by(lens!(Post.status))
}
?group_by=status, named after the field, groups the current page’s rows under headers with a
row count. Grouping runs on the loaded page, so the counts cover that page, not the whole table. A
?group_by= value the table does not declare is ignored.
Export
GET /admin/{slug}/export returns the list as a CSV file named {slug}.csv, with the current
search, filters and sort applied, and the relations the columns include loaded.
- Rows the policy may not
Vieware left out. - The export delivers at most 10,000 rows. When the search and filters match more, it answers 413 rather than a truncated file.
- Cells that a spreadsheet would read as a formula are escaped. Add
?bom=1to prefix the file with a byte-order mark for Excel.
Row actions and deletes
Each row shows the actions its record allows:
- View when the resource declares a detail page (a non-empty view) and the policy allows
Viewof the record; - Edit when the resource has a record form and the policy allows
ViewandUpdate; - Delete when the policy allows
DeleteAny, andViewandDeleteof the record; - each custom action the record allows.
A row that allows none keeps an empty actions cell.
When the policy allows DeleteAny, or the resource declares a bulk custom action, the list adds
a checkbox column and a bulk bar. A row that neither delete nor any bulk action allows gets no
checkbox, so select-all only selects rows something can be done to. A bulk delete accepts at most
400 records and deletes all of them or none: a selection holding a record that may not be deleted
deletes nothing and returns to the list with an error notification.
Both deletes require confirmation. The Delete action opens a confirmation dialog on the list page; confirming it deletes the row, shows a notification and refreshes the table without leaving the page. The bulk bar’s button opens a dialog stating how many rows are selected. The delete handlers refuse a POST that was not confirmed through the dialog with 400, and without JavaScript the Delete link renders the list with its dialog already open.
Custom actions
An action is a mutation beyond create, update and delete, declared as a type implementing
Action<R> and added to the def with ResourceDef::action:
#![allow(unused)]
fn main() {
pub(crate) struct Publish;
impl Action<PostResource> for Publish {
const NAME: &'static str = "publish";
fn label(_cx: &Cx) -> String {
"Publish".to_string()
}
fn can_run(_cx: &Cx, post: &Post) -> bool {
post.status != "published"
}
async fn run(_cx: &Cx, posts: &[Post], ex: &mut dyn toasty::Executor) -> Result<()> {
for post in posts {
Post::filter(Post::fields().id().eq(post.id))
.update()
.status("published".to_string())
.exec(&mut *ex)
.await?;
}
Ok(())
}
}
impl Resource for PostResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.action::<Publish>()
}
}
}
A row renders the action’s button when the policy’s View and the action’s can_run allow its
record, and the bulk bar
renders it for the selection. const ROW: bool = false keeps it off the rows, and
const BULK: bool = false off the bulk bar.
The framework runs an action the way it runs a delete. The POST goes to
{list}/{key}/-/actions/{NAME} for a row and {list}/-/actions/{NAME} for the selection, carries the
CSRF token, and loads the records through the tenant-scoped query inside a transaction. Every
record must pass the policy’s View and the action’s can_run, and run writes through the same transaction, so an
error rolls everything back. After the commit, after_commit receives Mutation::Action(NAME)
with the records and the list shows Action::success, by default the label and the record count.
A row the action refuses answers 403. A selection that holds one writes nothing and returns to the list with an error notification. An action name that is not one URL segment does not compile, and mounting the panel refuses a name two actions of a resource share. Custom actions run without a confirmation dialog.
If the table fails to load, the list shows an error state with a retry link in place of the rows; the rest of the page still renders.
Forms
A resource with create and edit pages declares two things: a record form, the typed struct a
submission parses into, and a schema, set with ResourceDef::form, the controls the page
renders. The schema defaults to the record form’s derived schema, so a resource that wants one
control per field in declaration order declares no form at all. Mounting the panel builds the
declarations once and checks that the two agree.
The record form
#![allow(unused)]
fn main() {
#[derive(tablo::Options)]
pub enum Role {
Admin,
Member,
}
#[derive(Debug, Clone, tablo_core::RecordForm)]
#[form(model = User)]
pub struct UserForm {
pub name: String,
pub email: String,
#[form(options = Role, blank = Role::Member.value())]
pub role: String,
#[form(blank = 0)]
pub age: i64,
}
}
Each field names a model field and has that field’s type, so renaming or retyping a column breaks
the build. A field is either a scalar — String, a typed value, or an
Option of one — bound to the one key its control posts, or an embedded value
marked #[form(embed)].
Leave out the columns the form does not write: the tenant column of a tenant-owned resource, which
the framework sets on create, and columns with a Toasty #[default(..)] or #[auto].
Blank values. #[form(blank = <expr>)] is what a field stores when its control is submitted
empty. String answers "" and Option<T> answers None through the type’s own blank, and a
bool answers false through the derive’s default, since an unchecked toggle posts false. Any
other type needs blank when its control is optional.
The resource names the struct as its Form and, to arrange the controls, declares them from the
derive’s controls():
#![allow(unused)]
fn main() {
impl Resource for UserResource {
type Model = User;
type Form = UserForm;
fn declare() -> ResourceDef<Self> {
let c = UserForm::controls();
ResourceDef::new()
.form(Schema::new(Section::new("Profile").schema((
c.name.placeholder("Ada Lovelace"),
c.email.email(),
c.role.optional(),
c.age.optional(),
))))
// .table(..), .policy(..) …
}
fn validate_record(_cx: &Cx, form: &UserForm) -> FieldErrors {
let mut errors = FieldErrors::new();
if form.age < 0 {
errors.add("age", "Age must be zero or more");
}
errors
}
}
}
Mounting the panel refuses the resource unless every control is bound by exactly one form field,
every form field has a control, every optional control’s field has a blank value, and — when
the policy allows Create — every non-nullable column is filled by the form, by Toasty, by the tenant
stamp, or by an overridden create_record whose def lists it in create_columns.
Controls
The derive picks each field’s control from the field: a bool is a toggle, #[form(options = T)]
a choice over T’s options, #[form(choice)] a bare choice, #[form(file)] a file field,
#[form(embed)] the embedded value’s schema, and any other field a text field. controls()
hands each one over ready for its modifiers, so an override arranges rather than rebinds:
#![allow(unused)]
fn main() {
pub fn account_layout() -> Schema {
let c = UserForm::controls();
Schema::new((
Section::new("Account").schema((c.email.email(), c.role)),
Grid::new(2).schema((c.name, c.age)),
))
}
}
The role control already offers Role’s options: #[form(options = Role)] chose a choice over
that list. A closed set of values is a #[derive(Options)] enum, shared by the form, the filter
and the column:
#![allow(unused)]
fn main() {
pub fn role_fields() {
Field::choice(User::fields().role()).options(Role::options());
SelectFilter::new(User::fields().role(), Role::options());
}
}
Each variant stores its snake_case name and reads as that name in sentence case;
#[option(value = "..", label = "..")] overrides either. The derive also gives the enum
value(), label(), from_value() and, through the Options trait, label_of(). .options
takes Vec<(String, String)> (an Options enum’s list), Vec<String>, or [&str; N] (["admin", "member"]).
Layout blocks arrange fields: Section::new(title) is a titled card, Group::new() an untitled
container, and Grid::new(cols) a grid of 1 to 12 columns. Repeater::new(label) is a titled group
that may be left entirely empty: its fields’ rules apply once one of them is filled, and
.required() refuses an all-empty group. A schema or block takes a tuple of at most eight children;
nest a Group for more.
Fields are built from a Toasty field lens:
| Constructor | Column | Control | Builder |
|---|---|---|---|
Field::text(lens) | String, a typed value, or an Option of one | <input>, or <textarea> with .multiline(rows) | TextField |
Field::choice(lens) | any | <select> over static options or a relationship | ChoiceField |
Field::file(lens) | String holding the file’s path | file input: see File uploads | FileField |
Field::toggle(lens) | bool | checkbox | CustomField |
Field::custom(lens, control) | String, a typed value, or an Option of one | your own Control: see Custom controls | CustomField |
Every field takes .label(..), .required() and .optional(). The label defaults to the column
name in sentence case, and required defaults to whether the column is non-nullable. Each
constructor returns its control’s builder, which offers only that control’s modifiers, so a
modifier on the wrong control does not compile:
- text (
TextField):.email(),.unique(),.placeholder(..),.multiline(rows); - choice (
ChoiceField):.options(..),.relationship(..),.searchable().
Custom controls
A Control renders the input of a Field::custom field. The field keeps everything fields share —
the key, the label, the required rule, the type’s parse rule, the error slot and the chrome around
the input — and the control renders only the input, from a ControlInput carrying the key, the
current value and the validation state:
#![allow(unused)]
fn main() {
struct Color;
impl Control for Color {
fn render<'a>(&self, cx: &'a Cx, input: ControlInput) -> BoxView<'a> {
let attrs = input.attributes(cx); // id, name, value, required, aria-*
view! { cx => <input type="color" (attrs)> }.boxed()
}
}
pub fn accent_field() -> CustomField {
Field::custom(Theme::fields().accent(), Color)
}
}
display renders the stored value on the detail page, as text by default. The submission is read
like any other field’s: the value posted under the field’s key, the last one when it is posted
twice. Field::toggle is built this way: Toggle renders a hidden false before the checkbox
under the same name, so an unchecked box submits false rather than nothing. A toggle is
optional, since it is never empty, and a bool record-form field reads an empty submission as
false without a declared blank.
Typed values
Field::text binds more than strings. Over an integer, float, bool, Uuid or
jiff::Timestamp column it renders the stored value and parses the submission back through the
type. A value the type refuses is an inline error naming it: `twelve` is not a valid whole number. A jiff::Timestamp renders a datetime-local input, which carries no time zone, so
values display and parse as UTC.
Implement TypedValue to bind your own type: NOUN names it in errors, INPUT_TYPE sets the
input’s type, and parse_input reads a submission (by default through FromStr).
Email and uniqueness
.email() renders type="email" and refuses what is not an address: a domain needs two labels
(a@b is refused), a display name (Ada <ada@example.com>) is refused, and the address is capped
at 254 bytes.
A text field over a column with a unique index checks uniqueness before the write, and reports a
taken value inline. .unique() states it explicitly; mounting the panel refuses .unique() on a
column without a unique index, single-column or composite. The check is a query before the write,
so two concurrent submissions can both pass it; the database index stays the final guard.
On a non-nullable column, uniqueness also makes the field required, even with .optional():
an empty String is stored as "", and the index admits only one. An Option column stores
NULL for an empty value, so a unique Option field may stay optional.
Relationships
A choice over a foreign key loads its options from the related resource:
#![allow(unused)]
fn main() {
pub fn author_field() -> ChoiceField {
Field::choice(Post::fields().author_id())
// The source, whose scoped query loads the options, and each option's label.
.relationship::<AuthorResource>(|a: &Author| a.name.clone())
.searchable()
.label("Author")
}
}
Each option’s value is the related record’s primary key.
- Options come from the related resource’s tenant-scoped query and follow its policy: the list is
empty and the field shows “not available” unless the related resource’s policy allows
ViewAny, and each record must pass itsView. - A submitted key must be one of those records, so a hand-crafted POST cannot point at another tenant’s row. The write checks the key again inside its transaction, so a record deleted, moved to another tenant or hidden since the form validated refuses the write with the same field error.
- At most 200 options load. Past that, a
.searchable()choice turns into type-to-search againstGET {list_url}/options, which searches the related table’ssearchable()columns; a non-searchable choice shows an error instead..searchable()also filters a short list as you type. Without JavaScript the plain select remains.
Submitting
- Validation runs in one round. Required fields, email, relationships, uniqueness and typed
parsing are checked together, and the form re-renders with every error inline, with status 200
and nothing written.
validate_recordneeds the parsed form, so it runs only when every field parses. Each error names the key it renders under: the control’s key, an embedded value’s flattened key, or a repeater’s label. An error keyed to something the form does not render fails the request instead of being dropped. - Unknown keys are refused. A POST carrying a key the form does not declare answers 400, so a
client cannot write
roleortenant_id. The CSRF token and the file fields’clear_andkeep_keys are the exceptions. - An edit writes only what was posted. On an edit, a declared key missing from the submission
keeps its stored value, and the update assigns only the fields the submission named, plus the
model’s own
#[update(..)]defaults and#[version]bump. An API client can post a single field. A control submitted empty is posted, and stores its field’s blank value.
To write more than the form holds, override create_record or update_record
(Resources) and use the builders: form.into_create() is the model’s
create builder with every form field set, and posted.into_update(&mut record) is the update
builder with one assignment per posted field, or None when nothing was posted.
File uploads
Field::file binds a String column that stores the file’s path or URL, never its bytes. A form
with a file field is sent as multipart/form-data, with a 10 MiB body limit (larger answers 413).
Filenames are reduced to a safe basename before anything sees them.
Where the bytes go is your app’s decision. Install an Uploader on the panel:
#![allow(unused)]
fn main() {
struct DirUploader {
dir: PathBuf,
}
impl Uploader for DirUploader {
async fn store(&self, filename: &str, bytes: &[u8]) -> Result<String, String> {
let name = format!("{}-{filename}", uuid::Uuid::new_v4());
tokio::fs::write(self.dir.join(&name), bytes)
.await
.map_err(|_| "the upload could not be written".to_string())?;
Ok(format!("/uploads/{name}")) // the value the record stores
}
}
pub fn uploads_panel(dir: PathBuf) -> Panel {
Panel::new("admin")
.uploads(DirUploader { dir: dir.clone() })
.serve_dir("/uploads/{*file}", dir)
}
}
storereturns the value to save. AnErr(reason)is shown to the user inline as<Label> could not be uploaded: <reason>, so keep the reason free of paths and driver messages. Without an uploader the panel stores the sanitized filename and discards the bytes.- Keeping a file across a failed submit. When a form re-renders with errors, the file input is
empty again. The form carries the path
storejust returned, and the panel reuses it on the next submit only whenUploader::holds(path)answerstrue. The default answersfalse, so the user uploads again. Answertrueonly for a path your store produced and still holds inside its own root; checking whether an arbitrary path exists would let a client pick any file. - Editing. The edit form shows the stored file as a link and an empty file input. Leaving it
empty keeps the file; the “Remove the current file” checkbox (
clear_<field>) empties the field. The input is required only while nothing is stored, and clearing a required field fails validation, so declare.optional()when a record may lose its file. - Links. The stored value renders as a link, on the edit form and the detail page, only when
it is a root-relative path (
/uploads/a.png, not//host) or anhttp(s)URL; anything else renders as text. - Serving.
Panel::serve_dir(path, dir)serves a directory, and the served files are public: the auth gate does not cover them. An app that needs protected files serves them from its own route. See Security for the headers served files carry.
Embedded values
A Toasty #[derive(Embed)] struct or enum is stored in its parent’s row as flattened columns
(seo_title, seo_description). Derive EmbeddedForm on it and the form binds the whole value:
#![allow(unused)]
fn main() {
pub struct Seo {
pub title: String,
#[form(multiline = 3)]
pub description: String,
}
// In the def's form: one call renders a control per field.
pub fn seo_schema() -> Section {
Section::new("SEO").schema(Seo::form(Post::fields().seo()))
}
// In the record form: the value is one field.
#[derive(Debug, Clone, tablo_core::RecordForm)]
#[form(model = Post)]
pub struct PostForm {
pub title: String,
#[form(embed)]
pub seo: Seo,
}
}
- Every field of the value is a scalar, or a nested value marked
#[form(embed)].#[form(label = "…")],#[form(multiline = N)]and#[form(blank = ..)]customize a field; an unknown attribute is a compile error. - Embedded fields are optional by default. An emptied field stores its blank value, and a field with no blank value refuses an empty submission inline.
- Enums render a choice of variant plus one group of fields per variant; the page shows only the chosen variant’s group, and the variant can change on edit. The submission’s variant decides which fields are read and validated, so a stale value in a hidden group never blocks a submit. Without JavaScript every group renders, and the variant choice still decides.
- Not supported inside a value: a
#[document]field, a relation, an enum nested inside an enum variant, and tuple structs.
To bind a single embedded field on its own, pass its path: Field::text(Post::fields().seo().title())
binds the flattened seo_title column, which the panel resolves through the database schema when
it mounts. Such a field is optional unless you call .required(). A schema built outside a panel,
such as one a custom page renders, resolves the same way inside tablo::declare(&db, || ..).
Detail pages
A detail page shows one record, read-only, at GET /admin/{slug}/{id}. A resource declares it
with ResourceDef::view: a Schema built from the same fields and layout blocks as a form, which
the panel builds once when it mounts. The view defaults to the form, so a resource with a form has
a detail page showing the form’s fields read-only. A form with fields that must stay off the
detail page sets a view with a subset or Schema::empty(). Set it to show other fields or another
layout:
#![allow(unused)]
fn main() {
impl Resource for PostResource {
// …
fn declare() -> ResourceDef<Self> {
let c = PostForm::controls();
ResourceDef::new()
// …
.view(Schema::new(Section::new("Post").schema((
c.title,
c.body.multiline(6),
c.status,
))))
}
}
}
A non-empty view adds a View action to each row. An empty one, Schema::empty(), turns the
detail page off: the route answers 404 and no row links to it. A NoForm resource has an empty
view unless it declares one.
What the page shows
The header carries the record’s title, a link back to the list, and an Edit link when the resource
has a form and the policy allows Update of this record. Below it come the view’s fields, then any
free-form content, then the related tables.
Each field renders its label and its stored value, never a control:
- a text field shows the value as text, typed values included;
- a choice shows the label of the matching option, or the stored value when none matches, so a relationship choice shows the stored key, not the related record’s name;
- a file field shows the stored path as a link, under the rules in File uploads;
- an embedded enum shows its variant’s name and that variant’s fields;
- layout blocks keep their structure.
Where values come from. A field the record form binds shows the form’s value for it, the
same value the edit form starts with. view_values(cx, record) supplies every other key, as a map
from field name to display text; when both supply a key, the form’s value wins. A NoForm
resource supplies every key there:
#![allow(unused)]
fn main() {
pub fn view_values(_cx: &Cx, audit: &Audit) -> HashMap<String, String> {
HashMap::from([
("action".to_string(), audit.action.clone()),
("created_at".to_string(), audit.created_at.to_string()),
])
}
}
To show something that is not a column’s own value, such as the author’s name behind
author_id, render it as free-form content.
A field no source fills renders (missing), and fails a debug_assert! in debug builds.
Loading. The record loads through view_query — query by default — with the tenant scope
applied. Include there every relation view_values or view_content reads:
#![allow(unused)]
fn main() {
impl Resource for PostResource {
// …
fn view_query(cx: &Cx) -> Query<List<Post>> {
let author: Include<Post, Author> = Post::fields().author().into();
Self::query(cx).include(author)
}
}
}
An unknown id and an id outside the request’s tenant are the same 404; a record the policy may not
View is a 403.
Title. record_label sets the page title; without it the title is the resource’s label() and
the record’s key, such as “Post 3f2a…”:
#![allow(unused)]
fn main() {
pub fn record_label(_cx: &Cx, post: &Post) -> Option<String> {
Some(post.title.clone())
}
}
public_url(cx, record) adds a “View public post” link to the header when it returns a URL.
Free-form content
view_content(cx, record) renders anything that is not a field, such as a word count, below the
fields:
#![allow(unused)]
fn main() {
pub fn view_content<'a>(cx: &'a Cx, post: &Post) -> Option<BoxView<'a>> {
let words = post.body.split_whitespace().count();
Some(
view! {
cx =>
<p class="text-sm text-muted-foreground">(format!("{words} words"))</p>
}
.boxed(),
)
}
}
The returned view may borrow cx but not the record: compute what you need from the record first.
Related tables
ResourceDef::relation adds a related resource whose rows belong to a record. Each renders on
the record’s detail and edit pages as that resource’s own list table, narrowed to the record:
#![allow(unused)]
fn main() {
impl Resource for PostResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
// The related model's foreign key, which holds the post's primary key.
.relation(Relation::has_many::<CommentResource>(
Comment::fields().post_id(),
))
}
}
}
The foreign key’s type is the owner’s primary key type, or its Option for a nullable foreign
key; any other type does not compile. The table is
CommentResource’s — its columns, search, sort, filters and pager — over its tenant-scoped query
plus post_id = <this post>. It is titled with the related resource’s plural label, or
.label(..). The panel must register the related resource too: mounting refuses a relation to
one it does not.
- Policy. The related resource’s policies apply as on its own list: no section renders when
its policy refuses
ViewAny, or when it is tenant-scoped and the request has no tenant, and each row keeps only the actions its record allows. - Detail page versus edit page. On the detail page the table is read-only: rows keep only
their View action. On the edit page rows also carry Edit and Delete, the table has bulk delete,
and a create button appears when the related resource has a form and its policy allows
Create. - Creating from the parent. The create button opens the related resource’s create page with
the owner preselected, as in
/admin/comments/create?post_id=…. The create page accepts such a parameter only for a relationship choice; the value is a default, and the submitted form is validated like any other create. - Returning. Writes started from a related table carry
?return=and redirect back to the page they started on. The panel followsreturnonly to a path under its own prefix. - URL parameters. Each related table’s parameters are prefixed with the related resource’s
slug —
?comments.q=,?comments.sort=,?comments.after=— so several tables share one page. - Live search. A related table whose resource declares
live_search()stays live: sorting, searching, filtering and paging re-render that table in place. Without JavaScript its links and forms fall back to full page loads.
Mounting the panel refuses a relation to a resource the panel does not register, and two relations of one resource to the same related resource.
Policy, auth, tenancy
Three layers decide what a request may do. Authentication decides who is signed in.
Tenancy limits a signed-in user to the rows of the tenant they act for. Policy — the
resource’s ResourceDef::policy — decides what that user may do with each resource and record.
Policy
A policy answers one Ability at a time. The default policy is Deny, so a new resource exposes
nothing until you allow it:
#![allow(unused)]
fn main() {
use tablo::prelude::*;
impl Resource for UserResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.policy(|cx: &Cx, ability: Ability<'_, User>| match ability {
Ability::ViewAny | Ability::View(_) => true,
Ability::Create | Ability::DeleteAny | Ability::Delete(_) => is_admin(cx),
Ability::Update(user) => !user.sso_managed,
})
}
}
}
| Ability | Asked by |
|---|---|
ViewAny | the list and its live refresh, the export, related tables, relationship options |
View(record) | the detail page, the edit page and POST, deletes, each exported row, each relationship option, each row’s actions |
Create | the create page and POST, the Create button |
Update(record) | the edit page and POST, the row’s Edit action |
DeleteAny | the delete and bulk-delete POSTs, the Delete action and the bulk column |
Delete(record) | each record a delete removes, the row’s Delete action and checkbox |
Handlers ask the same abilities that decide which buttons render, so a hidden action is also a refused request. A denied request answers 403.
- The list asks
ViewAnyonly. A policy is Rust code that cannot run in the database, and filtering rows after pagination would leave pages short. Rows a user must not see on the list belong out ofquery(); see Resources. - A record is viewed before it is written. The edit and delete handlers ask
Viewtogether withUpdateorDelete, and a delete asksDeleteAnybefore any record loads. - Writes are checked against the stored row. The update and delete handlers load the record inside the write’s transaction and ask the policy about that row, not the submitted id. A bulk delete fails as a whole if any selected record is refused.
- Relationship options require
ViewAnyandViewfrom the related resource’s policy.
Building a policy
Allow, Deny, ReadOnly (ViewAny and View) and when(predicate) are policies, and each
combines with another through and and or. when allows every ability while a predicate on the
request holds, which suits a rule about the user or the tenant that several resources share. The
rule reads the app’s own user type, Staff here (see Your own user table):
#![allow(unused)]
fn main() {
pub fn editors_only(cx: &Cx) -> bool {
tablo::auth::user::<Staff>(cx).is_some_and(|staff| staff.editor)
}
impl Resource for PostResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.policy(ReadOnly.or(when(editors_only)))
}
}
impl Resource for AuthorResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.policy(when(editors_only))
}
}
}
A closure |cx: &Cx, ability: Ability<'_, M>| -> bool is a policy too, as in the first example,
and so is any type that implements Policy<M>.
In your own code
can::<R>(cx, ability) asks R’s policy, for a page or route that renders or writes R’s
records itself. can_list::<R>(cx) answers whether the request may open R’s list: the panel’s
sign-in, R’s tenant, and ViewAny, the checks the list handler makes. A dashboard that links
to lists checks it before rendering each link.
A page, route or shard the app serves under a panel calls auth::guard(cx)? to require the
panel’s signed-in user exactly as the panel’s own pages do. A shard needs it: Topcoat serves
shards at its runtime path, where no page guard runs. A form route verifies its CSRF token with
csrf::verify.
Authentication
Authentication is on by default. The built-in login checks an email and password against the
AdminUser table and keeps sessions in the AuthSession table; register both models with
Toasty and create a user with a hashed password:
#![allow(unused)]
fn main() {
pub fn auth_models() {
let _ = toasty::models!(
crate::Book,
tablo_core::auth::AdminUser,
tablo_core::auth::AuthSession
);
}
}
#![allow(unused)]
fn main() {
pub fn admin_hash() -> Result<String> {
let password_hash = tablo_core::auth::hash_password("secret")?; // Argon2id
Ok(password_hash)
}
}
Your first panel shows the complete setup.
What a request gets
| Request | Signed in with panel access | Signed in without panel access | Not signed in |
|---|---|---|---|
GET of a panel page | the page | 403 | redirect to /admin/login?next=… |
| any other panel request | handled | 403 | 401 |
/_topcoat/runtime | handled | 403 | 401 |
After login, the user returns to next, which must be a same-origin path. A user has panel access
when their AdminUser.active is true. The gate covers only the panel prefix and
/_topcoat/runtime; routes elsewhere, including your app’s own routes and directories served with
Panel::serve_dir, are public. On a router with several panels, /_topcoat/runtime answers 401
without a session only when every panel is gated.
- Login verifies Argon2id hashes. An unknown email costs the same work as a wrong password, and every failure shows the same message.
- Sessions are rows in
AuthSession, identified by a hash of the cookie’s token. A session lasts seven days from login, is replaced on each login and deleted on logout. Each successful login also deletes up to 500 expired sessions. The row records the prefix of the panel that signed the user in, so an existingauth_sessiontable needs a migration: add a non-nullpanelcolumn and backfill it to the panel’s prefix (for exampleALTER TABLE auth_session ADD COLUMN panel TEXT NOT NULL DEFAULT '/admin'), then drop the default when every row names its panel. Renaming a panel’s prefix invalidates the sessions it issued. - Revoking. Call
auth::revoke_sessions_for_user(cx, user_id)when a user’s password changes or their account is deactivated; otherwise existing sessions stay valid until they expire. On a panel’s request it revokes the sessions that panel issued; outside any panel, the user’s sessions on every panel, so a reset flow for a user with two panels revokes from outside a panel or once per panel. - Several panels. Each panel has its own
Authand its own login page, and a session belongs to the panel that signed the user in: the session row records the panel’s prefix. Another panel’s gate treats the request as anonymous, andauth::useranswersNonethere. One browser holds one session, so signing in to a second panel ends the first.
Read the signed-in user in your own code as your user type: auth::user::<AdminUser>(cx) returns
Option<&AdminUser>, and auth::require_user::<AdminUser>(cx)? answers the request as the table
above when nobody is signed in. auth::signed_in(cx) answers whether anyone is.
Your own user table
Implement PanelUser for the type the panel signs in, and Authenticator to load it:
#![allow(unused)]
fn main() {
use tablo::{Membership, PanelUser, auth::{Authenticator, verify_password}};
pub struct SignedStaff {
pub staff: Staff, // your model
pub workspaces: Vec<Membership>,
}
impl PanelUser for SignedStaff {
fn user_id(&self) -> String {
self.staff.id.to_string()
}
fn display_name(&self) -> &str {
&self.staff.name
}
fn can_access_panel(&self) -> bool {
self.staff.active
}
fn tenants(&self) -> &[Membership] {
&self.workspaces
}
}
pub struct StaffAuth;
impl Authenticator for StaffAuth {
type User = SignedStaff;
async fn verify(&self, cx: &Cx, login: &str, password: &str) -> Result<Option<SignedStaff>> {
let staff = find_staff_by_email(cx, login).await?;
if !verify_password(password, staff.as_ref().map(|s| s.password_hash.as_str())) {
return Ok(None);
}
// … load the memberships and return Some(SignedStaff { .. })
todo!()
}
async fn find_by_id(&self, _cx: &Cx, _id: &str) -> Result<Option<SignedStaff>> {
// … the same user and memberships, by `user_id`
todo!()
}
}
pub async fn find_staff_by_email(_cx: &Cx, _login: &str) -> Result<Option<Staff>> {
todo!()
}
}
#![allow(unused)]
fn main() {
pub fn staff_auth_panel() -> Panel {
Panel::new("admin").auth(Auth::custom(StaffAuth))
}
}
- The user type is yours. It need not be a model: the showcase’s
SignedStaffis a row plus the workspaces it holds a seat in.auth::user::<SignedStaff>(cx)reads it back, fields and all, and answersNoneon a panel whose authenticator loads another type. verifychecks credentials. ReturnOk(None)for every credential failure.verify_passwordchecks an Argon2id hash and, givenNonefor an unknown account, does the same work, so response times do not reveal which accounts exist.find_by_idreloads the user on every request, so deactivating a user or removing a membership takes effect immediately.
Sessions stay in AuthSession, so register it; the provided AdminUser is needed only by
PasswordAuth.
Turning it off
#![allow(unused)]
fn main() {
pub fn open_panel() -> Panel {
Panel::new("admin").auth(Auth::disabled())
}
}
Every panel route is then public and the login routes are not registered. Use it for public demos and tests only.
Tenancy
A tenant-owned resource shows each user only the rows of the tenant they act for. A user may act
for each tenant PanelUser::tenants lists, as a Membership { tenant, name }; the provided
AdminUser belongs to none, so a tenanted app signs in its own user table.
- The request’s tenant is the membership the user selected, else their first, read with
tenant_id(cx);membership(cx)returns the wholeMembership. A user with no membership has no tenant. - Switching. With two or more memberships, the top bar shows a tenant switcher. It posts to
{prefix}/tenant, which stores the choice on the session and returns to the panel’s home page. A tenant the user is not a member of answers 403, and a stored choice applies only while the membership lasts. The session table records the choice, so an existingauth_sessiontable needs a nullabletenantcolumn, typed as Toasty stores aUuidon your database. One browser acts for one tenant at a time, in every tab. - Overrides. No request header sets the tenant. Server code such as a middleware or a test
can, by inserting a
Tenantrequest extension or puttingTenant(id)on theCx.
Declare the resource’s tenancy with the column its rows carry their tenant in:
#![allow(unused)]
fn main() {
impl Resource for PostResource {
type Model = Post; // has `tenant_id: uuid::Uuid`
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.tenancy(Tenancy::column(Post::fields().tenant_id()))
}
}
}
That one declaration does two things:
- The gate. Every handler of the resource answers 403 when the request has no tenant.
- The scope. Every loader — list, export, detail, edit, delete, bulk delete, relationship
options, related tables — adds
tenant_id = <request tenant>to the resource’squery. A create sets the tenant column itself, so the record form leaves it out.
The column is a Uuid or Option<Uuid> field of the model, named by its lens, and it can have any
name. Do not repeat the filter in query. Mounting the panel refuses a Tenancy::column lens
that is not one field of the model.
A row that inherits its tenant. A comment has no tenant column of its own; it belongs to a post
that does. Tenancy::via names the tenant through the relation, and the framework gates and
filters exactly as it does for a column:
#![allow(unused)]
fn main() {
impl Resource for CommentResource {
// …
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
// …
.tenancy(Tenancy::via(Comment::fields().post().tenant_id()))
}
}
}
Nothing is stamped on create: a comment’s tenant is its post’s. The lens starts at a belongs_to
relation, and the form declares that relation’s foreign key (post_id) as a
relationship field over a tenant-scoped resource, so a submitted post
must be one of the request tenant’s posts. Mounting the panel refuses a form that writes the key any
other way, or a lens that does not start at a belongs_to.
A deliberately cross-tenant resource — a super-admin view — declares no tenancy and filters in
query(). That gives up both the gate and the scope.
In your own code, load rows with scoped_query::<PostResource>(cx)?. It applies the tenant
scope and answers 403 when the request has no tenant. PostResource::query(cx) does not apply the
tenant scope.
Foreign keys are re-checked inside the write. A relationship field’s key must be one of the
related resource’s records for the request: in its tenant-scoped query and allowed by its View.
The create and edit handlers check it when they validate the form, and again inside the write’s
transaction, so a related record deleted, moved to another tenant or hidden in between refuses the
write with the same field error. A create_record or update_record the panel’s create and edit
POSTs call needs no check of its own.
Data access
The panel loads rows on its own pages. This chapter covers querying Toasty from your code: a page, a record function, or a public page. The Toasty guide covers the query API in full.
Getting the database
app_context(db) registers the Db on the app’s router, where every panel mounted on it reads it;
tablo_core::db::db(cx) returns it. Cloning a
Db is cheap, and statements take it by &mut:
#![allow(unused)]
fn main() {
let mut db = tablo_core::db::db(cx);
let users = User::all().exec(&mut db).await?;
}
Inside a record function, run statements through the transaction you were handed (ex), never
through a second handle: a single-connection pool such as sqlite::memory: would wait forever for
the connection the transaction holds.
Querying
#![allow(unused)]
fn main() {
User::filter(User::fields().email().eq("ada@example.com"));
User::filter(User::fields().name().starts_with(prefix)).order_by(User::fields().name().asc());
}
Toasty binds values as parameters. If you build a LIKE pattern from user input, escape %, _
and your escape character first and pass it with like_with_escape, as the table search does.
Load a resource’s rows through scoped_query. See Tenancy.
A public page has no resource behind it, so it states its own filters, tenant included:
#![allow(unused)]
fn main() {
let posts = Post::filter(Post::fields().status().eq("published".to_string()))
.include(Post::fields().author())
.exec(&mut db)
.await?;
}
Relations
Toasty loads a relation only when the query includes it. Include every relation you read, in the same query:
#![allow(unused)]
fn main() {
let posts = Post::all()
.include(Post::fields().author())
.exec(&mut db)
.await?;
for post in &posts {
let _name = &post.author.get().name; // no extra query
}
}
Reading a relation that was not included panics in get(); check is_unloaded() first where a
missing include is possible. In a table column, declare the relation with ComputedColumn::include
instead: see Tables.
A resource’s table on your own page
A page can render a resource’s list table over its own query — here, only featured posts — with the same columns, filters and row actions as the resource’s list:
#![allow(unused)]
fn main() {
let table = tablo_core::panel::wired_table::<PostResource>(cx)?;
let state = TableState::from_cx(cx);
let query = scoped_query::<PostResource>(cx)?.filter(Post::fields().featured().eq(true));
let page = TablePage::load(cx, &table, query, &state).await?;
let body = table
.render_with_state(cx, page, &state, "/admin/featured")
.await?;
}
wired_table is the table the request’s panel mounted for the resource, with the row actions
its policy allows; it returns an error when that panel does not mount the resource. The last argument of
render_with_state is the URL the table’s search, sort and pager links point at: the page’s own.
Schema setup
db.push_schema().await? creates every registered table, which suits a prototype or a test. A
production app manages its schema with toasty-cli migrations.
Rendering rules
Topcoat may render a page’s regions concurrently and re-render them in place, so code that renders follows three rules:
- No side effects. Pages, layouts and components only read. Writes belong in record functions or app routes.
- Deterministic output. No
HashMapiteration order, current time or random values in a region that re-renders: a re-render must produce the same markup for the same data. - Untrusted signals. A value read on the server with a signal’s
get()orread()comes from the client. Validate it like any request input.
Share a query between components of one request with Topcoat’s #[memoize], and add a Toasty
#[index] to columns you filter on.
A table with live_search() refreshes through a Topcoat shard request. Page and layout guards do
not run for shard requests, so the panel’s shard checks authentication, the tenant and
ViewAny itself; a shard you write starts with auth::guard(cx)?, and checks
can_list::<R>(cx) before it lists R’s rows.
Extension points
The seams for reaching outside a resource declaration. Four have full sections
of their own: Authenticator,
Uploader, Panel::layout_shell, and
wired_table. The remaining three are documented here.
Relationship option sources
A relationship choice field loads its options through
tablo::schema::OptionSource. Every resource is one, answering from its def
as the request’s panel mounted it: scoped_query states the tenant-scoped
seed query, allows gates rows per ability (default deny), and
requires_tenant, search_expr, and order_by shape scoping, search, and
ordering. Loads are bounded to 200 options, memoized per request, and fail
closed on denial.
Table state values
tablo::Sort (?sort=<column>&dir=asc|desc) and tablo::Cursor (?after=
/ ?before=) are the parsed sort and pagination of a list URL; see
Tables. Cursors are opaque: conflicting cursors parse as none
and render the first page.
Notifications
An Action result flashes a tablo::Notification, rendered in the panel
shell and stored as a one-time hardened cookie. Build one with
Notification::success / error / info / warning plus .description(),
and store it with tablo::set_notification; see
Security.
Security
What Tablo does by default, and what your deployment must provide for those defaults to hold.
Your deployment must provide
- HTTPS. The session, CSRF and notification cookies are
__Host-cookies markedSecure. Browsers accept them over plain HTTP only onlocalhost; anywhere else, without HTTPS, the browser drops them and every form POST fails its CSRF check with 403. - Login rate limiting. Tablo has no rate limiter or account lockout. Limit login attempts at your proxy or firewall.
- Session revocation. Call
auth::revoke_sessions_for_userwhen a password changes or an account is deactivated; see Authentication.
Requests and writes
- Every panel POST, login and logout included, verifies a double-submit CSRF token before touching the database; a missing or wrong token answers 403.
- Delete and bulk-delete POSTs also require the
confirm=1marker the confirmation dialog sends. It prevents accidental deletes; it is not a security boundary. - A form POST with a key the form does not declare answers 400, so a client cannot write a
field the form does not show, such as
roleortenant_id. - Update and delete handlers load the target through the resource’s scoped query inside the write’s transaction and check policy on that row. See Policy.
- Table search escapes
%and_and binds the term as a parameter; no user input is interpolated into SQL.
Authentication
- Passwords are hashed with Argon2id. A login for an unknown email verifies against a dummy hash, so it takes as long as a wrong password, and every failure shows the same message.
- Sessions are stored server-side, keyed by a hash of the cookie’s token, and replaced on login.
- A redirect after login follows
nextonly to a same-origin path.
Responses
- Framing. Every response to a request under the panel prefix, including its 404 and 405
pages, carries
Content-Security-Policy: frame-ancestors 'self', so another site cannot frame the admin. The app’s own routes outside the prefix carry whatever policy the app sets.Panel::frame_ancestors("'self' https://intranet.example")changes the directive, andPanel::without_frame_ancestors()omits it for a proxy that sets its own policy. AContent-Security-Policyyour own handler sets is kept. The router answers three errors before any layer runs, so they carry no header: the 403 for a cross-site request, the 400 for a malformedx-topcoat-identityheader, and the 500 for a panic. - Redirects. Return
Err(redirect(..))(307) from aGETandErr(see_other(..))(303) after a write, so a reload does not repeat it. A redirect raised after streaming started becomes awindow.location.replace. Wrap a layout’sSlotinerror_boundaryfor a branded error page. - Notifications are one-time flash cookies set on the redirect after a write and consumed by the next page, so a reload does not show them again.
Uploaded files
- A file field stores only a value that came from a file part: the uploader’s answer, the stored value on an untouched edit, or nothing when cleared. A path typed into the form is never stored. See File uploads.
- A stored value renders as a link only when it is a root-relative path (
/…, not//host) or anhttp(s)URL; anything else, such asjavascript:, renders as text. - Files served with
Panel::serve_dirshare the panel’s origin, so every file response carriesX-Content-Type-Options: nosniff, a sandboxingContent-Security-Policy, andContent-Disposition: attachmentunless the file is a common image, audio or video type or plain text. An uploaded HTML or SVG file therefore downloads instead of running script. The policy is fixed; serve active documents from another origin. - Served directories are public: the auth gate does not cover them.
Testing and benchmarks
Testing a panel
Test a panel over HTTP, in memory: build the router against a test database and send it requests.
TestClient carries cookies, a tenant and a CSRF token, with helpers to build form bodies and read
responses. An app reaches it as tablo::testing through the facade’s testing feature; enable it
for tests only:
[dev-dependencies]
tablo = { version = "0.2.0", features = ["sqlite", "testing"] }
#![allow(unused)]
fn main() {
use tablo::testing::{TestClient, form_body};
#[tokio::test]
async fn books_cannot_be_deleted() {
let mut db = seeded_db().await; // your fixture: an in-memory database with rows
let id = first_book_id(&db).await;
let router = Router::builder()
.discover()
.app_context(db.clone())
.panel(
Panel::new("admin")
.resource::<BookResource>()
.auth(Auth::disabled()),
)
.expect("mount the panel")
.build();
let client = TestClient::new(&router);
assert_eq!(client.get("/admin/books").await.status(), 200);
// A POST needs the CSRF cookie and a matching `csrf_token` field.
let token = uuid::Uuid::new_v4().to_string();
let response = client
.csrf(&token)
.post_form(
&format!("/admin/books/{id}/delete"),
form_body(&[("csrf_token", &token), ("confirm", "1")]),
)
.await;
assert_eq!(response.status(), 403); // the policy does not allow `DeleteAny`
let books = Book::all().exec(&mut db).await.expect("list books");
assert_eq!(books.len(), 1); // nothing was deleted
}
}
- Cover every ability your policy decides with a request it allows and one it refuses, and assert on the database as well as the status code.
- Cover
query()scoping by seeding a row the scope excludes and asserting that the list, the detail page and a delete all miss it. - Tenancy.
client.tenant(id)scopes a request to a tenant, the way a signed-in user’s tenant would. - Signed-in requests. With authentication on, sign in through
POST /admin/loginonce and reuse the client’s cookies, or insert anAuthSessionrow directly to skip the password hash. - Declaration mistakes. A panel that refuses to mount returns a
MountErrorinside the router builder’s error. Downcast to it and match on each mistake’sDeclarationErrorKindrather than on its message:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn the_panel_refuses_a_db_without_the_auth_models() {
let db = Db::builder()
.models(toasty::models!(Book))
.connect("sqlite::memory:")
.await
.expect("connect");
let error = Router::builder()
.discover()
.app_context(db)
.panel(Panel::new("admin").resource::<BookResource>())
.err()
.expect("auth is on, and the Db does not register its models");
let refusal = error
.downcast_ref::<tablo::MountError>()
.expect("a declaration mistake");
assert!(matches!(
refusal.errors()[0].kind,
tablo::DeclarationErrorKind::MissingAuthModels { .. }
));
}
}
examples/showcase/tests/ is a complete suite covering lists, forms, deletes, filters, export,
tenancy, uploads and authentication; its common module holds the fixtures above.
The browser scripts
tablo-ui’s client scripts are plain browser scripts with no build step. Their unit tests run on
Node’s built-in runner:
node --test crates/tablo-ui/assets/*.test.js
Benchmarks
The benchmark renders a 50-row list with two included relations, under tenancy and policy, through the same path as the panel’s list page:
cargo run --manifest-path benchmarks/tablo/Cargo.toml -- --bench
./benchmarks/scripts/bench.sh # adds an HTTP run with `oha`
The target is under 40 ms at the median on local SQLite. It is a reference, not a pass/fail gate:
the harness prints the target beside the measurement. Results are written to
benchmarks/results/, which is not committed. benchmarks/README.md covers the setup, the
optional PostgreSQL run and the method.