docs/developer/dashboard/customization/tables.mdx
Every list page in the dashboard renders through <ResourceTable>, which reads its column definitions from a table registry keyed by table name (products, orders, customers, …). That gives you two levers:
defineTable, then render them with <ResourceTable> on your own page.Imperatively, from src/plugins.ts (or a plugin entry):
import { tables } from '@spree/dashboard-core'
tables.products.addColumn({
key: 'brand',
label: 'Brand',
default: true, // visible out of the box
render: (product) => product.brand?.name ?? '—',
})
Or declaratively through defineDashboardPlugin — same effect, grouped with your other extensions:
defineDashboardPlugin({
tables: {
products: {
add: [{ key: 'brand', label: 'Brand', default: true, render: (p) => p.brand?.name ?? '—' }],
},
},
})
New columns append after the existing ones. Column keys must be unique per table — adding a duplicate throws with a message telling you to use updateColumn instead.
tables.products.updateColumn('price', { label: 'Retail price' })
tables.products.removeColumn('cost_price')
Declaratively:
defineDashboardPlugin({
tables: {
products: {
update: { price: { label: 'Retail price' } },
remove: ['cost_price'],
},
},
})
updateColumn patches shallowly — pass only the fields you want to change. removeColumn of an unknown key is a no-op.
interface ColumnDef<T> {
key: string // unique per table; also the default Ransack sort/filter attribute
label: string // header text — use i18n.t(...) for translated labels
render?: (row: T) => ReactNode // cell renderer; defaults to rendering row[key] as text
className?: string // cell class (e.g. 'text-right tabular-nums')
default?: boolean // part of the visible column set out of the box
sortable?: boolean // header toggles Ransack sort on the attribute
filterable?: boolean // appears in the filter panel
ransackAttribute?: string // backend attribute when it differs from key
displayable?: boolean // set false for filter-only entries (no column rendered)
}
When a column is filterable, filterType decides which filter UI it gets:
filterType | Filter UI | Extra fields |
|---|---|---|
'string' (default) | Text input with contains/equals operators | — |
'boolean' | Yes/no toggle | — |
'number', 'currency' | Numeric comparisons | — |
'date' | Date range picker (store-timezone aware) | — |
'enum' | Fixed option list | filterOptions: [{ value, label }] (required) |
'resource' | Multi-select autocomplete against another resource | filterResource (required) — queryKey, search, hydrate, getOptionLabel |
'tags' | Tag autocomplete | taggableType (required), e.g. 'Spree::Product' |
A 'resource' filter searches as the admin types and resolves already-selected IDs back to labels on page reload:
tables.products.addColumn({
key: 'brand_id',
label: 'Brand',
displayable: false, // filter only — no column in the table
filterable: true,
filterType: 'resource',
filterResource: {
queryKey: 'brands',
search: (query) => brandsClient.list({ q: { name_cont: query } }),
hydrate: (ids) => brandsClient.list({ q: { id_in: ids } }),
getOptionLabel: (brand) => brand.name,
},
})
Filtering and sorting are executed by the Admin API (Ransack), not in the browser — key (or ransackAttribute) must be a filterable/sortable attribute the API allows.
For your own list page, declare the table once — typically right next to your defineDashboardPlugin call:
import { defineTable } from '@spree/dashboard-core'
defineTable<Brand>('brands', {
title: 'Brands',
searchParam: 'search',
searchPlaceholder: 'Search by name…',
defaultSort: { field: 'name', direction: 'asc' },
emptyMessage: 'No brands yet',
columns: [
{ key: 'name', label: 'Name', sortable: true, filterable: true, default: true },
{ key: 'products_count', label: 'Products', default: true, className: 'text-right tabular-nums' },
{ key: 'created_at', label: 'Created', sortable: true, default: false },
],
})
Then render it from your page component:
<ResourceTable<Brand>
tableKey="brands" // which table definition to read
queryKey="brands" // TanStack Query cache key
queryFn={(params) => brandsClient.list(params)}
searchParams={searchParams}
/>
<ResourceTable> handles pagination, sorting, the filter panel, column visibility, and URL round-tripping of all of it. See Routes for the page around it — the example Brands plugin shows the complete wiring.
These are props on <ResourceTable>, composed by the page that renders the table:
<ResourceTable<Brand>
// …
bulkActions={[{ key: 'archive', label: 'Archive selected', onSelect: async (rows) => { /* … */ } }]}
rowActions={(row) => <BrandRowMenu brand={row} />}
/>
Use them freely on your own pages. They are not registry-driven — a plugin can't inject bulk or row actions into a built-in page's table today. If a built-in page needs an injection point, that's what slots are for.
table-registry.ts — defineTable, tables, the full ColumnDef union<ResourceTable> — props for bulk actions, row actions, drag-reorderingdefineTable + <ResourceTable> + products-column extension