Grouping rows
Table.defaultGroup('col') bands rows by a column. Add a .groups([...])
list of TableGroup options and the renderer mounts a "Group by"
dropdown above the table so users can switch between groupings. Each
group can be collapsible, date-bucketed, ordered with a custom
comparator, and (opt-in) clickable to drill into a single bucket.
#Quick example
import { Table, TableGroup } from '@pilotiq/pilotiq'
Table.make()
.columns([
Column.make('title').sortable().searchable(),
Column.make('status'),
Column.make('createdAt').dateTime(),
])
.groups([
TableGroup.make('status')
.label('Status')
.collapsible(),
TableGroup.make('createdAt')
.label('Date')
.date()
.collapsible(),
])
.defaultGroup('status')Bare-column form is the minimal opt-in — defaultGroup('status') alone
bands rows without exposing a dropdown. Adding entries to .groups([…])
unlocks switching plus all the per-group chrome below.
#Per-group chrome
TableGroup.make('status')
.label('Status')
.collapsible() // chevron on each heading row
.collapsed() // start collapsed; per-bucket fold persists
// in localStorage under
// pilotiq.table.<currentPath>.groups.<col>.<value>
.getTitleFromRecordUsing((r) =>
r.status === 'draft' ? 'Drafts' : 'Published',
)
.getDescriptionFromRecordUsing((r) => `${r.count} records`)getTitleFromRecordUsing(fn) runs per row server-side; the first row in
each bucket sets the heading text. getDescriptionFromRecordUsing(fn)
shows a smaller line below the title.
#Date bucketing
TableGroup.make('createdAt').date()Reads the column as a date and buckets rows by day (YYYY-MM-DD is the
stable-sort key). The heading auto-formats as "May 4, 2026" unless you
supply your own getTitleFromRecordUsing. Buckets are UTC; for timezone-
aware grouping, override getKeyFromRecordUsing to return your own
bucket key.
#Group ordering
By default group buckets sort alphabetically by their resolved key.
Override with .orderUsing(comparator):
import { TableGroup, orderByKeys } from '@pilotiq/pilotiq'
TableGroup.make('status').orderUsing(
orderByKeys(['draft', 'published', 'archived']),
)orderByKeys(keys) is the sugar for "rank these in order; everything
else falls through alphabetically after them." Use a raw
(a, b) => number comparator for anything more bespoke. The empty
bucket (rows with no value for the column) always sorts to the bottom
regardless of what your comparator returns for it.
#Click-to-drill: scopeQueryByKey
Opt into a clickable group heading that drills the table into just one bucket. When the user clicks "Status: Draft", the banded layout collapses, a "Drilled into Status: Draft" chip mounts above the table with an × to clear, and the visible rows are already narrowed to that bucket server-side.
TableGroup.make('status')
.label('Status')
.scopeQueryByKey((q, key) => q.where('status', '=', key)).scopeQueryByKey(fn) auto-arms .scopable(true) since the surface
needs both pieces. The handler receives the raw model query and the
resolved group key (the same value getKeyFromRecordUsing produced)
and returns the narrowed query.
#Defaults (no .scopeQueryByKey(...) call)
- Plain groups: exact-match —
(q, key) => q.where(column, '=', key). - Date groups (
.date()): whole-day range —(q, key) => q.where(col, '>=', '${key} 00:00:00').where(col, '<=', '${key} 23:59:59'). Override with.scopeQueryByKey(...)for sub-day buckets or timezone-aware ranges.
#Custom bucket key
When the stable bucket key differs from the column's raw value (enum
objects, JSON columns, derived keys), set getKeyFromRecordUsing:
TableGroup.make('priority')
.getKeyFromRecordUsing((r) => String(r.priority.value))
.scopeQueryByKey((q, key) => q.where('priorityValue', '=', Number(key))).getKeyFromRecordUsing(fn) also auto-arms .scopable(true).
#URL state
Drill-in uses a dedicated ?groupKey=<value> URL key, prefix-aware via
Table.queryStringIdentifier. Pairs with ?group=<col>. Clicking a
heading resets ?page to 1 so drill-in always lands on the first page
of the bucket. The chip's × clears ?groupKey= and restores the banded
view.
#Composition
- Filters / search: chain. Drilled-in rows respect any active filter state.
TrashedFilter: chains — drill-in into the bucket of records the current trashed-filter state surfaces.persistFiltersInSession: drill-in is page-state, not filter-state.<prefix>groupKeyis excluded from the persisted slice, so bare-URL visits return to the banded view. The drilled-in URL is still shareable directly.RelationManagertables: supported. The samectx.groupScopeflows throughmodelRelationTableRecords.Table.queryStringIdentifier('orders'): keys parse asorders_groupKeyalongsideorders_group.
#v1 limits
- One key at a time. Multi-select drill-in is deferred.
- Drill-in is presentation-only state — doesn't survive
persistFiltersInSession. - Date range default covers a whole calendar day in UTC. Sub-day
buckets or timezone-aware ranges need a custom
scopeQueryByKey. - The chip's display value falls back to the raw key when no visible
row carries a
_groupTitlefor it (empty drilled-in pages show the raw bucket). getKeyFromRecordUsingdefault casts to string; object-typed columns (JSON blobs grouped by their stringified form) need a custom resolver.