feat(ui): Implement virtual scrolling for DataTable component
This commit is contained in:
parent
509065e28d
commit
07ca63347e
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue