From 07ca63347e9bae5bc59b8f0f8073e64da1df68f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 8 Nov 2025 23:38:27 +0000 Subject: [PATCH] feat(ui): Implement virtual scrolling for DataTable component --- .../transactions/edit-transaction-dialog.tsx | 53 ++-- resources/js/components/ui/data-table.tsx | 150 +++++++---- resources/js/components/ui/table.tsx | 242 ++++++++++-------- resources/js/pages/transactions/index.tsx | 73 ++++-- 4 files changed, 329 insertions(+), 189 deletions(-) diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index fc343940..a33282c4 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -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); diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx index 44d357d1..d32fd069 100644 --- a/resources/js/components/ui/data-table.tsx +++ b/resources/js/components/ui/data-table.tsx @@ -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({ columns, emptyMessage = 'No results found.', }: DataTableProps) { + const tableContainerRef = useRef(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 (
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ); + })} - )) - ) : ( - - - {emptyMessage} - - - )} - -
+ ))} + + + {rows.length ? ( + <> + {paddingTop > 0 && ( + + + + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + return ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext(), + )} + + ))} + + ); + })} + {paddingBottom > 0 && ( + + + + )} + + ) : ( + + + {emptyMessage} + + + )} + + +
); } diff --git a/resources/js/components/ui/table.tsx b/resources/js/components/ui/table.tsx index 5513a5cd..436a952f 100644 --- a/resources/js/components/ui/table.tsx +++ b/resources/js/components/ui/table.tsx @@ -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 ( -
- - - ) -} +const Table = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+
+ + ); + }, +); +Table.displayName = 'Table'; -function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { - return ( - - ) -} +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.ComponentProps<'thead'> +>(({ className, ...props }, ref) => { + return ( + + ); +}); +TableHeader.displayName = 'TableHeader'; -function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { - return ( - - ) -} +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.ComponentProps<'tbody'> +>(({ className, ...props }, ref) => { + return ( + + ); +}); +TableBody.displayName = 'TableBody'; -function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { - return ( - tr]:last:border-b-0", - className - )} - {...props} - /> - ) -} +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.ComponentProps<'tfoot'> +>(({ className, ...props }, ref) => { + return ( + tr]:last:border-b-0', + className, + )} + {...props} + /> + ); +}); +TableFooter.displayName = 'TableFooter'; -function TableRow({ className, ...props }: React.ComponentProps<"tr">) { - return ( - - ) -} +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.ComponentProps<'tr'> +>(({ className, ...props }, ref) => { + return ( + + ); +}); +TableRow.displayName = 'TableRow'; -function TableHead({ className, ...props }: React.ComponentProps<"th">) { - return ( -
[role=checkbox]]:translate-y-[2px]", - className - )} - {...props} - /> - ) -} +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ComponentProps<'th'> +>(({ className, ...props }, ref) => { + return ( + [role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> + ); +}); +TableHead.displayName = 'TableHead'; -function TableCell({ className, ...props }: React.ComponentProps<"td">) { - return ( - [role=checkbox]]:translate-y-[2px]", - className - )} - {...props} - /> - ) -} +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.ComponentProps<'td'> +>(({ className, ...props }, ref) => { + return ( + [role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> + ); +}); +TableCell.displayName = 'TableCell'; -function TableCaption({ - className, - ...props -}: React.ComponentProps<"caption">) { - return ( -
- ) -} +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.ComponentProps<'caption'> +>(({ className, ...props }, ref) => { + return ( + + ); +}); +TableCaption.displayName = 'TableCaption'; export { - Table, - TableHeader, - TableBody, - TableFooter, - TableHead, - TableRow, - TableCell, - TableCaption, -} + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 2d4cdff4..ccee3aab 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -82,15 +82,37 @@ export default function Transactions({ categories, accounts, banks }: Props) { const [displayedCount, setDisplayedCount] = useState(25); const observerTarget = useRef(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} />