improved datatable

This commit is contained in:
Boki 2025-06-18 07:54:42 -04:00
parent 344478c577
commit 7f4a70309c
2 changed files with 137 additions and 175 deletions

View file

@ -4,29 +4,30 @@ import {
ColumnDef, ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getExpandedRowModel,
getSortedRowModel, getSortedRowModel,
Row,
SortingState, SortingState,
useReactTable, useReactTable,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { useState } from 'react'; import { Fragment, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
interface DataTableProps<T> { interface DataTableProps<T> {
data: T[]; data: T[];
columns: ColumnDef<T>[]; columns: ColumnDef<T>[];
height?: number;
loading?: boolean; loading?: boolean;
onRowClick?: (row: T) => void;
className?: string; className?: string;
getRowCanExpand?: (row: Row<T>) => boolean;
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
} }
export function DataTable<T>({ export function DataTable<T>({
data, data,
columns, columns,
height,
loading = false, loading = false,
onRowClick,
className = '', className = '',
getRowCanExpand,
renderSubComponent,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@ -39,11 +40,12 @@ export function DataTable<T>({
onSortingChange: setSorting, onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
enableSorting: true, getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand,
enableColumnResizing: true,
columnResizeMode: 'onChange',
}); });
const { rows } = table.getRowModel();
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64 bg-background border border-border rounded-lg"> <div className="flex items-center justify-center h-64 bg-background border border-border rounded-lg">
@ -53,87 +55,88 @@ export function DataTable<T>({
} }
return ( return (
<TableVirtuoso <div className={cn('border border-border rounded-lg overflow-auto', className)}>
style={height ? { height: `${height}px` } : { height: '100%' }} <table className="w-full">
className={cn('border border-border rounded-lg', className)} <thead className="bg-surface border-b border-border">
totalCount={rows.length} {table.getHeaderGroups().map(headerGroup => (
components={{ <tr key={headerGroup.id}>
Table: ({ style, ...props }) => ( {headerGroup.headers.map(header => (
<table <th
{...props} key={header.id}
{...{ colSpan={header.colSpan}
style: { className="relative px-3 py-2 text-xs font-medium text-text-secondary uppercase tracking-wider text-left"
...style, style={{ width: header.getSize() }}
width: table.getCenterTotalSize(),
minWidth: '100%',
},
}}
className="bg-background"
/>
),
TableRow: props => {
const index = props['data-index'] as number;
const row = rows[index];
return (
<tr
{...props}
className="hover:bg-surface cursor-pointer border-b border-border"
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="px-3 py-2 text-sm text-text-primary border-r border-border"
style={{ width: cell.column.getSize() }}
> >
<div className="truncate"> {header.isPlaceholder ? null : (
{flexRender(cell.column.columnDef.cell, cell.getContext())} <>
</div> <div
</td> className={cn(
))} 'flex items-center justify-between',
</tr> header.column.getCanSort() && 'cursor-pointer select-none hover:text-text-primary'
); )}
}, onClick={header.column.getToggleSortingHandler()}
}} >
fixedHeaderContent={() => <span className="truncate">
table.getHeaderGroups().map(headerGroup => ( {flexRender(header.column.columnDef.header, header.getContext())}
<tr key={headerGroup.id} className="bg-surface border-b border-border"> </span>
{headerGroup.headers.map(header => ( {header.column.getCanSort() && (
<th <div className="ml-2 flex-shrink-0">
key={header.id} {header.column.getIsSorted() === 'asc' ? (
colSpan={header.colSpan} <ChevronUpIcon className="h-4 w-4 text-primary-400" />
className={cn( ) : header.column.getIsSorted() === 'desc' ? (
'px-3 py-2 text-xs font-medium text-text-secondary uppercase tracking-wider text-left border-r border-border', <ChevronDownIcon className="h-4 w-4 text-primary-400" />
header.column.getCanSort() && ) : (
'cursor-pointer select-none hover:bg-surface-secondary' <div className="h-4 w-4" />
)} )}
style={{ width: header.getSize() }} </div>
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder ? null : (
<div className="flex items-center justify-between">
<span className="truncate">
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{header.column.getCanSort() && (
<div className="ml-2 flex-shrink-0">
{header.column.getIsSorted() === 'asc' ? (
<ChevronUpIcon className="h-4 w-4 text-primary-400" />
) : header.column.getIsSorted() === 'desc' ? (
<ChevronDownIcon className="h-4 w-4 text-primary-400" />
) : (
<div className="h-4 w-4" />
)} )}
</div> </div>
)} {/* Column resizer */}
</div> {header.column.getCanResize() && (
)} <div
</th> onMouseDown={header.getResizeHandler()}
))} onTouchStart={header.getResizeHandler()}
</tr> className={cn(
)) 'absolute right-0 top-0 h-full w-1 cursor-col-resize user-select-none touch-none',
} 'hover:bg-primary-500 hover:opacity-100',
/> header.column.getIsResizing() ? 'bg-primary-500 opacity-100' : 'bg-border opacity-0'
)}
/>
)}
</>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<Fragment key={row.id}>
<tr className="hover:bg-surface border-b border-border">
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="px-3 py-2 text-sm text-text-primary"
style={{ width: cell.column.getSize() }}
>
<div className="truncate">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</td>
))}
</tr>
{row.getIsExpanded() && renderSubComponent && (
<tr className="bg-surface-secondary/50">
<td colSpan={row.getVisibleCells().length} className="p-0">
{renderSubComponent({ row })}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
); );
} }

View file

@ -13,20 +13,17 @@ export function ExchangesTable() {
error, error,
updateExchange, updateExchange,
fetchExchangeDetails, fetchExchangeDetails,
fetchProviderMappings,
updateProviderMapping, updateProviderMapping,
createProviderMapping, createProviderMapping,
refetch refetch
} = useExchanges(); } = useExchanges();
console.log('ExchangesTable render:', { exchanges, loading, error });
const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null); const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [addProviderDialog, setAddProviderDialog] = useState<{ const [addProviderDialog, setAddProviderDialog] = useState<{
exchangeId: string; exchangeId: string;
exchangeName: string; exchangeName: string;
} | null>(null); } | null>(null);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [expandedRowData, setExpandedRowData] = useState<Record<string, ProviderMapping[]>>({}); const [expandedRowData, setExpandedRowData] = useState<Record<string, ProviderMapping[]>>({});
const handleCellEdit = useCallback( const handleCellEdit = useCallback(
@ -61,59 +58,50 @@ export function ExchangesTable() {
[updateProviderMapping, refetch] [updateProviderMapping, refetch]
); );
const handleToggleExpandRow = useCallback(async (rowId: string) => { const handleRowExpand = useCallback(
setExpandedRows(prev => { async (row: any) => {
const next = new Set(prev); const exchangeId = row.original.id;
if (next.has(rowId)) { if (!expandedRowData[exchangeId]) {
next.delete(rowId); const details = await fetchExchangeDetails(exchangeId);
} else { if (details) {
next.add(rowId); setExpandedRowData(prev => ({
// Load provider mappings for this exchange ...prev,
if (!expandedRowData[rowId]) { [exchangeId]: details.provider_mappings
fetchExchangeDetails(rowId).then(details => { }));
if (details) {
setExpandedRowData(prev => ({
...prev,
[rowId]: details.provider_mappings
}));
}
});
} }
} }
return next; },
}); [fetchExchangeDetails]
}, [fetchExchangeDetails, expandedRowData]); );
const columns = useMemo<ColumnDef<Exchange>[]>(() => { const columns = useMemo<ColumnDef<Exchange>[]>(() => {
return [ return [
{ {
id: 'expand', id: 'expander',
header: '', header: '',
size: 30,
enableResizing: false,
cell: ({ row }) => { cell: ({ row }) => {
const isExpanded = expandedRows.has(row.original.id); return row.getCanExpand() ? (
return (
<button <button
onClick={() => handleToggleExpandRow(row.original.id)} onClick={() => {
className="text-text-secondary hover:text-text-primary transition-colors" row.getToggleExpandedHandler()();
handleRowExpand(row);
}}
className="text-text-secondary hover:text-text-primary transition-colors w-6 h-6 flex items-center justify-center"
> >
{isExpanded ? '▼' : '▶'} {row.getIsExpanded() ? '▼' : '▶'}
</button> </button>
); ) : null;
}, },
size: 40,
enableResizing: false,
}, },
{ {
id: 'id', id: 'id',
header: 'ID', header: 'ID',
accessorKey: 'id', accessorKey: 'id',
size: 50, size: 80,
enableResizing: false, cell: ({ getValue }) => (
cell: ({ getValue, cell }) => ( <span className="font-mono text-primary-400 text-xs">
<span
className="font-mono text-primary-400 text-xs"
style={{ width: cell.column.getSize() }}
>
{getValue() as string} {getValue() as string}
</span> </span>
), ),
@ -122,13 +110,9 @@ export function ExchangesTable() {
id: 'code', id: 'code',
header: 'Code', header: 'Code',
accessorKey: 'code', accessorKey: 'code',
size: 80, size: 100,
enableResizing: false, cell: ({ getValue }) => (
cell: ({ getValue, cell }) => ( <span className="font-mono text-text-primary text-sm font-medium">
<span
className="font-mono text-text-primary text-sm font-medium"
style={{ width: cell.column.getSize() }}
>
{getValue() as string} {getValue() as string}
</span> </span>
), ),
@ -137,9 +121,7 @@ export function ExchangesTable() {
id: 'name', id: 'name',
header: 'Name', header: 'Name',
accessorKey: 'name', accessorKey: 'name',
size: 200, size: 250,
maxSize: 300,
enableResizing: true,
cell: ({ getValue, row, cell }) => { cell: ({ getValue, row, cell }) => {
const isEditing = const isEditing =
editingCell?.id === row.original.id && editingCell?.field === 'name'; editingCell?.id === row.original.id && editingCell?.field === 'name';
@ -184,7 +166,6 @@ export function ExchangesTable() {
header: 'Country', header: 'Country',
accessorKey: 'country', accessorKey: 'country',
size: 80, size: 80,
maxSize: 80,
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<span className="text-text-secondary text-sm">{getValue() as string}</span> <span className="text-text-secondary text-sm">{getValue() as string}</span>
), ),
@ -193,7 +174,7 @@ export function ExchangesTable() {
id: 'currency', id: 'currency',
header: 'Currency', header: 'Currency',
accessorKey: 'currency', accessorKey: 'currency',
size: 70, size: 80,
cell: ({ getValue, cell }) => ( cell: ({ getValue, cell }) => (
<span <span
className="font-mono text-text-secondary text-sm" className="font-mono text-text-secondary text-sm"
@ -208,7 +189,6 @@ export function ExchangesTable() {
header: 'Active', header: 'Active',
accessorKey: 'active', accessorKey: 'active',
size: 80, size: 80,
maxSize: 80,
cell: ({ getValue, row, cell }) => { cell: ({ getValue, row, cell }) => {
const isActive = getValue() as boolean; const isActive = getValue() as boolean;
return ( return (
@ -231,7 +211,7 @@ export function ExchangesTable() {
id: 'provider_mappings', id: 'provider_mappings',
header: 'Provider Mappings', header: 'Provider Mappings',
accessorKey: 'provider_mapping_count', accessorKey: 'provider_mapping_count',
size: 150, size: 180,
cell: ({ getValue, row }) => { cell: ({ getValue, row }) => {
const totalMappings = parseInt(getValue() as string) || 0; const totalMappings = parseInt(getValue() as string) || 0;
const activeMappings = parseInt(row.original.active_mapping_count) || 0; const activeMappings = parseInt(row.original.active_mapping_count) || 0;
@ -265,7 +245,7 @@ export function ExchangesTable() {
{ {
id: 'actions', id: 'actions',
header: 'Actions', header: 'Actions',
size: 100, size: 120,
cell: ({ row }) => ( cell: ({ row }) => (
<button <button
onClick={() => handleAddProviderMapping(row.original.id, row.original.name)} onClick={() => handleAddProviderMapping(row.original.id, row.original.name)}
@ -282,7 +262,6 @@ export function ExchangesTable() {
header: 'Last Updated', header: 'Last Updated',
accessorKey: 'updated_at', accessorKey: 'updated_at',
size: 120, size: 120,
maxSize: 120,
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<span className="text-xs text-text-muted"> <span className="text-xs text-text-muted">
{new Date(getValue() as string).toLocaleDateString()} {new Date(getValue() as string).toLocaleDateString()}
@ -293,11 +272,10 @@ export function ExchangesTable() {
}, [ }, [
editingCell, editingCell,
editValue, editValue,
expandedRows,
handleCellEdit, handleCellEdit,
handleToggleActive, handleToggleActive,
handleAddProviderMapping, handleAddProviderMapping,
handleToggleExpandRow, handleRowExpand,
]); ]);
if (error) { if (error) {
@ -312,7 +290,8 @@ export function ExchangesTable() {
); );
} }
const renderExpandedRow = (exchange: Exchange) => { const renderSubComponent = ({ row }: { row: any }) => {
const exchange = row.original as Exchange;
const mappings = expandedRowData[exchange.id] || []; const mappings = expandedRowData[exchange.id] || [];
if (mappings.length === 0) { if (mappings.length === 0) {
@ -383,36 +362,16 @@ export function ExchangesTable() {
); );
}; };
console.log('About to render DataTable with:', {
dataLength: (exchanges || []).length,
columnsLength: columns.length,
loading,
error
});
return ( return (
<> <>
<div className="space-y-0"> <DataTable
<DataTable data={exchanges || []}
data={exchanges || []} columns={columns}
columns={columns} loading={loading}
loading={loading} className="max-h-[calc(100vh-300px)]"
height={600} getRowCanExpand={() => true}
className="rounded-lg border border-border" renderSubComponent={renderSubComponent}
/> />
{/* Expanded rows */}
{Array.from(expandedRows).map(exchangeId => {
const exchange = exchanges?.find(e => e.id === exchangeId);
if (!exchange) return null;
return (
<div key={`expanded-${exchangeId}`} className="border-l border-r border-b border-border rounded-b-lg -mt-1">
{renderExpandedRow(exchange)}
</div>
);
})}
</div>
{addProviderDialog && ( {addProviderDialog && (
<AddProviderMappingDialog <AddProviderMappingDialog