mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Compare commits
20 Commits
prerelease
...
tests-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db5913b86 | ||
|
|
29da17ae76 | ||
|
|
5bf65cb20f | ||
|
|
d487a609ae | ||
|
|
8b7d6ba520 | ||
|
|
153d4e2d18 | ||
|
|
6a77b04ad7 | ||
|
|
fb8f89d411 | ||
|
|
874a2cd8cc | ||
|
|
4dc41356b9 | ||
|
|
e7e2fe28b6 | ||
|
|
3ef0ea256a | ||
|
|
a99846d3f6 | ||
|
|
987aafd4d4 | ||
|
|
e54a81881c | ||
|
|
7be77b836c | ||
|
|
2b87e7c388 | ||
|
|
22aed82c39 | ||
|
|
5ff59ae3c9 | ||
|
|
54f4427423 |
@@ -34,6 +34,7 @@ export const Popover = ({
|
||||
|
||||
return (
|
||||
<ReactAriaPopover
|
||||
data-popover={true}
|
||||
ref={ref}
|
||||
placement="bottom end"
|
||||
offset={1}
|
||||
|
||||
@@ -194,47 +194,71 @@ export class AccountPage {
|
||||
transaction: TransactionEntry,
|
||||
) {
|
||||
if (transaction.debit) {
|
||||
// double click to ensure the content is selected when adding split transactions
|
||||
await transactionRow.getByTestId('debit').dblclick();
|
||||
await this.page.keyboard.type(transaction.debit);
|
||||
const debitCell = transactionRow.getByTestId('debit');
|
||||
await debitCell.click();
|
||||
const debitInput = debitCell.getByRole('textbox');
|
||||
await this.selectInputText(debitInput);
|
||||
await debitInput.pressSequentially(transaction.debit);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.credit) {
|
||||
await transactionRow.getByTestId('credit').click();
|
||||
await this.page.keyboard.type(transaction.credit);
|
||||
const creditCell = transactionRow.getByTestId('credit');
|
||||
await creditCell.click();
|
||||
const creditInput = creditCell.getByRole('textbox');
|
||||
await this.selectInputText(creditInput);
|
||||
await creditInput.pressSequentially(transaction.credit);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.account) {
|
||||
await transactionRow.getByTestId('account').click();
|
||||
await this.page.keyboard.type(transaction.account);
|
||||
const accountCell = transactionRow.getByTestId('account');
|
||||
await accountCell.click();
|
||||
const accountInput = accountCell.getByRole('textbox');
|
||||
await this.selectInputText(accountInput);
|
||||
await accountInput.pressSequentially(transaction.account);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.payee) {
|
||||
await transactionRow.getByTestId('payee').click();
|
||||
await this.page.keyboard.type(transaction.payee);
|
||||
const payeeCell = transactionRow.getByTestId('payee');
|
||||
await payeeCell.click();
|
||||
const payeeInput = payeeCell.getByRole('textbox');
|
||||
await this.selectInputText(payeeInput);
|
||||
await payeeInput.pressSequentially(transaction.payee);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.notes) {
|
||||
await transactionRow.getByTestId('notes').click();
|
||||
await this.page.keyboard.type(transaction.notes);
|
||||
const notesCell = transactionRow.getByTestId('notes');
|
||||
await notesCell.click();
|
||||
const notesInput = notesCell.getByRole('textbox');
|
||||
await this.selectInputText(notesInput);
|
||||
await notesInput.pressSequentially(transaction.notes);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.category) {
|
||||
await transactionRow.getByTestId('category').click();
|
||||
const categoryCell = transactionRow.getByTestId('category');
|
||||
await categoryCell.click();
|
||||
|
||||
if (transaction.category === 'split') {
|
||||
await this.page.getByTestId('split-transaction-button').click();
|
||||
} else {
|
||||
await this.page.keyboard.type(transaction.category);
|
||||
const categoryInput = categoryCell.getByRole('textbox');
|
||||
await this.selectInputText(categoryInput);
|
||||
await categoryInput.pressSequentially(transaction.category);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectInputText(input: Locator) {
|
||||
const value = await input.inputValue();
|
||||
if (value) {
|
||||
await input.selectText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterTooltip {
|
||||
|
||||
171
packages/desktop-client/e2e/page-models/edit-rule-modal.ts
Normal file
171
packages/desktop-client/e2e/page-models/edit-rule-modal.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
|
||||
type ConditionsEntry = {
|
||||
field: string;
|
||||
op: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ActionsEntry = {
|
||||
field: string;
|
||||
op?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SplitsEntry = {
|
||||
field: string;
|
||||
op?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
type RuleEntry = {
|
||||
conditionsOp?: string | RegExp;
|
||||
conditions?: ConditionsEntry[];
|
||||
actions?: ActionsEntry[];
|
||||
splits?: Array<SplitsEntry[]>;
|
||||
};
|
||||
|
||||
export class EditRuleModal {
|
||||
readonly page: Page;
|
||||
readonly locator: Locator;
|
||||
readonly heading: Locator;
|
||||
readonly conditionsOpButton: Locator;
|
||||
readonly conditionList: Locator;
|
||||
readonly actionList: Locator;
|
||||
readonly splitIntoMultipleTransactionsButton: Locator;
|
||||
readonly saveButton: Locator;
|
||||
readonly cancelButton: Locator;
|
||||
|
||||
constructor(locator: Locator) {
|
||||
this.locator = locator;
|
||||
this.page = locator.page();
|
||||
|
||||
this.heading = locator.getByRole('heading');
|
||||
this.conditionsOpButton = locator
|
||||
.getByTestId('conditions-op')
|
||||
.getByRole('button');
|
||||
this.conditionList = locator.getByTestId('condition-list');
|
||||
this.actionList = locator.getByTestId('action-list');
|
||||
this.splitIntoMultipleTransactionsButton = locator.getByTestId(
|
||||
'add-split-transactions',
|
||||
);
|
||||
this.saveButton = locator.getByRole('button', { name: 'Save' });
|
||||
this.cancelButton = locator.getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
async fill(data: RuleEntry) {
|
||||
if (data.conditionsOp) {
|
||||
await this.selectConditionsOp(data.conditionsOp);
|
||||
}
|
||||
|
||||
if (data.conditions) {
|
||||
await this.fillEditorFields(data.conditions, this.conditionList, true);
|
||||
}
|
||||
|
||||
if (data.actions) {
|
||||
await this.fillEditorFields(data.actions, this.actionList);
|
||||
}
|
||||
|
||||
if (data.splits) {
|
||||
let idx = data.actions?.length ?? 0;
|
||||
for (const splitActions of data.splits) {
|
||||
await this.splitIntoMultipleTransactionsButton.click();
|
||||
await this.fillEditorFields(splitActions, this.actionList.nth(idx));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fillEditorFields(
|
||||
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
|
||||
rootElement: Locator,
|
||||
fieldFirst = false,
|
||||
) {
|
||||
for (const [idx, entry] of data.entries()) {
|
||||
const { field, op, value } = entry;
|
||||
|
||||
const row = await this.getRow(rootElement, idx);
|
||||
|
||||
if (!(await row.isVisible())) {
|
||||
await this.addEntry(rootElement);
|
||||
}
|
||||
|
||||
if (op && !fieldFirst) {
|
||||
await this.selectOp(row, op);
|
||||
}
|
||||
|
||||
if (field) {
|
||||
await this.selectField(row, field);
|
||||
}
|
||||
|
||||
if (op && fieldFirst) {
|
||||
await this.selectOp(row, op);
|
||||
}
|
||||
|
||||
if (value && value.length > 0) {
|
||||
const input = row.getByRole('textbox');
|
||||
const existingValue = await input.inputValue();
|
||||
if (existingValue) {
|
||||
await input.selectText();
|
||||
}
|
||||
// Using pressSequentially here to simulate user typing.
|
||||
// When using .fill(...), playwright just "pastes" the entire word onto the input
|
||||
// and for some reason this breaks the autocomplete highlighting logic
|
||||
// e.g. "Create payee" option is not being highlighted.
|
||||
await input.pressSequentially(value);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectConditionsOp(conditionsOp: string | RegExp) {
|
||||
await this.conditionsOpButton.click();
|
||||
|
||||
const conditionsOpSelectOption =
|
||||
await this.getPopoverSelectOption(conditionsOp);
|
||||
await conditionsOpSelectOption.click();
|
||||
}
|
||||
|
||||
async selectOp(row: Locator, op: string) {
|
||||
await row.getByTestId('op-select').getByRole('button').click();
|
||||
|
||||
const opSelectOption = await this.getPopoverSelectOption(op);
|
||||
await opSelectOption.waitFor({ state: 'visible' });
|
||||
await opSelectOption.click();
|
||||
}
|
||||
|
||||
async selectField(row: Locator, field: string) {
|
||||
await row.getByTestId('field-select').getByRole('button').click();
|
||||
|
||||
const fieldSelectOption = await this.getPopoverSelectOption(field);
|
||||
await fieldSelectOption.waitFor({ state: 'visible' });
|
||||
await fieldSelectOption.click();
|
||||
}
|
||||
|
||||
async getRow(locator: Locator, index: number) {
|
||||
return locator.getByTestId('editor-row').nth(index);
|
||||
}
|
||||
|
||||
async addEntry(locator: Locator) {
|
||||
await locator.getByRole('button', { name: 'Add entry' }).click();
|
||||
}
|
||||
|
||||
async getPopoverSelectOption(value: string | RegExp) {
|
||||
// Need to use page because popover is rendered outside of modal locator
|
||||
return this.page
|
||||
.locator('[data-popover]')
|
||||
.getByRole('button', { name: value, exact: true });
|
||||
}
|
||||
|
||||
async save() {
|
||||
await this.saveButton.click();
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
await this.cancelButton.click();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.heading.getByRole('button', { name: 'Close' }).click();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,26 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
type ConditionsEntry = {
|
||||
field: string;
|
||||
op: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ActionsEntry = {
|
||||
field: string;
|
||||
op?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SplitsEntry = {
|
||||
field: string;
|
||||
op?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
type RuleEntry = {
|
||||
conditionsOp?: string | RegExp;
|
||||
conditions?: ConditionsEntry[];
|
||||
actions?: ActionsEntry[];
|
||||
splits?: Array<SplitsEntry[]>;
|
||||
};
|
||||
import { EditRuleModal } from './edit-rule-modal';
|
||||
|
||||
export class RulesPage {
|
||||
readonly page: Page;
|
||||
readonly searchBox: Locator;
|
||||
readonly createNewRuleButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.searchBox = page.getByPlaceholder('Filter rules...');
|
||||
this.createNewRuleButton = page.getByRole('button', {
|
||||
name: 'Create new rule',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new rule
|
||||
* Open the edit rule modal to create a new rule.
|
||||
*/
|
||||
async createRule(data: RuleEntry) {
|
||||
await this.page
|
||||
.getByRole('button', {
|
||||
name: 'Create new rule',
|
||||
})
|
||||
.click();
|
||||
|
||||
await this._fillRuleFields(data);
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
async createNewRule() {
|
||||
await this.createNewRuleButton.click();
|
||||
return new EditRuleModal(this.page.getByTestId('edit-rule-modal'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,108 +39,4 @@ export class RulesPage {
|
||||
async searchFor(text: string) {
|
||||
await this.searchBox.fill(text);
|
||||
}
|
||||
|
||||
async _fillRuleFields(data: RuleEntry) {
|
||||
if (data.conditionsOp) {
|
||||
await this.page
|
||||
.getByTestId('conditions-op')
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('button', { exact: true, name: data.conditionsOp })
|
||||
.click();
|
||||
}
|
||||
|
||||
if (data.conditions) {
|
||||
await this._fillEditorFields(
|
||||
data.conditions,
|
||||
this.page.getByTestId('condition-list'),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.actions) {
|
||||
await this._fillEditorFields(
|
||||
data.actions,
|
||||
this.page.getByTestId('action-list'),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.splits) {
|
||||
let idx = data.actions?.length ?? 0;
|
||||
for (const splitActions of data.splits) {
|
||||
await this.page.getByTestId('add-split-transactions').click();
|
||||
await this._fillEditorFields(
|
||||
splitActions,
|
||||
this.page.getByTestId('action-list').nth(idx),
|
||||
);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _fillEditorFields(
|
||||
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
|
||||
rootElement: Locator,
|
||||
fieldFirst = false,
|
||||
) {
|
||||
for (const [idx, entry] of data.entries()) {
|
||||
const { field, op, value } = entry;
|
||||
|
||||
const row = rootElement.getByTestId('editor-row').nth(idx);
|
||||
|
||||
if (!(await row.isVisible())) {
|
||||
await rootElement.getByRole('button', { name: 'Add entry' }).click();
|
||||
}
|
||||
|
||||
if (op && !fieldFirst) {
|
||||
await row.getByTestId('op-select').getByRole('button').first().click();
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
if (field) {
|
||||
await row
|
||||
.getByTestId('field-select')
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('button', { name: field, exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: field, exact: true })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
if (op && fieldFirst) {
|
||||
await row.getByTestId('op-select').getByRole('button').first().click();
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: op, exact: true })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
if (value) {
|
||||
await row.getByRole('textbox').fill(value);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
|
||||
type ScheduleEntry = {
|
||||
scheduleName?: string;
|
||||
payee?: string;
|
||||
account?: string;
|
||||
amount?: number;
|
||||
};
|
||||
|
||||
export class ScheduleEditModal {
|
||||
readonly page: Page;
|
||||
readonly locator: Locator;
|
||||
readonly heading: Locator;
|
||||
readonly scheduleNameInput: Locator;
|
||||
readonly payeeInput: Locator;
|
||||
readonly accountInput: Locator;
|
||||
readonly amountInput: Locator;
|
||||
readonly addButton: Locator;
|
||||
readonly saveButton: Locator;
|
||||
readonly cancelButton: Locator;
|
||||
|
||||
constructor(locator: Locator) {
|
||||
this.locator = locator;
|
||||
this.page = locator.page();
|
||||
|
||||
this.heading = locator.getByRole('heading');
|
||||
this.scheduleNameInput = locator.getByRole('textbox', {
|
||||
name: 'Schedule name',
|
||||
});
|
||||
this.payeeInput = locator.getByRole('textbox', { name: 'Payee' });
|
||||
this.accountInput = locator.getByRole('textbox', { name: 'Account' });
|
||||
this.amountInput = locator.getByLabel('Amount');
|
||||
this.addButton = locator.getByRole('button', { name: 'Add' });
|
||||
this.saveButton = locator.getByRole('button', { name: 'Save' });
|
||||
this.cancelButton = locator.getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
async fill(data: ScheduleEntry) {
|
||||
// Using pressSequentially on autocomplete fields here to simulate user typing.
|
||||
// When using .fill(...), playwright just "pastes" the entire word onto the input
|
||||
// and for some reason this breaks the autocomplete highlighting logic
|
||||
// e.g. "Create payee" option is not being highlighted.
|
||||
|
||||
if (data.scheduleName) {
|
||||
await this.scheduleNameInput.fill(data.scheduleName);
|
||||
}
|
||||
|
||||
if (data.payee) {
|
||||
await this.payeeInput.pressSequentially(data.payee);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
if (data.account) {
|
||||
await this.accountInput.pressSequentially(data.account);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
if (data.amount) {
|
||||
await this.amountInput.fill(String(data.amount));
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
await this.saveButton.click();
|
||||
}
|
||||
|
||||
async add() {
|
||||
await this.addButton.click();
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
await this.cancelButton.click();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.heading.getByRole('button', { name: 'Close' }).click();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
type ScheduleEntry = {
|
||||
payee?: string;
|
||||
account?: string;
|
||||
amount?: number;
|
||||
};
|
||||
import { ScheduleEditModal } from './schedule-edit-modal';
|
||||
|
||||
export class SchedulesPage {
|
||||
readonly page: Page;
|
||||
@@ -21,17 +17,12 @@ export class SchedulesPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new schedule
|
||||
* Open the schedule edit modal.
|
||||
*/
|
||||
async addNewSchedule(data: ScheduleEntry) {
|
||||
async addNewSchedule() {
|
||||
await this.addNewScheduleButton.click();
|
||||
|
||||
await this._fillScheduleFields(data);
|
||||
|
||||
await this.page
|
||||
.getByTestId('schedule-edit-modal')
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
return new ScheduleEditModal(this.page.getByTestId('schedule-edit-modal'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,26 +74,4 @@ export class SchedulesPage {
|
||||
await actions.getByRole('button').click();
|
||||
await this.page.getByRole('button', { name: actionName }).click();
|
||||
}
|
||||
|
||||
async _fillScheduleFields(data: ScheduleEntry) {
|
||||
if (data.payee) {
|
||||
await this.page.getByRole('textbox', { name: 'Payee' }).fill(data.payee);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
if (data.account) {
|
||||
await this.page
|
||||
.getByRole('textbox', { name: 'Account' })
|
||||
.fill(data.account);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
if (data.amount) {
|
||||
await this.page.getByLabel('Amount').fill(String(data.amount));
|
||||
// For some readon, the input field does not trigger the change event on tests
|
||||
// but it works on the browser. We can revisit this once migration to
|
||||
// react aria components is complete.
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ test.describe('Rules', () => {
|
||||
|
||||
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
|
||||
await rulesPage.searchFor('Fast Internet');
|
||||
await rulesPage.createRule({
|
||||
const editRuleModal = await rulesPage.createNewRule();
|
||||
await editRuleModal.fill({
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
@@ -50,6 +51,7 @@ test.describe('Rules', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
await editRuleModal.save();
|
||||
|
||||
const rule = rulesPage.getNthRule(0);
|
||||
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
|
||||
@@ -73,7 +75,8 @@ test.describe('Rules', () => {
|
||||
test('creates a split transaction rule and makes sure it is applied when creating a transaction', async () => {
|
||||
rulesPage = await navigation.goToRulesPage();
|
||||
|
||||
await rulesPage.createRule({
|
||||
const editRuleModal = await rulesPage.createNewRule();
|
||||
await editRuleModal.fill({
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
@@ -110,6 +113,7 @@ test.describe('Rules', () => {
|
||||
],
|
||||
],
|
||||
});
|
||||
await editRuleModal.save();
|
||||
|
||||
const accountPage = await navigation.goToAccountPage(
|
||||
'Capital One Checking',
|
||||
|
||||
@@ -35,11 +35,13 @@ test.describe('Schedules', () => {
|
||||
test('creates a new schedule, posts the transaction and later completes it', async () => {
|
||||
test.setTimeout(40000);
|
||||
|
||||
await schedulesPage.addNewSchedule({
|
||||
const scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
payee: 'Home Depot',
|
||||
account: 'HSBC',
|
||||
amount: 25,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
|
||||
const schedule = schedulesPage.getNthSchedule(2);
|
||||
await expect(schedule.payee).toHaveText('Home Depot');
|
||||
@@ -91,17 +93,21 @@ test.describe('Schedules', () => {
|
||||
test.setTimeout(40000);
|
||||
|
||||
// Adding two schedules with the same payee and account and amount, mimicking two different subscriptions
|
||||
await schedulesPage.addNewSchedule({
|
||||
let scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
payee: 'Apple',
|
||||
account: 'HSBC',
|
||||
amount: 5,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
|
||||
await schedulesPage.addNewSchedule({
|
||||
scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
payee: 'Apple',
|
||||
account: 'HSBC',
|
||||
amount: 5,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
|
||||
const schedule = schedulesPage.getNthSchedule(2);
|
||||
await expect(schedule.payee).toHaveText('Apple');
|
||||
@@ -154,11 +160,13 @@ test.describe('Schedules', () => {
|
||||
test('creates a "full" list of schedules', async () => {
|
||||
// Schedules search shouldn't shrink with many schedules
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await schedulesPage.addNewSchedule({
|
||||
const scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
payee: 'Home Depot',
|
||||
account: 'HSBC',
|
||||
amount: 0,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
}
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 91 KiB |
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
userAgent: 'playwright',
|
||||
screenshot: 'on',
|
||||
browserName: 'chromium',
|
||||
baseURL: process.env.E2E_START_URL ?? 'http://localhost:3001',
|
||||
baseURL: process.env.E2E_START_URL ?? 'https://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Reports } from './reports';
|
||||
import { LoadingIndicator } from './reports/LoadingIndicator';
|
||||
import { NarrowAlternate, WideComponent } from './responsive';
|
||||
import { UserDirectoryPage } from './responsive/wide';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { useMultiuserEnabled } from './ServerContext';
|
||||
import { Settings } from './settings';
|
||||
import { FloatableSidebar } from './sidebar';
|
||||
@@ -36,6 +35,7 @@ import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { ScrollProvider } from '@desktop-client/hooks/useScrollListener';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useSelector, useDispatch } from '@desktop-client/redux';
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ import { UnmigrateBudgetAutomationsModal } from './modals/UnmigrateBudgetAutomat
|
||||
import { CategoryLearning } from './payees/CategoryLearning';
|
||||
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
|
||||
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
||||
import { ScheduleDetails } from './schedules/ScheduleDetails';
|
||||
import { ScheduleEditModal } from './schedules/ScheduleEditModal';
|
||||
import { ScheduleLink } from './schedules/ScheduleLink';
|
||||
import { UpcomingLength } from './schedules/UpcomingLength';
|
||||
|
||||
@@ -224,7 +224,7 @@ export function Modals() {
|
||||
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
|
||||
|
||||
case 'schedule-edit':
|
||||
return <ScheduleDetails key={key} {...modal.options} />;
|
||||
return <ScheduleEditModal key={key} {...modal.options} />;
|
||||
|
||||
case 'schedule-link':
|
||||
return <ScheduleLink key={key} {...modal.options} />;
|
||||
|
||||
@@ -57,6 +57,7 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
@@ -1760,154 +1761,158 @@ class AccountInternal extends PureComponent<
|
||||
filtered={transactionsFiltered}
|
||||
>
|
||||
{(allTransactions, allBalances) => (
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={allTransactions}
|
||||
fetchAllIds={this.fetchAllIds}
|
||||
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
|
||||
selectAllFilter={selectAllFilter}
|
||||
>
|
||||
<View style={styles.page}>
|
||||
<AccountHeader
|
||||
tableRef={this.table}
|
||||
isNameEditable={isNameEditable ?? false}
|
||||
workingHard={workingHard ?? false}
|
||||
accountId={accountId}
|
||||
account={account}
|
||||
filterId={filterId}
|
||||
savedFilters={this.props.savedFilters}
|
||||
accountName={accountName}
|
||||
accountsSyncing={accountsSyncing}
|
||||
failedAccounts={failedAccounts}
|
||||
accounts={accounts}
|
||||
transactions={transactions}
|
||||
showBalances={showBalances ?? false}
|
||||
showExtraBalances={showExtraBalances ?? false}
|
||||
showCleared={showCleared ?? false}
|
||||
showReconciled={showReconciled ?? false}
|
||||
showEmptyMessage={showEmptyMessage ?? false}
|
||||
balanceQuery={balanceQuery}
|
||||
canCalculateBalance={this?.canCalculateBalance ?? undefined}
|
||||
filteredAmount={filteredAmount}
|
||||
isFiltered={transactionsFiltered ?? false}
|
||||
isSorted={this.state.sort !== null}
|
||||
reconcileAmount={reconcileAmount}
|
||||
search={this.state.search}
|
||||
// @ts-expect-error fix me
|
||||
filterConditions={this.state.filterConditions}
|
||||
filterConditionsOp={this.state.filterConditionsOp}
|
||||
onSearch={this.onSearch}
|
||||
onShowTransactions={this.onShowTransactions}
|
||||
onMenuSelect={this.onMenuSelect}
|
||||
onAddTransaction={this.onAddTransaction}
|
||||
onToggleExtraBalances={this.onToggleExtraBalances}
|
||||
onSaveName={this.onSaveName}
|
||||
saveNameError={this.state.nameError}
|
||||
onReconcile={this.onReconcile}
|
||||
onDoneReconciling={this.onDoneReconciling}
|
||||
onCreateReconciliationTransaction={
|
||||
this.onCreateReconciliationTransaction
|
||||
}
|
||||
onSync={this.onSync}
|
||||
onImport={this.onImport}
|
||||
onBatchDelete={this.onBatchDelete}
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onRunRules={this.onRunRules}
|
||||
onBatchEdit={this.onBatchEdit}
|
||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onUpdateFilter={this.onUpdateFilter}
|
||||
onClearFilters={this.onClearFilters}
|
||||
onReloadSavedFilter={this.onReloadSavedFilter}
|
||||
onConditionsOpChange={this.onConditionsOpChange}
|
||||
onDeleteFilter={this.onDeleteFilter}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onSetTransfer={this.onSetTransfer}
|
||||
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={this.onMakeAsNonSplitTransactions}
|
||||
onMergeTransactions={this.onMergeTransactions}
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<TransactionList
|
||||
headerContent={undefined}
|
||||
// @ts-ignore TODO
|
||||
<DisplayPayeeProvider transactions={allTransactions}>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={allTransactions}
|
||||
fetchAllIds={this.fetchAllIds}
|
||||
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
|
||||
selectAllFilter={selectAllFilter}
|
||||
>
|
||||
<View style={styles.page}>
|
||||
<AccountHeader
|
||||
tableRef={this.table}
|
||||
isNameEditable={isNameEditable ?? false}
|
||||
workingHard={workingHard ?? false}
|
||||
accountId={accountId}
|
||||
account={account}
|
||||
transactions={transactions}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={() =>
|
||||
this.paged && this.paged.fetchNext()
|
||||
}
|
||||
filterId={filterId}
|
||||
savedFilters={this.props.savedFilters}
|
||||
accountName={accountName}
|
||||
accountsSyncing={accountsSyncing}
|
||||
failedAccounts={failedAccounts}
|
||||
accounts={accounts}
|
||||
category={category}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={allBalances}
|
||||
showBalances={!!allBalances}
|
||||
showReconciled={showReconciled}
|
||||
showCleared={!!showCleared}
|
||||
showAccount={
|
||||
!accountId ||
|
||||
accountId === 'offbudget' ||
|
||||
accountId === 'onbudget' ||
|
||||
accountId === 'uncategorized'
|
||||
transactions={transactions}
|
||||
showBalances={showBalances ?? false}
|
||||
showExtraBalances={showExtraBalances ?? false}
|
||||
showCleared={showCleared ?? false}
|
||||
showReconciled={showReconciled ?? false}
|
||||
showEmptyMessage={showEmptyMessage ?? false}
|
||||
balanceQuery={balanceQuery}
|
||||
canCalculateBalance={this?.canCalculateBalance ?? undefined}
|
||||
filteredAmount={filteredAmount}
|
||||
isFiltered={transactionsFiltered ?? false}
|
||||
isSorted={this.state.sort !== null}
|
||||
reconcileAmount={reconcileAmount}
|
||||
search={this.state.search}
|
||||
// @ts-expect-error fix me
|
||||
filterConditions={this.state.filterConditions}
|
||||
filterConditionsOp={this.state.filterConditionsOp}
|
||||
onSearch={this.onSearch}
|
||||
onShowTransactions={this.onShowTransactions}
|
||||
onMenuSelect={this.onMenuSelect}
|
||||
onAddTransaction={this.onAddTransaction}
|
||||
onToggleExtraBalances={this.onToggleExtraBalances}
|
||||
onSaveName={this.onSaveName}
|
||||
saveNameError={this.state.nameError}
|
||||
onReconcile={this.onReconcile}
|
||||
onDoneReconciling={this.onDoneReconciling}
|
||||
onCreateReconciliationTransaction={
|
||||
this.onCreateReconciliationTransaction
|
||||
}
|
||||
isAdding={this.state.isAdding}
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
isFiltered={transactionsFiltered}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
renderEmpty={() =>
|
||||
showEmptyMessage ? (
|
||||
<AccountEmptyMessage
|
||||
onAdd={() =>
|
||||
this.props.dispatch(
|
||||
replaceModal({
|
||||
modal: { name: 'add-account', options: {} },
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : !loading ? (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
onSort={this.onSort}
|
||||
sortField={this.state.sort?.field ?? ''}
|
||||
ascDesc={this.state.sort?.ascDesc ?? 'asc'}
|
||||
onChange={this.onTransactionsChange}
|
||||
onSync={this.onSync}
|
||||
onImport={this.onImport}
|
||||
onBatchDelete={this.onBatchDelete}
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onRunRules={this.onRunRules}
|
||||
onBatchEdit={this.onBatchEdit}
|
||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onUpdateFilter={this.onUpdateFilter}
|
||||
onClearFilters={this.onClearFilters}
|
||||
onReloadSavedFilter={this.onReloadSavedFilter}
|
||||
onConditionsOpChange={this.onConditionsOpChange}
|
||||
onDeleteFilter={this.onDeleteFilter}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onSetTransfer={this.onSetTransfer}
|
||||
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={
|
||||
this.onMakeAsNonSplitTransactions
|
||||
}
|
||||
onRefetch={this.refetchTransactions}
|
||||
onCloseAddTransaction={() =>
|
||||
this.setState({ isAdding: false })
|
||||
}
|
||||
onCreatePayee={this.onCreatePayee}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
onMergeTransactions={this.onMergeTransactions}
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<TransactionList
|
||||
headerContent={undefined}
|
||||
// @ts-ignore TODO
|
||||
tableRef={this.table}
|
||||
account={account}
|
||||
transactions={transactions}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={() =>
|
||||
this.paged && this.paged.fetchNext()
|
||||
}
|
||||
accounts={accounts}
|
||||
category={category}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={allBalances}
|
||||
showBalances={!!allBalances}
|
||||
showReconciled={showReconciled}
|
||||
showCleared={!!showCleared}
|
||||
showAccount={
|
||||
!accountId ||
|
||||
accountId === 'offbudget' ||
|
||||
accountId === 'onbudget' ||
|
||||
accountId === 'uncategorized'
|
||||
}
|
||||
isAdding={this.state.isAdding}
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
isFiltered={transactionsFiltered}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
renderEmpty={() =>
|
||||
showEmptyMessage ? (
|
||||
<AccountEmptyMessage
|
||||
onAdd={() =>
|
||||
this.props.dispatch(
|
||||
replaceModal({
|
||||
modal: { name: 'add-account', options: {} },
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : !loading ? (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
onSort={this.onSort}
|
||||
sortField={this.state.sort?.field ?? ''}
|
||||
ascDesc={this.state.sort?.ascDesc ?? 'asc'}
|
||||
onChange={this.onTransactionsChange}
|
||||
onBatchDelete={this.onBatchDelete}
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onMakeAsNonSplitTransactions={
|
||||
this.onMakeAsNonSplitTransactions
|
||||
}
|
||||
onRefetch={this.refetchTransactions}
|
||||
onCloseAddTransaction={() =>
|
||||
this.setState({ isAdding: false })
|
||||
}
|
||||
onCreatePayee={this.onCreatePayee}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SelectedProviderWithItems>
|
||||
</SelectedProviderWithItems>
|
||||
</DisplayPayeeProvider>
|
||||
)}
|
||||
</AllTransactions>
|
||||
);
|
||||
|
||||
@@ -265,6 +265,7 @@ export function CategoryAutocomplete({
|
||||
suggestions: CategoryAutocompleteItem[],
|
||||
value: string,
|
||||
): CategoryAutocompleteItem[] => {
|
||||
const normalizedValue = getNormalisedString(value);
|
||||
return suggestions
|
||||
.filter(suggestion => {
|
||||
if (suggestion.id === 'split') {
|
||||
@@ -274,11 +275,11 @@ export function CategoryAutocomplete({
|
||||
if (suggestion.group) {
|
||||
return (
|
||||
getNormalisedString(suggestion.group.name).includes(
|
||||
getNormalisedString(value),
|
||||
normalizedValue,
|
||||
) ||
|
||||
getNormalisedString(
|
||||
suggestion.group.name + ' ' + suggestion.name,
|
||||
).includes(getNormalisedString(value))
|
||||
).includes(normalizedValue)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,8 +287,7 @@ export function CategoryAutocomplete({
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
customSort(a, getNormalisedString(value)) -
|
||||
customSort(b, getNormalisedString(value)),
|
||||
customSort(a, normalizedValue) - customSort(b, normalizedValue),
|
||||
);
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
PayeeAutocomplete,
|
||||
type PayeeAutocompleteItem,
|
||||
type PayeeAutocompleteProps,
|
||||
} from './PayeeAutocomplete';
|
||||
|
||||
@@ -137,7 +136,7 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
|
||||
|
||||
test('list with less than the maximum favorites adds common payees', async () => {
|
||||
//Note that the payees list assumes the payees are already sorted
|
||||
const payees: PayeeAutocompleteItem[] = [
|
||||
const payees: PayeeEntity[] = [
|
||||
makePayee('Alice'),
|
||||
makePayee('Bob'),
|
||||
makePayee('Eve', { favorite: true }),
|
||||
|
||||
@@ -40,22 +40,22 @@ import {
|
||||
} from '@desktop-client/payees/payeesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export type PayeeAutocompleteItem = PayeeEntity;
|
||||
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
|
||||
|
||||
const MAX_AUTO_SUGGESTIONS = 5;
|
||||
|
||||
function getPayeeSuggestions(
|
||||
commonPayees: PayeeAutocompleteItem[],
|
||||
payees: PayeeAutocompleteItem[],
|
||||
): (PayeeAutocompleteItem & PayeeItemType)[] {
|
||||
const favoritePayees = payees
|
||||
commonPayees: PayeeEntity[],
|
||||
payees: PayeeEntity[],
|
||||
): PayeeAutocompleteItem[] {
|
||||
const favoritePayees: PayeeAutocompleteItem[] = payees
|
||||
.filter(p => p.favorite)
|
||||
.map(p => {
|
||||
return { ...p, itemType: determineItemType(p, true) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let additionalCommonPayees: (PayeeAutocompleteItem & PayeeItemType)[] = [];
|
||||
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
|
||||
if (commonPayees?.length > 0) {
|
||||
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
|
||||
additionalCommonPayees = commonPayees
|
||||
@@ -71,10 +71,10 @@ function getPayeeSuggestions(
|
||||
}
|
||||
|
||||
if (favoritePayees.length + additionalCommonPayees.length) {
|
||||
const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
|
||||
const filteredPayees: PayeeAutocompleteItem[] = payees
|
||||
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
|
||||
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
|
||||
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
|
||||
.map<PayeeAutocompleteItem>(p => {
|
||||
return { ...p, itemType: determineItemType(p, false) };
|
||||
});
|
||||
|
||||
@@ -86,14 +86,14 @@ function getPayeeSuggestions(
|
||||
});
|
||||
}
|
||||
|
||||
function filterActivePayees(
|
||||
payees: PayeeAutocompleteItem[],
|
||||
function filterActivePayees<T extends PayeeEntity>(
|
||||
payees: T[],
|
||||
accounts: AccountEntity[],
|
||||
) {
|
||||
return accounts ? getActivePayees(payees, accounts) : payees;
|
||||
): T[] {
|
||||
return accounts ? (getActivePayees(payees, accounts) as T[]) : payees;
|
||||
}
|
||||
|
||||
function filterTransferPayees(payees: PayeeAutocompleteItem[]) {
|
||||
function filterTransferPayees<T extends PayeeEntity>(payees: T[]): T[] {
|
||||
return payees.filter(payee => !!payee.transfer_acct);
|
||||
}
|
||||
|
||||
@@ -139,10 +139,7 @@ type PayeeItemType = {
|
||||
itemType: ItemTypes;
|
||||
};
|
||||
|
||||
function determineItemType(
|
||||
item: PayeeAutocompleteItem,
|
||||
isCommon: boolean,
|
||||
): ItemTypes {
|
||||
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
|
||||
if (item.transfer_acct) {
|
||||
return 'account';
|
||||
}
|
||||
@@ -223,7 +220,7 @@ function PayeeList({
|
||||
|
||||
// We limit the number of payees shown to 100.
|
||||
// So we show a hint that more are available via search.
|
||||
const showSearchForMore = items.length > 100;
|
||||
const showSearchForMore = items.length >= 100;
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -299,6 +296,17 @@ function PayeeList({
|
||||
);
|
||||
}
|
||||
|
||||
function customSort(obj: PayeeAutocompleteItem, value: string): number {
|
||||
const name = getNormalisedString(obj.name);
|
||||
if (obj.id === 'new') {
|
||||
return -2;
|
||||
}
|
||||
if (name.includes(value)) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export type PayeeAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<PayeeAutocompleteItem>
|
||||
> & {
|
||||
@@ -317,7 +325,7 @@ export type PayeeAutocompleteProps = ComponentProps<
|
||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||
) => ReactElement<typeof PayeeItem>;
|
||||
accounts?: AccountEntity[];
|
||||
payees?: PayeeAutocompleteItem[];
|
||||
payees?: PayeeEntity[];
|
||||
};
|
||||
|
||||
export function PayeeAutocomplete({
|
||||
@@ -370,7 +378,10 @@ export function PayeeAutocomplete({
|
||||
return filteredSuggestions;
|
||||
}
|
||||
|
||||
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
|
||||
return [
|
||||
{ id: 'new', favorite: false, name: '' } as PayeeAutocompleteItem,
|
||||
...filteredSuggestions,
|
||||
];
|
||||
}, [
|
||||
commonPayees,
|
||||
payees,
|
||||
@@ -404,6 +415,40 @@ export function PayeeAutocomplete({
|
||||
|
||||
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
|
||||
|
||||
const filterSuggestions = (
|
||||
suggestions: PayeeAutocompleteItem[],
|
||||
value: string,
|
||||
) => {
|
||||
const normalizedValue = getNormalisedString(value);
|
||||
const filtered = suggestions
|
||||
.filter(suggestion => {
|
||||
if (suggestion.id === 'new') {
|
||||
return !value || value === '' || focusTransferPayees ? false : true;
|
||||
}
|
||||
|
||||
return defaultFilterSuggestion(suggestion, value);
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
customSort(a, normalizedValue) - customSort(b, normalizedValue),
|
||||
)
|
||||
// Only show the first 100 results, users can search to find more.
|
||||
// If user want to view all payees, it can be done via the manage payees page.
|
||||
.slice(0, 100);
|
||||
|
||||
if (filtered.length >= 2 && filtered[0].id === 'new') {
|
||||
const firstFiltered = filtered[1];
|
||||
if (
|
||||
getNormalisedString(firstFiltered.name) === normalizedValue &&
|
||||
!firstFiltered.transfer_acct
|
||||
) {
|
||||
// Exact match found, remove the 'Create payee` option.
|
||||
return filtered.slice(1);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
key={focusTransferPayees ? 'transfers' : 'all'}
|
||||
@@ -435,66 +480,15 @@ export function PayeeAutocomplete({
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
getHighlightedIndex={suggestions => {
|
||||
if (suggestions.length > 1 && suggestions[0].id === 'new') {
|
||||
return 1;
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
} else if (suggestions[0].id === 'new') {
|
||||
// Highlight the first payee since the create payee option is at index 0.
|
||||
return suggestions.length > 1 ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}}
|
||||
filterSuggestions={(suggestions, value) => {
|
||||
let filtered = suggestions.filter(suggestion => {
|
||||
if (suggestion.id === 'new') {
|
||||
return !value || value === '' || focusTransferPayees ? false : true;
|
||||
}
|
||||
|
||||
return defaultFilterSuggestion(suggestion, value);
|
||||
});
|
||||
|
||||
filtered.sort((p1, p2) => {
|
||||
const r1 = getNormalisedString(p1.name).startsWith(
|
||||
getNormalisedString(value),
|
||||
);
|
||||
const r2 = getNormalisedString(p2.name).startsWith(
|
||||
getNormalisedString(value),
|
||||
);
|
||||
const r1exact = p1.name.toLowerCase() === value.toLowerCase();
|
||||
const r2exact = p2.name.toLowerCase() === value.toLowerCase();
|
||||
|
||||
// (maniacal laughter) mwahaHAHAHAHAH
|
||||
if (p1.id === 'new') {
|
||||
return -1;
|
||||
} else if (p2.id === 'new') {
|
||||
return 1;
|
||||
} else {
|
||||
if (r1exact && !r2exact) {
|
||||
return -1;
|
||||
} else if (!r1exact && r2exact) {
|
||||
return 1;
|
||||
} else {
|
||||
if (r1 === r2) {
|
||||
return 0;
|
||||
} else if (r1 && !r2) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only show the first 100 results, users can search to find more.
|
||||
// If user want to view all payees, it can be done via the manage payees page.
|
||||
filtered = filtered.slice(0, 100);
|
||||
|
||||
if (filtered.length >= 2 && filtered[0].id === 'new') {
|
||||
if (
|
||||
filtered[1].name.toLowerCase() === value.toLowerCase() &&
|
||||
!filtered[1].transfer_acct
|
||||
) {
|
||||
return filtered.slice(1);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}}
|
||||
filterSuggestions={filterSuggestions}
|
||||
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
|
||||
<PayeeList
|
||||
items={items}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
PayeeAutocomplete,
|
||||
type PayeeAutocompleteItem,
|
||||
} from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
||||
import { type PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
type PayeeFilterValue =
|
||||
| PayeeAutocompleteItem['id']
|
||||
| PayeeAutocompleteItem['id'][];
|
||||
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
||||
|
||||
type PayeeFilterValue = PayeeEntity['id'] | PayeeEntity['id'][];
|
||||
|
||||
/** This component only supports single- or multi-select operations. */
|
||||
type PayeeFilterOp = 'is' | 'isNot' | 'oneOf' | 'notOneOf';
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import * as Platform from 'loot-core/shared/platform';
|
||||
|
||||
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
||||
import { useScrollListener } from '@desktop-client/hooks/useScrollListener';
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
|
||||
const COLUMN_COUNT = 3;
|
||||
|
||||
@@ -40,12 +40,12 @@ import { type TransactionEntity } from 'loot-core/types/models';
|
||||
import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem';
|
||||
|
||||
import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar';
|
||||
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useScrollListener } from '@desktop-client/hooks/useScrollListener';
|
||||
import {
|
||||
useSelectedDispatch,
|
||||
useSelectedItems,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||
import {
|
||||
SelectedProvider,
|
||||
useSelected,
|
||||
@@ -109,8 +110,8 @@ export function TransactionListWithBalances({
|
||||
const selectedInst = useSelected('transactions', [...transactions], []);
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<>
|
||||
<DisplayPayeeProvider transactions={transactions}>
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
@@ -151,8 +152,8 @@ export function TransactionListWithBalances({
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</>
|
||||
</SelectedProvider>
|
||||
</SelectedProvider>
|
||||
</DisplayPayeeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||
@@ -618,139 +619,145 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={[]}
|
||||
fetchAllIds={async () => []}
|
||||
registerDispatch={() => {}}
|
||||
selectAllFilter={(item: TransactionEntity) =>
|
||||
!item._unmatched && !item.is_parent
|
||||
}
|
||||
>
|
||||
<SchedulesProvider query={undefined}>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
||||
}}
|
||||
// TODO: make TableHandleRef conform to HTMLDivEle
|
||||
ref={table as unknown as Ref<HTMLDivElement>}
|
||||
>
|
||||
{!isNarrowWidth ? (
|
||||
<SplitsExpandedProvider initialMode="collapse">
|
||||
<TransactionList
|
||||
tableRef={table}
|
||||
account={undefined}
|
||||
transactions={transactionsGrouped}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
accounts={accounts}
|
||||
category={undefined}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={null}
|
||||
showBalances={false}
|
||||
showReconciled={true}
|
||||
showCleared={false}
|
||||
showAccount={true}
|
||||
isAdding={false}
|
||||
isNew={() => false}
|
||||
isMatched={() => false}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={false}
|
||||
renderEmpty={() => (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
)}
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
onChange={() => {}}
|
||||
onRefetch={() => setDirty(true)}
|
||||
onCloseAddTransaction={() => {}}
|
||||
onCreatePayee={async () => null}
|
||||
onApplyFilter={() => {}}
|
||||
onBatchDelete={() => {}}
|
||||
onBatchDuplicate={() => {}}
|
||||
onBatchLinkSchedule={() => {}}
|
||||
onBatchUnlinkSchedule={() => {}}
|
||||
onCreateRule={() => {}}
|
||||
onScheduleAction={() => {}}
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
y,
|
||||
touchAction: 'pan-x',
|
||||
backgroundColor: theme.mobileNavBackground,
|
||||
borderTop: `1px solid ${theme.menuBorder}`,
|
||||
...styles.shadow,
|
||||
height: totalHeight + CHEVRON_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
display: isNarrowWidth ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
!mobileTransactionsOpen
|
||||
? open({ canceled: false })
|
||||
: close()
|
||||
}
|
||||
className={css({
|
||||
color: theme.pageTextSubdued,
|
||||
height: 42,
|
||||
'&[data-pressed]': { backgroundColor: 'transparent' },
|
||||
})}
|
||||
>
|
||||
{!mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronUp width={16} height={16} />
|
||||
<Trans>Show transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
{mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronDown width={16} height={16} />
|
||||
<Trans>Hide transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<View
|
||||
style={{ height: '100%', width: '100%', overflow: 'auto' }}
|
||||
>
|
||||
<TransactionListMobile
|
||||
isLoading={false}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
transactions={allTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
isLoadingMore={false}
|
||||
<DisplayPayeeProvider transactions={allTransactions}>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={[]}
|
||||
fetchAllIds={async () => []}
|
||||
registerDispatch={() => {}}
|
||||
selectAllFilter={(item: TransactionEntity) =>
|
||||
!item._unmatched && !item.is_parent
|
||||
}
|
||||
>
|
||||
<SchedulesProvider query={undefined}>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
||||
}}
|
||||
// TODO: make TableHandleRef conform to HTMLDivEle
|
||||
ref={table as unknown as Ref<HTMLDivElement>}
|
||||
>
|
||||
{!isNarrowWidth ? (
|
||||
<SplitsExpandedProvider initialMode="collapse">
|
||||
<TransactionList
|
||||
tableRef={table}
|
||||
account={undefined}
|
||||
transactions={transactionsGrouped}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
accounts={accounts}
|
||||
category={undefined}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={null}
|
||||
showBalances={false}
|
||||
showReconciled={true}
|
||||
showCleared={false}
|
||||
showAccount={true}
|
||||
isAdding={false}
|
||||
isNew={() => false}
|
||||
isMatched={() => false}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={false}
|
||||
renderEmpty={() => (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
)}
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
onChange={() => {}}
|
||||
onRefetch={() => setDirty(true)}
|
||||
onCloseAddTransaction={() => {}}
|
||||
onCreatePayee={async () => null}
|
||||
onApplyFilter={() => {}}
|
||||
onBatchDelete={() => {}}
|
||||
onBatchDuplicate={() => {}}
|
||||
onBatchLinkSchedule={() => {}}
|
||||
onBatchUnlinkSchedule={() => {}}
|
||||
onCreateRule={() => {}}
|
||||
onScheduleAction={() => {}}
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
/>
|
||||
</View>
|
||||
</animated.div>
|
||||
)}
|
||||
</View>
|
||||
</SchedulesProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
y,
|
||||
touchAction: 'pan-x',
|
||||
backgroundColor: theme.mobileNavBackground,
|
||||
borderTop: `1px solid ${theme.menuBorder}`,
|
||||
...styles.shadow,
|
||||
height: totalHeight + CHEVRON_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
display: isNarrowWidth ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
!mobileTransactionsOpen
|
||||
? open({ canceled: false })
|
||||
: close()
|
||||
}
|
||||
className={css({
|
||||
color: theme.pageTextSubdued,
|
||||
height: 42,
|
||||
'&[data-pressed]': { backgroundColor: 'transparent' },
|
||||
})}
|
||||
>
|
||||
{!mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronUp width={16} height={16} />
|
||||
<Trans>Show transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
{mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronDown width={16} height={16} />
|
||||
<Trans>Hide transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<TransactionListMobile
|
||||
isLoading={false}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
transactions={allTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
isLoadingMore={false}
|
||||
/>
|
||||
</View>
|
||||
</animated.div>
|
||||
)}
|
||||
</View>
|
||||
</SchedulesProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</DisplayPayeeProvider>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -119,12 +119,12 @@ function updateScheduleConditions(
|
||||
};
|
||||
}
|
||||
|
||||
type ScheduleDetailsProps = Extract<
|
||||
type ScheduleEditModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'schedule-edit' }
|
||||
>['options'];
|
||||
|
||||
export function ScheduleDetails({ id, transaction }: ScheduleDetailsProps) {
|
||||
export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) {
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TransactionTable } from './TransactionsTable';
|
||||
|
||||
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
|
||||
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
|
||||
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
|
||||
@@ -199,30 +200,32 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
|
||||
<AuthProvider>
|
||||
<SpreadsheetProvider>
|
||||
<SchedulesProvider>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={transactions}
|
||||
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
|
||||
>
|
||||
<SplitsExpandedProvider>
|
||||
<TransactionTable
|
||||
{...props}
|
||||
transactions={transactions}
|
||||
loadMoreTransactions={() => {}}
|
||||
// @ts-ignore TODO:
|
||||
commonPayees={[]}
|
||||
payees={payees}
|
||||
addNotification={console.log}
|
||||
onSave={onSave}
|
||||
onSplit={onSplit}
|
||||
onAdd={onAdd}
|
||||
onAddSplit={onAddSplit}
|
||||
onCreatePayee={onCreatePayee}
|
||||
showSelection={true}
|
||||
allowSplitTransaction={true}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SelectedProviderWithItems>
|
||||
<DisplayPayeeProvider transactions={transactions}>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={transactions}
|
||||
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
|
||||
>
|
||||
<SplitsExpandedProvider>
|
||||
<TransactionTable
|
||||
{...props}
|
||||
transactions={transactions}
|
||||
loadMoreTransactions={() => {}}
|
||||
// @ts-ignore TODO:
|
||||
commonPayees={[]}
|
||||
payees={payees}
|
||||
addNotification={console.log}
|
||||
onSave={onSave}
|
||||
onSplit={onSplit}
|
||||
onAdd={onAdd}
|
||||
onAddSplit={onAddSplit}
|
||||
onCreatePayee={onCreatePayee}
|
||||
showSelection={true}
|
||||
allowSplitTransaction={true}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</DisplayPayeeProvider>
|
||||
</SchedulesProvider>
|
||||
</SpreadsheetProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type PayeeEntity,
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { usePayeesById } from './usePayees';
|
||||
import { useTransactions } from './useTransactions';
|
||||
|
||||
type Counts = {
|
||||
counts: Record<PayeeEntity['id'], number>;
|
||||
maxCount: number;
|
||||
mostCommonPayeeTransaction: TransactionEntity | null;
|
||||
};
|
||||
|
||||
type UseDisplayPayeeProps = {
|
||||
transaction?: TransactionEntity | undefined;
|
||||
};
|
||||
|
||||
export function useDisplayPayee({ transaction }: UseDisplayPayeeProps) {
|
||||
const { t } = useTranslation();
|
||||
const subtransactionsQuery = useMemo(
|
||||
() => q('transactions').filter({ parent_id: transaction?.id }).select('*'),
|
||||
[transaction?.id],
|
||||
);
|
||||
const { transactions: subtransactions = [] } = useTransactions({
|
||||
query: subtransactionsQuery,
|
||||
});
|
||||
|
||||
const accounts = useAccounts();
|
||||
const payeesById = usePayeesById();
|
||||
const payee = payeesById[transaction?.payee || ''];
|
||||
|
||||
return useMemo(() => {
|
||||
if (subtransactions.length === 0) {
|
||||
return getPrettyPayee({
|
||||
t,
|
||||
transaction,
|
||||
payee,
|
||||
transferAccount: accounts.find(
|
||||
a => a.id === payeesById[transaction?.payee || '']?.transfer_acct,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const { counts, mostCommonPayeeTransaction } =
|
||||
subtransactions?.reduce(
|
||||
({ counts, ...result }, sub) => {
|
||||
if (sub.payee) {
|
||||
counts[sub.payee] = (counts[sub.payee] || 0) + 1;
|
||||
if (counts[sub.payee] > result.maxCount) {
|
||||
return {
|
||||
counts,
|
||||
maxCount: counts[sub.payee],
|
||||
mostCommonPayeeTransaction: sub,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { counts, ...result };
|
||||
},
|
||||
{ counts: {}, maxCount: 0, mostCommonPayeeTransaction: null } as Counts,
|
||||
) || {};
|
||||
|
||||
if (!mostCommonPayeeTransaction) {
|
||||
return t('Split (no payee)');
|
||||
}
|
||||
|
||||
const mostCommonPayee = payeesById[mostCommonPayeeTransaction.payee || ''];
|
||||
|
||||
if (!mostCommonPayee) {
|
||||
return t('Split (no payee)');
|
||||
}
|
||||
|
||||
const numDistinctPayees = Object.keys(counts).length;
|
||||
|
||||
return getPrettyPayee({
|
||||
t,
|
||||
transaction: mostCommonPayeeTransaction,
|
||||
payee: mostCommonPayee,
|
||||
transferAccount: accounts.find(
|
||||
a =>
|
||||
a.id ===
|
||||
payeesById[mostCommonPayeeTransaction.payee || '']?.transfer_acct,
|
||||
),
|
||||
numHiddenPayees: numDistinctPayees - 1,
|
||||
});
|
||||
}, [subtransactions, payeesById, accounts, transaction, payee, t]);
|
||||
}
|
||||
|
||||
type GetPrettyPayeeProps = {
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
transaction?: TransactionEntity | undefined;
|
||||
payee?: PayeeEntity | undefined;
|
||||
transferAccount?: AccountEntity | undefined;
|
||||
numHiddenPayees?: number | undefined;
|
||||
};
|
||||
|
||||
function getPrettyPayee({
|
||||
t,
|
||||
transaction,
|
||||
payee,
|
||||
transferAccount,
|
||||
numHiddenPayees = 0,
|
||||
}: GetPrettyPayeeProps) {
|
||||
if (!transaction) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formatPayeeName = (payeeName: string) =>
|
||||
numHiddenPayees > 0
|
||||
? `${payeeName} ${t('(+{{numHiddenPayees}} more)', {
|
||||
numHiddenPayees,
|
||||
})}`
|
||||
: payeeName;
|
||||
|
||||
const { payee: payeeId } = transaction;
|
||||
|
||||
if (transferAccount) {
|
||||
return formatPayeeName(transferAccount.name);
|
||||
} else if (payee) {
|
||||
return formatPayeeName(payee.name);
|
||||
} else if (payeeId && payeeId.startsWith('new:')) {
|
||||
return formatPayeeName(payeeId.slice('new:'.length));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
189
packages/desktop-client/src/hooks/useDisplayPayee.tsx
Normal file
189
packages/desktop-client/src/hooks/useDisplayPayee.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type PayeeEntity,
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { usePayeesById } from './usePayees';
|
||||
import { useTransactions } from './useTransactions';
|
||||
|
||||
type DisplayPayeeContextValue = {
|
||||
displayPayees: Record<TransactionEntity['id'], string>;
|
||||
};
|
||||
|
||||
const DisplayPayeeContext = createContext<DisplayPayeeContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
type DisplayPayeeProviderProps = {
|
||||
transactions: readonly TransactionEntity[];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DisplayPayeeProvider({
|
||||
transactions,
|
||||
children,
|
||||
}: DisplayPayeeProviderProps) {
|
||||
const { t } = useTranslation();
|
||||
const subtransactionsQuery = useMemo(
|
||||
() =>
|
||||
q('transactions')
|
||||
.filter({ parent_id: { $oneof: transactions.map(t => t.id) } })
|
||||
.select('*'),
|
||||
[transactions],
|
||||
);
|
||||
const { transactions: allSubtransactions = [] } = useTransactions({
|
||||
query: subtransactionsQuery,
|
||||
options: { pageCount: transactions.length * 5 },
|
||||
});
|
||||
|
||||
const accounts = useAccounts();
|
||||
const payeesById = usePayeesById();
|
||||
|
||||
const displayPayees = useMemo(() => {
|
||||
return transactions.reduce(
|
||||
(acc, transaction) => {
|
||||
const subtransactions = allSubtransactions.filter(
|
||||
st => st.parent_id === transaction.id,
|
||||
);
|
||||
|
||||
if (subtransactions.length === 0) {
|
||||
acc[transaction.id] = getPrettyPayee({
|
||||
t,
|
||||
transaction,
|
||||
payee: payeesById[transaction?.payee || ''],
|
||||
transferAccount: accounts.find(
|
||||
a => a.id === payeesById[transaction?.payee || '']?.transfer_acct,
|
||||
),
|
||||
});
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const { counts, mostCommonPayeeTransaction } =
|
||||
subtransactions.reduce(
|
||||
({ counts, ...result }, sub) => {
|
||||
if (sub.payee) {
|
||||
counts[sub.payee] = (counts[sub.payee] || 0) + 1;
|
||||
if (counts[sub.payee] > result.maxCount) {
|
||||
return {
|
||||
counts,
|
||||
maxCount: counts[sub.payee],
|
||||
mostCommonPayeeTransaction: sub,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { counts, ...result };
|
||||
},
|
||||
{
|
||||
counts: {},
|
||||
maxCount: 0,
|
||||
mostCommonPayeeTransaction: null,
|
||||
} as Counts,
|
||||
) || {};
|
||||
|
||||
if (!mostCommonPayeeTransaction) {
|
||||
acc[transaction.id] = t('Split (no payee)');
|
||||
return acc;
|
||||
}
|
||||
|
||||
const mostCommonPayee =
|
||||
payeesById[mostCommonPayeeTransaction.payee || ''];
|
||||
|
||||
if (!mostCommonPayee) {
|
||||
acc[transaction.id] = t('Split (no payee)');
|
||||
return acc;
|
||||
}
|
||||
|
||||
const numDistinctPayees = Object.keys(counts).length;
|
||||
|
||||
acc[transaction.id] = getPrettyPayee({
|
||||
t,
|
||||
transaction: mostCommonPayeeTransaction,
|
||||
payee: mostCommonPayee,
|
||||
transferAccount: accounts.find(
|
||||
a =>
|
||||
a.id ===
|
||||
payeesById[mostCommonPayeeTransaction.payee || '']?.transfer_acct,
|
||||
),
|
||||
numHiddenPayees: numDistinctPayees - 1,
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<TransactionEntity['id'], string>,
|
||||
);
|
||||
}, [transactions, allSubtransactions, payeesById, accounts, t]);
|
||||
|
||||
return (
|
||||
<DisplayPayeeContext.Provider value={{ displayPayees }}>
|
||||
{children}
|
||||
</DisplayPayeeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type Counts = {
|
||||
counts: Record<PayeeEntity['id'], number>;
|
||||
maxCount: number;
|
||||
mostCommonPayeeTransaction: TransactionEntity | null;
|
||||
};
|
||||
|
||||
type UseDisplayPayeeProps = {
|
||||
transaction?: TransactionEntity | undefined;
|
||||
};
|
||||
|
||||
export function useDisplayPayee({ transaction }: UseDisplayPayeeProps) {
|
||||
const context = useContext(DisplayPayeeContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useDisplayPayee must be used within a DisplayPayeeContextProvider.',
|
||||
);
|
||||
}
|
||||
const { displayPayees } = context;
|
||||
|
||||
return transaction ? displayPayees[transaction.id] || '' : '';
|
||||
}
|
||||
|
||||
type GetPrettyPayeeProps = {
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
transaction?: TransactionEntity | undefined;
|
||||
payee?: PayeeEntity | undefined;
|
||||
transferAccount?: AccountEntity | undefined;
|
||||
numHiddenPayees?: number | undefined;
|
||||
};
|
||||
|
||||
function getPrettyPayee({
|
||||
t,
|
||||
transaction,
|
||||
payee,
|
||||
transferAccount,
|
||||
numHiddenPayees = 0,
|
||||
}: GetPrettyPayeeProps) {
|
||||
if (!transaction) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formatPayeeName = (payeeName: string) =>
|
||||
numHiddenPayees > 0
|
||||
? `${payeeName} ${t('(+{{numHiddenPayees}} more)', {
|
||||
numHiddenPayees,
|
||||
})}`
|
||||
: payeeName;
|
||||
|
||||
const { payee: payeeId } = transaction;
|
||||
|
||||
if (transferAccount) {
|
||||
return formatPayeeName(transferAccount.name);
|
||||
} else if (payee) {
|
||||
return formatPayeeName(payee.name);
|
||||
} else if (payeeId && payeeId.startsWith('new:')) {
|
||||
return formatPayeeName(payeeId.slice('new:'.length));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -42,7 +42,7 @@ type ScrollProviderProps<T extends Element> = {
|
||||
export function ScrollProvider<T extends Element>({
|
||||
scrollableRef,
|
||||
isDisabled,
|
||||
delayMs = 250,
|
||||
delayMs = 100,
|
||||
children,
|
||||
}: ScrollProviderProps<T>) {
|
||||
const previousScrollX = useRef<number | undefined>(undefined);
|
||||
6
upcoming-release-notes/5795.md
Normal file
6
upcoming-release-notes/5795.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Re-implement useDisplayPayee to use context to minimize SQL queries
|
||||
Reference in New Issue
Block a user