mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 12:12:39 -05:00
Compare commits
2 Commits
react-quer
...
cursor/des
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
318526a22f | ||
|
|
b4fe5a002b |
@@ -17,6 +17,9 @@
|
||||
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
|
||||
"playwright": "playwright"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-table": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actual-app/components": "workspace:*",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
|
||||
@@ -214,6 +214,11 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Budget Analysis Report</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle flag="reactTableTransactions">
|
||||
<Trans>
|
||||
React Table transactions (experimental table rewrite)
|
||||
</Trans>
|
||||
</FeatureToggle>
|
||||
{showServerPrefs && (
|
||||
<ServerFeatureToggle
|
||||
prefName="flags.plugins"
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import {
|
||||
createRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type RefObject,
|
||||
type UIEvent,
|
||||
} from 'react';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import type { TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { isLastChild } from './table/utils';
|
||||
import {
|
||||
NewTransaction,
|
||||
Transaction,
|
||||
TransactionError,
|
||||
TransactionHeader,
|
||||
type TransactionTableInnerProps,
|
||||
} from './TransactionsTable';
|
||||
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
type TableHandleRef,
|
||||
} from '@desktop-client/components/table';
|
||||
import { usePrevious } from '@desktop-client/hooks/usePrevious';
|
||||
|
||||
/**
|
||||
* Column definitions for the transactions table using TanStack React Table.
|
||||
* These define the table structure declaratively while the actual cell
|
||||
* rendering is handled by the existing Transaction component.
|
||||
*/
|
||||
function useTransactionColumns({
|
||||
showAccount,
|
||||
showCategory,
|
||||
showBalance,
|
||||
showCleared,
|
||||
}: {
|
||||
showAccount: boolean;
|
||||
showCategory: boolean;
|
||||
showBalance: boolean;
|
||||
showCleared: boolean;
|
||||
}) {
|
||||
return useMemo(() => {
|
||||
const columns: ColumnDef<TransactionEntity, unknown>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
size: 20,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
{
|
||||
id: 'date',
|
||||
accessorKey: 'date',
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (showAccount) {
|
||||
columns.push({
|
||||
id: 'account',
|
||||
accessorKey: 'account',
|
||||
enableSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
id: 'payee',
|
||||
accessorKey: 'payee',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
accessorKey: 'notes',
|
||||
enableSorting: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (showCategory) {
|
||||
columns.push({
|
||||
id: 'category',
|
||||
accessorKey: 'category',
|
||||
enableSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
id: 'payment',
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'deposit',
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (showBalance) {
|
||||
columns.push({
|
||||
id: 'balance',
|
||||
size: 103,
|
||||
enableSorting: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (showCleared) {
|
||||
columns.push({
|
||||
id: 'cleared',
|
||||
accessorKey: 'cleared',
|
||||
size: 38,
|
||||
enableSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}, [showAccount, showCategory, showBalance, showCleared]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom virtualization hook for the transaction list.
|
||||
* Mirrors the behavior of the existing FixedSizeList virtualization.
|
||||
*/
|
||||
function useVirtualTransactions({
|
||||
items,
|
||||
rowHeight,
|
||||
containerRef,
|
||||
}: {
|
||||
items: TransactionEntity[];
|
||||
rowHeight: number;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
setContainerHeight(entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
|
||||
setScrollOffset((e.target as HTMLDivElement).scrollTop);
|
||||
}, []);
|
||||
|
||||
const itemSize = rowHeight - 1;
|
||||
const totalHeight = items.length * itemSize;
|
||||
const overscan = 5;
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.floor(scrollOffset / itemSize) - overscan,
|
||||
);
|
||||
const endIndex = Math.min(
|
||||
items.length - 1,
|
||||
Math.ceil((scrollOffset + containerHeight) / itemSize) + overscan,
|
||||
);
|
||||
|
||||
const virtualItems = useMemo(() => {
|
||||
const result: Array<{
|
||||
index: number;
|
||||
start: number;
|
||||
item: TransactionEntity;
|
||||
}> = [];
|
||||
for (let i = startIndex; i <= endIndex && i < items.length; i++) {
|
||||
result.push({
|
||||
index: i,
|
||||
start: i * itemSize,
|
||||
item: items[i],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [startIndex, endIndex, items, itemSize]);
|
||||
|
||||
return {
|
||||
virtualItems,
|
||||
totalHeight,
|
||||
handleScroll,
|
||||
containerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ReactTableTransactionTableInner - A React Table-powered replacement for
|
||||
* the TransactionTableInner component.
|
||||
*
|
||||
* Uses @tanstack/react-table for declarative column definitions and table
|
||||
* model while reusing the existing Transaction component for row rendering
|
||||
* and useTableNavigator for keyboard navigation.
|
||||
*
|
||||
* The visual and functional output is identical to the legacy implementation.
|
||||
*/
|
||||
export function ReactTableTransactionTableInner({
|
||||
tableNavigator,
|
||||
tableRef,
|
||||
listContainerRef,
|
||||
dateFormat = 'MM/dd/yyyy',
|
||||
newNavigator,
|
||||
renderEmpty,
|
||||
showHiddenCategories,
|
||||
...props
|
||||
}: TransactionTableInnerProps) {
|
||||
const containerRef = createRef<HTMLDivElement>();
|
||||
const isAddingPrev = usePrevious(props.isAdding);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<{
|
||||
scrollToItem: (index: number, alignment?: string) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Track scroll width for header padding
|
||||
function saveScrollWidth(parent: number, child: number) {
|
||||
const width = parent > 0 && child > 0 && parent - child;
|
||||
setScrollWidth(!width ? 0 : width);
|
||||
}
|
||||
|
||||
const {
|
||||
onCloseAddTransaction: onCloseAddTransactionProp,
|
||||
onNavigateToTransferAccount: onNavigateToTransferAccountProp,
|
||||
onNavigateToSchedule: onNavigateToScheduleProp,
|
||||
onNotesTagClick: onNotesTagClickProp,
|
||||
} = props;
|
||||
|
||||
const onNavigateToTransferAccount = useCallback(
|
||||
(accountId: string) => {
|
||||
onCloseAddTransactionProp();
|
||||
onNavigateToTransferAccountProp(accountId);
|
||||
},
|
||||
[onCloseAddTransactionProp, onNavigateToTransferAccountProp],
|
||||
);
|
||||
|
||||
const onNavigateToSchedule = useCallback(
|
||||
(scheduleId: string) => {
|
||||
onCloseAddTransactionProp();
|
||||
onNavigateToScheduleProp(scheduleId);
|
||||
},
|
||||
[onCloseAddTransactionProp, onNavigateToScheduleProp],
|
||||
);
|
||||
|
||||
const onNotesTagClick = useCallback(
|
||||
(noteTag: string) => {
|
||||
onCloseAddTransactionProp();
|
||||
onNotesTagClickProp(noteTag);
|
||||
},
|
||||
[onCloseAddTransactionProp, onNotesTagClickProp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddingPrev && props.isAdding) {
|
||||
newNavigator.onEdit('temp', 'date');
|
||||
}
|
||||
}, [isAddingPrev, props.isAdding, newNavigator]);
|
||||
|
||||
// Don't render reconciled transactions if we're hiding them.
|
||||
const transactionsToRender = useMemo(
|
||||
() =>
|
||||
props.showReconciled
|
||||
? props.transactions
|
||||
: props.transactions.filter(t => !t.reconciled),
|
||||
[props.transactions, props.showReconciled],
|
||||
);
|
||||
|
||||
// TanStack React Table column definitions
|
||||
const columns = useTransactionColumns({
|
||||
showAccount: props.showAccount,
|
||||
showCategory: props.showCategory,
|
||||
showBalance: props.showBalances,
|
||||
showCleared: props.showCleared,
|
||||
});
|
||||
|
||||
// TanStack React Table instance - manages table state and column model.
|
||||
// The table instance is used for future enhancements like column resizing
|
||||
// and reordering. Currently, the row model drives the rendering pipeline.
|
||||
const _table = useReactTable({
|
||||
data: transactionsToRender,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
state: {
|
||||
sorting: props.sortField
|
||||
? [{ id: props.sortField, desc: props.ascDesc === 'desc' }]
|
||||
: [],
|
||||
},
|
||||
getRowId: row => row.id,
|
||||
});
|
||||
|
||||
// Expose the same ref interface as the legacy Table component
|
||||
const imperativeRef = useRef<TableHandleRef<TransactionEntity>>({
|
||||
scrollTo: (id: string, alignment = 'smart') => {
|
||||
const index = transactionsToRender.findIndex(item => item.id === id);
|
||||
if (index !== -1) {
|
||||
listRef.current?.scrollToItem(index, alignment);
|
||||
}
|
||||
},
|
||||
scrollToTop: () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
getScrolledItem: () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const offset = scrollContainerRef.current.scrollTop;
|
||||
const index = Math.floor(offset / (ROW_HEIGHT - 1));
|
||||
return transactionsToRender[index]?.id ?? (0 as unknown as string);
|
||||
}
|
||||
return 0 as unknown as string;
|
||||
},
|
||||
setRowAnimation: () => {
|
||||
// No-op in React Table implementation;
|
||||
// animation is handled differently
|
||||
},
|
||||
edit(id: number, field: string, shouldScroll: boolean) {
|
||||
tableNavigator.onEdit(id as unknown as TransactionEntity['id'], field);
|
||||
if (id && shouldScroll) {
|
||||
imperativeRef.current.scrollTo(
|
||||
id as unknown as TransactionEntity['id'],
|
||||
);
|
||||
}
|
||||
},
|
||||
anchor() {
|
||||
// No-op in React Table implementation
|
||||
},
|
||||
unanchor() {
|
||||
// No-op in React Table implementation
|
||||
},
|
||||
isAnchored() {
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// Bind the forwarded ref
|
||||
useEffect(() => {
|
||||
if (typeof tableRef === 'function') {
|
||||
tableRef(imperativeRef.current);
|
||||
} else if (tableRef && 'current' in tableRef) {
|
||||
(
|
||||
tableRef as { current: TableHandleRef<TransactionEntity> | null }
|
||||
).current = imperativeRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
// Virtualization for the transaction list
|
||||
const {
|
||||
virtualItems,
|
||||
totalHeight,
|
||||
handleScroll: handleVirtualScroll,
|
||||
} = useVirtualTransactions({
|
||||
items: transactionsToRender,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
containerRef: scrollContainerRef,
|
||||
});
|
||||
|
||||
// Scroll width calculation
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (el?.offsetParent) {
|
||||
saveScrollWidth(el.offsetParent.clientWidth, el.clientWidth);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
const { loadMoreTransactions } = props;
|
||||
|
||||
// Load more when approaching the end
|
||||
const handleScroll = useCallback(
|
||||
(e: UIEvent<HTMLDivElement>) => {
|
||||
handleVirtualScroll(e);
|
||||
if (loadMoreTransactions) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const scrollBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
if (scrollBottom < ROW_HEIGHT * 100) {
|
||||
loadMoreTransactions();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleVirtualScroll, loadMoreTransactions],
|
||||
);
|
||||
|
||||
/**
|
||||
* Row renderer that uses the React Table row model while delegating
|
||||
* actual rendering to the existing Transaction component.
|
||||
*/
|
||||
function renderRow(item: TransactionEntity, index: number) {
|
||||
const {
|
||||
transactions,
|
||||
selectedItems,
|
||||
accounts,
|
||||
categoryGroups,
|
||||
payees,
|
||||
showCleared,
|
||||
showAccount,
|
||||
showBalances,
|
||||
balances,
|
||||
hideFraction,
|
||||
isNew,
|
||||
isMatched,
|
||||
isExpanded,
|
||||
showSelection,
|
||||
allowSplitTransaction,
|
||||
} = props;
|
||||
|
||||
const trans = item;
|
||||
const editing = tableNavigator.editingId === trans.id;
|
||||
const selected = selectedItems.has(trans.id);
|
||||
|
||||
const parent = trans.parent_id && props.transactionMap.get(trans.parent_id);
|
||||
const isChildDeposit = parent ? parent.amount > 0 : undefined;
|
||||
const expanded = isExpanded && isExpanded((parent || trans).id);
|
||||
|
||||
// For backwards compatibility, read the error of the transaction
|
||||
// since in previous versions we stored it there.
|
||||
const error = expanded
|
||||
? (parent && parent.error) || trans.error
|
||||
: trans.error;
|
||||
|
||||
const hasSplitError =
|
||||
(trans.is_parent || trans.is_child) &&
|
||||
(!expanded || isLastChild(transactions, index)) &&
|
||||
error &&
|
||||
error.type === 'SplitTransactionError';
|
||||
|
||||
const childTransactions = trans.is_parent
|
||||
? props.transactionsByParent[trans.id]
|
||||
: null;
|
||||
const emptyChildTransactions = props.transactionsByParent[
|
||||
(trans.is_parent ? trans.id : trans.parent_id) || ''
|
||||
]?.filter(t => t.amount === 0);
|
||||
|
||||
return (
|
||||
<Transaction
|
||||
allTransactions={props.transactions}
|
||||
editing={editing}
|
||||
transaction={trans}
|
||||
transferAccountsByTransaction={props.transferAccountsByTransaction}
|
||||
subtransactions={childTransactions}
|
||||
showAccount={showAccount}
|
||||
showBalance={showBalances}
|
||||
showCleared={showCleared}
|
||||
selected={selected}
|
||||
highlighted={false}
|
||||
added={isNew?.(trans.id)}
|
||||
expanded={isExpanded?.(trans.id)}
|
||||
matched={isMatched?.(trans.id)}
|
||||
showZeroInDeposit={isChildDeposit}
|
||||
balance={balances?.[trans.id] ?? 0}
|
||||
focusedField={editing ? tableNavigator.focusedField : undefined}
|
||||
accounts={accounts}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
onEdit={tableNavigator.onEdit}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onBatchDelete={props.onBatchDelete}
|
||||
onBatchDuplicate={props.onBatchDuplicate}
|
||||
onBatchLinkSchedule={props.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={props.onBatchUnlinkSchedule}
|
||||
onCreateRule={props.onCreateRule}
|
||||
onScheduleAction={props.onScheduleAction}
|
||||
onMakeAsNonSplitTransactions={props.onMakeAsNonSplitTransactions}
|
||||
onSplit={props.onSplit}
|
||||
onManagePayees={props.onManagePayees}
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
onToggleSplit={props.onToggleSplit}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNotesTagClick={onNotesTagClick}
|
||||
splitError={
|
||||
hasSplitError && (
|
||||
<TransactionError
|
||||
error={error}
|
||||
isDeposit={!!isChildDeposit}
|
||||
onAddSplit={() => props.onAddSplit(trans.id)}
|
||||
onDistributeRemainder={() =>
|
||||
props.onDistributeRemainder(trans.id)
|
||||
}
|
||||
canDistributeRemainder={
|
||||
emptyChildTransactions
|
||||
? emptyChildTransactions.length > 0
|
||||
: false
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
listContainerRef={listContainerRef}
|
||||
showSelection={showSelection}
|
||||
allowSplitTransaction={allowSplitTransaction}
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = transactionsToRender.length === 0;
|
||||
|
||||
function getEmptyContent(empty: typeof renderEmpty): ReactNode | null {
|
||||
if (empty == null) {
|
||||
return null;
|
||||
} else if (typeof empty === 'function') {
|
||||
return (empty as () => ReactNode)();
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontStyle: 'italic',
|
||||
color: theme.tableText,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{empty}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
innerRef={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
cursor: 'default',
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
{/* Header rendering using the same TransactionHeader component.
|
||||
The React Table instance drives column visibility while the
|
||||
existing component handles the visual rendering. */}
|
||||
<TransactionHeader
|
||||
hasSelected={props.selectedItems.size > 0}
|
||||
showAccount={props.showAccount}
|
||||
showCategory={props.showCategory}
|
||||
showBalance={props.showBalances}
|
||||
showCleared={props.showCleared}
|
||||
scrollWidth={scrollWidth}
|
||||
onSort={props.onSort}
|
||||
ascDesc={props.ascDesc}
|
||||
field={props.sortField}
|
||||
showSelection={props.showSelection}
|
||||
/>
|
||||
|
||||
{props.isAdding && (
|
||||
<View
|
||||
{...newNavigator.getNavigatorProps({
|
||||
onKeyDown: (e: KeyboardEvent) => props.onCheckNewEnter(e),
|
||||
})}
|
||||
>
|
||||
<NewTransaction
|
||||
transactions={props.newTransactions}
|
||||
transferAccountsByTransaction={
|
||||
props.transferAccountsByTransaction
|
||||
}
|
||||
editingTransaction={newNavigator.editingId}
|
||||
focusedField={newNavigator.focusedField}
|
||||
accounts={props.accounts}
|
||||
categoryGroups={props.categoryGroups}
|
||||
payees={props.payees || []}
|
||||
showAccount={props.showAccount}
|
||||
showBalance={props.showBalances}
|
||||
showCleared={props.showCleared}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={props.hideFraction}
|
||||
onClose={props.onCloseAddTransaction}
|
||||
onAdd={props.onAddTemporary}
|
||||
onAddAndClose={props.onAddAndCloseTemporary}
|
||||
onAddSplit={props.onAddSplit}
|
||||
onToggleSplit={props.onToggleSplit}
|
||||
onSplit={props.onSplit}
|
||||
onEdit={newNavigator.onEdit}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onManagePayees={props.onManagePayees}
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNotesTagClick={onNotesTagClick}
|
||||
onDistributeRemainder={props.onDistributeRemainder}
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Virtualized transaction list powered by React Table row model */}
|
||||
<View
|
||||
style={{ flex: 1, overflow: 'hidden' }}
|
||||
data-testid="transaction-table"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
tabIndex={0}
|
||||
{...tableNavigator.getNavigatorProps({
|
||||
onKeyDown: (e: KeyboardEvent) => props.onCheckEnter(e),
|
||||
})}
|
||||
data-testid="table"
|
||||
>
|
||||
{isEmpty ? (
|
||||
<View
|
||||
style={{
|
||||
flex: `1 1 ${ROW_HEIGHT * 2}px`,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
{getEmptyContent(renderEmpty)}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: `1 1 ${ROW_HEIGHT * Math.max(2, transactionsToRender.length)}px`,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={listContainerRef as Ref<HTMLDivElement>}
|
||||
style={{
|
||||
height: totalHeight,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualItems.map(virtualRow => {
|
||||
const item = virtualRow.item;
|
||||
const editing = tableNavigator.editingId === item.id;
|
||||
const selected = props.selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: ROW_HEIGHT,
|
||||
zIndex: editing || selected ? 101 : 'auto',
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
nativeStyle={{
|
||||
'--pos': `${virtualRow.start}px`,
|
||||
transform: 'translateY(var(--pos))',
|
||||
}}
|
||||
data-focus-key={item.id}
|
||||
>
|
||||
{renderRow(item, virtualRow.index)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{props.isAdding && (
|
||||
<div
|
||||
key="shadow"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 20,
|
||||
backgroundColor: theme.errorText,
|
||||
boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@ import {
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { ReactTableTransactionTableInner } from './ReactTableTransactionTableInner';
|
||||
import {
|
||||
deserializeTransaction,
|
||||
isLastChild,
|
||||
@@ -120,6 +121,7 @@ import {
|
||||
DisplayPayeeProvider,
|
||||
useDisplayPayee,
|
||||
} from '@desktop-client/hooks/useDisplayPayee';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||
import { usePrevious } from '@desktop-client/hooks/usePrevious';
|
||||
@@ -139,7 +141,7 @@ import { addNotification } from '@desktop-client/notifications/notificationsSlic
|
||||
import { getPayeesById } from '@desktop-client/payees/payeesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type TransactionHeaderProps = {
|
||||
export type TransactionHeaderProps = {
|
||||
hasSelected: boolean;
|
||||
showAccount: boolean;
|
||||
showCategory: boolean;
|
||||
@@ -152,7 +154,7 @@ type TransactionHeaderProps = {
|
||||
field: string;
|
||||
};
|
||||
|
||||
const TransactionHeader = memo(
|
||||
export const TransactionHeader = memo(
|
||||
({
|
||||
hasSelected,
|
||||
showAccount,
|
||||
@@ -826,7 +828,7 @@ function PayeeIcons({
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionProps = {
|
||||
export type TransactionProps = {
|
||||
allTransactions?: TransactionEntity[];
|
||||
transaction: TransactionEntity;
|
||||
subtransactions: TransactionEntity[] | null;
|
||||
@@ -882,7 +884,7 @@ type TransactionProps = {
|
||||
showHiddenCategories?: boolean;
|
||||
};
|
||||
|
||||
const Transaction = memo(function Transaction({
|
||||
export const Transaction = memo(function Transaction({
|
||||
allTransactions,
|
||||
transaction: originalTransaction,
|
||||
subtransactions,
|
||||
@@ -1707,7 +1709,7 @@ const Transaction = memo(function Transaction({
|
||||
);
|
||||
});
|
||||
|
||||
type TransactionErrorProps = {
|
||||
export type TransactionErrorProps = {
|
||||
error: NonNullable<TransactionEntity['error']>;
|
||||
isDeposit: boolean;
|
||||
onAddSplit: () => void;
|
||||
@@ -1716,7 +1718,7 @@ type TransactionErrorProps = {
|
||||
canDistributeRemainder: boolean;
|
||||
};
|
||||
|
||||
function TransactionError({
|
||||
export function TransactionError({
|
||||
error,
|
||||
isDeposit,
|
||||
onAddSplit,
|
||||
@@ -1772,7 +1774,7 @@ function TransactionError({
|
||||
}
|
||||
}
|
||||
|
||||
type NewTransactionProps = {
|
||||
export type NewTransactionProps = {
|
||||
accounts: AccountEntity[];
|
||||
categoryGroups: CategoryGroupEntity[];
|
||||
dateFormat: string;
|
||||
@@ -1809,7 +1811,7 @@ type NewTransactionProps = {
|
||||
};
|
||||
showHiddenCategories?: boolean;
|
||||
};
|
||||
function NewTransaction({
|
||||
export function NewTransaction({
|
||||
transactions,
|
||||
accounts,
|
||||
categoryGroups,
|
||||
@@ -1955,7 +1957,7 @@ function NewTransaction({
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionTableInnerProps = {
|
||||
export type TransactionTableInnerProps = {
|
||||
tableRef: Ref<TableHandleRef<TransactionEntity>>;
|
||||
listContainerRef: RefObject<HTMLDivElement>;
|
||||
tableNavigator: TableNavigator<TransactionEntity>;
|
||||
@@ -2993,44 +2995,52 @@ export const TransactionTable = forwardRef(
|
||||
|
||||
const allSchedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
|
||||
const isReactTableEnabled = useFeatureFlag('reactTableTransactions');
|
||||
|
||||
const innerProps = {
|
||||
tableRef: mergedRef,
|
||||
listContainerRef,
|
||||
...props,
|
||||
transactions: transactionsWithExpandedSplits,
|
||||
transactionMap,
|
||||
transactionsByParent,
|
||||
transferAccountsByTransaction,
|
||||
selectedItems,
|
||||
isExpanded: splitsExpanded.isExpanded,
|
||||
onSave,
|
||||
onDelete,
|
||||
onBatchDelete,
|
||||
onBatchDuplicate,
|
||||
onBatchLinkSchedule,
|
||||
onBatchUnlinkSchedule,
|
||||
onCreateRule,
|
||||
onScheduleAction,
|
||||
onMakeAsNonSplitTransactions,
|
||||
onSplit,
|
||||
onCheckNewEnter,
|
||||
onCheckEnter,
|
||||
onAddTemporary,
|
||||
onAddAndCloseTemporary,
|
||||
onAddSplit,
|
||||
onDistributeRemainder,
|
||||
onCloseAddTransaction,
|
||||
onToggleSplit,
|
||||
newTransactions: newTransactions ?? [],
|
||||
tableNavigator,
|
||||
newNavigator,
|
||||
showSelection: props.showSelection,
|
||||
allowSplitTransaction: props.allowSplitTransaction,
|
||||
showHiddenCategories,
|
||||
};
|
||||
|
||||
const InnerComponent = isReactTableEnabled
|
||||
? ReactTableTransactionTableInner
|
||||
: TransactionTableInner;
|
||||
|
||||
return (
|
||||
<DisplayPayeeProvider transactions={displayPayeeTransactions}>
|
||||
<SchedulesProvider query={allSchedulesQuery}>
|
||||
<TransactionTableInner
|
||||
tableRef={mergedRef}
|
||||
listContainerRef={listContainerRef}
|
||||
{...props}
|
||||
transactions={transactionsWithExpandedSplits}
|
||||
transactionMap={transactionMap}
|
||||
transactionsByParent={transactionsByParent}
|
||||
transferAccountsByTransaction={transferAccountsByTransaction}
|
||||
selectedItems={selectedItems}
|
||||
isExpanded={splitsExpanded.isExpanded}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onBatchDelete={onBatchDelete}
|
||||
onBatchDuplicate={onBatchDuplicate}
|
||||
onBatchLinkSchedule={onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onCreateRule={onCreateRule}
|
||||
onScheduleAction={onScheduleAction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
onSplit={onSplit}
|
||||
onCheckNewEnter={onCheckNewEnter}
|
||||
onCheckEnter={onCheckEnter}
|
||||
onAddTemporary={onAddTemporary}
|
||||
onAddAndCloseTemporary={onAddAndCloseTemporary}
|
||||
onAddSplit={onAddSplit}
|
||||
onDistributeRemainder={onDistributeRemainder}
|
||||
onCloseAddTransaction={onCloseAddTransaction}
|
||||
onToggleSplit={onToggleSplit}
|
||||
newTransactions={newTransactions ?? []}
|
||||
tableNavigator={tableNavigator}
|
||||
newNavigator={newNavigator}
|
||||
showSelection={props.showSelection}
|
||||
allowSplitTransaction={props.allowSplitTransaction}
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
/>
|
||||
<InnerComponent {...innerProps} />
|
||||
</SchedulesProvider>
|
||||
</DisplayPayeeProvider>
|
||||
);
|
||||
@@ -3039,7 +3049,7 @@ export const TransactionTable = forwardRef(
|
||||
|
||||
TransactionTable.displayName = 'TransactionTable';
|
||||
|
||||
const getCategoriesById = memoizeOne(
|
||||
export const getCategoriesById = memoizeOne(
|
||||
(categoryGroups: CategoryGroupEntity[] | null | undefined) => {
|
||||
const res: { [id: CategoryEntity['id']]: CategoryEntity } = {};
|
||||
categoryGroups?.forEach(group => {
|
||||
|
||||
@@ -11,6 +11,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
crossoverReport: false,
|
||||
customThemes: false,
|
||||
budgetAnalysisReport: false,
|
||||
reactTableTransactions: false,
|
||||
};
|
||||
|
||||
export function useFeatureFlag(name: FeatureFlag): boolean {
|
||||
|
||||
@@ -6,7 +6,8 @@ export type FeatureFlag =
|
||||
| 'currency'
|
||||
| 'crossoverReport'
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport';
|
||||
| 'budgetAnalysisReport'
|
||||
| 'reactTableTransactions';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
20
yarn.lock
20
yarn.lock
@@ -156,6 +156,7 @@ __metadata:
|
||||
"@swc/core": "npm:^1.15.8"
|
||||
"@swc/helpers": "npm:^0.5.18"
|
||||
"@tanstack/react-query": "npm:^5.90.5"
|
||||
"@tanstack/react-table": "npm:^8.21.3"
|
||||
"@testing-library/dom": "npm:10.4.1"
|
||||
"@testing-library/jest-dom": "npm:^6.9.1"
|
||||
"@testing-library/react": "npm:16.3.0"
|
||||
@@ -8509,6 +8510,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-table@npm:^8.21.3":
|
||||
version: 8.21.3
|
||||
resolution: "@tanstack/react-table@npm:8.21.3"
|
||||
dependencies:
|
||||
"@tanstack/table-core": "npm:8.21.3"
|
||||
peerDependencies:
|
||||
react: ">=16.8"
|
||||
react-dom: ">=16.8"
|
||||
checksum: 10/a32217ebe64d24e71dea6a6742bc288dcabf389657b16805a1ab3f347d3dca8262759c45c604a1f65bd97925d5cbdfb66d1be7637100a12eb5b279bdd420962d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/table-core@npm:8.21.3":
|
||||
version: 8.21.3
|
||||
resolution: "@tanstack/table-core@npm:8.21.3"
|
||||
checksum: 10/aa05e5f80110f0f56d66161e950668ea6ef3e2ea4f3a2ccd5d5980b39b4feea987245b20531aee2c6743e6edd12c0361503413b63090c807f88a61b19bfd04f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/dom@npm:10.4.1":
|
||||
version: 10.4.1
|
||||
resolution: "@testing-library/dom@npm:10.4.1"
|
||||
|
||||
Reference in New Issue
Block a user