mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
20 Commits
js-proxy
...
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 (
|
return (
|
||||||
<ReactAriaPopover
|
<ReactAriaPopover
|
||||||
|
data-popover={true}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
placement="bottom end"
|
placement="bottom end"
|
||||||
offset={1}
|
offset={1}
|
||||||
|
|||||||
@@ -194,47 +194,71 @@ export class AccountPage {
|
|||||||
transaction: TransactionEntry,
|
transaction: TransactionEntry,
|
||||||
) {
|
) {
|
||||||
if (transaction.debit) {
|
if (transaction.debit) {
|
||||||
// double click to ensure the content is selected when adding split transactions
|
const debitCell = transactionRow.getByTestId('debit');
|
||||||
await transactionRow.getByTestId('debit').dblclick();
|
await debitCell.click();
|
||||||
await this.page.keyboard.type(transaction.debit);
|
const debitInput = debitCell.getByRole('textbox');
|
||||||
|
await this.selectInputText(debitInput);
|
||||||
|
await debitInput.pressSequentially(transaction.debit);
|
||||||
await this.page.keyboard.press('Tab');
|
await this.page.keyboard.press('Tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.credit) {
|
if (transaction.credit) {
|
||||||
await transactionRow.getByTestId('credit').click();
|
const creditCell = transactionRow.getByTestId('credit');
|
||||||
await this.page.keyboard.type(transaction.credit);
|
await creditCell.click();
|
||||||
|
const creditInput = creditCell.getByRole('textbox');
|
||||||
|
await this.selectInputText(creditInput);
|
||||||
|
await creditInput.pressSequentially(transaction.credit);
|
||||||
await this.page.keyboard.press('Tab');
|
await this.page.keyboard.press('Tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.account) {
|
if (transaction.account) {
|
||||||
await transactionRow.getByTestId('account').click();
|
const accountCell = transactionRow.getByTestId('account');
|
||||||
await this.page.keyboard.type(transaction.account);
|
await accountCell.click();
|
||||||
|
const accountInput = accountCell.getByRole('textbox');
|
||||||
|
await this.selectInputText(accountInput);
|
||||||
|
await accountInput.pressSequentially(transaction.account);
|
||||||
await this.page.keyboard.press('Tab');
|
await this.page.keyboard.press('Tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.payee) {
|
if (transaction.payee) {
|
||||||
await transactionRow.getByTestId('payee').click();
|
const payeeCell = transactionRow.getByTestId('payee');
|
||||||
await this.page.keyboard.type(transaction.payee);
|
await payeeCell.click();
|
||||||
|
const payeeInput = payeeCell.getByRole('textbox');
|
||||||
|
await this.selectInputText(payeeInput);
|
||||||
|
await payeeInput.pressSequentially(transaction.payee);
|
||||||
await this.page.keyboard.press('Tab');
|
await this.page.keyboard.press('Tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.notes) {
|
if (transaction.notes) {
|
||||||
await transactionRow.getByTestId('notes').click();
|
const notesCell = transactionRow.getByTestId('notes');
|
||||||
await this.page.keyboard.type(transaction.notes);
|
await notesCell.click();
|
||||||
|
const notesInput = notesCell.getByRole('textbox');
|
||||||
|
await this.selectInputText(notesInput);
|
||||||
|
await notesInput.pressSequentially(transaction.notes);
|
||||||
await this.page.keyboard.press('Tab');
|
await this.page.keyboard.press('Tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.category) {
|
if (transaction.category) {
|
||||||
await transactionRow.getByTestId('category').click();
|
const categoryCell = transactionRow.getByTestId('category');
|
||||||
|
await categoryCell.click();
|
||||||
|
|
||||||
if (transaction.category === 'split') {
|
if (transaction.category === 'split') {
|
||||||
await this.page.getByTestId('split-transaction-button').click();
|
await this.page.getByTestId('split-transaction-button').click();
|
||||||
} else {
|
} 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');
|
await this.page.keyboard.press('Tab');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async selectInputText(input: Locator) {
|
||||||
|
const value = await input.inputValue();
|
||||||
|
if (value) {
|
||||||
|
await input.selectText();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FilterTooltip {
|
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';
|
import { type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
type ConditionsEntry = {
|
import { EditRuleModal } from './edit-rule-modal';
|
||||||
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 RulesPage {
|
export class RulesPage {
|
||||||
readonly page: Page;
|
readonly page: Page;
|
||||||
readonly searchBox: Locator;
|
readonly searchBox: Locator;
|
||||||
|
readonly createNewRuleButton: Locator;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.searchBox = page.getByPlaceholder('Filter rules...');
|
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) {
|
async createNewRule() {
|
||||||
await this.page
|
await this.createNewRuleButton.click();
|
||||||
.getByRole('button', {
|
return new EditRuleModal(this.page.getByTestId('edit-rule-modal'));
|
||||||
name: 'Create new rule',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await this._fillRuleFields(data);
|
|
||||||
|
|
||||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,108 +39,4 @@ export class RulesPage {
|
|||||||
async searchFor(text: string) {
|
async searchFor(text: string) {
|
||||||
await this.searchBox.fill(text);
|
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';
|
import { type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
type ScheduleEntry = {
|
import { ScheduleEditModal } from './schedule-edit-modal';
|
||||||
payee?: string;
|
|
||||||
account?: string;
|
|
||||||
amount?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SchedulesPage {
|
export class SchedulesPage {
|
||||||
readonly page: Page;
|
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.addNewScheduleButton.click();
|
||||||
|
|
||||||
await this._fillScheduleFields(data);
|
return new ScheduleEditModal(this.page.getByTestId('schedule-edit-modal'));
|
||||||
|
|
||||||
await this.page
|
|
||||||
.getByTestId('schedule-edit-modal')
|
|
||||||
.getByRole('button', { name: 'Add' })
|
|
||||||
.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,26 +74,4 @@ export class SchedulesPage {
|
|||||||
await actions.getByRole('button').click();
|
await actions.getByRole('button').click();
|
||||||
await this.page.getByRole('button', { name: actionName }).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 () => {
|
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
|
||||||
await rulesPage.searchFor('Fast Internet');
|
await rulesPage.searchFor('Fast Internet');
|
||||||
await rulesPage.createRule({
|
const editRuleModal = await rulesPage.createNewRule();
|
||||||
|
await editRuleModal.fill({
|
||||||
conditions: [
|
conditions: [
|
||||||
{
|
{
|
||||||
field: 'payee',
|
field: 'payee',
|
||||||
@@ -50,6 +51,7 @@ test.describe('Rules', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await editRuleModal.save();
|
||||||
|
|
||||||
const rule = rulesPage.getNthRule(0);
|
const rule = rulesPage.getNthRule(0);
|
||||||
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
|
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 () => {
|
test('creates a split transaction rule and makes sure it is applied when creating a transaction', async () => {
|
||||||
rulesPage = await navigation.goToRulesPage();
|
rulesPage = await navigation.goToRulesPage();
|
||||||
|
|
||||||
await rulesPage.createRule({
|
const editRuleModal = await rulesPage.createNewRule();
|
||||||
|
await editRuleModal.fill({
|
||||||
conditions: [
|
conditions: [
|
||||||
{
|
{
|
||||||
field: 'payee',
|
field: 'payee',
|
||||||
@@ -110,6 +113,7 @@ test.describe('Rules', () => {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await editRuleModal.save();
|
||||||
|
|
||||||
const accountPage = await navigation.goToAccountPage(
|
const accountPage = await navigation.goToAccountPage(
|
||||||
'Capital One Checking',
|
'Capital One Checking',
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ test.describe('Schedules', () => {
|
|||||||
test('creates a new schedule, posts the transaction and later completes it', async () => {
|
test('creates a new schedule, posts the transaction and later completes it', async () => {
|
||||||
test.setTimeout(40000);
|
test.setTimeout(40000);
|
||||||
|
|
||||||
await schedulesPage.addNewSchedule({
|
const scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||||
|
await scheduleEditModal.fill({
|
||||||
payee: 'Home Depot',
|
payee: 'Home Depot',
|
||||||
account: 'HSBC',
|
account: 'HSBC',
|
||||||
amount: 25,
|
amount: 25,
|
||||||
});
|
});
|
||||||
|
await scheduleEditModal.add();
|
||||||
|
|
||||||
const schedule = schedulesPage.getNthSchedule(2);
|
const schedule = schedulesPage.getNthSchedule(2);
|
||||||
await expect(schedule.payee).toHaveText('Home Depot');
|
await expect(schedule.payee).toHaveText('Home Depot');
|
||||||
@@ -91,17 +93,21 @@ test.describe('Schedules', () => {
|
|||||||
test.setTimeout(40000);
|
test.setTimeout(40000);
|
||||||
|
|
||||||
// Adding two schedules with the same payee and account and amount, mimicking two different subscriptions
|
// 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',
|
payee: 'Apple',
|
||||||
account: 'HSBC',
|
account: 'HSBC',
|
||||||
amount: 5,
|
amount: 5,
|
||||||
});
|
});
|
||||||
|
await scheduleEditModal.add();
|
||||||
|
|
||||||
await schedulesPage.addNewSchedule({
|
scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||||
|
await scheduleEditModal.fill({
|
||||||
payee: 'Apple',
|
payee: 'Apple',
|
||||||
account: 'HSBC',
|
account: 'HSBC',
|
||||||
amount: 5,
|
amount: 5,
|
||||||
});
|
});
|
||||||
|
await scheduleEditModal.add();
|
||||||
|
|
||||||
const schedule = schedulesPage.getNthSchedule(2);
|
const schedule = schedulesPage.getNthSchedule(2);
|
||||||
await expect(schedule.payee).toHaveText('Apple');
|
await expect(schedule.payee).toHaveText('Apple');
|
||||||
@@ -154,11 +160,13 @@ test.describe('Schedules', () => {
|
|||||||
test('creates a "full" list of schedules', async () => {
|
test('creates a "full" list of schedules', async () => {
|
||||||
// Schedules search shouldn't shrink with many schedules
|
// Schedules search shouldn't shrink with many schedules
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await schedulesPage.addNewSchedule({
|
const scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||||
|
await scheduleEditModal.fill({
|
||||||
payee: 'Home Depot',
|
payee: 'Home Depot',
|
||||||
account: 'HSBC',
|
account: 'HSBC',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
});
|
});
|
||||||
|
await scheduleEditModal.add();
|
||||||
}
|
}
|
||||||
await expect(page).toMatchThemeScreenshots();
|
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',
|
userAgent: 'playwright',
|
||||||
screenshot: 'on',
|
screenshot: 'on',
|
||||||
browserName: 'chromium',
|
browserName: 'chromium',
|
||||||
baseURL: process.env.E2E_START_URL ?? 'http://localhost:3001',
|
baseURL: process.env.E2E_START_URL ?? 'https://localhost:3001',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { Reports } from './reports';
|
|||||||
import { LoadingIndicator } from './reports/LoadingIndicator';
|
import { LoadingIndicator } from './reports/LoadingIndicator';
|
||||||
import { NarrowAlternate, WideComponent } from './responsive';
|
import { NarrowAlternate, WideComponent } from './responsive';
|
||||||
import { UserDirectoryPage } from './responsive/wide';
|
import { UserDirectoryPage } from './responsive/wide';
|
||||||
import { ScrollProvider } from './ScrollProvider';
|
|
||||||
import { useMultiuserEnabled } from './ServerContext';
|
import { useMultiuserEnabled } from './ServerContext';
|
||||||
import { Settings } from './settings';
|
import { Settings } from './settings';
|
||||||
import { FloatableSidebar } from './sidebar';
|
import { FloatableSidebar } from './sidebar';
|
||||||
@@ -36,6 +35,7 @@ import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
|||||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||||
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
|
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
|
||||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
|
import { ScrollProvider } from '@desktop-client/hooks/useScrollListener';
|
||||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||||
import { useSelector, useDispatch } from '@desktop-client/redux';
|
import { useSelector, useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ import { UnmigrateBudgetAutomationsModal } from './modals/UnmigrateBudgetAutomat
|
|||||||
import { CategoryLearning } from './payees/CategoryLearning';
|
import { CategoryLearning } from './payees/CategoryLearning';
|
||||||
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
|
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
|
||||||
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
||||||
import { ScheduleDetails } from './schedules/ScheduleDetails';
|
import { ScheduleEditModal } from './schedules/ScheduleEditModal';
|
||||||
import { ScheduleLink } from './schedules/ScheduleLink';
|
import { ScheduleLink } from './schedules/ScheduleLink';
|
||||||
import { UpcomingLength } from './schedules/UpcomingLength';
|
import { UpcomingLength } from './schedules/UpcomingLength';
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ export function Modals() {
|
|||||||
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
|
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'schedule-edit':
|
case 'schedule-edit':
|
||||||
return <ScheduleDetails key={key} {...modal.options} />;
|
return <ScheduleEditModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'schedule-link':
|
case 'schedule-link':
|
||||||
return <ScheduleLink key={key} {...modal.options} />;
|
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 { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||||
|
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||||
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
|
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
|
||||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||||
@@ -1760,154 +1761,158 @@ class AccountInternal extends PureComponent<
|
|||||||
filtered={transactionsFiltered}
|
filtered={transactionsFiltered}
|
||||||
>
|
>
|
||||||
{(allTransactions, allBalances) => (
|
{(allTransactions, allBalances) => (
|
||||||
<SelectedProviderWithItems
|
<DisplayPayeeProvider transactions={allTransactions}>
|
||||||
name="transactions"
|
<SelectedProviderWithItems
|
||||||
items={allTransactions}
|
name="transactions"
|
||||||
fetchAllIds={this.fetchAllIds}
|
items={allTransactions}
|
||||||
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
|
fetchAllIds={this.fetchAllIds}
|
||||||
selectAllFilter={selectAllFilter}
|
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
|
||||||
>
|
selectAllFilter={selectAllFilter}
|
||||||
<View style={styles.page}>
|
>
|
||||||
<AccountHeader
|
<View style={styles.page}>
|
||||||
tableRef={this.table}
|
<AccountHeader
|
||||||
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
|
|
||||||
tableRef={this.table}
|
tableRef={this.table}
|
||||||
|
isNameEditable={isNameEditable ?? false}
|
||||||
|
workingHard={workingHard ?? false}
|
||||||
|
accountId={accountId}
|
||||||
account={account}
|
account={account}
|
||||||
transactions={transactions}
|
filterId={filterId}
|
||||||
allTransactions={allTransactions}
|
savedFilters={this.props.savedFilters}
|
||||||
loadMoreTransactions={() =>
|
accountName={accountName}
|
||||||
this.paged && this.paged.fetchNext()
|
accountsSyncing={accountsSyncing}
|
||||||
}
|
failedAccounts={failedAccounts}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
category={category}
|
transactions={transactions}
|
||||||
categoryGroups={categoryGroups}
|
showBalances={showBalances ?? false}
|
||||||
payees={payees}
|
showExtraBalances={showExtraBalances ?? false}
|
||||||
balances={allBalances}
|
showCleared={showCleared ?? false}
|
||||||
showBalances={!!allBalances}
|
showReconciled={showReconciled ?? false}
|
||||||
showReconciled={showReconciled}
|
showEmptyMessage={showEmptyMessage ?? false}
|
||||||
showCleared={!!showCleared}
|
balanceQuery={balanceQuery}
|
||||||
showAccount={
|
canCalculateBalance={this?.canCalculateBalance ?? undefined}
|
||||||
!accountId ||
|
filteredAmount={filteredAmount}
|
||||||
accountId === 'offbudget' ||
|
isFiltered={transactionsFiltered ?? false}
|
||||||
accountId === 'onbudget' ||
|
isSorted={this.state.sort !== null}
|
||||||
accountId === 'uncategorized'
|
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}
|
onSync={this.onSync}
|
||||||
isNew={this.isNew}
|
onImport={this.onImport}
|
||||||
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}
|
onBatchDelete={this.onBatchDelete}
|
||||||
onBatchDuplicate={this.onBatchDuplicate}
|
onBatchDuplicate={this.onBatchDuplicate}
|
||||||
|
onRunRules={this.onRunRules}
|
||||||
|
onBatchEdit={this.onBatchEdit}
|
||||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||||
onCreateRule={this.onCreateRule}
|
onCreateRule={this.onCreateRule}
|
||||||
|
onUpdateFilter={this.onUpdateFilter}
|
||||||
|
onClearFilters={this.onClearFilters}
|
||||||
|
onReloadSavedFilter={this.onReloadSavedFilter}
|
||||||
|
onConditionsOpChange={this.onConditionsOpChange}
|
||||||
|
onDeleteFilter={this.onDeleteFilter}
|
||||||
|
onApplyFilter={this.onApplyFilter}
|
||||||
onScheduleAction={this.onScheduleAction}
|
onScheduleAction={this.onScheduleAction}
|
||||||
|
onSetTransfer={this.onSetTransfer}
|
||||||
|
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
|
||||||
onMakeAsNonSplitTransactions={
|
onMakeAsNonSplitTransactions={
|
||||||
this.onMakeAsNonSplitTransactions
|
this.onMakeAsNonSplitTransactions
|
||||||
}
|
}
|
||||||
onRefetch={this.refetchTransactions}
|
onMergeTransactions={this.onMergeTransactions}
|
||||||
onCloseAddTransaction={() =>
|
|
||||||
this.setState({ isAdding: false })
|
|
||||||
}
|
|
||||||
onCreatePayee={this.onCreatePayee}
|
|
||||||
onApplyFilter={this.onApplyFilter}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
</View>
|
</SelectedProviderWithItems>
|
||||||
</SelectedProviderWithItems>
|
</DisplayPayeeProvider>
|
||||||
)}
|
)}
|
||||||
</AllTransactions>
|
</AllTransactions>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export function CategoryAutocomplete({
|
|||||||
suggestions: CategoryAutocompleteItem[],
|
suggestions: CategoryAutocompleteItem[],
|
||||||
value: string,
|
value: string,
|
||||||
): CategoryAutocompleteItem[] => {
|
): CategoryAutocompleteItem[] => {
|
||||||
|
const normalizedValue = getNormalisedString(value);
|
||||||
return suggestions
|
return suggestions
|
||||||
.filter(suggestion => {
|
.filter(suggestion => {
|
||||||
if (suggestion.id === 'split') {
|
if (suggestion.id === 'split') {
|
||||||
@@ -274,11 +275,11 @@ export function CategoryAutocomplete({
|
|||||||
if (suggestion.group) {
|
if (suggestion.group) {
|
||||||
return (
|
return (
|
||||||
getNormalisedString(suggestion.group.name).includes(
|
getNormalisedString(suggestion.group.name).includes(
|
||||||
getNormalisedString(value),
|
normalizedValue,
|
||||||
) ||
|
) ||
|
||||||
getNormalisedString(
|
getNormalisedString(
|
||||||
suggestion.group.name + ' ' + suggestion.name,
|
suggestion.group.name + ' ' + suggestion.name,
|
||||||
).includes(getNormalisedString(value))
|
).includes(normalizedValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +287,7 @@ export function CategoryAutocomplete({
|
|||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
customSort(a, getNormalisedString(value)) -
|
customSort(a, normalizedValue) - customSort(b, normalizedValue),
|
||||||
customSort(b, getNormalisedString(value)),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
PayeeAutocomplete,
|
PayeeAutocomplete,
|
||||||
type PayeeAutocompleteItem,
|
|
||||||
type PayeeAutocompleteProps,
|
type PayeeAutocompleteProps,
|
||||||
} from './PayeeAutocomplete';
|
} from './PayeeAutocomplete';
|
||||||
|
|
||||||
@@ -137,7 +136,7 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
|
|||||||
|
|
||||||
test('list with less than the maximum favorites adds common payees', async () => {
|
test('list with less than the maximum favorites adds common payees', async () => {
|
||||||
//Note that the payees list assumes the payees are already sorted
|
//Note that the payees list assumes the payees are already sorted
|
||||||
const payees: PayeeAutocompleteItem[] = [
|
const payees: PayeeEntity[] = [
|
||||||
makePayee('Alice'),
|
makePayee('Alice'),
|
||||||
makePayee('Bob'),
|
makePayee('Bob'),
|
||||||
makePayee('Eve', { favorite: true }),
|
makePayee('Eve', { favorite: true }),
|
||||||
|
|||||||
@@ -40,22 +40,22 @@ import {
|
|||||||
} from '@desktop-client/payees/payeesSlice';
|
} from '@desktop-client/payees/payeesSlice';
|
||||||
import { useDispatch } from '@desktop-client/redux';
|
import { useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
export type PayeeAutocompleteItem = PayeeEntity;
|
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
|
||||||
|
|
||||||
const MAX_AUTO_SUGGESTIONS = 5;
|
const MAX_AUTO_SUGGESTIONS = 5;
|
||||||
|
|
||||||
function getPayeeSuggestions(
|
function getPayeeSuggestions(
|
||||||
commonPayees: PayeeAutocompleteItem[],
|
commonPayees: PayeeEntity[],
|
||||||
payees: PayeeAutocompleteItem[],
|
payees: PayeeEntity[],
|
||||||
): (PayeeAutocompleteItem & PayeeItemType)[] {
|
): PayeeAutocompleteItem[] {
|
||||||
const favoritePayees = payees
|
const favoritePayees: PayeeAutocompleteItem[] = payees
|
||||||
.filter(p => p.favorite)
|
.filter(p => p.favorite)
|
||||||
.map(p => {
|
.map(p => {
|
||||||
return { ...p, itemType: determineItemType(p, true) };
|
return { ...p, itemType: determineItemType(p, true) };
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
let additionalCommonPayees: (PayeeAutocompleteItem & PayeeItemType)[] = [];
|
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
|
||||||
if (commonPayees?.length > 0) {
|
if (commonPayees?.length > 0) {
|
||||||
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
|
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
|
||||||
additionalCommonPayees = commonPayees
|
additionalCommonPayees = commonPayees
|
||||||
@@ -71,10 +71,10 @@ function getPayeeSuggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (favoritePayees.length + additionalCommonPayees.length) {
|
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 => !favoritePayees.find(fp => fp.id === p.id))
|
||||||
.filter(p => !additionalCommonPayees.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) };
|
return { ...p, itemType: determineItemType(p, false) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,14 +86,14 @@ function getPayeeSuggestions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterActivePayees(
|
function filterActivePayees<T extends PayeeEntity>(
|
||||||
payees: PayeeAutocompleteItem[],
|
payees: T[],
|
||||||
accounts: AccountEntity[],
|
accounts: AccountEntity[],
|
||||||
) {
|
): T[] {
|
||||||
return accounts ? getActivePayees(payees, accounts) : payees;
|
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);
|
return payees.filter(payee => !!payee.transfer_acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +139,7 @@ type PayeeItemType = {
|
|||||||
itemType: ItemTypes;
|
itemType: ItemTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
function determineItemType(
|
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
|
||||||
item: PayeeAutocompleteItem,
|
|
||||||
isCommon: boolean,
|
|
||||||
): ItemTypes {
|
|
||||||
if (item.transfer_acct) {
|
if (item.transfer_acct) {
|
||||||
return 'account';
|
return 'account';
|
||||||
}
|
}
|
||||||
@@ -223,7 +220,7 @@ function PayeeList({
|
|||||||
|
|
||||||
// We limit the number of payees shown to 100.
|
// We limit the number of payees shown to 100.
|
||||||
// So we show a hint that more are available via search.
|
// So we show a hint that more are available via search.
|
||||||
const showSearchForMore = items.length > 100;
|
const showSearchForMore = items.length >= 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<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<
|
export type PayeeAutocompleteProps = ComponentProps<
|
||||||
typeof Autocomplete<PayeeAutocompleteItem>
|
typeof Autocomplete<PayeeAutocompleteItem>
|
||||||
> & {
|
> & {
|
||||||
@@ -317,7 +325,7 @@ export type PayeeAutocompleteProps = ComponentProps<
|
|||||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||||
) => ReactElement<typeof PayeeItem>;
|
) => ReactElement<typeof PayeeItem>;
|
||||||
accounts?: AccountEntity[];
|
accounts?: AccountEntity[];
|
||||||
payees?: PayeeAutocompleteItem[];
|
payees?: PayeeEntity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PayeeAutocomplete({
|
export function PayeeAutocomplete({
|
||||||
@@ -370,7 +378,10 @@ export function PayeeAutocomplete({
|
|||||||
return filteredSuggestions;
|
return filteredSuggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
|
return [
|
||||||
|
{ id: 'new', favorite: false, name: '' } as PayeeAutocompleteItem,
|
||||||
|
...filteredSuggestions,
|
||||||
|
];
|
||||||
}, [
|
}, [
|
||||||
commonPayees,
|
commonPayees,
|
||||||
payees,
|
payees,
|
||||||
@@ -404,6 +415,40 @@ export function PayeeAutocomplete({
|
|||||||
|
|
||||||
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
|
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 (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
key={focusTransferPayees ? 'transfers' : 'all'}
|
key={focusTransferPayees ? 'transfers' : 'all'}
|
||||||
@@ -435,66 +480,15 @@ export function PayeeAutocomplete({
|
|||||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
getHighlightedIndex={suggestions => {
|
getHighlightedIndex={suggestions => {
|
||||||
if (suggestions.length > 1 && suggestions[0].id === 'new') {
|
if (suggestions.length === 0) {
|
||||||
return 1;
|
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;
|
return 0;
|
||||||
}}
|
}}
|
||||||
filterSuggestions={(suggestions, value) => {
|
filterSuggestions={filterSuggestions}
|
||||||
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;
|
|
||||||
}}
|
|
||||||
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
|
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
|
||||||
<PayeeList
|
<PayeeList
|
||||||
items={items}
|
items={items}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import { type PayeeEntity } from 'loot-core/types/models';
|
||||||
PayeeAutocomplete,
|
|
||||||
type PayeeAutocompleteItem,
|
|
||||||
} from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
|
||||||
|
|
||||||
type PayeeFilterValue =
|
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
||||||
| PayeeAutocompleteItem['id']
|
|
||||||
| PayeeAutocompleteItem['id'][];
|
type PayeeFilterValue = PayeeEntity['id'] | PayeeEntity['id'][];
|
||||||
|
|
||||||
/** This component only supports single- or multi-select operations. */
|
/** This component only supports single- or multi-select operations. */
|
||||||
type PayeeFilterOp = 'is' | 'isNot' | 'oneOf' | 'notOneOf';
|
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 * 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';
|
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||||
|
|
||||||
const COLUMN_COUNT = 3;
|
const COLUMN_COUNT = 3;
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ import { type TransactionEntity } from 'loot-core/types/models';
|
|||||||
import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem';
|
import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem';
|
||||||
|
|
||||||
import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar';
|
import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar';
|
||||||
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
|
||||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||||
|
import { useScrollListener } from '@desktop-client/hooks/useScrollListener';
|
||||||
import {
|
import {
|
||||||
useSelectedDispatch,
|
useSelectedDispatch,
|
||||||
useSelectedItems,
|
useSelectedItems,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CellValue,
|
CellValue,
|
||||||
CellValueText,
|
CellValueText,
|
||||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||||
|
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||||
import {
|
import {
|
||||||
SelectedProvider,
|
SelectedProvider,
|
||||||
useSelected,
|
useSelected,
|
||||||
@@ -109,8 +110,8 @@ export function TransactionListWithBalances({
|
|||||||
const selectedInst = useSelected('transactions', [...transactions], []);
|
const selectedInst = useSelected('transactions', [...transactions], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectedProvider instance={selectedInst}>
|
<DisplayPayeeProvider transactions={transactions}>
|
||||||
<>
|
<SelectedProvider instance={selectedInst}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -151,8 +152,8 @@ export function TransactionListWithBalances({
|
|||||||
showMakeTransfer={showMakeTransfer}
|
showMakeTransfer={showMakeTransfer}
|
||||||
/>
|
/>
|
||||||
</PullToRefresh>
|
</PullToRefresh>
|
||||||
</>
|
</SelectedProvider>
|
||||||
</SelectedProvider>
|
</DisplayPayeeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
|||||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||||
|
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||||
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
|
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||||
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||||
@@ -618,139 +619,145 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<SelectedProviderWithItems
|
<DisplayPayeeProvider transactions={allTransactions}>
|
||||||
name="transactions"
|
<SelectedProviderWithItems
|
||||||
items={[]}
|
name="transactions"
|
||||||
fetchAllIds={async () => []}
|
items={[]}
|
||||||
registerDispatch={() => {}}
|
fetchAllIds={async () => []}
|
||||||
selectAllFilter={(item: TransactionEntity) =>
|
registerDispatch={() => {}}
|
||||||
!item._unmatched && !item.is_parent
|
selectAllFilter={(item: TransactionEntity) =>
|
||||||
}
|
!item._unmatched && !item.is_parent
|
||||||
>
|
}
|
||||||
<SchedulesProvider query={undefined}>
|
>
|
||||||
<View
|
<SchedulesProvider query={undefined}>
|
||||||
style={{
|
<View
|
||||||
width: '100%',
|
style={{
|
||||||
flexGrow: 1,
|
width: '100%',
|
||||||
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
flexGrow: 1,
|
||||||
}}
|
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
||||||
// TODO: make TableHandleRef conform to HTMLDivEle
|
}}
|
||||||
ref={table as unknown as Ref<HTMLDivElement>}
|
// TODO: make TableHandleRef conform to HTMLDivEle
|
||||||
>
|
ref={table as unknown as Ref<HTMLDivElement>}
|
||||||
{!isNarrowWidth ? (
|
>
|
||||||
<SplitsExpandedProvider initialMode="collapse">
|
{!isNarrowWidth ? (
|
||||||
<TransactionList
|
<SplitsExpandedProvider initialMode="collapse">
|
||||||
tableRef={table}
|
<TransactionList
|
||||||
account={undefined}
|
tableRef={table}
|
||||||
transactions={transactionsGrouped}
|
account={undefined}
|
||||||
allTransactions={allTransactions}
|
transactions={transactionsGrouped}
|
||||||
loadMoreTransactions={loadMoreTransactions}
|
allTransactions={allTransactions}
|
||||||
accounts={accounts}
|
loadMoreTransactions={loadMoreTransactions}
|
||||||
category={undefined}
|
accounts={accounts}
|
||||||
categoryGroups={categoryGroups}
|
category={undefined}
|
||||||
payees={payees}
|
categoryGroups={categoryGroups}
|
||||||
balances={null}
|
payees={payees}
|
||||||
showBalances={false}
|
balances={null}
|
||||||
showReconciled={true}
|
showBalances={false}
|
||||||
showCleared={false}
|
showReconciled={true}
|
||||||
showAccount={true}
|
showCleared={false}
|
||||||
isAdding={false}
|
showAccount={true}
|
||||||
isNew={() => false}
|
isAdding={false}
|
||||||
isMatched={() => false}
|
isNew={() => false}
|
||||||
dateFormat={dateFormat}
|
isMatched={() => false}
|
||||||
hideFraction={false}
|
dateFormat={dateFormat}
|
||||||
renderEmpty={() => (
|
hideFraction={false}
|
||||||
<View
|
renderEmpty={() => (
|
||||||
style={{
|
<View
|
||||||
color: theme.tableText,
|
style={{
|
||||||
marginTop: 20,
|
color: theme.tableText,
|
||||||
textAlign: 'center',
|
marginTop: 20,
|
||||||
fontStyle: 'italic',
|
textAlign: 'center',
|
||||||
}}
|
fontStyle: 'italic',
|
||||||
>
|
}}
|
||||||
<Trans>No transactions</Trans>
|
>
|
||||||
</View>
|
<Trans>No transactions</Trans>
|
||||||
)}
|
</View>
|
||||||
onSort={onSort}
|
)}
|
||||||
sortField={sortField}
|
onSort={onSort}
|
||||||
ascDesc={ascDesc}
|
sortField={sortField}
|
||||||
onChange={() => {}}
|
ascDesc={ascDesc}
|
||||||
onRefetch={() => setDirty(true)}
|
onChange={() => {}}
|
||||||
onCloseAddTransaction={() => {}}
|
onRefetch={() => setDirty(true)}
|
||||||
onCreatePayee={async () => null}
|
onCloseAddTransaction={() => {}}
|
||||||
onApplyFilter={() => {}}
|
onCreatePayee={async () => null}
|
||||||
onBatchDelete={() => {}}
|
onApplyFilter={() => {}}
|
||||||
onBatchDuplicate={() => {}}
|
onBatchDelete={() => {}}
|
||||||
onBatchLinkSchedule={() => {}}
|
onBatchDuplicate={() => {}}
|
||||||
onBatchUnlinkSchedule={() => {}}
|
onBatchLinkSchedule={() => {}}
|
||||||
onCreateRule={() => {}}
|
onBatchUnlinkSchedule={() => {}}
|
||||||
onScheduleAction={() => {}}
|
onCreateRule={() => {}}
|
||||||
onMakeAsNonSplitTransactions={() => {}}
|
onScheduleAction={() => {}}
|
||||||
showSelection={false}
|
onMakeAsNonSplitTransactions={() => {}}
|
||||||
allowSplitTransaction={false}
|
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}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</SplitsExpandedProvider>
|
||||||
</animated.div>
|
) : (
|
||||||
)}
|
<animated.div
|
||||||
</View>
|
{...bind()}
|
||||||
</SchedulesProvider>
|
style={{
|
||||||
</SelectedProviderWithItems>
|
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>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ function updateScheduleConditions(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScheduleDetailsProps = Extract<
|
type ScheduleEditModalProps = Extract<
|
||||||
ModalType,
|
ModalType,
|
||||||
{ name: 'schedule-edit' }
|
{ name: 'schedule-edit' }
|
||||||
>['options'];
|
>['options'];
|
||||||
|
|
||||||
export function ScheduleDetails({ id, transaction }: ScheduleDetailsProps) {
|
export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ import { TransactionTable } from './TransactionsTable';
|
|||||||
|
|
||||||
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
|
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
|
||||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||||
|
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||||
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
|
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
|
||||||
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
|
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
|
||||||
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
|
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
|
||||||
@@ -199,30 +200,32 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SpreadsheetProvider>
|
<SpreadsheetProvider>
|
||||||
<SchedulesProvider>
|
<SchedulesProvider>
|
||||||
<SelectedProviderWithItems
|
<DisplayPayeeProvider transactions={transactions}>
|
||||||
name="transactions"
|
<SelectedProviderWithItems
|
||||||
items={transactions}
|
name="transactions"
|
||||||
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
|
items={transactions}
|
||||||
>
|
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
|
||||||
<SplitsExpandedProvider>
|
>
|
||||||
<TransactionTable
|
<SplitsExpandedProvider>
|
||||||
{...props}
|
<TransactionTable
|
||||||
transactions={transactions}
|
{...props}
|
||||||
loadMoreTransactions={() => {}}
|
transactions={transactions}
|
||||||
// @ts-ignore TODO:
|
loadMoreTransactions={() => {}}
|
||||||
commonPayees={[]}
|
// @ts-ignore TODO:
|
||||||
payees={payees}
|
commonPayees={[]}
|
||||||
addNotification={console.log}
|
payees={payees}
|
||||||
onSave={onSave}
|
addNotification={console.log}
|
||||||
onSplit={onSplit}
|
onSave={onSave}
|
||||||
onAdd={onAdd}
|
onSplit={onSplit}
|
||||||
onAddSplit={onAddSplit}
|
onAdd={onAdd}
|
||||||
onCreatePayee={onCreatePayee}
|
onAddSplit={onAddSplit}
|
||||||
showSelection={true}
|
onCreatePayee={onCreatePayee}
|
||||||
allowSplitTransaction={true}
|
showSelection={true}
|
||||||
/>
|
allowSplitTransaction={true}
|
||||||
</SplitsExpandedProvider>
|
/>
|
||||||
</SelectedProviderWithItems>
|
</SplitsExpandedProvider>
|
||||||
|
</SelectedProviderWithItems>
|
||||||
|
</DisplayPayeeProvider>
|
||||||
</SchedulesProvider>
|
</SchedulesProvider>
|
||||||
</SpreadsheetProvider>
|
</SpreadsheetProvider>
|
||||||
</AuthProvider>
|
</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>({
|
export function ScrollProvider<T extends Element>({
|
||||||
scrollableRef,
|
scrollableRef,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
delayMs = 250,
|
delayMs = 100,
|
||||||
children,
|
children,
|
||||||
}: ScrollProviderProps<T>) {
|
}: ScrollProviderProps<T>) {
|
||||||
const previousScrollX = useRef<number | undefined>(undefined);
|
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