Skip to content

4. The index table

Leave table() off and the index columns are derived from the schema: the first five tabulatable fields, in document order, no filters and no search box. That is a good default and a bad blog admin — what an editor scans for is the author, the tags, how many comments arrived and when it went out, none of which is the first five form fields.

Declaring table() replaces derivation wholesale. There is no merge, on purpose: a partial override would silently change the visible column set every time somebody added an unrelated field.

Columns

t.columns([
  C.text('title').label('Title').sortable().searchable().link('detail').limit(60),
  C.badge('status')
    .label('Status')
    .sortable()
    .colors({ draft: 'gray', review: 'amber', published: 'green' }),
  C.relation('author.fullName').label('Author').sortable('author.fullName').searchable(),
  C.relation('tags.name').label('Tags').counts(),
  C.count('comments').label('Comments').sortable(),
  C.date('publishedAt').label('Published').sortable().since(),
  C.text('slug').label('Slug').searchable().toggleable(true),
])

examples/blog-admin/app/adonia/resources/post_resource.ts

  • C.relation('author.fullName') keys the row author.fullName — the dotted key travels to the client verbatim — and preloads author.
  • C.relation('tags.name') over a many-to-many serializes an array of names; .counts() renders it as “Design +3”.
  • C.count('comments') compiles to a withCount sub-select. Its row key is comments but its SQL alias is comments_count, and .sortable() knows to order by the alias — sorting by the row key would name a column that does not exist.
  • .toggleable(true) starts slug hidden. Every column is toggleable by default, so this call only ever means “start collapsed”.
  • .link('detail') makes the cell the row’s link. Put it on the column a human recognises the row by.

The query budget

The index costs a fixed number of queries regardless of row count: one for the pagination count, one for the page itself (with every withCount folded in as a correlated sub-select), and one preload per relation. Nothing is fetched per row — a cell reads only materialized data ($attributes, $preloaded, $extras), so no traversal can wake a lazy relation. That property is asserted in examples/blog-admin/tests/functional/adonia_pipeline.spec.ts.

This is also why a computed column may not be sortable() or searchable(): there is no SQL column behind it.

Sorting, search and filters

t.filters([
  Filter.select('status').label('Status').options(STATUS_OPTIONS),
  Filter.relation('author').label('Author').searchable().optionLabel('fullName'),
  Filter.dateRange('publishedAt').label('Published'),
])
  .defaultSort('publishedAt', 'desc')
  .searchPlaceholder('Search posts…')
  .perPageOptions([10, 25, 50])

searchPlaceholder is what makes the search input appear at all — no placeholder, no box. defaultSort names a column key, which the sort stage maps through that column’s sortColumn.

Search runs over every column that declared .searchable(). For author.fullName that means an orWhereHas('author', …) sub-query rather than a join, so searching cannot multiply rows or corrupt the paginator total:

curl '/admin/posts?q=Ada+Author'   # matches posts whose AUTHOR is Ada

Terms are AND-ed, targets are OR-ed, and the whole predicate sits inside one outer where(…) group — without that grouping a leading or would associate with the next stage’s filters and search would widen past an active filter.

Filters are declared, validated, then applied. Each filter’s valueSchema() parses the query string value; a value that fails is dropped before apply() ever runs.

Factory Query string SQL
Filter.select('status') filters[status]=published where status = 'published'
Filter.relation('author') filters[author][]=3 whereHas('author', q => q.whereIn('users.id', [3]))
Filter.dateRange('publishedAt') filters[publishedAt][from]=2026-01-01 where published_at >= '2026-01-01'

::: info Relation filters need their options supplied compileTableDescriptor does not attach a urls.options block to filter nodes yet, so a Filter.relation has no way to fetch choices on its own. It applies correctly when a value arrives, and you can hand it a static list with .withProps({ options: [...] }) until the endpoint lands. :::

Unknown input is dropped, never rejected

Sort keys, filter keys, column keys and per-page values are checked against allowlists derived from the declared table. Anything not on one is silently ignored:

curl '/admin/posts?sort=-passwordHash&filters[secret]=1&trashed=only&perPage=999'

still answers 200 with the ordinary first page at the default 25 per page. This is a deliberate rule, not laxity: a panel URL is user-editable and bookmarkable, and an error page for a stale query string helps nobody. The flip side is that a key you did declare really applies — both halves are asserted in adonia_pipeline.spec.ts.

Next: authorization.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close