@payfit/unity-components
Version:
490 lines (412 loc) • 13.3 kB
Markdown
name: unity-data-table
description: >
Load when rendering tabular data with Unity. Use it to choose DataTable for
managed table state or primitive Table for static/custom markup, including
filtering, pagination, virtualization, or bulk actions.
type: core
library: '@payfit/unity-components'
library_version: '2.x'
sources:
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/DataTable.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/parts/DataTableRoot.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/parts/DataTableBulkActions.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/table/Table.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/filter-toolbar/FilterToolbar.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/filter/Filter.tsx'
- 'PayFit/hr-apps:libs/shared/unity/components/src/docs/guides/data/Building Tables.mdx'
DataTable is the composite (Tanstack Table + Unity Table + pagination + empty
states + virtualization). Table is the primitive — use only when you have no
table state to manage.
## Setup
Minimum working client-side DataTable. Columns and data MUST be memoized.
```tsx
import { useMemo, useState } from 'react'
import {
DataTable,
DataTableRoot,
TableCell,
TableRow,
} from '@payfit/unity-components'
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
type Employee = {
id: string
name: string
position: string
status: 'active' | 'inactive'
}
const columnHelper = createColumnHelper<Employee>()
export function EmployeeTable({ employees }: { employees: Employee[] }) {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const columns = useMemo(
() => [
columnHelper.accessor('name', {
header: 'Name',
meta: { isRowHeader: true, headerClassName: 'uy:w-1/3' },
}),
columnHelper.accessor('position', { header: 'Position' }),
columnHelper.accessor('status', { header: 'Status' }),
],
[],
)
const data = useMemo(() => employees, [employees])
const table = useReactTable({
data,
columns,
getRowId: row => row.id,
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<DataTableRoot>
<DataTable table={table} layout="fixed">
{row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
</DataTable>
</DataTableRoot>
)
}
```
## Core Patterns
### Define columns with the column helper and ColumnMeta
ColumnMeta drives accessibility (`isRowHeader`), keyboard navigation
(`isFocusable: false` for cells whose children are themselves focusable like
checkboxes), `helperText` (renders a tooltip next to the header), and
`headerClassName` (required when `layout="fixed"`).
```tsx
import { Badge } from '@payfit/unity-components'
import { createColumnHelper } from '@tanstack/react-table'
const columnHelper = createColumnHelper<Employee>()
export const employeeColumns = [
columnHelper.accessor('name', {
id: 'employee',
header: 'Employee',
enableSorting: true,
meta: { isRowHeader: true, headerClassName: 'uy:w-[260px]' },
}),
columnHelper.accessor('status', {
header: 'Status',
enableColumnFilter: true,
filterFn: 'arrIncludesSome',
cell: info => {
const status = info.getValue()
return (
<Badge variant={status === 'active' ? 'success' : 'neutral'}>
{status}
</Badge>
)
},
meta: { helperText: 'Active employees can sign in.' },
}),
columnHelper.display({
id: 'actions',
header: '',
cell: ({ row }) => <RowMenu row={row.original} />,
meta: { isFocusable: false },
}),
]
```
### Server-side pagination
`manualPagination: true` disables Tanstack's internal slicing. Pass only the
current page's slice as `data` and supply `pageCount`.
```tsx
import { useMemo, useState } from 'react'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
export function ServerTable({ totalCount, fetchPage }: Props) {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
const { data: pageRows = [] } = useQuery({
queryKey: ['employees', pagination],
queryFn: () => fetchPage(pagination.pageIndex, pagination.pageSize),
})
const columns = useMemo(() => employeeColumns, [])
const data = useMemo(() => pageRows, [pageRows])
const table = useReactTable({
data,
columns,
manualPagination: true,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
})
return (
<DataTableRoot>
<DataTable table={table}>
{row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
</DataTable>
</DataTableRoot>
)
}
```
### Filtering with FilterToolbar
`FilterToolbar.onChange` emits `SerializableAppliedFilter[]`. Map that onto
`table.setColumnFilters` / `table.setGlobalFilter`. `renderControl` takes any
control — it is a render function on purpose so you can drop in date pickers,
multi-selects, text fields, etc.
```tsx
import type { FilterDef } from '@payfit/unity-components'
import { FilterToolbar, Select, SelectItem } from '@payfit/unity-components'
import { getFilteredRowModel } from '@tanstack/react-table'
const filterDefs: FilterDef[] = [
{
id: 'status',
label: 'Status',
renderControl: (value, onChange) => (
<Select selectedKey={value as string} onSelectionChange={onChange}>
<SelectItem id="active">Active</SelectItem>
<SelectItem id="inactive">Inactive</SelectItem>
</Select>
),
renderLabel: value => String(value),
},
]
export function FilteredTable() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const table = useReactTable({
data,
columns,
state: { columnFilters, pagination },
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<>
<FilterToolbar
filterDefs={filterDefs}
onChange={applied =>
setColumnFilters(applied.map(f => ({ id: f.id, value: f.value })))
}
/>
<DataTableRoot>
<DataTable table={table}>
{row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
</DataTable>
</DataTableRoot>
</>
)
}
```
### Bulk actions with row selection
`DataTableBulkActions` lives next to `DataTable` inside `DataTableRoot`.
It auto-shows when rows are selected and provides the F6 focus trap. Use a
display column with `meta.isFocusable: false` for the checkbox.
```tsx
import {
CheckboxStandalone,
DataTable,
DataTableBulkActions,
DataTableRoot,
} from '@payfit/unity-components'
const checkboxColumn = columnHelper.display({
id: 'select',
header: ({ table }) => (
<CheckboxStandalone
isSelected={table.getIsAllPageRowsSelected()}
isIndeterminate={table.getIsSomePageRowsSelected()}
onChange={value => table.toggleAllPageRowsSelected(value)}
slot="selection"
>
Select all
</CheckboxStandalone>
),
cell: ({ row }) => (
<CheckboxStandalone
isSelected={row.getIsSelected()}
onChange={value => row.toggleSelected(value)}
slot="selection"
>
Select row
</CheckboxStandalone>
),
enableSorting: false,
meta: { isFocusable: false },
})
export function BulkTable() {
const [rowSelection, setRowSelection] = useState({})
const columns = useMemo(() => [checkboxColumn, ...employeeColumns], [])
const table = useReactTable({
data,
columns,
state: { rowSelection, pagination },
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<DataTableRoot>
<DataTable table={table}>
{row => (
<TableRow key={row.id} isSelected={row.getIsSelected()}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
</DataTable>
<DataTableBulkActions
table={table}
actions={[
{ id: 'archive', label: 'Archive', onAction: rows => archive(rows) },
{ id: 'delete', label: 'Delete', onAction: rows => remove(rows) },
]}
/>
</DataTableRoot>
)
}
```
## Common Mistakes
### CRITICAL Define columns inline (not memoized)
Wrong:
```tsx
function MyTable() {
const columns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status' },
]
const table = useReactTable({ data, columns, ... })
return <DataTable table={table}>{row => ...}</DataTable>
}
```
Correct:
```tsx
function MyTable() {
const columns = useMemo(() => [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status' },
], [])
const table = useReactTable({ data, columns, ... })
return <DataTable table={table}>{row => ...}</DataTable>
}
```
A new columns array each render makes Tanstack Table re-run the whole pipeline; rows re-render even when data is unchanged.
### HIGH Return string from cell function instead of JSX
Wrong:
```tsx
{ accessorKey: 'status', cell: info => info.getValue() }
```
Correct:
```tsx
{
accessorKey: 'status',
cell: info => <Badge color={statusColor(info.getValue())}>{info.getValue()}</Badge>
}
```
flexRender expects ReactNode. A raw string renders unstyled and may overflow. Cell layout is the maintainer-flagged hotspot for table questions.
### HIGH Use the primitive Table when DataTable + Tanstack would do
Wrong:
```tsx
<Table layout="fixed">
<TableHeader>…</TableHeader>
<TableBody>
{data.map(row => (
<TableRow>…</TableRow>
))}
</TableBody>
</Table>
```
Correct:
```tsx
const table = useReactTable({ data, columns, getCoreRowModel(),
getPaginationRowModel(), state: { pagination }, onPaginationChange: setPagination })
<DataTableRoot>
<DataTable table={table}>{row => /* ... */}</DataTable>
</DataTableRoot>
```
Primitive Table has no state; agent hand-rolls pagination/sorting/selection that DataTable provides out of the box.
### HIGH Manage filter state separately from FilterToolbar
Wrong:
```tsx
const [filters, setFilters] = useState([])
<FilterToolbar filterDefs={…} onChange={setFilters} />
<DataTable table={table} />
```
Correct:
```tsx
const [columnFilters, setColumnFilters] = useState([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({ /* … */ state: { columnFilters, globalFilter },
onGlobalFilterChange: setGlobalFilter, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel() })
<FilterToolbar filterDefs={…} onChange={mapToTableState(setColumnFilters, setGlobalFilter)} />
```
FilterToolbar.onChange emits AppliedFilter[]. Agent stores them locally without mapping to table.setGlobalFilter / setColumnFilters, so rows do not actually filter.
### CRITICAL Server-side pagination without slicing data
Wrong:
```tsx
useReactTable({
data: allEmployees,
manualPagination: true,
pageCount: Math.ceil(allEmployees.length / 10),
})
```
Correct:
```tsx
const currentData = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize
return allEmployees.slice(start, start + pagination.pageSize)
}, [pagination])
useReactTable({
data: currentData,
manualPagination: true,
pageCount: Math.ceil(allEmployees.length / pagination.pageSize),
state: { pagination },
onPaginationChange: setPagination,
})
```
manualPagination: true tells Tanstack not to slice. Agents pass the full dataset and expect TanStack to paginate anyway.
### MEDIUM Render large datasets without enableVirtualization
Wrong:
```tsx
<DataTable table={table}>{row => …}</DataTable>
```
Correct:
```tsx
<DataTable
table={table}
enableVirtualization
estimatedRowHeight={40}
overscan={10}
>
{row => …}
</DataTable>
```
Without virtualization, 500+ rows mount in the DOM. Keyboard nav context clones cells; reconciliation is O(n) per render.