Back to Spree

Tables

docs/developer/dashboard/customization/tables.mdx

5.6.06.7 KB
Original Source

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:

  1. Extend a built-in table — add, patch, or remove columns (including their filters) without forking the page.
  2. Define your own table — declare columns once with defineTable, then render them with <ResourceTable> on your own page.

Add a column to a built-in table

Imperatively, from src/plugins.ts (or a plugin entry):

tsx
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:

tsx
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.

<Note> **Timing is taken care of.** Built-in tables register when their page first loads, which may be after your code runs. Mutations against a table that isn't registered yet are queued and replayed the moment it appears — and mutations against a table that never registers simply never fire. That means extending an optional feature's table is safe even when that feature isn't installed. </Note>

Modify or remove built-in columns

tsx
tables.products.updateColumn('price', { label: 'Retail price' })
tables.products.removeColumn('cost_price')

Declaratively:

tsx
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.

Column definition

ts
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)
}

Filter types

When a column is filterable, filterType decides which filter UI it gets:

filterTypeFilter UIExtra 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 listfilterOptions: [{ value, label }] (required)
'resource'Multi-select autocomplete against another resourcefilterResource (required) — queryKey, search, hydrate, getOptionLabel
'tags'Tag autocompletetaggableType (required), e.g. 'Spree::Product'

A 'resource' filter searches as the admin types and resolves already-selected IDs back to labels on page reload:

tsx
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.

Define your own table

For your own list page, declare the table once — typically right next to your defineDashboardPlugin call:

tsx
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:

tsx
<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.

Bulk actions, row actions, reordering

These are props on <ResourceTable>, composed by the page that renders the table:

tsx
<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.

Reference