---
name: catalyst-data-table-management
description: >
  Data table management patterns for Aurora Catalyst (TanStack Table + Spartan). Covers column
  definitions, cell rendering (text, HTML, components), reactive translations, and the data-table
  infrastructure (sort, filter, export, toggle, pagination, search). Trigger: When defining data
  table columns, rendering custom cells, managing column translations, or working with
  sortable/filterable/exportable columns.
license: MIT
metadata:
  author: aurora
  version: '1.0'
---

## When to Use

- Defining columns in `*.columns.ts` files for list views
- Rendering custom cell content (text, HTML, Angular components)
- Adding sortable, filterable, exportable, or searchable columns
- Adding a column that displays / sorts / filters / searches by a related entity's field
- Passing translated column labels to data-table infrastructure components
- Debugging translation reactivity on language change in tables
- Understanding the separation between entity i18n and component UI i18n

---

## Column Definition Pattern

Columns are defined in `*.columns.ts` files using `GridColumnDef<T>`. Each column declares its
capabilities explicitly:

```typescript
// tag.columns.ts
export const getTagBaseColumns = (properties?: {
  translator?: TranslocoService;
}): GridColumnDef<IamTag>[] => {
  const { translator = null } = properties ?? {};

  return [
    {
      accessorKey: 'name',
      id: 'name',
      label: () => translator?.translate('iam.tag.Name') ?? 'Name',
      sortable: true,
      filterable: true,
      exportable: true,
      searchable: true,
      cell: (info) => `${info.getValue<string>()}`,
    },
  ];
};
```

### GridColumnDef Properties

| Property     | Type                       | Purpose                                                 |
| ------------ | -------------------------- | ------------------------------------------------------- |
| `label`      | `string \| (() => string)` | Translation key or function returning translated string |
| `sortable`   | `boolean`                  | Auto-generates SortButton header when true              |
| `filterable` | `boolean`                  | Included in `getFilterableColumns()`                    |
| `exportable` | `boolean`                  | Included in `getExportableColumns()`                    |
| `searchable` | `boolean`                  | Included in `getSearchableColumnIds()`                  |

### CRITICAL

- **`sortable: true`** is required for auto-generating `SortButton` headers. Without it, no header
  is generated even if `label` is set. This is explicit by design.
- **No `header` needed** for standard sortable columns. `useDataTable` calls `applySortHeaders`
  internally.

---

## Cell Rendering Patterns

Three approaches for rendering cell content, from simple to complex:

### 1. Plain text

For cells that display raw values without formatting:

```typescript
{
  accessorKey: 'name',
  id: 'name',
  cell: (info) => `${info.getValue<string>()}`,
}
```

Rendered via `[innerHTML]` in the table template. Suitable for strings, numbers, dates as text.

### 2. HTML formatting

For cells that need inline styling, badges, or simple markup:

```typescript
{
  accessorKey: 'status',
  id: 'status',
  cell: (info) => {
    const status = info.getValue<string>();
    const color = status === 'active' ? 'green' : 'red';
    return `<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium
      bg-${color}-50 text-${color}-700">${status}</span>`;
  },
}
```

Still rendered via `[innerHTML]`. Suitable for formatted text, badges, colored indicators. Limited
to static HTML — no Angular bindings or event handlers.

### 3. Angular component

For cells that need interactivity, Angular bindings, icons, tooltips, or complex layouts:

```typescript
import { flexRenderComponent } from '@tanstack/angular-table';

{
  id: 'actions',
  cell: () => flexRenderComponent(TagListActionsCell, {
    inputs: { onRequestDelete: (tag: IamTag) => this.requestDelete(tag) },
  }),
}
```

The component is a standard Angular standalone component that uses `injectFlexRenderContext` to
access row data:

```typescript
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button (click)="onEdit()">Edit</button>
    <button (click)="onDelete()">Delete</button>
  `,
})
export class TagListActionsCell {
  private readonly context = injectFlexRenderContext<CellContext<IamTag, unknown>>();
  readonly onRequestDelete = input.required<(tag: IamTag) => void>();

  onEdit() {
    // access row data via this.context.row.original
  }

  onDelete() {
    this.onRequestDelete()(this.context.row.original);
  }
}
```

### Decision guide

```
Need event handlers, Angular bindings, or icons?
  YES → flexRenderComponent (Angular component)
  NO  → Need HTML formatting (badges, colors)?
          YES → HTML string with Tailwind classes
          NO  → Plain text string
```

---

## Translation Strategy

### Core Principle: Separation of Responsibility

| Type                                                            | Who translates              | How                                       |
| --------------------------------------------------------------- | --------------------------- | ----------------------------------------- |
| **Entity data** (column names like "Name", "RowId")             | Parent component            | `input()` with already-translated strings |
| **Component UI** (buttons, headings like "Filters", "Contains") | Data-table component itself | `*transloco` with global translation keys |

Data-table infrastructure components (`SortButton`, `TableFilters`, `TableExport`, `ColumnToggle`)
are **dumb** regarding entity translations. They receive `LabeledColumn[]` with labels already
resolved.

### Reactive labels with `label` as function

`label` accepts `string | (() => string)`. When a `TranslocoService` is available, use a function to
make translations reactive:

```typescript
label: () => translator?.translate('iam.tag.Name') ?? 'Name',
```

The function is called at render time — inside `*transloco` which re-renders on language change. The
fallback (`?? 'Name'`) covers the case where the scope isn't loaded yet.

---

## List Component Pattern

The list component passes `TranslocoService` to column definitions and exposes column helpers as
**functions** so they re-execute on each render inside `*transloco`:

```typescript
export default class TagListComponent {
  private readonly transloco = inject(TranslocoService);

  // columns — label functions close over transloco
  private readonly baseColumns = getTagBaseColumns({ translator: this.transloco });

  // lazy functions — re-execute on each render for reactive translations
  protected readonly filterableColumns = () => getFilterableColumns(this.baseColumns);
  protected readonly exportableColumns = () => getExportableColumns(this.baseColumns);
  protected readonly toggleableColumns = () => getToggleableColumns(this.baseColumns);
  private readonly searchColumns = getSearchableColumnIds(this.baseColumns);

  // table columns — baseColumns spread directly, useDataTable handles sort headers
  protected readonly columns: GridColumnDef<Entity>[] = [...actionColumns, ...this.baseColumns];
}
```

### CRITICAL

- `filterableColumns`, `exportableColumns`, `toggleableColumns` MUST be **functions** (arrow
  functions), not static properties. They are called in the template inside `*transloco`, which
  re-renders on language change. Each call resolves `label()` with the current language.
- `searchColumns` can be static — search column IDs don't change with language.

---

## Template Binding Pattern

Inside `*transloco="let t; scope: 'scopeName'"`, the column functions are called on each render:

```html
<div *transloco="let t; scope: 'iam'">
  <au-table-filters [columns]="filterableColumns()" />
  <au-column-toggle
    [table]="table"
    [columns]="toggleableColumns()"
  />
  <au-table-export
    [table]="table"
    [columns]="exportableColumns()"
  />
  <au-table-search [placeholder]="t('FilterPlaceholder')" />
</div>
```

All three components receive `LabeledColumn[]` — a unified type with
`{ key: string; label: string }`.

---

## Infrastructure Types

Shared types from `@aurora/components/data-table/types.ts`:

```typescript
export interface LabeledColumn {
  key: string;
  label: string;
}

export interface ColumnFilter {
  column: string;
  columnLabel: string;
  operator: string;
  value: string;
}
```

Both are exported from the `@aurora` barrel.

---

## Column Helper Functions

All in `@aurora/components/data-table/lib/`:

| Function                          | Returns                     | Used by                                        |
| --------------------------------- | --------------------------- | ---------------------------------------------- |
| `resolveLabel(label)`             | `string`                    | Internal — resolves `string \| (() => string)` |
| `applySortHeaders(columns)`       | `GridColumnDef[]`           | Internal — called by `useDataTable`            |
| `getFilterableColumns(columns)`   | `LabeledColumn[]`           | List component → `au-table-filters`            |
| `getExportableColumns(columns)`   | `LabeledColumn[]`           | List component → `au-table-export`             |
| `getToggleableColumns(columns)`   | `LabeledColumn[]`           | List component → `au-column-toggle`            |
| `getSearchableColumnIds(columns)` | `string[]`                  | List component → search logic                  |
| `getRelationIncludes(columns)`    | `{ association: string }[]` | List component / resolver → query `include`    |

---

## Relation Columns

A column that displays / sorts / filters / searches a field from an **associated entity** (not from
the row's own table) is a relation column. Two pieces describe it:

1. The column id uses dot notation (e.g. `'boundedContext.name'`) — TanStack uses it as the accessor
   and the data-table builders translate it into Sequelize syntax.
2. The column declares its association explicitly via `relation: { association }` — used by
   `getRelationIncludes` to derive the `include` array.

### How to declare one

```typescript
// One-level relation (most common case)
{
  // TanStack supports dot notation in `accessorKey` for nested objects.
  // The explicit `id` MUST be set when `accessorKey` contains a dot.
  accessorKey: 'boundedContext.name',
  id: 'boundedContext.name',
  // Explicit declaration: this column reads from the `boundedContext` association.
  // The string MUST match the Sequelize association name on the backend model.
  relation: { association: 'boundedContext' },
  label: () => translator?.translate('iam.permission.BoundedContext') ?? 'Bounded Context',
  sortable: true,
  searchable: true,
  filterable: true,
  exportable: true,
  cell: (info) => `${info.getValue<string>() ?? ''}`,
}

// Deep chain (nested associations) — same shape as Sequelize's `include`
{
  accessorKey: 'account.user.name',
  id: 'account.user.name',
  relation: {
    association: 'account',
    include: [{ association: 'user' }],
  },
  label: () => translator?.translate('OAuth.UserName') ?? 'User',
  sortable: true,
  searchable: true,
  cell: (info) => `${info.getValue<string>() ?? ''}`,
}
```

### What the builders do automatically

| Builder            | Flat id (`'name'`)               | Relation id (`'boundedContext.name'`)               |
| ------------------ | -------------------------------- | --------------------------------------------------- |
| `buildSearchWhere` | `{ name: { '[iLike]': '%v%' } }` | `{ '$boundedContext.name$': { '[iLike]': '%v%' } }` |
| `buildFilterWhere` | `{ name: { '[iLike]': '%v%' } }` | `{ '$boundedContext.name$': { '[iLike]': '%v%' } }` |
| `buildSortOrder`   | `[['name', 'ASC']]`              | `[['boundedContext', 'name', 'ASC']]`               |

The dollar-wrapped `$assoc.col$` and the array form `['assoc','col',dir]` are Sequelize's documented
syntax for predicates and order over an `include`d association.

### Wiring the `include`

For Sequelize to resolve `$assoc.col$` it must eager-load the association. Use `getRelationIncludes`
on the columns and pass the result on every paginate query (initial resolver and subsequent
fetches):

```typescript
// permission-list.component.ts
private readonly relationIncludes = getRelationIncludes(this.baseColumns);

// inside fetchData:
const include = this.relationIncludes.length > 0 ? this.relationIncludes : undefined;
this.list.paginate({
  query: { where, order, offset, limit, include },
  // constraint mirrors `where`+`include` so total reflects the filtered count
  constraint: where ? { where, include } : undefined,
});
```

`getRelationIncludes` reads the column's `relation` field (it does NOT scan ids), so the dependency
is visible at the column definition site. It also **deep-merges** relation trees: two columns
referencing the same root association share an entry, and their nested `include` chains are unioned
without duplicates. Source column objects are never mutated.

### Limitations

- The dot in the column id is reserved for "relation field." Don't use a literal dot in an id for
  any other purpose.
- The `relation` field expresses the chain to **load**, not the field path itself — keep them in
  sync. Mismatched declarations (e.g. `id: 'account.user.name'` paired with
  `relation: { association: 'account' }` only) will produce a query whose `where`/`order` cannot
  resolve at the database level.

---

## Component Responsibilities

| Component         | Entity labels                       | UI text                                | Notes                       |
| ----------------- | ----------------------------------- | -------------------------------------- | --------------------------- |
| `SortButton`      | Via `input()` — already translated  | None                                   | Completely dumb             |
| `TableFilters`    | Via `[columns]` — `LabeledColumn[]` | `*transloco` (Filters, Contains, etc.) | Operator labels are UI text |
| `TableExport`     | Via `[columns]` — `LabeledColumn[]` | `*transloco` (Export, CSV, Excel)      |                             |
| `ColumnToggle`    | Via `[columns]` — `LabeledColumn[]` | `*transloco` (Columns, Reset)          | Uses `effect` + `untracked` |
| `TablePagination` | None                                | `*transloco` (Rows per page, etc.)     |                             |
| `TableSearch`     | Placeholder via `input()`           | None                                   | Completely dumb             |

---

## ColumnToggle Reactivity

`ColumnToggle` maintains internal state (visibility, sticky, drag order) that must survive language
changes. It uses `effect` + `untracked` to sync labels without infinite loops:

```typescript
effect(() => {
  const table = this.table();                              // tracked
  const labelMap = new Map(this.columns().map(...));        // tracked

  untracked(() => {
    const current = this.toggleItems();                    // NOT tracked
    if (current.length === 0) {
      // initialize from table
    } else {
      // only update if labels actually changed
      const needsUpdate = current.some(item => {
        const newLabel = labelMap.get(item.key);
        return newLabel && newLabel !== item.label;
      });
      if (needsUpdate) { this.toggleItems.set(...); }
    }
  });
});
```

### CRITICAL

- `untracked()` prevents reading `toggleItems` from becoming a dependency of the effect.
- The `needsUpdate` check prevents writing when labels haven't changed, avoiding re-render loops.
- Without both guards, the component enters an infinite loop.

---

## Anti-patterns

| Anti-pattern                                        | Why it fails                                | Correct approach                                                |
| --------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------- |
| Static `filterableColumns` property                 | Captures one language at init time          | Use arrow function: `() => getFilterableColumns(...)`           |
| `translator.translate()` at init time               | Returns key if scope not loaded             | Use `label: () => translator?.translate(...) ?? fallback`       |
| `TranslocoPipe` in `SortButton`                     | Adds transloco dependency to dumb component | `SortButton` receives translated string from `applySortHeaders` |
| `t(col.label)` in infrastructure for entity data    | Mixes UI and entity responsibilities        | Receive translated labels via `input()`                         |
| Signal read + write in `effect` without `untracked` | Infinite loop                               | Wrap in `untracked()` + guard with `needsUpdate`                |
| Inferring sortable from `label` presence            | Opaque, implicit behavior                   | Use explicit `sortable: true`                                   |
| HTML string for interactive cells                   | No Angular bindings or events               | Use `flexRenderComponent` with Angular component                |
