UNPKG

@payfit/unity-components

Version:

490 lines (412 loc) 13.3 kB
--- 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.