mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-29 19:14:22 -05:00
- Fix React.* namespace references to use named imports (UIEvent, ReactNode) - Remove unused imports (useTranslation, TableNavigator) - Fix exhaustive-deps warning in useCallback - Add ReactTableTransactionsTable.test.tsx with 20 tests covering: - Data rendering correctness - Keyboard navigation (Enter/Tab/Shift+Enter/Shift+Tab/Escape) - Text field save behavior on navigation - Dropdown autocomplete (open, filter, keyboard select, click select) - New transaction creation (single, split, ctrl+enter, ctrl+click) - Escape to close new transaction form - Transaction selection - Split transactions (create, update, error handling) - Zero amount display in correct column - React Table-specific column visibility tests - Payee dropdown display tests Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
1128 lines
36 KiB
TypeScript
1128 lines
36 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { format as formatDate, parse as parseDate } from 'date-fns';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
import {
|
|
generateAccount,
|
|
generateCategoryGroups,
|
|
generateTransaction,
|
|
} from 'loot-core/mocks';
|
|
import { initServer } from 'loot-core/platform/client/fetch';
|
|
import {
|
|
addSplitTransaction,
|
|
realizeTempTransactions,
|
|
splitTransaction,
|
|
updateTransaction,
|
|
} from 'loot-core/shared/transactions';
|
|
import { integerToCurrency } from 'loot-core/shared/util';
|
|
import {
|
|
type AccountEntity,
|
|
type CategoryEntity,
|
|
type CategoryGroupEntity,
|
|
type PayeeEntity,
|
|
type TransactionEntity,
|
|
} from 'loot-core/types/models';
|
|
|
|
import { TransactionTable } from './TransactionsTable';
|
|
|
|
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
|
|
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
|
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
|
|
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
|
|
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
|
|
import { TestProvider } from '@desktop-client/redux/mock';
|
|
|
|
vi.mock('loot-core/platform/client/fetch');
|
|
|
|
// Enable the React Table feature flag for all tests in this file
|
|
vi.mock('../../hooks/useFeatureFlag', () => ({
|
|
useFeatureFlag: (flag: string) => {
|
|
if (flag === 'reactTableTransactions') {
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
}));
|
|
vi.mock('../../hooks/useSyncedPref', () => ({
|
|
useSyncedPref: vi.fn().mockReturnValue([undefined, vi.fn()]),
|
|
}));
|
|
|
|
const accounts = [generateAccount('Bank of America')];
|
|
vi.mock('../../hooks/useAccounts', () => ({
|
|
useAccounts: () => accounts,
|
|
}));
|
|
|
|
const payees: PayeeEntity[] = [
|
|
{
|
|
id: 'bob-id',
|
|
name: 'Bob',
|
|
favorite: true,
|
|
},
|
|
{
|
|
id: 'alice-id',
|
|
name: 'Alice',
|
|
favorite: true,
|
|
},
|
|
{
|
|
id: 'guy',
|
|
favorite: false,
|
|
name: 'This guy on the side of the road',
|
|
},
|
|
];
|
|
vi.mock('../../hooks/usePayees', async importOriginal => {
|
|
const actual =
|
|
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
await importOriginal<typeof import('../../hooks/usePayees')>();
|
|
return {
|
|
...actual,
|
|
usePayees: () => payees,
|
|
usePayeesById: () => {
|
|
const payeesById: Record<string, PayeeEntity> = {};
|
|
payees.forEach(payee => {
|
|
payeesById[payee.id] = payee;
|
|
});
|
|
return payeesById;
|
|
},
|
|
};
|
|
});
|
|
|
|
const categoryGroups = generateCategoryGroups([
|
|
{
|
|
name: 'Investments and Savings',
|
|
categories: [{ name: 'Savings' }],
|
|
},
|
|
{
|
|
name: 'Usual Expenses',
|
|
categories: [{ name: 'Food' }, { name: 'General' }, { name: 'Home' }],
|
|
},
|
|
{
|
|
name: 'Projects',
|
|
categories: [{ name: 'Big Projects' }, { name: 'Shed' }],
|
|
},
|
|
]);
|
|
vi.mock('../../hooks/useCategories', () => ({
|
|
useCategories: () => ({
|
|
list: categoryGroups.flatMap(g => g.categories),
|
|
grouped: categoryGroups,
|
|
}),
|
|
}));
|
|
|
|
const usualGroup = categoryGroups[1];
|
|
|
|
function generateTransactions(
|
|
count: number,
|
|
splitAtIndexes: number[] = [],
|
|
showError: boolean = false,
|
|
) {
|
|
const transactions: TransactionEntity[] = [];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const isSplit = splitAtIndexes.includes(i);
|
|
|
|
transactions.push.apply(
|
|
transactions,
|
|
generateTransaction(
|
|
{
|
|
account: accounts[0].id,
|
|
payee: 'alice-id',
|
|
category:
|
|
i === 0
|
|
? undefined
|
|
: i === 1
|
|
? usualGroup.categories?.[1].id
|
|
: usualGroup.categories?.[0].id,
|
|
amount: isSplit ? 50 : undefined,
|
|
sort_order: i,
|
|
},
|
|
isSplit ? 30 : undefined,
|
|
showError,
|
|
),
|
|
);
|
|
}
|
|
|
|
return transactions;
|
|
}
|
|
|
|
type LiveTransactionTableProps = {
|
|
transactions: TransactionEntity[];
|
|
payees: PayeeEntity[];
|
|
accounts: AccountEntity[];
|
|
categoryGroups: CategoryGroupEntity[];
|
|
currentAccountId: string | null;
|
|
showAccount: boolean;
|
|
showCategory: boolean;
|
|
showCleared: boolean;
|
|
isAdding: boolean;
|
|
onTransactionsChange?: (newTrans: TransactionEntity[]) => void;
|
|
onCloseAddTransaction?: () => void;
|
|
};
|
|
|
|
function LiveTransactionTable(props: LiveTransactionTableProps) {
|
|
const { transactions: transactionsProp, onTransactionsChange } = props;
|
|
|
|
const [transactions, setTransactions] = useState(transactionsProp);
|
|
|
|
useEffect(() => {
|
|
if (transactions === transactionsProp) return;
|
|
onTransactionsChange?.(transactions);
|
|
}, [transactions, transactionsProp, onTransactionsChange]);
|
|
|
|
const onSplit = (id: string) => {
|
|
const { data, diff } = splitTransaction(transactions, id);
|
|
setTransactions(data);
|
|
return diff.added[0].id;
|
|
};
|
|
|
|
const onSave = (transaction: TransactionEntity) => {
|
|
const { data } = updateTransaction(transactions, transaction);
|
|
setTransactions(data);
|
|
};
|
|
|
|
const onAdd = (newTransactions: TransactionEntity[]) => {
|
|
newTransactions = realizeTempTransactions(newTransactions);
|
|
setTransactions(trans => [...newTransactions, ...trans]);
|
|
};
|
|
|
|
const onAddSplit = (id: string) => {
|
|
const { data, diff } = addSplitTransaction(transactions, id);
|
|
setTransactions(data);
|
|
return diff.added[0].id;
|
|
};
|
|
|
|
const onCreatePayee = async () => 'id';
|
|
|
|
return (
|
|
<TestProvider>
|
|
<AuthProvider>
|
|
<SpreadsheetProvider>
|
|
<SchedulesProvider>
|
|
<SelectedProviderWithItems
|
|
name="transactions"
|
|
items={transactions}
|
|
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
|
|
>
|
|
<SplitsExpandedProvider>
|
|
<TransactionTable
|
|
{...props}
|
|
transactions={transactions}
|
|
loadMoreTransactions={vi.fn()}
|
|
// @ts-expect-error TODO: fix me
|
|
commonPayees={[]}
|
|
payees={payees}
|
|
addNotification={console.log}
|
|
onSave={onSave}
|
|
onSplit={onSplit}
|
|
onAdd={onAdd}
|
|
onAddSplit={onAddSplit}
|
|
onCreatePayee={onCreatePayee}
|
|
showSelection
|
|
allowSplitTransaction
|
|
/>
|
|
</SplitsExpandedProvider>
|
|
</SelectedProviderWithItems>
|
|
</SchedulesProvider>
|
|
</SpreadsheetProvider>
|
|
</AuthProvider>
|
|
</TestProvider>
|
|
);
|
|
}
|
|
|
|
function initBasicServer() {
|
|
initServer({
|
|
query: async query => {
|
|
switch (query.table) {
|
|
case 'payees':
|
|
return { data: payees, dependencies: [] };
|
|
case 'accounts':
|
|
return { data: accounts, dependencies: [] };
|
|
case 'transactions':
|
|
return {
|
|
data: generateTransactions(5, [6]),
|
|
dependencies: [],
|
|
};
|
|
default:
|
|
throw new Error(`queried unknown table: ${query.table}`);
|
|
}
|
|
},
|
|
'get-cell': async () => ({
|
|
name: 'test-cell',
|
|
value: 129_87,
|
|
}),
|
|
'get-categories': async () => ({
|
|
grouped: categoryGroups,
|
|
list: categories,
|
|
}),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
initBasicServer();
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.__resetWorld();
|
|
});
|
|
|
|
// Not good, see `Autocomplete.js` for details
|
|
function waitForAutocomplete() {
|
|
return new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
|
|
const categories = categoryGroups.reduce<CategoryEntity[]>(
|
|
(all, group) => (group.categories ? [...all, ...group.categories] : all),
|
|
[],
|
|
);
|
|
|
|
function prettyDate(date: string) {
|
|
return formatDate(parseDate(date, 'yyyy-MM-dd', new Date()), 'MM/dd/yyyy');
|
|
}
|
|
|
|
function renderTransactions(extraProps?: Partial<LiveTransactionTableProps>) {
|
|
let transactions = generateTransactions(5, [6]);
|
|
// Hardcoding the first value makes it easier for tests to do
|
|
// various this
|
|
transactions[0].amount = -2777;
|
|
|
|
const defaultProps: LiveTransactionTableProps = {
|
|
transactions,
|
|
payees,
|
|
accounts,
|
|
categoryGroups,
|
|
currentAccountId: accounts[0].id,
|
|
showAccount: true,
|
|
showCategory: true,
|
|
showCleared: true,
|
|
isAdding: false,
|
|
onTransactionsChange: t => {
|
|
transactions = t;
|
|
},
|
|
};
|
|
|
|
const result = render(
|
|
<LiveTransactionTable {...defaultProps} {...extraProps} />,
|
|
);
|
|
return {
|
|
...result,
|
|
getTransactions: () => transactions,
|
|
updateProps: (props: Partial<LiveTransactionTableProps>) =>
|
|
render(
|
|
<LiveTransactionTable {...defaultProps} {...extraProps} {...props} />,
|
|
{ container: result.container },
|
|
),
|
|
};
|
|
}
|
|
|
|
function queryNewField(
|
|
container: HTMLElement,
|
|
name: string,
|
|
subSelector: string = '',
|
|
idx: number = 0,
|
|
): HTMLInputElement {
|
|
const field = container.querySelectorAll(
|
|
`[data-testid="new-transaction"] [data-testid="${name}"]`,
|
|
)[idx];
|
|
if (subSelector !== '') {
|
|
return field.querySelector(subSelector)!;
|
|
}
|
|
return field as HTMLInputElement;
|
|
}
|
|
|
|
function queryField(
|
|
container: HTMLElement,
|
|
name: string,
|
|
subSelector: string = '',
|
|
idx: number,
|
|
) {
|
|
const field = container.querySelectorAll(
|
|
`[data-testid="transaction-table"] [data-testid="${name}"]`,
|
|
)[idx];
|
|
if (subSelector !== '') {
|
|
return field.querySelector(subSelector)!;
|
|
}
|
|
return field;
|
|
}
|
|
|
|
async function _editField(field: Element, container: HTMLElement) {
|
|
// We only short-circuit this for inputs
|
|
const input = field.querySelector(`input`);
|
|
if (input) {
|
|
expect(container.ownerDocument.activeElement).toBe(input);
|
|
return input;
|
|
}
|
|
|
|
let element: HTMLInputElement;
|
|
const buttonQuery = 'button,div[data-testid=cell-button]';
|
|
|
|
if (field.querySelector(buttonQuery)) {
|
|
const btn = field.querySelector(buttonQuery)!;
|
|
await userEvent.click(btn);
|
|
element = field.querySelector(':focus')!;
|
|
expect(element).toBeTruthy();
|
|
} else {
|
|
await userEvent.click(field.querySelector('div')!);
|
|
element = field.querySelector('input')!;
|
|
expect(element).toBeTruthy();
|
|
expect(container.ownerDocument.activeElement).toBe(element);
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
function editNewField(container: HTMLElement, name: string, rowIndex?: number) {
|
|
const field = queryNewField(container, name, '', rowIndex);
|
|
return _editField(field, container);
|
|
}
|
|
|
|
function editField(container: HTMLElement, name: string, rowIndex: number) {
|
|
const field = queryField(container, name, '', rowIndex);
|
|
return _editField(field, container);
|
|
}
|
|
|
|
function expectToBeEditingField(
|
|
container: HTMLElement,
|
|
name: string,
|
|
rowIndex: number,
|
|
isNew?: boolean,
|
|
) {
|
|
let field: Element;
|
|
if (isNew) {
|
|
field = queryNewField(container, name, '', rowIndex);
|
|
} else {
|
|
field = queryField(container, name, '', rowIndex);
|
|
}
|
|
const input: HTMLInputElement = field.querySelector(':focus')!;
|
|
expect(input).toBeTruthy();
|
|
expect(container.ownerDocument.activeElement).toBe(input);
|
|
return input;
|
|
}
|
|
|
|
describe('React Table Transactions', () => {
|
|
test('transactions table renders with React Table and shows correct data', () => {
|
|
const { container, getTransactions } = renderTransactions();
|
|
|
|
getTransactions().forEach((transaction, idx) => {
|
|
expect(queryField(container, 'date', 'div', idx).textContent).toBe(
|
|
prettyDate(transaction.date),
|
|
);
|
|
expect(queryField(container, 'account', 'div', idx).textContent).toBe(
|
|
accounts.find(acct => acct.id === transaction.account)?.name,
|
|
);
|
|
expect(queryField(container, 'payee', 'div', idx).textContent).toBe(
|
|
payees.find(p => p.id === transaction.payee)?.name,
|
|
);
|
|
expect(queryField(container, 'notes', 'div', idx).textContent).toBe(
|
|
transaction.notes,
|
|
);
|
|
expect(queryField(container, 'category', 'div', idx).textContent).toBe(
|
|
transaction.category
|
|
? categories.find(category => category.id === transaction.category)
|
|
?.name
|
|
: 'Categorize',
|
|
);
|
|
if (transaction.amount <= 0) {
|
|
expect(queryField(container, 'debit', 'div', idx).textContent).toBe(
|
|
integerToCurrency(-transaction.amount),
|
|
);
|
|
expect(queryField(container, 'credit', 'div', idx).textContent).toBe(
|
|
'',
|
|
);
|
|
} else {
|
|
expect(queryField(container, 'debit', 'div', idx).textContent).toBe('');
|
|
expect(queryField(container, 'credit', 'div', idx).textContent).toBe(
|
|
integerToCurrency(transaction.amount),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('keybindings enter/tab/alt should move around', async () => {
|
|
const { container } = renderTransactions();
|
|
|
|
// Enter/tab goes down/right
|
|
let input = await editField(container, 'notes', 2);
|
|
await userEvent.type(input, '[Enter]');
|
|
expectToBeEditingField(container, 'notes', 3);
|
|
|
|
input = await editField(container, 'payee', 2);
|
|
await userEvent.type(input, '[Tab]');
|
|
expectToBeEditingField(container, 'notes', 2);
|
|
|
|
// Shift+enter/tab goes up/left
|
|
input = await editField(container, 'notes', 2);
|
|
await userEvent.type(input, '{Shift>}[Enter]{/Shift}');
|
|
expectToBeEditingField(container, 'notes', 1);
|
|
|
|
input = await editField(container, 'payee', 2);
|
|
await userEvent.type(input, '{Shift>}[Tab]{/Shift}');
|
|
expectToBeEditingField(container, 'account', 2);
|
|
|
|
// Moving forward on the last cell moves to the next row
|
|
input = await editField(container, 'cleared', 2);
|
|
await userEvent.type(input, '[Tab]');
|
|
expectToBeEditingField(container, 'select', 3);
|
|
|
|
// Moving backward on the first cell moves to the previous row
|
|
await editField(container, 'date', 2);
|
|
input = await editField(container, 'select', 2);
|
|
await userEvent.type(input, '{Shift>}[Tab]{/Shift}');
|
|
expectToBeEditingField(container, 'cleared', 1);
|
|
|
|
// Blurring should close the input
|
|
input = await editField(container, 'credit', 1);
|
|
fireEvent.blur(input);
|
|
expect(container.querySelector('input')).toBe(null);
|
|
|
|
// When reaching the bottom it shouldn't error
|
|
input = await editField(container, 'notes', 4);
|
|
await userEvent.type(input, '[Enter]');
|
|
});
|
|
|
|
test('keybinding escape resets the value', async () => {
|
|
const { container } = renderTransactions();
|
|
|
|
let input = await editField(container, 'notes', 2);
|
|
let oldValue = input.value;
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'yo new value');
|
|
expect(input.value).toEqual('yo new value');
|
|
await userEvent.type(input, '[Escape]');
|
|
expect(input.value).toEqual(oldValue);
|
|
|
|
input = await editField(container, 'category', 2);
|
|
oldValue = input.value;
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'Gener');
|
|
expect(input.value).toEqual('Gener');
|
|
await userEvent.type(input, '[Escape]');
|
|
expect(input.value).toEqual(oldValue);
|
|
});
|
|
|
|
test('text fields save when moved away from', async () => {
|
|
const { container, getTransactions } = renderTransactions();
|
|
|
|
// All of these keys move to a different field, and the value in
|
|
// the previous input should be saved
|
|
const ks = [
|
|
'[Tab]',
|
|
'[Enter]',
|
|
'{Shift>}[Tab]{/Shift}',
|
|
'{Shift>}[Enter]{/Shift}',
|
|
];
|
|
|
|
for (const idx in ks) {
|
|
const input = await editField(container, 'notes', 2);
|
|
const oldValue = input.value;
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'a happy little note' + idx);
|
|
// It's not saved yet
|
|
expect(getTransactions()[2].notes).toBe(oldValue);
|
|
await userEvent.type(input, '[Tab]');
|
|
// Now it should be saved!
|
|
expect(getTransactions()[2].notes).toBe('a happy little note' + idx);
|
|
expect(queryField(container, 'notes', 'div', 2).textContent).toBe(
|
|
'a happy little note' + idx,
|
|
);
|
|
}
|
|
|
|
const input = await editField(container, 'notes', 2);
|
|
const oldValue = input.value;
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'another happy note');
|
|
// It's not saved yet
|
|
expect(getTransactions()[2].notes).toBe(oldValue);
|
|
// Blur the input to make it stop editing
|
|
await userEvent.tab();
|
|
expect(getTransactions()[2].notes).toBe('another happy note');
|
|
});
|
|
|
|
test('dropdown automatically opens and can be filtered', async () => {
|
|
const { container } = renderTransactions();
|
|
|
|
const categories = categoryGroups.flatMap(group => group.categories);
|
|
const input = await editField(container, 'category', 2);
|
|
expect(
|
|
[
|
|
...screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid*="category-item"]'),
|
|
].length,
|
|
).toBe(categoryGroups.length + categories.length);
|
|
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'Gener');
|
|
|
|
// Make sure the list is filtered, the right items exist, and the
|
|
// first item is highlighted
|
|
let items = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid*="category-item"]');
|
|
expect(items.length).toBe(2);
|
|
expect(items[0].textContent).toBe('Usual Expenses');
|
|
expect(items[1].textContent).toBe('General 129.87');
|
|
// @ts-expect-error fix me
|
|
expect(items[1].dataset['highlighted']).toBeDefined();
|
|
|
|
// It should not allow filtering on group names
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'Usual Expenses');
|
|
|
|
items = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid$="category-item"]');
|
|
expect(items.length).toBe(3);
|
|
}, 30000);
|
|
|
|
test('dropdown selects an item with keyboard', async () => {
|
|
const { container, getTransactions } = renderTransactions();
|
|
|
|
const input = await editField(container, 'category', 2);
|
|
|
|
// No item should be highlighted
|
|
let highlighted = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelector('[data-highlighted]');
|
|
expect(highlighted).toBeNull();
|
|
|
|
await userEvent.keyboard('[ArrowDown][ArrowDown][ArrowDown][ArrowDown]');
|
|
|
|
// The right item should be highlighted
|
|
highlighted = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelector('[data-highlighted]');
|
|
expect(highlighted).not.toBeNull();
|
|
expect(highlighted!.textContent).toBe('General 129.87');
|
|
|
|
expect(getTransactions()[2].category).toBe(
|
|
categories.find(category => category.name === 'Food')?.id,
|
|
);
|
|
|
|
await userEvent.type(input, '[Enter]');
|
|
await waitForAutocomplete();
|
|
|
|
// The transactions data should be updated with the right category
|
|
expect(getTransactions()[2].category).toBe(
|
|
categories.find(category => category.name === 'General')?.id,
|
|
);
|
|
|
|
// The category field should still be editing
|
|
expectToBeEditingField(container, 'category', 2);
|
|
// No dropdown should be open
|
|
expect(screen.queryByTestId('autocomplete')).toBe(null);
|
|
|
|
// Pressing enter should now move down
|
|
await userEvent.type(input, '[Enter]');
|
|
expectToBeEditingField(container, 'category', 3);
|
|
});
|
|
|
|
test('dropdown selects an item when clicking', async () => {
|
|
const { container, getTransactions } = renderTransactions();
|
|
|
|
await editField(container, 'category', 2);
|
|
|
|
// Make sure none of the items are highlighted
|
|
const items = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid$="category-item"]');
|
|
let highlighted = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelector('[data-highlighted]');
|
|
expect(highlighted).toBeNull();
|
|
|
|
// Hover over an item
|
|
await userEvent.hover(items[2]);
|
|
|
|
// Make sure the expected category is highlighted
|
|
highlighted = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelector('[data-highlighted]');
|
|
expect(highlighted).not.toBeNull();
|
|
expect(highlighted!.textContent).toBe('General 129.87');
|
|
|
|
// Click the item and check the before/after values
|
|
expect(getTransactions()[2].category).toBe(
|
|
categories.find(c => c.name === 'Food')?.id,
|
|
);
|
|
await userEvent.click(items[2]);
|
|
await waitForAutocomplete();
|
|
expect(getTransactions()[2].category).toBe(
|
|
categories.find(c => c.name === 'General')?.id,
|
|
);
|
|
|
|
// It should still be editing the category
|
|
expect(screen.queryByTestId('autocomplete')).toBe(null);
|
|
expectToBeEditingField(container, 'category', 2);
|
|
});
|
|
|
|
test("dropdown hovers but doesn't change value", async () => {
|
|
const { container, getTransactions } = renderTransactions();
|
|
|
|
const input = await editField(container, 'category', 2);
|
|
const oldCategory = getTransactions()[2].category;
|
|
|
|
const items = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid$="category-item"]');
|
|
|
|
// Hover over a few of the items to highlight them
|
|
await userEvent.hover(items[2]);
|
|
await userEvent.hover(items[3]);
|
|
|
|
// Make sure one of them is highlighted
|
|
const highlighted = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-highlighted]');
|
|
expect(highlighted).toHaveLength(1);
|
|
|
|
// Navigate away from the field with the keyboard
|
|
await userEvent.type(input, '[Tab]');
|
|
|
|
// Make sure the category didn't update, and that the highlighted
|
|
// field was different than the transactions' category
|
|
const currentCategory = getTransactions()[2].category;
|
|
expect(currentCategory).toBe(oldCategory);
|
|
// @ts-expect-error fix me
|
|
expect(highlighted.textContent).not.toBe(
|
|
categories.find(c => c.id === currentCategory)?.name,
|
|
);
|
|
});
|
|
|
|
test('adding a new transaction works', async () => {
|
|
const { queryByTestId, container, getTransactions, updateProps } =
|
|
renderTransactions();
|
|
|
|
expect(getTransactions().length).toBe(5);
|
|
expect(queryByTestId('new-transaction')).toBe(null);
|
|
updateProps({ isAdding: true });
|
|
expect(queryByTestId('new-transaction')).toBeTruthy();
|
|
|
|
let input = queryNewField(container, 'date', 'input');
|
|
|
|
// The date input should exist and have a default value
|
|
expect(input).toBeTruthy();
|
|
expect(container.ownerDocument.activeElement).toBe(input);
|
|
expect(input.value).not.toBe('');
|
|
|
|
input = await editNewField(container, 'notes');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'a transaction');
|
|
|
|
input = await editNewField(container, 'debit');
|
|
expect(input.value).toBe('0.00');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '100[Enter]');
|
|
|
|
expect(getTransactions().length).toBe(6);
|
|
expect(getTransactions()[0].amount).toBe(-10000);
|
|
expect(getTransactions()[0].notes).toBe('a transaction');
|
|
|
|
// The date field should be re-focused to enter a new transaction
|
|
expect(container.ownerDocument.activeElement).toBe(
|
|
queryNewField(container, 'date', 'input'),
|
|
);
|
|
expect(queryNewField(container, 'debit').textContent).toBe('0.00');
|
|
});
|
|
|
|
test('adding a new split transaction works', async () => {
|
|
const { container, getTransactions, updateProps } = renderTransactions();
|
|
updateProps({ isAdding: true });
|
|
|
|
let input = await editNewField(container, 'debit');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '55.00');
|
|
|
|
await editNewField(container, 'category');
|
|
await userEvent.click(screen.getByTestId('split-transaction-button'));
|
|
await waitForAutocomplete();
|
|
await waitForAutocomplete();
|
|
await waitForAutocomplete();
|
|
|
|
await userEvent.click(
|
|
container.querySelector('[data-testid="add-split-button"]')!,
|
|
);
|
|
|
|
input = await editNewField(container, 'debit', 1);
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '45.00');
|
|
expect(
|
|
container.querySelector('[data-testid="transaction-error"]'),
|
|
).toBeTruthy();
|
|
|
|
input = await editNewField(container, 'debit', 2);
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '10.00');
|
|
await userEvent.tab();
|
|
expect(container.querySelector('[data-testid="transaction-error"]')).toBe(
|
|
null,
|
|
);
|
|
|
|
const addButton = container.querySelector('[data-testid="add-button"]')!;
|
|
|
|
expect(getTransactions().length).toBe(5);
|
|
await userEvent.click(addButton);
|
|
expect(getTransactions().length).toBe(8);
|
|
expect(getTransactions()[0].is_parent).toBe(true);
|
|
expect(getTransactions()[0].amount).toBe(-5500);
|
|
expect(getTransactions()[1].is_child).toBe(true);
|
|
expect(getTransactions()[1].amount).toBe(-4500);
|
|
expect(getTransactions()[2].is_child).toBe(true);
|
|
expect(getTransactions()[2].amount).toBe(-1000);
|
|
});
|
|
|
|
test('escape closes the new transaction rows', async () => {
|
|
const { container, updateProps } = renderTransactions({
|
|
onCloseAddTransaction: () => {
|
|
updateProps({ isAdding: false });
|
|
},
|
|
});
|
|
updateProps({ isAdding: true });
|
|
|
|
// While adding a transaction, pressing escape should close the
|
|
// new transaction form
|
|
let input = expectToBeEditingField(container, 'date', 0, true);
|
|
await userEvent.type(input, '[Tab]');
|
|
input = expectToBeEditingField(container, 'account', 0, true);
|
|
|
|
await userEvent.type(input, '[Escape]');
|
|
await userEvent.type(input, '[Escape]');
|
|
expect(
|
|
container.querySelector('[data-testid="new-transaction"]'),
|
|
).toBeNull();
|
|
|
|
// The cancel button should also close the new transaction form
|
|
updateProps({ isAdding: true });
|
|
const cancelButton = container.querySelectorAll(
|
|
'[data-testid="new-transaction"] [data-testid="cancel-button"]',
|
|
)[0];
|
|
await userEvent.click(cancelButton);
|
|
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('ctrl/cmd+enter adds transaction and closes form', async () => {
|
|
const { container, getTransactions, updateProps } = renderTransactions({
|
|
onCloseAddTransaction: () => {
|
|
updateProps({ isAdding: false });
|
|
},
|
|
});
|
|
|
|
expect(getTransactions().length).toBe(5);
|
|
updateProps({ isAdding: true });
|
|
expect(
|
|
container.querySelector('[data-testid="new-transaction"]'),
|
|
).toBeTruthy();
|
|
|
|
let input = await editNewField(container, 'notes');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'test transaction');
|
|
|
|
input = await editNewField(container, 'debit');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '50.00');
|
|
|
|
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
|
|
|
expect(getTransactions().length).toBe(6);
|
|
expect(getTransactions()[0].amount).toBe(-5000);
|
|
expect(getTransactions()[0].notes).toBe('test transaction');
|
|
|
|
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('ctrl/cmd+click on add button adds transaction and closes form', async () => {
|
|
const { container, getTransactions, updateProps } = renderTransactions({
|
|
onCloseAddTransaction: () => {
|
|
updateProps({ isAdding: false });
|
|
},
|
|
});
|
|
|
|
expect(getTransactions().length).toBe(5);
|
|
updateProps({ isAdding: true });
|
|
expect(
|
|
container.querySelector('[data-testid="new-transaction"]'),
|
|
).toBeTruthy();
|
|
|
|
let input = await editNewField(container, 'notes');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, 'test transaction');
|
|
|
|
input = await editNewField(container, 'debit');
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '50.00');
|
|
await userEvent.tab();
|
|
|
|
const addButton = container.querySelector('[data-testid="add-button"]')!;
|
|
fireEvent.click(addButton, { ctrlKey: true });
|
|
|
|
expect(getTransactions().length).toBe(6);
|
|
expect(getTransactions()[0].amount).toBe(-5000);
|
|
expect(getTransactions()[0].notes).toBe('test transaction');
|
|
|
|
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('transaction can be selected', async () => {
|
|
const { container } = renderTransactions();
|
|
|
|
await editField(container, 'date', 2);
|
|
const selectCell = queryField(
|
|
container,
|
|
'select',
|
|
'[data-testid=cell-button]',
|
|
2,
|
|
);
|
|
|
|
await userEvent.click(selectCell);
|
|
// The header is is selected as well as the single transaction
|
|
expect(container.querySelectorAll('[data-testid=select] svg').length).toBe(
|
|
2,
|
|
);
|
|
});
|
|
|
|
test('transaction can be split, updated, and deleted', async () => {
|
|
const { container, getTransactions, updateProps } = renderTransactions();
|
|
|
|
const transactions = [...getTransactions()];
|
|
// Change the id to simulate a new transaction being added, and
|
|
// work with that one.
|
|
transactions[0] = { ...transactions[0], id: uuidv4() };
|
|
updateProps({ transactions });
|
|
|
|
function expectErrorToNotExist(transactions: TransactionEntity[]) {
|
|
transactions.forEach(transaction => {
|
|
expect(transaction.error).toBeFalsy();
|
|
});
|
|
}
|
|
|
|
function expectErrorToExist(transactions: TransactionEntity[]) {
|
|
transactions.forEach((transaction, idx) => {
|
|
if (idx === 0) {
|
|
expect(transaction.error).toBeTruthy();
|
|
} else {
|
|
expect(transaction.error).toBeFalsy();
|
|
}
|
|
});
|
|
}
|
|
|
|
let input = await editField(container, 'category', 0);
|
|
|
|
// Make it clear that we are expected a negative transaction
|
|
expect(getTransactions()[0].amount).toBe(-2777);
|
|
expectErrorToNotExist([getTransactions()[0]]);
|
|
|
|
// Make sure splitting a transaction works
|
|
expect(getTransactions().length).toBe(5);
|
|
await userEvent.click(screen.getByTestId('split-transaction-button'));
|
|
await waitForAutocomplete();
|
|
|
|
expect(getTransactions().length).toBe(6);
|
|
expect(getTransactions()[0].is_parent).toBe(true);
|
|
expect(getTransactions()[1].is_child).toBe(true);
|
|
expect(getTransactions()[1].amount).toBe(0);
|
|
expectErrorToExist(getTransactions().slice(0, 2));
|
|
|
|
const toolbars = screen.queryAllByTestId('transaction-error');
|
|
// Make sure the toolbar has appeared
|
|
expect(toolbars.length).toBe(1);
|
|
const toolbar = toolbars[0];
|
|
|
|
// Enter an amount for the new split transaction and make sure the
|
|
// toolbar updates
|
|
input = await editField(container, 'debit', 1);
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '10.00[tab]');
|
|
expect(toolbar.innerHTML.includes('17.77')).toBeTruthy();
|
|
|
|
// Add another split transaction and make sure everything is
|
|
// updated properly
|
|
await userEvent.click(
|
|
toolbar.querySelector('[data-testid="add-split-button"]')!,
|
|
);
|
|
expect(getTransactions().length).toBe(7);
|
|
expect(getTransactions()[2].amount).toBe(0);
|
|
expectErrorToExist(getTransactions().slice(0, 3));
|
|
|
|
// Change the amount to resolve the whole transaction. The toolbar
|
|
// should disappear and no error should exist
|
|
input = await editField(container, 'debit', 2);
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, '17.77[tab]');
|
|
await userEvent.tab();
|
|
expect(screen.queryAllByTestId('transaction-error')).toHaveLength(0);
|
|
expectErrorToNotExist(getTransactions().slice(0, 3));
|
|
|
|
// Verify the data structure
|
|
const parentId = getTransactions()[0].id;
|
|
expect(getTransactions().slice(0, 3)).toEqual([
|
|
{
|
|
account: accounts[0].id,
|
|
amount: -2777,
|
|
category: undefined,
|
|
cleared: false,
|
|
date: '2017-01-01',
|
|
error: null,
|
|
id: expect.any(String),
|
|
is_parent: true,
|
|
notes: 'Notes',
|
|
payee: 'alice-id',
|
|
sort_order: 0,
|
|
},
|
|
{
|
|
account: accounts[0].id,
|
|
amount: -1000,
|
|
category: undefined,
|
|
cleared: false,
|
|
date: '2017-01-01',
|
|
error: null,
|
|
id: expect.any(String),
|
|
is_child: true,
|
|
parent_id: parentId,
|
|
payee: 'alice-id',
|
|
reconciled: undefined,
|
|
sort_order: -1,
|
|
starting_balance_flag: null,
|
|
},
|
|
{
|
|
account: accounts[0].id,
|
|
amount: -1777,
|
|
category: undefined,
|
|
cleared: false,
|
|
date: '2017-01-01',
|
|
error: null,
|
|
id: expect.any(String),
|
|
is_child: true,
|
|
parent_id: parentId,
|
|
payee: 'alice-id',
|
|
reconciled: undefined,
|
|
sort_order: -2,
|
|
starting_balance_flag: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('transaction with splits shows 0 in correct column', async () => {
|
|
const { container, getTransactions } = renderTransactions();
|
|
|
|
let input = await editField(container, 'category', 0);
|
|
|
|
// The first transaction should always be a negative amount
|
|
expect(getTransactions()[0].amount).toBe(-2777);
|
|
|
|
// Add two new split transactions
|
|
expect(getTransactions().length).toBe(5);
|
|
await userEvent.click(screen.getByTestId('split-transaction-button'));
|
|
await waitForAutocomplete();
|
|
await userEvent.click(screen.getByTestId('add-split-button'));
|
|
expect(getTransactions().length).toBe(7);
|
|
|
|
// The debit field should show the zeros
|
|
expect(queryField(container, 'debit', '', 1).textContent).toBe('0.00');
|
|
expect(queryField(container, 'credit', '', 1).textContent).toBe('');
|
|
expect(queryField(container, 'debit', '', 2).textContent).toBe('0.00');
|
|
expect(queryField(container, 'credit', '', 2).textContent).toBe('');
|
|
|
|
// Change it to a credit transaction
|
|
input = await editField(container, 'credit', 0);
|
|
await userEvent.type(input, '55.00{Tab}');
|
|
|
|
// The zeros should now display in the credit column
|
|
expect(queryField(container, 'debit', '', 1).textContent).toBe('');
|
|
expect(queryField(container, 'credit', '', 1).textContent).toBe('0.00');
|
|
expect(queryField(container, 'debit', '', 2).textContent).toBe('');
|
|
expect(queryField(container, 'credit', '', 2).textContent).toBe('0.00');
|
|
});
|
|
|
|
// React Table-specific tests
|
|
|
|
test('React Table instance is created with correct columns', () => {
|
|
const { container } = renderTransactions();
|
|
|
|
// The table should have the transaction-table data-testid
|
|
const tableContainer = container.querySelector(
|
|
'[data-testid="transaction-table"]',
|
|
);
|
|
expect(tableContainer).toBeTruthy();
|
|
|
|
// Verify the header contains expected column names
|
|
const headerRow = container.querySelector('[data-testid="row"]');
|
|
expect(headerRow).toBeTruthy();
|
|
|
|
// Check that the Date header exists
|
|
const dateHeader = container.querySelector('[data-testid="date"]');
|
|
expect(dateHeader).toBeTruthy();
|
|
|
|
// Check that Account header is visible when showAccount is true
|
|
const accountHeader = container.querySelector('[data-testid="account"]');
|
|
expect(accountHeader).toBeTruthy();
|
|
|
|
// Check that Category header is visible when showCategory is true
|
|
const categoryHeader = container.querySelector('[data-testid="category"]');
|
|
expect(categoryHeader).toBeTruthy();
|
|
});
|
|
|
|
test('React Table hides account column when showAccount is false', () => {
|
|
const { container } = renderTransactions({ showAccount: false });
|
|
|
|
// Get only header-level testids (not data cells)
|
|
// The header row won't have an account column
|
|
const headerRow = container.querySelector('[data-testid="row"]');
|
|
expect(headerRow).toBeTruthy();
|
|
|
|
// Account cells in data rows should still not appear
|
|
// This verifies column visibility is controlled by the React Table config
|
|
const allRows = container.querySelectorAll('[data-testid="row"]');
|
|
expect(allRows.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('dropdown payee displays on new transaction with account list column', async () => {
|
|
const { container, updateProps, queryByTestId } = renderTransactions({
|
|
currentAccountId: null,
|
|
});
|
|
updateProps({ isAdding: true });
|
|
expect(queryByTestId('new-transaction')).toBeTruthy();
|
|
|
|
await editNewField(container, 'payee');
|
|
|
|
const renderedPayees = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid$="payee-item"]');
|
|
|
|
expect(
|
|
Array.from(renderedPayees.values()).map(p =>
|
|
p.getAttribute('data-testid'),
|
|
),
|
|
).toStrictEqual([
|
|
'Alice-payee-item',
|
|
'Bob-payee-item',
|
|
'This guy on the side of the road-payee-item',
|
|
]);
|
|
});
|
|
|
|
test('dropdown payee displays on existing non-transfer transaction', async () => {
|
|
const { container } = renderTransactions();
|
|
|
|
await editField(container, 'payee', 2);
|
|
|
|
const renderedPayees = screen
|
|
.getByTestId('autocomplete')
|
|
.querySelectorAll('[data-testid$="payee-item"]');
|
|
|
|
expect(
|
|
Array.from(renderedPayees.values()).map(p =>
|
|
p.getAttribute('data-testid'),
|
|
),
|
|
).toStrictEqual([
|
|
'Alice-payee-item',
|
|
'Bob-payee-item',
|
|
'This guy on the side of the road-payee-item',
|
|
]);
|
|
});
|
|
});
|