Documentation Index
Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
Use this file to discover all available pages before exploring further.
Created: 2026-01-08
Status: Complete
Task: T17
Overview
This guide provides step-by-step instructions for migrating existing table implementations to the new @/platform/table DataTable framework.
Quick Start
1. Add Imports
import {
DataTable,
useDataTable,
TableFilters,
TablePagination,
type Column
} from '@/platform/table';
2. Define Columns
const columns: Column<YourEntityType>[] = [
{
key: 'name',
header: 'Name',
accessor: (row) => row.name,
filterable: true,
},
{
key: 'status',
header: 'Status',
accessor: (row) => row.status,
render: (value) => <StatusBadge status={value as string} />,
},
{
key: 'amount',
header: 'Amount',
accessor: (row) => row.amount,
type: 'currency',
align: 'right',
},
];
3. Use the Hook
const {
processedData,
filteringProps,
paginationProps
} = useDataTable({
data: entities,
columns,
initialPageSize: 20,
});
4. Render Components
return (
<div className="space-y-4">
<TableFilters {...filteringProps} showGlobalSearch />
<DataTable
data={processedData}
columns={columns}
isLoading={isLoading}
emptyState={{ title: 'No items found' }}
/>
<TablePagination {...paginationProps} />
</div>
);
Migration Patterns
Pattern 1: Basic Table with Loading/Empty States
BEFORE:
export function ItemsTable({ items, isLoading }: Props) {
if (isLoading) {
return <Skeleton className="h-[400px]" />;
}
if (!items || items.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No items found
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
AFTER:
import { DataTable, type Column } from '@/platform/table';
const columns: Column<Item>[] = [
{ key: 'name', header: 'Name', accessor: (row) => row.name },
{ key: 'description', header: 'Description', accessor: (row) => row.description },
{ key: 'status', header: 'Status', accessor: (row) => row.status },
];
export function ItemsTable({ items, isLoading }: Props) {
return (
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
emptyState={{ title: 'No items found' }}
/>
);
}
Pattern 2: Table with Search and Filters
BEFORE:
export function VendorsTable({ vendors, isLoading }: Props) {
const [searchTerm, setSearchTerm] = useState("");
const [activeFilter, setActiveFilter] = useState<string>("all");
const [is1099Filter, setIs1099Filter] = useState<string>("all");
const filteredVendors = vendors.filter((vendor) => {
const matchesSearch = vendor.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesActive =
activeFilter === "all" ||
(activeFilter === "active" ? vendor.is_active : !vendor.is_active);
const matches1099 =
is1099Filter === "all" ||
(is1099Filter === "1099" ? vendor.is_1099 : !vendor.is_1099);
return matchesSearch && matchesActive && matches1099;
});
return (
<div className="space-y-4">
<div className="flex gap-4">
<Input
placeholder="Search vendors..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
<Table>
{/* ... table body with filteredVendors */}
</Table>
</div>
);
}
AFTER:
import { DataTable, useDataTable, TableFilters, type Column } from '@/platform/table';
const columns: Column<Vendor>[] = [
{
key: 'name',
header: 'Vendor Name',
accessor: (row) => row.name,
filterable: true,
},
{
key: 'is_active',
header: 'Status',
accessor: (row) => row.is_active,
type: 'boolean',
render: (value) => (
<Badge variant={value ? 'success' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'is_1099',
header: '1099',
accessor: (row) => row.is_1099,
type: 'boolean',
},
];
export function VendorsTable({ vendors, isLoading }: Props) {
const { processedData, filteringProps } = useDataTable({
data: vendors,
columns,
});
return (
<div className="space-y-4">
<TableFilters {...filteringProps} showGlobalSearch />
<DataTable
data={processedData}
columns={columns}
isLoading={isLoading}
emptyState={{ title: 'No vendors found' }}
/>
</div>
);
}
BEFORE:
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(item)}>
<Eye className="h-4 w-4 mr-2" />
View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(item)}
className="text-destructive"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
AFTER:
const columns: Column<Item>[] = [
// ... other columns
{
key: 'actions',
header: '',
accessor: () => null,
render: (_, row) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(row)}>
<Eye className="h-4 w-4 mr-2" />
View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(row)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row)}
className="text-destructive"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
Pattern 4: Status Badges
BEFORE:
function getStatusBadge(status: string) {
const variants: Record<string, string> = {
active: 'success',
pending: 'warning',
inactive: 'secondary',
error: 'destructive',
};
return (
<Badge variant={variants[status] || 'default'}>
{status}
</Badge>
);
}
// In table cell
<TableCell>{getStatusBadge(item.status)}</TableCell>
AFTER:
const columns: Column<Item>[] = [
{
key: 'status',
header: 'Status',
accessor: (row) => row.status,
render: (value) => {
const variants: Record<string, string> = {
active: 'success',
pending: 'warning',
inactive: 'secondary',
error: 'destructive',
};
return (
<Badge variant={variants[value as string] || 'default'}>
{value}
</Badge>
);
},
},
];
BEFORE:
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
// In table cell
<TableCell className="text-right font-mono">
{formatCurrency(Number(item.amount))}
</TableCell>
AFTER:
const columns: Column<Item>[] = [
{
key: 'amount',
header: 'Amount',
accessor: (row) => row.amount,
type: 'currency',
align: 'right',
},
];
BEFORE:
import { format } from 'date-fns';
<TableCell>
{item.created_at ? format(new Date(item.created_at), 'MMM d, yyyy') : '-'}
</TableCell>
AFTER:
const columns: Column<Item>[] = [
{
key: 'created_at',
header: 'Created',
accessor: (row) => row.created_at,
type: 'date',
},
];
BEFORE:
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = data.slice(startIndex, endIndex);
const totalPages = Math.ceil(data.length / pageSize);
// Render pagination controls manually
AFTER:
const { processedData, paginationProps } = useDataTable({
data,
columns,
initialPageSize: 20,
});
return (
<>
<DataTable data={processedData} columns={columns} />
<TablePagination {...paginationProps} />
</>
);
Pattern 8: Row Selection with Bulk Actions
BEFORE:
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelection = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const toggleAll = () => {
if (selectedIds.size === data.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(data.map(d => d.id)));
}
};
AFTER:
import { DataTable, BulkActionsBar, type BulkAction } from '@/platform/table';
const bulkActions: BulkAction<Item>[] = [
{
id: 'delete',
label: 'Delete Selected',
icon: <Trash className="h-4 w-4" />,
variant: 'destructive',
requireConfirmation: true,
confirmationMessage: 'Delete selected items?',
onExecute: async (items) => {
await deleteItems(items.map(i => i.id));
},
},
];
export function ItemsTable({ items }: Props) {
const [selectedRows, setSelectedRows] = useState<Item[]>([]);
return (
<>
<BulkActionsBar
selectedItems={selectedRows}
actions={bulkActions}
onClearSelection={() => setSelectedRows([])}
/>
<DataTable
data={items}
columns={columns}
selectable
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
</>
);
}
Common Gotchas
1. Column Key Must Be Unique
// ❌ WRONG - duplicate keys
const columns = [
{ key: 'name', ... },
{ key: 'name', ... }, // Error!
];
// ✅ CORRECT
const columns = [
{ key: 'first_name', ... },
{ key: 'last_name', ... },
];
2. Accessor Must Return Sortable Value
// ❌ WRONG - returns JSX
{ key: 'status', accessor: (row) => <Badge>{row.status}</Badge> }
// ✅ CORRECT - accessor returns value, render handles display
{
key: 'status',
accessor: (row) => row.status,
render: (value) => <Badge>{value}</Badge>,
}
3. Remember filterable: true for Searchable Columns
// ❌ WRONG - column won't appear in filters
{ key: 'name', header: 'Name', accessor: (row) => row.name }
// ✅ CORRECT
{ key: 'name', header: 'Name', accessor: (row) => row.name, filterable: true }
4. Use hideOnMobile for Non-Essential Columns
// Mobile optimization
const columns: Column<Item>[] = [
{ key: 'name', header: 'Name', accessor: (row) => row.name },
{ key: 'email', header: 'Email', accessor: (row) => row.email, hideOnMobile: true },
{ key: 'phone', header: 'Phone', accessor: (row) => row.phone, hideOnMobile: true },
];
5. Custom Render Must Handle Null/Undefined
// ❌ WRONG - may crash on null
{ render: (value) => value.toUpperCase() }
// ✅ CORRECT
{ render: (value) => value ? String(value).toUpperCase() : '-' }
Step-by-Step Migration Checklist
For each table file:
Preparation
Migration
Testing
Cleanup
Need Help?
- DataTable API: See
src/platform/table/README.md
- Column Types: See
src/platform/table/types.ts
- Examples: See existing platform tables
- Issues: Document in migration audit file