feat(ui): Implement virtual scrolling for DataTable component

This commit is contained in:
Víctor Falcón 2025-11-08 23:38:27 +00:00
parent 509065e28d
commit 07ca63347e
4 changed files with 329 additions and 189 deletions

View File

@ -30,7 +30,7 @@ interface EditTransactionDialogProps {
categories: Category[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
onSuccess: (transaction: DecryptedTransaction) => void;
}
export function EditTransactionDialog({
@ -61,30 +61,49 @@ export function EditTransactionDialog({
setIsSubmitting(true);
try {
const updateData: {
category_id: number | null;
notes?: string;
notes_iv?: string;
} = {
category_id: categoryId === 'null' ? null : parseInt(categoryId),
};
const selectedCategoryId =
categoryId === 'null' ? null : parseInt(categoryId, 10);
const trimmedNotes = notes.trim();
let encryptedNotes: string | null = null;
let notesIv: string | null = null;
if (notes.trim()) {
if (trimmedNotes) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(notes, key);
updateData.notes = encrypted.encrypted;
updateData.notes_iv = encrypted.iv;
} else {
updateData.notes = null;
updateData.notes_iv = null;
const encrypted = await encrypt(trimmedNotes, key);
encryptedNotes = encrypted.encrypted;
notesIv = encrypted.iv;
}
await transactionSyncService.update(transaction.id, updateData);
onSuccess();
await transactionSyncService.update(transaction.id, {
category_id: selectedCategoryId,
notes: encryptedNotes,
notes_iv: notesIv,
});
const updatedRecord = await transactionSyncService.getById(
transaction.id,
);
const updatedCategory = selectedCategoryId
? categories.find(
(category) => category.id === selectedCategoryId,
) || null
: null;
const updatedTransaction: DecryptedTransaction = {
...transaction,
category_id: selectedCategoryId,
category: updatedCategory,
decryptedNotes: trimmedNotes || null,
notes: encryptedNotes,
notes_iv: notesIv,
updated_at: updatedRecord?.updated_at ?? transaction.updated_at,
};
onSuccess(updatedTransaction);
onOpenChange(false);
} catch (error) {
console.error('Failed to update transaction:', error);

View File

@ -1,8 +1,10 @@
import { useRef } from 'react';
import {
ColumnDef,
flexRender,
Table as TableType,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Table,
@ -24,57 +26,109 @@ export function DataTable<TData, TValue>({
columns,
emptyMessage = 'No results found.',
}: DataTableProps<TData, TValue>) {
const tableContainerRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 56,
overscan: 12,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop =
virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
<div
ref={tableContainerRef}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
))}
</TableHeader>
<TableBody>
{rows.length ? (
<>
{paddingTop > 0 && (
<TableRow className="border-none hover:bg-transparent">
<TableCell
colSpan={columns.length}
style={{ height: paddingTop }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<TableRow
key={row.id}
ref={rowVirtualizer.measureElement}
data-state={
row.getIsSelected() &&
'selected'
}
data-index={virtualRow.index}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<TableRow className="border-none hover:bg-transparent">
<TableCell
colSpan={columns.length}
style={{ height: paddingBottom }}
/>
</TableRow>
)}
</>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -1,114 +1,150 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
const Table = React.forwardRef<HTMLTableElement, React.ComponentProps<'table'>>(
({ className, ...props }, ref) => {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
ref={ref}
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
},
);
Table.displayName = 'Table';
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.ComponentProps<'thead'>
>(({ className, ...props }, ref) => {
return (
<thead
ref={ref}
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
});
TableHeader.displayName = 'TableHeader';
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.ComponentProps<'tbody'>
>(({ className, ...props }, ref) => {
return (
<tbody
ref={ref}
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
});
TableBody.displayName = 'TableBody';
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.ComponentProps<'tfoot'>
>(({ className, ...props }, ref) => {
return (
<tfoot
ref={ref}
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
});
TableFooter.displayName = 'TableFooter';
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.ComponentProps<'tr'>
>(({ className, ...props }, ref) => {
return (
<tr
ref={ref}
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
});
TableRow.displayName = 'TableRow';
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ComponentProps<'th'>
>(({ className, ...props }, ref) => {
return (
<th
ref={ref}
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
});
TableHead.displayName = 'TableHead';
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.ComponentProps<'td'>
>(({ className, ...props }, ref) => {
return (
<td
ref={ref}
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
});
TableCell.displayName = 'TableCell';
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.ComponentProps<'caption'>
>(({ className, ...props }, ref) => {
return (
<caption
ref={ref}
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
});
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -82,15 +82,37 @@ export default function Transactions({ categories, accounts, banks }: Props) {
const [displayedCount, setDisplayedCount] = useState(25);
const observerTarget = useRef<HTMLDivElement>(null);
function updateTransaction(updatedTransaction: DecryptedTransaction) {
setTransactions((prev) =>
prev.map((t) =>
t.id === updatedTransaction.id ? updatedTransaction : t
)
);
}
const updateTransaction = useCallback(
(updatedTransaction: DecryptedTransaction) => {
setTransactions((previous) =>
previous.map((transaction) => {
if (transaction.id !== updatedTransaction.id) {
return transaction;
}
async function loadTransactions() {
return {
...transaction,
...updatedTransaction,
account:
updatedTransaction.account === undefined
? transaction.account
: updatedTransaction.account,
bank:
updatedTransaction.bank === undefined
? transaction.bank
: updatedTransaction.bank,
category:
updatedTransaction.category === undefined
? transaction.category ?? null
: updatedTransaction.category,
};
}),
);
},
[setTransactions],
);
const loadTransactions = useCallback(async () => {
setIsLoading(true);
try {
const rawTransactions = await transactionSyncService.getAll();
@ -172,7 +194,8 @@ export default function Transactions({ categories, accounts, banks }: Props) {
setTransactions(
decrypted.filter(
(t): t is DecryptedTransaction => t !== null,
(transaction): transaction is DecryptedTransaction =>
transaction !== null,
),
);
} catch (error) {
@ -180,11 +203,11 @@ export default function Transactions({ categories, accounts, banks }: Props) {
} finally {
setIsLoading(false);
}
}
}, [accounts, banks, categories, isKeySet]);
useEffect(() => {
loadTransactions();
}, []);
}, [loadTransactions]);
useEffect(() => {
async function reDecryptTransactions() {
@ -328,14 +351,18 @@ export default function Transactions({ categories, accounts, banks }: Props) {
return filteredTransactions.slice(0, displayedCount);
}, [filteredTransactions, displayedCount]);
const columns = createTransactionColumns({
categories,
accounts,
banks,
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
});
const columns = useMemo(
() =>
createTransactionColumns({
categories,
accounts,
banks,
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
}),
[accounts, banks, categories, updateTransaction],
);
const table = useReactTable({
data: displayedTransactions,
@ -394,7 +421,11 @@ export default function Transactions({ categories, accounts, banks }: Props) {
setIsDeleting(true);
try {
await transactionSyncService.delete(deleteTransaction.id);
await loadTransactions();
setTransactions((previous) =>
previous.filter(
(transaction) => transaction.id !== deleteTransaction.id,
),
);
setDeleteTransaction(null);
} catch (error) {
console.error('Failed to delete transaction:', error);
@ -454,7 +485,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories={categories}
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={loadTransactions}
onSuccess={updateTransaction}
/>
<AlertDialog