mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -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",
|
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
|
||||||
"playwright": "playwright"
|
"playwright": "playwright"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-table": "^8.21.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actual-app/components": "workspace:*",
|
"@actual-app/components": "workspace:*",
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ export function ExperimentalFeatures() {
|
|||||||
>
|
>
|
||||||
<Trans>Budget Analysis Report</Trans>
|
<Trans>Budget Analysis Report</Trans>
|
||||||
</FeatureToggle>
|
</FeatureToggle>
|
||||||
|
<FeatureToggle flag="reactTableTransactions">
|
||||||
|
<Trans>
|
||||||
|
React Table transactions (experimental table rewrite)
|
||||||
|
</Trans>
|
||||||
|
</FeatureToggle>
|
||||||
{showServerPrefs && (
|
{showServerPrefs && (
|
||||||
<ServerFeatureToggle
|
<ServerFeatureToggle
|
||||||
prefName="flags.plugins"
|
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,
|
type TransactionEntity,
|
||||||
} from 'loot-core/types/models';
|
} from 'loot-core/types/models';
|
||||||
|
|
||||||
|
import { ReactTableTransactionTableInner } from './ReactTableTransactionTableInner';
|
||||||
import {
|
import {
|
||||||
deserializeTransaction,
|
deserializeTransaction,
|
||||||
isLastChild,
|
isLastChild,
|
||||||
@@ -120,6 +121,7 @@ import {
|
|||||||
DisplayPayeeProvider,
|
DisplayPayeeProvider,
|
||||||
useDisplayPayee,
|
useDisplayPayee,
|
||||||
} from '@desktop-client/hooks/useDisplayPayee';
|
} from '@desktop-client/hooks/useDisplayPayee';
|
||||||
|
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||||
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||||
import { usePrevious } from '@desktop-client/hooks/usePrevious';
|
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 { getPayeesById } from '@desktop-client/payees/payeesSlice';
|
||||||
import { useDispatch } from '@desktop-client/redux';
|
import { useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
type TransactionHeaderProps = {
|
export type TransactionHeaderProps = {
|
||||||
hasSelected: boolean;
|
hasSelected: boolean;
|
||||||
showAccount: boolean;
|
showAccount: boolean;
|
||||||
showCategory: boolean;
|
showCategory: boolean;
|
||||||
@@ -152,7 +154,7 @@ type TransactionHeaderProps = {
|
|||||||
field: string;
|
field: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TransactionHeader = memo(
|
export const TransactionHeader = memo(
|
||||||
({
|
({
|
||||||
hasSelected,
|
hasSelected,
|
||||||
showAccount,
|
showAccount,
|
||||||
@@ -826,7 +828,7 @@ function PayeeIcons({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionProps = {
|
export type TransactionProps = {
|
||||||
allTransactions?: TransactionEntity[];
|
allTransactions?: TransactionEntity[];
|
||||||
transaction: TransactionEntity;
|
transaction: TransactionEntity;
|
||||||
subtransactions: TransactionEntity[] | null;
|
subtransactions: TransactionEntity[] | null;
|
||||||
@@ -882,7 +884,7 @@ type TransactionProps = {
|
|||||||
showHiddenCategories?: boolean;
|
showHiddenCategories?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Transaction = memo(function Transaction({
|
export const Transaction = memo(function Transaction({
|
||||||
allTransactions,
|
allTransactions,
|
||||||
transaction: originalTransaction,
|
transaction: originalTransaction,
|
||||||
subtransactions,
|
subtransactions,
|
||||||
@@ -1707,7 +1709,7 @@ const Transaction = memo(function Transaction({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
type TransactionErrorProps = {
|
export type TransactionErrorProps = {
|
||||||
error: NonNullable<TransactionEntity['error']>;
|
error: NonNullable<TransactionEntity['error']>;
|
||||||
isDeposit: boolean;
|
isDeposit: boolean;
|
||||||
onAddSplit: () => void;
|
onAddSplit: () => void;
|
||||||
@@ -1716,7 +1718,7 @@ type TransactionErrorProps = {
|
|||||||
canDistributeRemainder: boolean;
|
canDistributeRemainder: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TransactionError({
|
export function TransactionError({
|
||||||
error,
|
error,
|
||||||
isDeposit,
|
isDeposit,
|
||||||
onAddSplit,
|
onAddSplit,
|
||||||
@@ -1772,7 +1774,7 @@ function TransactionError({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type NewTransactionProps = {
|
export type NewTransactionProps = {
|
||||||
accounts: AccountEntity[];
|
accounts: AccountEntity[];
|
||||||
categoryGroups: CategoryGroupEntity[];
|
categoryGroups: CategoryGroupEntity[];
|
||||||
dateFormat: string;
|
dateFormat: string;
|
||||||
@@ -1809,7 +1811,7 @@ type NewTransactionProps = {
|
|||||||
};
|
};
|
||||||
showHiddenCategories?: boolean;
|
showHiddenCategories?: boolean;
|
||||||
};
|
};
|
||||||
function NewTransaction({
|
export function NewTransaction({
|
||||||
transactions,
|
transactions,
|
||||||
accounts,
|
accounts,
|
||||||
categoryGroups,
|
categoryGroups,
|
||||||
@@ -1955,7 +1957,7 @@ function NewTransaction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionTableInnerProps = {
|
export type TransactionTableInnerProps = {
|
||||||
tableRef: Ref<TableHandleRef<TransactionEntity>>;
|
tableRef: Ref<TableHandleRef<TransactionEntity>>;
|
||||||
listContainerRef: RefObject<HTMLDivElement>;
|
listContainerRef: RefObject<HTMLDivElement>;
|
||||||
tableNavigator: TableNavigator<TransactionEntity>;
|
tableNavigator: TableNavigator<TransactionEntity>;
|
||||||
@@ -2993,44 +2995,52 @@ export const TransactionTable = forwardRef(
|
|||||||
|
|
||||||
const allSchedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
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 (
|
return (
|
||||||
<DisplayPayeeProvider transactions={displayPayeeTransactions}>
|
<DisplayPayeeProvider transactions={displayPayeeTransactions}>
|
||||||
<SchedulesProvider query={allSchedulesQuery}>
|
<SchedulesProvider query={allSchedulesQuery}>
|
||||||
<TransactionTableInner
|
<InnerComponent {...innerProps} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</SchedulesProvider>
|
</SchedulesProvider>
|
||||||
</DisplayPayeeProvider>
|
</DisplayPayeeProvider>
|
||||||
);
|
);
|
||||||
@@ -3039,7 +3049,7 @@ export const TransactionTable = forwardRef(
|
|||||||
|
|
||||||
TransactionTable.displayName = 'TransactionTable';
|
TransactionTable.displayName = 'TransactionTable';
|
||||||
|
|
||||||
const getCategoriesById = memoizeOne(
|
export const getCategoriesById = memoizeOne(
|
||||||
(categoryGroups: CategoryGroupEntity[] | null | undefined) => {
|
(categoryGroups: CategoryGroupEntity[] | null | undefined) => {
|
||||||
const res: { [id: CategoryEntity['id']]: CategoryEntity } = {};
|
const res: { [id: CategoryEntity['id']]: CategoryEntity } = {};
|
||||||
categoryGroups?.forEach(group => {
|
categoryGroups?.forEach(group => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
|||||||
crossoverReport: false,
|
crossoverReport: false,
|
||||||
customThemes: false,
|
customThemes: false,
|
||||||
budgetAnalysisReport: false,
|
budgetAnalysisReport: false,
|
||||||
|
reactTableTransactions: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useFeatureFlag(name: FeatureFlag): boolean {
|
export function useFeatureFlag(name: FeatureFlag): boolean {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export type FeatureFlag =
|
|||||||
| 'currency'
|
| 'currency'
|
||||||
| 'crossoverReport'
|
| 'crossoverReport'
|
||||||
| 'customThemes'
|
| 'customThemes'
|
||||||
| 'budgetAnalysisReport';
|
| 'budgetAnalysisReport'
|
||||||
|
| 'reactTableTransactions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-device preferences. These sync across devices when they are changed.
|
* 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/core": "npm:^1.15.8"
|
||||||
"@swc/helpers": "npm:^0.5.18"
|
"@swc/helpers": "npm:^0.5.18"
|
||||||
"@tanstack/react-query": "npm:^5.90.5"
|
"@tanstack/react-query": "npm:^5.90.5"
|
||||||
|
"@tanstack/react-table": "npm:^8.21.3"
|
||||||
"@testing-library/dom": "npm:10.4.1"
|
"@testing-library/dom": "npm:10.4.1"
|
||||||
"@testing-library/jest-dom": "npm:^6.9.1"
|
"@testing-library/jest-dom": "npm:^6.9.1"
|
||||||
"@testing-library/react": "npm:16.3.0"
|
"@testing-library/react": "npm:16.3.0"
|
||||||
@@ -8509,6 +8510,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@testing-library/dom@npm:10.4.1":
|
||||||
version: 10.4.1
|
version: 10.4.1
|
||||||
resolution: "@testing-library/dom@npm:10.4.1"
|
resolution: "@testing-library/dom@npm:10.4.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user