> ## Documentation Index
> Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Tables

> Add, change, or remove columns on built-in list tables (products, orders, customers, …), or define a table for your own resource.

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 theme={"theme":"night-owl"}
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 theme={"theme":"night-owl"}
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 theme={"theme":"night-owl"}
tables.products.updateColumn('price', { label: 'Retail price' })
tables.products.removeColumn('cost_price')
```

Declaratively:

```tsx theme={"theme":"night-owl"}
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 theme={"theme":"night-owl"}
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:

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

```tsx theme={"theme":"night-owl"}
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 theme={"theme":"night-owl"}
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 theme={"theme":"night-owl"}
<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](/developer/dashboard/customization/routes) for the page around it — the [example Brands plugin](https://github.com/spree/spree/tree/main/packages/dashboard-plugin-example) shows the complete wiring.

## Bulk actions, row actions, reordering

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

```tsx theme={"theme":"night-owl"}
<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](/developer/dashboard/customization/slots) are for.

## Reference

* [`table-registry.ts`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/table-registry.ts) — `defineTable`, `tables`, the full `ColumnDef` union
* [`<ResourceTable>`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/components/resource-table.tsx) — props for bulk actions, row actions, drag-reordering
* [Example Brands plugin](https://github.com/spree/spree/tree/main/packages/dashboard-plugin-example) — a real `defineTable` + `<ResourceTable>` + products-column extension
