Cell Customization

Rendering Strategies

There are three approaches to rendering table cells:

String

By default, cells will display their data model value as a string. The string value is used directly as the header or cell content.

const column: ColumnDef<User, string> = {
  accessorKey: "username",
  header: "Username",
}

Context Getter Function

A function that receives the cell context and returns a string. Use this for simple transformations like formatting numbers or dates.

const column1: ColumnDef<User, string> = {
  accessorKey: "username",
  // this approach is effectively equivalent to the string approach.
  cell: (context: CellContext<User, string>) => context.getValue(),
}

// example of derived data.
const column2: ColumnDef<User, number> = {
  accessorKey: "progress",
  // We can use this approach to format the cell's data.
  cell: ({getValue}) => `${Math.round(getValue<number>() * 100) / 100}%`,
}

The context object provides access to:

PropertyDescription
getValue()Returns the cell's value from the accessor
rowThe row object containing the original data and row-level methods
columnThe column definition and column-level methods
cellThe cell object itself
tableThe table instance for accessing global state

Angular Component Type

The third approach involves creating and importing a separate component. This is the most flexible approach and promotes cell re-use across your application.

Creating a Custom Cell Component

To create a custom cell component:

  1. Extend CellComponentContextDirective<TData, TValue> where TData is your row data type and TValue is the column's value type
  2. Access the cell context via the context() signal

Here's an example that renders a status column with conditional styling:

Username Status Country Avg Session
john_pond1
active
US 12m
anne.m15
suspended
US 35m
joe_dirte
pending
US 0m
import {Component} from "@angular/core"

import {createAngularTable, TableModule} from "@qualcomm-ui/angular/table"
import {getCoreRowModel} from "@qualcomm-ui/core/table"

import {type User, userColumns, users} from "./data"

@Component({
  imports: [TableModule],
  selector: "custom-cell-demo",
  template: `
    <div q-table-root>
      <div q-table-scroll-container>
        <table q-table-table>
          <thead q-table-header>
            @for (
              headerGroup of table.getHeaderGroups();
              track headerGroup.id
            ) {
              <tr q-table-row>
                @for (header of headerGroup.headers; track header.id) {
                  <th q-table-header-cell>
                    <ng-container *renderHeader="header; let value">
                      {{ value }}
                    </ng-container>
                  </th>
                }
              </tr>
            }
          </thead>
          <tbody q-table-body>
            @for (row of table.getRowModel().rows; track row.id) {
              <tr q-table-row>
                @for (cell of row.getVisibleCells(); track cell.id) {
                  <td q-table-cell>
                    <ng-container *renderCell="cell; let value">
                      {{ value }}
                    </ng-container>
                  </td>
                }
              </tr>
            }
          </tbody>
        </table>
      </div>
    </div>
  `,
})
export class CustomCellDemo {
  protected table = createAngularTable<User>(() => ({
    columns: userColumns,
    data: users,
    getCoreRowModel: getCoreRowModel(),
  }))
}

Cell Inputs

In some cases you may need to pass data to your cells. Before you reach for custom inputs on your cells, see if some of the following patterns will work instead.

Table State

Our table has built-in state management for editable data, filters, row expansion, sub-components, row selection, sorting, and more. Refer to the features section for examples of these patterns.

ColumnMeta

In this scenario, the user can view a list of jobs submitted by other users. Each job has a state that indicates the action that will be taken.

export type JobStatus = "Approve" | "Request Information" | "Deny"

export interface Job {
  id: string | number
  status?: JobStatus
  user: string
}

First, we define a type for the column's meta:

export interface JobStatusColumnMeta {
  onStatusUpdate: (rowIndex: number, status: JobStatus | undefined) => void
}

Next, we'll move the column definition to the table component's body so it has access to the table's data.

// ...
export class ExampleComponent {
  protected readonly data = signal<Job[]>([
    {id: 1, user: "rsmith"},
    {id: 2, user: "jpete"},
    {id: 3, user: "emartin"},
  ])

  columns: ColumnDef<Job>[] = [
    // ...
    {
      accessorKey: "status",
      cell: () => JobStatusCellComponent,
      header: "State",
      meta: {
        onStatusUpdate: (rowIndex, status) => {
          this.data.update((prevData: Job[]) => {
            const data = [...prevData]
            data[rowIndex].status = status
            return data
          })
        },
      } satisfies JobStatusColumnMeta,
      minSize: 250,
    },
  ]
  // ...
}

Finally, we can update the JobStatusCellComponent to use the column's meta for updates:

// ...
export class JobStatusCellComponent extends CellComponentContextDirective<
  Job,
  JobStatus,
  JobStatusColumnMeta
> {
  protected onChange(state: JobStatus | undefined) {
    this.context().column.columnDef.meta?.onStatusUpdate(
      this.context().row.index,
      state,
    )
  }
}
// ...

The following example features these changes:

ID User Action
1 rsmith
2 jpete
3 emartin
import {Component, signal} from "@angular/core"

import {
  type AngularTable,
  createAngularTable,
  TableModule,
} from "@qualcomm-ui/angular/table"
import {type ColumnDef, getCoreRowModel} from "@qualcomm-ui/core/table"

import {type Job, jobs, type JobStatus, type JobStatusColumnMeta} from "./data"
import {JobStatusCell} from "./job-status-cell"

@Component({
  imports: [TableModule],
  selector: "column-meta-demo",
  template: `
    <div q-table-root>
      <div q-table-scroll-container>
        <table q-table-table>
          <thead q-table-header>
            @for (
              headerGroup of table.getHeaderGroups();
              track headerGroup.id
            ) {
              <tr q-table-row>
                @for (header of headerGroup.headers; track header.id) {
                  <th q-table-header-cell [style.width.px]="header.getSize()">
                    <ng-container *renderHeader="header; let value">
                      {{ value }}
                    </ng-container>
                  </th>
                }
              </tr>
            }
          </thead>
          <tbody q-table-body>
            @for (row of table.getRowModel().rows; track row.id) {
              <tr q-table-row>
                @for (cell of row.getVisibleCells(); track cell.id) {
                  <td q-table-cell [style.width.px]="cell.column.getSize()">
                    <ng-container *renderCell="cell; let value">
                      {{ value }}
                    </ng-container>
                  </td>
                }
              </tr>
            }
          </tbody>
        </table>
      </div>
    </div>
  `,
})
export class ColumnMetaDemo {
  protected readonly data = signal<Job[]>(jobs)

  protected readonly columns: ColumnDef<Job, any>[] = [
    {
      accessorKey: "id",
      header: "ID",
    },
    {
      accessorKey: "user",
      header: "User",
    },
    {
      accessorKey: "status",
      cell: () => JobStatusCell,
      header: "Action",
      meta: {
        onStatusUpdate: (rowIndex: number, status: JobStatus | undefined) => {
          this.data.update((prevData) =>
            prevData.map((job, index) =>
              index === rowIndex ? {...job, status} : job,
            ),
          )
        },
      } satisfies JobStatusColumnMeta,
    },
  ]

  protected readonly table: AngularTable<Job> = createAngularTable(() => ({
    columns: this.columns,
    data: this.data(),
    getCoreRowModel: getCoreRowModel(),
  }))
}

Dependency Injection

For more complex scenarios where multiple cells need access to shared state or functionality, use Angular's dependency injection. This pattern is particularly useful when:

  • Multiple cells need to read from or write to the same data source
  • You want to keep column definitions separate from the table component
  • You need to share business logic across cells

First, create a service that manages the table data:

import {Injectable, signal} from "@angular/core"

@Injectable()
export class UserDataService {
  readonly data = signal<User[]>([
    {accountStatus: "active", username: "john_pond1" /* ... */},
    {accountStatus: "suspended", username: "anne.m15" /* ... */},
  ])

  updateStatus(rowIndex: number, status: string) {
    this.data.update((users) =>
      users.map((user, index) =>
        index === rowIndex ? {...user, accountStatus: status} : user,
      ),
    )
  }
}

Next, create a cell component that injects the service:

@Component({
  imports: [SelectModule, FormsModule],
  selector: "app-editable-status-cell",
  template: `
    <q-select
      size="sm"
      [collection]="collection"
      [ngModel]="value()"
      (ngModelChange)="updateStatus($event)"
    />
  `,
})
export class EditableStatusCell extends CellComponentContextDirective<User, string> {
  private readonly userDataService = inject(UserDataService)

  readonly value = computed(() => [this.context().getValue()])

  readonly collection = selectCollection({
    items: ["active", "suspended", "pending"],
  })

  updateStatus(value: string[]) {
    this.userDataService.updateStatus(this.context().row.index, value[0])
  }
}

Finally, provide the service and connect the table to the service's data:

@Component({
  imports: [TableModule],
  providers: [UserDataService],
  selector: "service-cell-demo",
  template: `<!-- table template -->`,
})
export class ServiceCellDemo {
  protected readonly data = inject(UserDataService).data

  protected readonly table: AngularTable<User> = createAngularTable(() => ({
    columns,
    data: this.data(),
    getCoreRowModel: getCoreRowModel(),
  }))
}

When the cell updates the service, the signal change propagates to the table, which re-renders with the new data. Full example:

Username Status Country Avg Session
john_pond1 US 12m
anne.m15 US 35m
joe_dirte US 0m
import {Component, inject} from "@angular/core"

import {
  type AngularTable,
  createAngularTable,
  TableModule,
} from "@qualcomm-ui/angular/table"
import {type ColumnDef, getCoreRowModel} from "@qualcomm-ui/core/table"

import {DurationCell} from "./duration-cell"
import {EditableStatusCell} from "./editable-status-cell"
import {type User, UserDataService} from "./user-data.service"

const userColumns: ColumnDef<User, any>[] = [
  {
    accessorKey: "username",
    header: "Username",
  },
  {
    accessorKey: "accountStatus",
    cell: () => EditableStatusCell,
    header: "Status",
  },
  {
    accessorKey: "country",
    header: "Country",
  },
  {
    accessorKey: "averageSessionDuration",
    cell: () => DurationCell,
    header: "Avg Session",
  },
]

@Component({
  imports: [TableModule],
  providers: [UserDataService],
  selector: "service-cell-demo",
  template: `
    <div q-table-root>
      <div q-table-scroll-container>
        <table q-table-table>
          <thead q-table-header>
            @for (
              headerGroup of table.getHeaderGroups();
              track headerGroup.id
            ) {
              <tr q-table-row>
                @for (header of headerGroup.headers; track header.id) {
                  <th q-table-header-cell>
                    <ng-container *renderHeader="header; let value">
                      {{ value }}
                    </ng-container>
                  </th>
                }
              </tr>
            }
          </thead>
          <tbody q-table-body>
            @for (row of table.getRowModel().rows; track row.id) {
              <tr q-table-row>
                @for (cell of row.getVisibleCells(); track cell.id) {
                  <td q-table-cell>
                    <ng-container *renderCell="cell; let value">
                      {{ value }}
                    </ng-container>
                  </td>
                }
              </tr>
            }
          </tbody>
        </table>
      </div>
    </div>
  `,
})
export class ServiceCellDemo {
  protected readonly data = inject(UserDataService).data

  protected readonly table: AngularTable<User> = createAngularTable(() => ({
    columns: userColumns,
    data: this.data(),
    getCoreRowModel: getCoreRowModel(),
  }))
}

Last Resort: Customize the Cell in the Template

If none of these approaches work for you, then you can always fall back to template customization. This approach should be avoided if possible. Refer to the Pitfall section below to learn why.

<div q-table-root>
  <div q-table-scroll-container>
    <table q-table-table showColumnDivider>
      <tbody q-table-body>
        @for (row of table.getRowModel().rows; track row.id) {
          <tr q-table-row>
            @for (cell of row.getVisibleCells(); track cell.id) {
              <td q-table-cell>
                @if (cell.column.id === "my-custom-cell") {
                  <my-custom-cell [cell]="cell" />
                } @else {
                  <ng-container *renderCell="cell; let value">
                    {{ value }}
                  </ng-container>
                }
              </td>
            }
          </tr>
        }
      </tbody>
    </table>
  </div>
</div>

Pitfall: Template Customization

In a typical table, you might be accustomed to doing something like the following:

<!-- Prime NG table -->
<p-table [value]="products" [tableStyle]="{'min-width': '50rem'}">
  <ng-template pTemplate="header">
    <tr>
      <!-- Custom headers, omitted for brevity -->
    </tr>
  </ng-template>
  <ng-template
    pTemplate="body"
    let-data
    let-columns="columns"
    let-rowIndex="rowIndex"
  >
    <tr>
      <td *ngFor="let column of columns">
        <!-- If empty placeholder is configured and there is no value,
            show empty text instead of any body template. -->
        <span *ngIf="column.emptyPlaceholder" class="empty-text">-</span>

        <!--  switch statement for column type. Each column renders its own template. -->
        <div *ngIf="!column.emptyPlaceholder" [ngSwitch]="column.type">
          <ng-template ngSwitchCase="deviceList">
            <!-- Custom template for this cell -->
          </ng-template>
          <ng-template ngSwitchCase="tenantLink">
            <!-- Custom template for this cell -->
          </ng-template>
          <ng-template ngSwitchCase="state">
            <div class="flex flex-1 gap-[5px]">
              <!-- Custom template for this cell -->
            </div>
          </ng-template>
          <ng-template ngSwitchCase="buildState">
            <!-- Custom template for this cell -->
          </ng-template>
          <ng-template ngSwitchCase="numberLink">
            <!-- Custom template for this cell -->
          </ng-template>
          <ng-template ngSwitchCase="testArtifact">
            <!-- Custom template for this cell -->
          </ng-template>
          <!-- Continues for 10+ more switch cases -->
        </div>
      </td>
    </tr>
  </ng-template>
</p-table>

While this approach may work for simple tables with limited column types, it becomes challenging to reuse this template customization as the number of column types increases. Say you need to introduce a new column type. You'd define that column type and add the code to this template. As more column types are added, this approach becomes harder to manage. The more you add, the more cluttered this base table becomes.

You can solve this problem by moving the configuration from the template to column definitions. This makes it much easier to reuse each column. That said, the QUI Table may not be an ideal fit for every use case. If your tables are simple, or you don't need advanced features like sorting, pagination, or filtering, then you may be better off with a template-heavy implementation like this.

Last updated on by Ryan Bower