diff --git a/packages/desktop-client/e2e/page-models/navigation.js b/packages/desktop-client/e2e/page-models/navigation.js index 839997652f9c6b30f1c61a317d84f79821d3b77a..af5ba6acb3479b12794065e8d7bad81e47324ec7 100644 --- a/packages/desktop-client/e2e/page-models/navigation.js +++ b/packages/desktop-client/e2e/page-models/navigation.js @@ -1,4 +1,5 @@ import { AccountPage } from './account-page'; +import { SchedulesPage } from './schedules-page'; export class Navigation { constructor(page) { @@ -12,4 +13,10 @@ export class Navigation { return new AccountPage(this.page); } + + async goToSchedulesPage() { + await this.page.getByRole('link', { name: 'Schedules' }).click(); + + return new SchedulesPage(this.page); + } } diff --git a/packages/desktop-client/e2e/page-models/schedules-page.js b/packages/desktop-client/e2e/page-models/schedules-page.js new file mode 100644 index 0000000000000000000000000000000000000000..c61dbad381f3cb325659ab131dfb57df64ba78b7 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/schedules-page.js @@ -0,0 +1,93 @@ +export class SchedulesPage { + constructor(page) { + this.page = page; + + this.addNewScheduleButton = this.page.getByRole('button', { + name: 'Add new schedule', + }); + this.schedulesTableRow = this.page.getByTestId('table').getByTestId('row'); + } + + /** + * Add a new schedule + */ + async addNewSchedule(data) { + await this.addNewScheduleButton.click(); + + await this._fillScheduleFields(data); + + await this.page.getByRole('button', { name: 'Add' }).click(); + } + + /** + * Retrieve the row element for the nth-schedule. + * 0-based index + */ + getNthScheduleRow(index) { + return this.schedulesTableRow.nth(index); + } + + /** + * Retrieve the data for the nth-schedule. + * 0-based index + */ + async getNthSchedule(index) { + const row = this.getNthScheduleRow(index); + + return { + payee: await row.getByTestId('payee').textContent(), + account: await row.getByTestId('account').textContent(), + date: await row.getByTestId('date').textContent(), + status: await row.getByTestId('status').textContent(), + amount: await row.getByTestId('amount').textContent(), + }; + } + + /** + * Create a transaction for the nth-schedule. + * 0-based index + */ + async postNthSchedule(index) { + await this._performNthAction(index, 'Post transaction'); + await this.page.waitForTimeout(1000); + } + + /** + * Complete the nth-schedule. + * 0-based index + */ + async completeNthSchedule(index) { + await this._performNthAction(index, 'Complete'); + await this.page.waitForTimeout(1000); + } + + async _performNthAction(index, actionName) { + const row = this.getNthScheduleRow(index); + const actions = row.getByTestId('actions'); + + await actions.getByRole('button').click(); + await this.page.getByRole('button', { name: actionName }).click(); + } + + async _fillScheduleFields(data) { + if (data.payee) { + await this.page.getByLabel('Payee').click(); + await this.page.keyboard.type(data.payee); + await this.page.keyboard.press('Enter'); + } + + if (data.account) { + await this.page.getByLabel('Account').click(); + await this.page.keyboard.type(data.account); + await this.page.keyboard.press('Enter'); + } + + if (data.amount) { + await this.page.getByLabel('Amount').click(); + await this.page.keyboard.press('Control+A'); + await this.page.keyboard.press('Delete'); + await this.page.keyboard.type(String(data.amount)); + await this.page.keyboard.press('Enter'); + } + } +} diff --git a/packages/desktop-client/e2e/schedules.test.js b/packages/desktop-client/e2e/schedules.test.js new file mode 100644 index 0000000000000000000000000000000000000000..3c194c3fb78a2af9d7b6df590aa091a49c385a04 --- /dev/null +++ b/packages/desktop-client/e2e/schedules.test.js @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Schedules', () => { + let page; + let navigation; + let schedulesPage; + let configurationPage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + await configurationPage.createTestFile(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.beforeEach(async () => { + schedulesPage = await navigation.goToSchedulesPage(); + }); + + test('creates a new schedule, posts the transaction and later completes it', async () => { + await schedulesPage.addNewSchedule({ + payee: 'Home Depot', + account: 'HSBC', + amount: 25, + }); + + expect(await schedulesPage.getNthSchedule(0)).toMatchObject({ + payee: 'Home Depot', + account: 'HSBC', + amount: '~25.00', + status: 'Due', + }); + + await schedulesPage.postNthSchedule(0); + expect(await schedulesPage.getNthSchedule(0)).toMatchObject({ + status: 'Paid', + }); + + // Go to transactions page + const accountPage = await navigation.goToAccountPage('HSBC'); + expect(await accountPage.getNthTransaction(0)).toMatchObject({ + payee: 'Home Depot', + category: 'Categorize', + debit: '25.00', + credit: '', + }); + + // Go back to schedules page + await navigation.goToSchedulesPage(); + await schedulesPage.completeNthSchedule(0); + expect(await schedulesPage.getNthScheduleRow(0)).toHaveText( + 'Show completed schedules', + ); + }); +}); diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.js b/packages/desktop-client/src/components/schedules/EditSchedule.js index 7fe5ce68ab3b5f3f04a0db096568d78cf8c997e2..5e61e2373f2f7adc8df5f9e5690529a6a40070a8 100644 --- a/packages/desktop-client/src/components/schedules/EditSchedule.js +++ b/packages/desktop-client/src/components/schedules/EditSchedule.js @@ -427,10 +427,10 @@ export default function ScheduleDetails() { > <Stack direction="row" style={{ marginTop: 20 }}> <FormField style={{ flex: 1 }}> - <FormLabel title="Payee" /> + <FormLabel title="Payee" htmlFor="payee-field" /> <PayeeAutocomplete value={state.fields.payee} - inputProps={{ placeholder: '(none)' }} + inputProps={{ id: 'payee-field', placeholder: '(none)' }} onSelect={id => dispatch({ type: 'set-field', field: 'payee', value: id }) } @@ -438,11 +438,11 @@ export default function ScheduleDetails() { </FormField> <FormField style={{ flex: 1 }}> - <FormLabel title="Account" /> + <FormLabel title="Account" htmlFor="account-field" /> <AccountAutocomplete includeClosedAccounts={false} value={state.fields.account} - inputProps={{ placeholder: '(none)' }} + inputProps={{ id: 'account-field', placeholder: '(none)' }} onSelect={id => dispatch({ type: 'set-field', field: 'account', value: id }) } @@ -451,7 +451,11 @@ export default function ScheduleDetails() { <FormField style={{ flex: 1 }}> <Stack direction="row" align="center" style={{ marginBottom: 3 }}> - <FormLabel title="Amount" style={{ margin: 0, flex: 1 }} /> + <FormLabel + title="Amount" + htmlFor="amount-field" + style={{ margin: 0, flex: 1 }} + /> <OpSelect ops={['is', 'isapprox', 'isbetween']} value={state.fields.amountOp} @@ -490,6 +494,7 @@ export default function ScheduleDetails() { /> ) : ( <AmountInput + id="amount-field" defaultValue={state.fields.amount} onChange={value => dispatch({ diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.js b/packages/desktop-client/src/components/schedules/SchedulesTable.js index 9b02316cf176b95d8b13e3381f6e5acf23ca3ba1..770504e6c67721de2c62642f283fd617a5521ed2 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.js +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.js @@ -95,6 +95,7 @@ export function ScheduleAmountCell({ amount, op }) { alignItems: 'center', padding: '0 5px', }} + name="amount" > {isApprox && ( <View @@ -206,18 +207,18 @@ export function SchedulesTable({ ':hover': { backgroundColor: colors.hover }, }} > - <Field width="flex"> + <Field width="flex" name="payee"> <DisplayId type="payees" id={item._payee} /> </Field> - <Field width="flex"> + <Field width="flex" name="account"> <DisplayId type="accounts" id={item._account} /> </Field> - <Field width={110}> + <Field width={110} name="date"> {item.next_date ? monthUtils.format(item.next_date, dateFormat) : null} </Field> - <Field width={120} style={{ alignItems: 'flex-start' }}> + <Field width={120} name="status" style={{ alignItems: 'flex-start' }}> <StatusBadge status={statuses.get(item.id)} /> </Field> <ScheduleAmountCell amount={item._amount} op={item._amountOp} /> @@ -229,7 +230,7 @@ export function SchedulesTable({ </Field> )} {!minimal && ( - <Field width={40}> + <Field width={40} name="actions"> <OverflowMenu schedule={item} status={statuses.get(item.id)} diff --git a/packages/desktop-client/src/components/util/AmountInput.js b/packages/desktop-client/src/components/util/AmountInput.js index d21fe311af374520b1d4ff6e7bcb5ad20bd6fa3d..d9849a78a7dbf3571b3ca8b5134cf63afb6144c4 100644 --- a/packages/desktop-client/src/components/util/AmountInput.js +++ b/packages/desktop-client/src/components/util/AmountInput.js @@ -12,7 +12,7 @@ import { import Add from 'loot-design/src/svg/v1/Add'; import Subtract from 'loot-design/src/svg/v1/Subtract'; -export function AmountInput({ defaultValue = 0, onChange, style }) { +export function AmountInput({ id, defaultValue = 0, onChange, style }) { let [negative, setNegative] = useState(defaultValue <= 0); let [value, setValue] = useState(integerToCurrency(Math.abs(defaultValue))); @@ -30,6 +30,7 @@ export function AmountInput({ defaultValue = 0, onChange, style }) { return ( <InputWithContent + id={id} leftContent={ <Button bare style={{ padding: '0 7px' }} onClick={onSwitch}> {negative ? ( diff --git a/packages/loot-design/src/components/common.js b/packages/loot-design/src/components/common.js index 876bd6275ca4945d99ef1a0b9f8603f9f9d728d2..cd98c800cb368376a1728a4a6bb708fa26f4ad47 100644 --- a/packages/loot-design/src/components/common.js +++ b/packages/loot-design/src/components/common.js @@ -626,6 +626,7 @@ export function Menu({ header, footer, items: allItems, onMenuSelect }) { return ( <View + role="button" key={item.name} style={[ { diff --git a/packages/loot-design/src/components/forms.js b/packages/loot-design/src/components/forms.js index 86d02bd8f6d4fe4a7481f7bb9cea469eaf99ea5a..50dbe703bfad247009a5522ca79892239722c897 100644 --- a/packages/loot-design/src/components/forms.js +++ b/packages/loot-design/src/components/forms.js @@ -25,10 +25,10 @@ export function SectionLabel({ title, style }) { ); } -export function FormLabel({ style, title }) { +export function FormLabel({ style, title, htmlFor }) { return ( <Text style={[{ fontSize: 13, marginBottom: 3, color: colors.n3 }, style]}> - {title} + <label htmlFor={htmlFor}>{title}</label> </Text> ); }