Skip to main content
zerotal

Tables

columns() returns Columns built with text(key) plus chainable modifiers.

static columns() {
  return [
    text("title").label("Title").sortable().searchable(),
    text("status").badge((v) => (v === "published" ? "success" : "muted")),
    text("views").align("end").sum("Total views"),
    text("created_at").label("Created").since().sortable(),
  ];
}

Column kinds & modifiers

Modifier / factoryEffect
.label(text)Header label (defaults to a title-cased key).
.sortable()Clickable, URL-driven sort header.
.searchable()Include in the table's search box.
.align("start" | "center" | "end")Cell alignment.
.format((v, row) => string)Custom value formatter.
.badge((v, row) => tone | null)Render as a colored pill.
.copyable()Copy-to-clipboard affordance.
toggleColumn(key) / .toggle()Inline boolean toggle — writes on click.
selectColumn(key, options)Inline select — saves the chosen value on change.
textInputColumn(key) / .editText()Inline text input — saves on change/blur.
imageColumn(key) / .circular()Image / avatar cell.
colorColumn(key), iconColumn(key)Color swatch, boolean check/cross icon.
.sum() / .avg() / .count() / .range()Column summaries (see below). Or .summarize([…]).

Summaries

.sum(), .avg(), .count(), .range() (each takes an optional label and number formatter) render a <tfoot> total computed over the full filtered dataset, plus per-group subtotals when grouping is active.

text("amount").align("end").sum("Revenue", (n) => `$${n.toFixed(2)}`),
text("id").count("Orders"),

Filters

import { selectFilter, ternaryFilter } from "@zerotal/admin";

static filters() {
  return [
    selectFilter("status").options({ draft: "Draft", published: "Published" }),
    ternaryFilter("featured").labels("Featured", "Standard"),
    // custom query:
    selectFilter("author").options(authorMap).query((q, v) => q.where("author_id", v)),
  ];
}

Filters are URL-driven and compose with tabs, search, sort, and pagination.

The query builder

Fixed filters work when you can name the useful questions in advance. A catalogue or a ledger is queried in too many ways for that, so queryBuilder lets the user stack their own comparisons and nest AND/OR groups:

import {
  queryBuilder, textConstraint, numberConstraint,
  selectConstraint, booleanConstraint, dateConstraint,
} from "@zerotal/admin";

static filters() {
  return [
    queryBuilder("q").label("Advanced filter").constraints([
      textConstraint("name"),
      numberConstraint("price").label("Price (cents)"),
      dateConstraint("created_at").label("Created"),
      selectConstraint("status").options({ draft: "Draft", active: "Active" }),
      booleanConstraint("featured"),
    ]),
  ];
}

You declare what may be compared; the panel supplies the operators each kind deserves — contains / starts with / is empty for text, is at least / is before for numbers and dates — and turns the result into predicates.

Two things are worth knowing about how it applies:

  • The whole tree is wrapped in one group. An OR inside a rule can never break out and widen a scope the page already applied: a tab, a parent record, a soft-delete filter. The tree narrows; it cannot escape.
  • A rule naming a constraint you never declared is dropped. The active tree travels in the URL, so it is user input; only the columns you listed are reachable, and a hand-edited URL cannot filter on a password hash.

Groups nest as deep as the question needs — a group inside a group inside a group — and the whole tree lives in the same ?filters= parameter as everything else, so it composes with tabs, search, sort and pagination. A narrowed view is a link someone can send to a colleague.

Tabs

tab(key) adds quick-filter tabs above the table, each scoping the query and optionally showing a count badge.

import { tab } from "@zerotal/admin";

static tabs() {
  return [
    tab("all").label("All"),
    tab("published").modifyQuery((q) => q.where("status", "published")).badge(),
    tab("draft").modifyQuery((q) => q.where("status", "draft")).badgeColor("muted"),
  ];
}

Grouping

group(column) adds a "Group by" menu; the page renders header rows per bucket with counts (and per-group summary subtotals).

import { group } from "@zerotal/admin";

static groups() {
  return [group("status"), group("author").label("Author")];
}
static defaultGroup = "status";   // optional

Reordering

Set reorderable to an integer position column to show up/down handles that persist order:

static reorderable = "sort";

Built-in table affordances

Per-page selector, a Columns visibility manager (?cols=), full-text search across .searchable() columns, and bulk selection with a toolbar all ship automatically and are URL-driven.

Empty states

A blank table teaches nobody anything. Override emptyState() to say why the list is empty and what will fill it:

static emptyState() {
  return {
    heading: "No orders yet",
    description: "Orders appear here as soon as a customer checks out.",
    icon: "inbox",
    actions: [createAction()],
  };
}

A search or filter that matches nothing gets a different, automatic message — this is for a genuinely empty resource, which is a different problem and wants a different answer.

Layout and placement

How the table renders — grid instead of rows, striping, sticky headers, density — and where the filters sit are covered in Extending the UI.

Types

What a column, filter and query rule are made of. All exported, so a helper that builds a column set for several resources can be typed rather than repeated.

TypeWhat it is
ColumnKindWhich column type this is — text, badge, boolean, date, and the rest.
ColumnOptionOne column's settings.
CellAlignstart / center / end.
ColumnSummary, SummaryKind, SummaryResultA footer aggregate: what to compute, and what comes back.
BadgeToneThe colouring a badge column maps a value to.
EmptyStateWhat the table shows instead of rows when there are none.
PivotColumnA column reading a pivot table's own attributes on a many-to-many.
FilterOption, FilterType, FilterApplyA filter's declaration, its kind, and how it modifies the query.
ConstraintKind, ConstraintOperator, ConstraintOptionThe query-builder constraints behind advanced filtering.
QueryRule, Conjunction, QueryModifierOne rule, how rules combine (and / or), and a raw modifier for what the builder cannot express.
SavedView, SavedViewProviderA stored filter set, and where those are kept.
TableRowsResolverSupplying rows yourself instead of letting the resource query.
ExportFormatWhat the export action writes.

Next steps

  • Admin overview — the guide's front page and the rest of the sections.
  • Reference — the full API surface in one table.