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.