diff --git a/packages/desktop-client/e2e/accounts.test.js b/packages/desktop-client/e2e/accounts.test.js index 45ca7096a28..93029854ef8 100644 --- a/packages/desktop-client/e2e/accounts.test.js +++ b/packages/desktop-client/e2e/accounts.test.js @@ -7,6 +7,7 @@ test.describe('Accounts', () => { let page; let navigation; let configurationPage; + let accountPage; test.beforeAll(async ({ browser }) => { page = await browser.newPage(); @@ -22,7 +23,7 @@ test.describe('Accounts', () => { }); test('creates a new account and views the initial balance transaction', async () => { - const accountPage = await navigation.createAccount({ + accountPage = await navigation.createAccount({ name: 'New Account', offBudget: false, balance: 100, @@ -38,7 +39,7 @@ test.describe('Accounts', () => { }); test('closes an account', async () => { - const accountPage = await navigation.goToAccountPage('Roth IRA'); + accountPage = await navigation.goToAccountPage('Roth IRA'); await expect(accountPage.accountName).toHaveText('Roth IRA'); @@ -50,4 +51,50 @@ test.describe('Accounts', () => { await expect(accountPage.accountName).toHaveText('Closed: Roth IRA'); await expect(page).toMatchThemeScreenshots(); }); + + test.describe('Budgeted Accounts', () => { + // Reset filters + test.afterEach(async () => { + await accountPage.removeFilter(0); + }); + + test('creates a transfer from two existing transactions', async () => { + accountPage = await navigation.goToAccountPage('For budget'); + await expect(accountPage.accountName).toHaveText('Budgeted Accounts'); + + await accountPage.filterByNote('Test Acc Transfer'); + + await accountPage.createSingleTransaction({ + account: 'Ally Savings', + payee: '', + notes: 'Test Acc Transfer', + category: 'Food', + debit: '34.56', + }); + + await accountPage.createSingleTransaction({ + account: 'HSBC', + payee: '', + notes: 'Test Acc Transfer', + category: 'Food', + credit: '34.56', + }); + + await accountPage.selectNthTransaction(0); + await accountPage.selectNthTransaction(1); + await accountPage.clickSelectAction('Make transfer'); + + let transaction = accountPage.getNthTransaction(0); + await expect(transaction.payee).toHaveText('Ally Savings'); + await expect(transaction.category).toHaveText('Transfer'); + await expect(transaction.credit).toHaveText('34.56'); + await expect(transaction.account).toHaveText('HSBC'); + + transaction = accountPage.getNthTransaction(1); + await expect(transaction.payee).toHaveText('HSBC'); + await expect(transaction.category).toHaveText('Transfer'); + await expect(transaction.debit).toHaveText('34.56'); + await expect(transaction.account).toHaveText('Ally Savings'); + }); + }); }); diff --git a/packages/desktop-client/e2e/page-models/account-page.js b/packages/desktop-client/e2e/page-models/account-page.js index cabd83f9535..6edf8588560 100644 --- a/packages/desktop-client/e2e/page-models/account-page.js +++ b/packages/desktop-client/e2e/page-models/account-page.js @@ -25,6 +25,9 @@ export class AccountPage { this.filterButton = this.page.getByRole('button', { name: 'Filter' }); this.filterSelectTooltip = this.page.getByTestId('filters-select-tooltip'); + + this.selectButton = this.page.getByTestId('transactions-select-button'); + this.selectTooltip = this.page.getByTestId('transactions-select-tooltip'); } /** @@ -68,14 +71,21 @@ export class AccountPage { await this.cancelTransactionButton.click(); } + async selectNthTransaction(index) { + const row = this.transactionTableRow.nth(index); + await row.getByTestId('select').click(); + } + /** * Retrieve the data for the nth-transaction. * 0-based index */ getNthTransaction(index) { const row = this.transactionTableRow.nth(index); + const account = row.getByTestId('account'); return { + ...(account ? { account } : {}), payee: row.getByTestId('payee'), notes: row.getByTestId('notes'), category: row.getByTestId('category'), @@ -84,6 +94,11 @@ export class AccountPage { }; } + async clickSelectAction(action) { + await this.selectButton.click(); + await this.selectTooltip.getByRole('button', { name: action }).click(); + } + /** * Open the modal for closing the account. */ @@ -106,6 +121,15 @@ export class AccountPage { return new FilterTooltip(this.page.getByTestId('filters-menu-tooltip')); } + /** + * Filter to a specific note + */ + async filterByNote(note) { + const filterTooltip = await this.filterBy('Note'); + await this.page.keyboard.type(note); + await filterTooltip.applyButton.click(); + } + /** * Remove the nth filter */ @@ -117,6 +141,12 @@ export class AccountPage { } async _fillTransactionFields(transactionRow, transaction) { + if (transaction.account) { + await transactionRow.getByTestId('account').click(); + await this.page.keyboard.type(transaction.account); + await this.page.keyboard.press('Tab'); + } + if (transaction.payee) { await transactionRow.getByTestId('payee').click(); await this.page.keyboard.type(transaction.payee); diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index 18a560f9979..4db7abb7c27 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -5,6 +5,7 @@ import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom'; import { debounce } from 'debounce'; import { bindActionCreators } from 'redux'; +import { validForTransfer } from 'loot-core/client/transfer'; import * as actions from 'loot-core/src/client/actions'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; import { @@ -1059,6 +1060,52 @@ class AccountInternal extends PureComponent { this.props.pushModal('edit-rule', { rule }); }; + onSetTransfer = async ids => { + const onConfirmTransfer = async ids => { + this.setState({ workingHard: true }); + + const payees = await this.props.getPayees(); + const { data: transactions } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*'), + ); + const [fromTrans, toTrans] = transactions; + + if (transactions.length === 2 && validForTransfer(fromTrans, toTrans)) { + const fromPayee = payees.find( + p => p.transfer_acct === fromTrans.account, + ); + const toPayee = payees.find(p => p.transfer_acct === toTrans.account); + + const changes = { + updated: [ + { + ...fromTrans, + payee: toPayee.id, + transfer_id: toTrans.id, + }, + { + ...toTrans, + payee: fromPayee.id, + transfer_id: fromTrans.id, + }, + ], + }; + + await send('transactions-batch-update', changes); + } + + await this.refetchTransactions(); + }; + + await this.checkForReconciledTransactions( + ids, + 'batchEditWithReconciled', + onConfirmTransfer, + ); + }; + onCondOpChange = (value, filters) => { this.setState({ conditionsOp: value }); this.setState({ filterId: { ...this.state.filterId, status: 'changed' } }); @@ -1443,6 +1490,7 @@ class AccountInternal extends PureComponent { onDeleteFilter={this.onDeleteFilter} onApplyFilter={this.onApplyFilter} onScheduleAction={this.onScheduleAction} + onSetTransfer={this.onSetTransfer} /> diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx index 789131335bb..eaade96bfb2 100644 --- a/packages/desktop-client/src/components/accounts/Header.jsx +++ b/packages/desktop-client/src/components/accounts/Header.jsx @@ -79,6 +79,7 @@ export function AccountHeader({ onCondOpChange, onDeleteFilter, onScheduleAction, + onSetTransfer, }) { const [menuOpen, setMenuOpen] = useState(false); const searchInput = useRef(null); @@ -94,6 +95,9 @@ export function AccountHeader({ canSync = !!accounts.find(account => !!account.account_id) && isUsingServer; } + // Only show the ability to make linked transfers on multi-account views. + const showMakeTransfer = !account; + function onToggleSplits() { if (tableRef.current) { splitsExpanded.dispatch({ @@ -276,8 +280,10 @@ export function AccountHeader({ onEdit={onBatchEdit} onUnlink={onBatchUnlink} onCreateRule={onCreateRule} + onSetTransfer={onSetTransfer} onScheduleAction={onScheduleAction} pushModal={pushModal} + showMakeTransfer={showMakeTransfer} /> )}