Skip to main content

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>
  );
}

Pattern 3: Row Actions Menu

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>
      );
    },
  },
];

Pattern 5: Currency Formatting

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',
  },
];

Pattern 6: Date Formatting

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',
  },
];

Pattern 7: Pagination

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

  • Review current implementation
  • Identify all features (filters, sorting, pagination, actions)
  • Note any custom rendering

Migration

  • Add imports from @/platform/table
  • Define Column<T>[] array
  • Map existing TableHead elements to columns
  • Add type for currency/date/boolean columns
  • Add filterable: true for searchable columns
  • Add hideOnMobile: true for non-essential columns
  • Move row actions to column render function
  • Replace filter state with useDataTable hook
  • Replace <Table> with <DataTable>
  • Add TableFilters component if filters exist
  • Add TablePagination component if pagination needed
  • Add bulk actions if selection exists

Testing

  • Verify loading state shows skeleton
  • Verify empty state displays correctly
  • Verify filtering works (global + column)
  • Verify sorting works
  • Verify pagination works
  • Verify row actions work
  • Verify mobile responsiveness
  • Verify accessibility (keyboard navigation)

Cleanup

  • Remove unused imports
  • Remove duplicate utility functions
  • Update component props if simplified

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