Compare commits
13 Commits
tests-upda
...
Transactio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ededcd854 | ||
|
|
fbc6a42662 | ||
|
|
247b0d970a | ||
|
|
b29a12799c | ||
|
|
b3c62fd69d | ||
|
|
cbac6116d4 | ||
|
|
e83cfba357 | ||
|
|
0cac66b203 | ||
|
|
7983ee45e1 | ||
|
|
844cd3433a | ||
|
|
ae6bea2b15 | ||
|
|
37481535e7 | ||
|
|
45a4f0a40d |
@@ -34,7 +34,6 @@ export const Popover = ({
|
||||
|
||||
return (
|
||||
<ReactAriaPopover
|
||||
data-popover={true}
|
||||
ref={ref}
|
||||
placement="bottom end"
|
||||
offset={1}
|
||||
|
||||
3
packages/desktop-client/.gitignore
vendored
@@ -31,3 +31,6 @@ public/*.wasm
|
||||
|
||||
# translations
|
||||
locale/
|
||||
|
||||
# service worker build output
|
||||
dev-dist
|
||||
|
||||
@@ -194,71 +194,47 @@ export class AccountPage {
|
||||
transaction: TransactionEntry,
|
||||
) {
|
||||
if (transaction.debit) {
|
||||
const debitCell = transactionRow.getByTestId('debit');
|
||||
await debitCell.click();
|
||||
const debitInput = debitCell.getByRole('textbox');
|
||||
await this.selectInputText(debitInput);
|
||||
await debitInput.pressSequentially(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);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (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 transactionRow.getByTestId('credit').click();
|
||||
await this.page.keyboard.type(transaction.credit);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (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 transactionRow.getByTestId('account').click();
|
||||
await this.page.keyboard.type(transaction.account);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (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 transactionRow.getByTestId('payee').click();
|
||||
await this.page.keyboard.type(transaction.payee);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (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 transactionRow.getByTestId('notes').click();
|
||||
await this.page.keyboard.type(transaction.notes);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.category) {
|
||||
const categoryCell = transactionRow.getByTestId('category');
|
||||
await categoryCell.click();
|
||||
await transactionRow.getByTestId('category').click();
|
||||
|
||||
if (transaction.category === 'split') {
|
||||
await this.page.getByTestId('split-transaction-button').click();
|
||||
} else {
|
||||
const categoryInput = categoryCell.getByRole('textbox');
|
||||
await this.selectInputText(categoryInput);
|
||||
await categoryInput.pressSequentially(transaction.category);
|
||||
await this.page.keyboard.type(transaction.category);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectInputText(input: Locator) {
|
||||
const value = await input.inputValue();
|
||||
if (value) {
|
||||
await input.selectText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterTooltip {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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,26 +1,52 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { EditRuleModal } from './edit-rule-modal';
|
||||
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 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',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit rule modal to create a new rule.
|
||||
* Create a new rule
|
||||
*/
|
||||
async createNewRule() {
|
||||
await this.createNewRuleButton.click();
|
||||
return new EditRuleModal(this.page.getByTestId('edit-rule-modal'));
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,4 +65,108 @@ 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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,6 +1,10 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { ScheduleEditModal } from './schedule-edit-modal';
|
||||
type ScheduleEntry = {
|
||||
payee?: string;
|
||||
account?: string;
|
||||
amount?: number;
|
||||
};
|
||||
|
||||
export class SchedulesPage {
|
||||
readonly page: Page;
|
||||
@@ -17,12 +21,17 @@ export class SchedulesPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the schedule edit modal.
|
||||
* Add a new schedule
|
||||
*/
|
||||
async addNewSchedule() {
|
||||
async addNewSchedule(data: ScheduleEntry) {
|
||||
await this.addNewScheduleButton.click();
|
||||
|
||||
return new ScheduleEditModal(this.page.getByTestId('schedule-edit-modal'));
|
||||
await this._fillScheduleFields(data);
|
||||
|
||||
await this.page
|
||||
.getByTestId('schedule-edit-modal')
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,4 +83,26 @@ 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,8 +35,7 @@ test.describe('Rules', () => {
|
||||
|
||||
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
|
||||
await rulesPage.searchFor('Fast Internet');
|
||||
const editRuleModal = await rulesPage.createNewRule();
|
||||
await editRuleModal.fill({
|
||||
await rulesPage.createRule({
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
@@ -51,7 +50,6 @@ test.describe('Rules', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
await editRuleModal.save();
|
||||
|
||||
const rule = rulesPage.getNthRule(0);
|
||||
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
|
||||
@@ -75,8 +73,7 @@ test.describe('Rules', () => {
|
||||
test('creates a split transaction rule and makes sure it is applied when creating a transaction', async () => {
|
||||
rulesPage = await navigation.goToRulesPage();
|
||||
|
||||
const editRuleModal = await rulesPage.createNewRule();
|
||||
await editRuleModal.fill({
|
||||
await rulesPage.createRule({
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
@@ -113,7 +110,6 @@ test.describe('Rules', () => {
|
||||
],
|
||||
],
|
||||
});
|
||||
await editRuleModal.save();
|
||||
|
||||
const accountPage = await navigation.goToAccountPage(
|
||||
'Capital One Checking',
|
||||
|
||||
@@ -35,13 +35,11 @@ test.describe('Schedules', () => {
|
||||
test('creates a new schedule, posts the transaction and later completes it', async () => {
|
||||
test.setTimeout(40000);
|
||||
|
||||
const scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
await schedulesPage.addNewSchedule({
|
||||
payee: 'Home Depot',
|
||||
account: 'HSBC',
|
||||
amount: 25,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
|
||||
const schedule = schedulesPage.getNthSchedule(2);
|
||||
await expect(schedule.payee).toHaveText('Home Depot');
|
||||
@@ -93,21 +91,17 @@ test.describe('Schedules', () => {
|
||||
test.setTimeout(40000);
|
||||
|
||||
// Adding two schedules with the same payee and account and amount, mimicking two different subscriptions
|
||||
let scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
await schedulesPage.addNewSchedule({
|
||||
payee: 'Apple',
|
||||
account: 'HSBC',
|
||||
amount: 5,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
|
||||
scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
await schedulesPage.addNewSchedule({
|
||||
payee: 'Apple',
|
||||
account: 'HSBC',
|
||||
amount: 5,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
|
||||
const schedule = schedulesPage.getNthSchedule(2);
|
||||
await expect(schedule.payee).toHaveText('Apple');
|
||||
@@ -160,13 +154,11 @@ 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++) {
|
||||
const scheduleEditModal = await schedulesPage.addNewSchedule();
|
||||
await scheduleEditModal.fill({
|
||||
await schedulesPage.addNewSchedule({
|
||||
payee: 'Home Depot',
|
||||
account: 'HSBC',
|
||||
amount: 0,
|
||||
});
|
||||
await scheduleEditModal.add();
|
||||
}
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 95 KiB |
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
userAgent: 'playwright',
|
||||
screenshot: 'on',
|
||||
browserName: 'chromium',
|
||||
baseURL: process.env.E2E_START_URL ?? 'https://localhost:3001',
|
||||
baseURL: process.env.E2E_START_URL ?? 'http://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
|
||||
@@ -100,6 +100,19 @@ global.Actual = {
|
||||
restartElectronServer: () => {},
|
||||
|
||||
openFileDialog: async ({ filters = [] }) => {
|
||||
const FILE_ACCEPT_OVERRIDES = {
|
||||
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
|
||||
qfx: [
|
||||
'application/vnd.intu.qfx',
|
||||
'application/x-qfx',
|
||||
'application/qfx',
|
||||
'application/ofx',
|
||||
'application/x-ofx',
|
||||
'application/octet-stream',
|
||||
'com.intuit.qfx',
|
||||
],
|
||||
};
|
||||
|
||||
return new Promise(resolve => {
|
||||
let createdElement = false;
|
||||
// Attempt to reuse an already-created file input.
|
||||
@@ -117,7 +130,15 @@ global.Actual = {
|
||||
|
||||
const filter = filters.find(filter => filter.extensions);
|
||||
if (filter) {
|
||||
input.accept = filter.extensions.map(ext => '.' + ext).join(',');
|
||||
input.accept = filter.extensions
|
||||
.flatMap(ext => {
|
||||
const normalizedExt = ext.startsWith('.')
|
||||
? ext.toLowerCase()
|
||||
: `.${ext.toLowerCase()}`;
|
||||
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
|
||||
return [normalizedExt, ...overrides];
|
||||
})
|
||||
.join(',');
|
||||
}
|
||||
|
||||
input.style.position = 'absolute';
|
||||
|
||||
@@ -16,11 +16,13 @@ import { GlobalKeys } from './GlobalKeys';
|
||||
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
|
||||
import { MobileNavTabs } from './mobile/MobileNavTabs';
|
||||
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
|
||||
import { TransactionFormPage } from './mobile/transactions/TransactionFormPage';
|
||||
import { Notifications } from './Notifications';
|
||||
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';
|
||||
@@ -35,7 +37,6 @@ 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';
|
||||
|
||||
@@ -316,7 +317,8 @@ export function FinancesApp() {
|
||||
path="/transactions/:transactionId"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
<TransactionEdit />
|
||||
{/* <TransactionEdit /> */}
|
||||
<TransactionFormPage />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 { ScheduleEditModal } from './schedules/ScheduleEditModal';
|
||||
import { ScheduleDetails } from './schedules/ScheduleDetails';
|
||||
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 <ScheduleEditModal key={key} {...modal.options} />;
|
||||
return <ScheduleDetails key={key} {...modal.options} />;
|
||||
|
||||
case 'schedule-link':
|
||||
return <ScheduleLink key={key} {...modal.options} />;
|
||||
|
||||
@@ -42,7 +42,7 @@ type ScrollProviderProps<T extends Element> = {
|
||||
export function ScrollProvider<T extends Element>({
|
||||
scrollableRef,
|
||||
isDisabled,
|
||||
delayMs = 100,
|
||||
delayMs = 250,
|
||||
children,
|
||||
}: ScrollProviderProps<T>) {
|
||||
const previousScrollX = useRef<number | undefined>(undefined);
|
||||
@@ -57,7 +57,6 @@ 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';
|
||||
@@ -1358,11 +1357,11 @@ class AccountInternal extends PureComponent<
|
||||
|
||||
onSetTransfer = async (ids: string[]) => {
|
||||
this.setState({ workingHard: true });
|
||||
await this.props.onSetTransfer(
|
||||
await this.props.onSetTransfer({
|
||||
ids,
|
||||
this.props.payees,
|
||||
this.refetchTransactions,
|
||||
);
|
||||
payees: this.props.payees,
|
||||
onSuccess: this.refetchTransactions,
|
||||
});
|
||||
};
|
||||
|
||||
onConditionsOpChange = (value: 'and' | 'or') => {
|
||||
@@ -1761,158 +1760,154 @@ class AccountInternal extends PureComponent<
|
||||
filtered={transactionsFiltered}
|
||||
>
|
||||
{(allTransactions, allBalances) => (
|
||||
<DisplayPayeeProvider transactions={allTransactions}>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={allTransactions}
|
||||
fetchAllIds={this.fetchAllIds}
|
||||
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
|
||||
selectAllFilter={selectAllFilter}
|
||||
>
|
||||
<View style={styles.page}>
|
||||
<AccountHeader
|
||||
<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
|
||||
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
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={() =>
|
||||
this.paged && this.paged.fetchNext()
|
||||
}
|
||||
onSync={this.onSync}
|
||||
onImport={this.onImport}
|
||||
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}
|
||||
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}
|
||||
onRefetch={this.refetchTransactions}
|
||||
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>
|
||||
</SelectedProviderWithItems>
|
||||
</DisplayPayeeProvider>
|
||||
</View>
|
||||
</SelectedProviderWithItems>
|
||||
)}
|
||||
</AllTransactions>
|
||||
);
|
||||
|
||||
@@ -265,7 +265,6 @@ export function CategoryAutocomplete({
|
||||
suggestions: CategoryAutocompleteItem[],
|
||||
value: string,
|
||||
): CategoryAutocompleteItem[] => {
|
||||
const normalizedValue = getNormalisedString(value);
|
||||
return suggestions
|
||||
.filter(suggestion => {
|
||||
if (suggestion.id === 'split') {
|
||||
@@ -275,11 +274,11 @@ export function CategoryAutocomplete({
|
||||
if (suggestion.group) {
|
||||
return (
|
||||
getNormalisedString(suggestion.group.name).includes(
|
||||
normalizedValue,
|
||||
getNormalisedString(value),
|
||||
) ||
|
||||
getNormalisedString(
|
||||
suggestion.group.name + ' ' + suggestion.name,
|
||||
).includes(normalizedValue)
|
||||
).includes(getNormalisedString(value))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,7 +286,8 @@ export function CategoryAutocomplete({
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
customSort(a, normalizedValue) - customSort(b, normalizedValue),
|
||||
customSort(a, getNormalisedString(value)) -
|
||||
customSort(b, getNormalisedString(value)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
PayeeAutocomplete,
|
||||
type PayeeAutocompleteItem,
|
||||
type PayeeAutocompleteProps,
|
||||
} from './PayeeAutocomplete';
|
||||
|
||||
@@ -136,7 +137,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: PayeeEntity[] = [
|
||||
const payees: PayeeAutocompleteItem[] = [
|
||||
makePayee('Alice'),
|
||||
makePayee('Bob'),
|
||||
makePayee('Eve', { favorite: true }),
|
||||
|
||||
@@ -40,22 +40,22 @@ import {
|
||||
} from '@desktop-client/payees/payeesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
|
||||
export type PayeeAutocompleteItem = PayeeEntity;
|
||||
|
||||
const MAX_AUTO_SUGGESTIONS = 5;
|
||||
|
||||
function getPayeeSuggestions(
|
||||
commonPayees: PayeeEntity[],
|
||||
payees: PayeeEntity[],
|
||||
): PayeeAutocompleteItem[] {
|
||||
const favoritePayees: PayeeAutocompleteItem[] = payees
|
||||
commonPayees: PayeeAutocompleteItem[],
|
||||
payees: PayeeAutocompleteItem[],
|
||||
): (PayeeAutocompleteItem & PayeeItemType)[] {
|
||||
const favoritePayees = payees
|
||||
.filter(p => p.favorite)
|
||||
.map(p => {
|
||||
return { ...p, itemType: determineItemType(p, true) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
|
||||
let additionalCommonPayees: (PayeeAutocompleteItem & PayeeItemType)[] = [];
|
||||
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[] = payees
|
||||
const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
|
||||
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
|
||||
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
|
||||
.map<PayeeAutocompleteItem>(p => {
|
||||
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
|
||||
return { ...p, itemType: determineItemType(p, false) };
|
||||
});
|
||||
|
||||
@@ -86,14 +86,14 @@ function getPayeeSuggestions(
|
||||
});
|
||||
}
|
||||
|
||||
function filterActivePayees<T extends PayeeEntity>(
|
||||
payees: T[],
|
||||
function filterActivePayees(
|
||||
payees: PayeeAutocompleteItem[],
|
||||
accounts: AccountEntity[],
|
||||
): T[] {
|
||||
return accounts ? (getActivePayees(payees, accounts) as T[]) : payees;
|
||||
) {
|
||||
return accounts ? getActivePayees(payees, accounts) : payees;
|
||||
}
|
||||
|
||||
function filterTransferPayees<T extends PayeeEntity>(payees: T[]): T[] {
|
||||
function filterTransferPayees(payees: PayeeAutocompleteItem[]) {
|
||||
return payees.filter(payee => !!payee.transfer_acct);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,10 @@ type PayeeItemType = {
|
||||
itemType: ItemTypes;
|
||||
};
|
||||
|
||||
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
|
||||
function determineItemType(
|
||||
item: PayeeAutocompleteItem,
|
||||
isCommon: boolean,
|
||||
): ItemTypes {
|
||||
if (item.transfer_acct) {
|
||||
return 'account';
|
||||
}
|
||||
@@ -220,7 +223,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>
|
||||
@@ -296,17 +299,6 @@ 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>
|
||||
> & {
|
||||
@@ -325,7 +317,7 @@ export type PayeeAutocompleteProps = ComponentProps<
|
||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||
) => ReactElement<typeof PayeeItem>;
|
||||
accounts?: AccountEntity[];
|
||||
payees?: PayeeEntity[];
|
||||
payees?: PayeeAutocompleteItem[];
|
||||
};
|
||||
|
||||
export function PayeeAutocomplete({
|
||||
@@ -378,10 +370,7 @@ export function PayeeAutocomplete({
|
||||
return filteredSuggestions;
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: 'new', favorite: false, name: '' } as PayeeAutocompleteItem,
|
||||
...filteredSuggestions,
|
||||
];
|
||||
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
|
||||
}, [
|
||||
commonPayees,
|
||||
payees,
|
||||
@@ -415,40 +404,6 @@ 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'}
|
||||
@@ -480,15 +435,66 @@ export function PayeeAutocomplete({
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
getHighlightedIndex={suggestions => {
|
||||
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;
|
||||
if (suggestions.length > 1 && suggestions[0].id === 'new') {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}}
|
||||
filterSuggestions={filterSuggestions}
|
||||
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;
|
||||
}}
|
||||
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
|
||||
<PayeeList
|
||||
items={items}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type PayeeEntity } from 'loot-core/types/models';
|
||||
import {
|
||||
PayeeAutocomplete,
|
||||
type PayeeAutocompleteItem,
|
||||
} from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
||||
|
||||
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
|
||||
|
||||
type PayeeFilterValue = PayeeEntity['id'] | PayeeEntity['id'][];
|
||||
type PayeeFilterValue =
|
||||
| PayeeAutocompleteItem['id']
|
||||
| PayeeAutocompleteItem['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/hooks/useScrollListener';
|
||||
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
|
||||
const COLUMN_COUNT = 3;
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import {
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useReducer,
|
||||
type Dispatch,
|
||||
useContext,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCheveronRight } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Label } from '@actual-app/components/label';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Toggle } from '@actual-app/components/toggle';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { currentDay } from 'loot-core/shared/months';
|
||||
import {
|
||||
appendDecimals,
|
||||
currencyToInteger,
|
||||
groupById,
|
||||
type IntegerAmount,
|
||||
integerToCurrency,
|
||||
} from 'loot-core/shared/util';
|
||||
import { type TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useTransactionBatchActions } from '@desktop-client/hooks/useTransactionBatchActions';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
|
||||
type TransactionFormState = {
|
||||
transactions: Record<
|
||||
TransactionEntity['id'],
|
||||
Pick<
|
||||
TransactionEntity,
|
||||
| 'id'
|
||||
| 'amount'
|
||||
| 'payee'
|
||||
| 'category'
|
||||
| 'account'
|
||||
| 'date'
|
||||
| 'cleared'
|
||||
| 'notes'
|
||||
>
|
||||
>;
|
||||
focusedTransaction: TransactionEntity['id'] | null;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
type TransactionFormActions =
|
||||
| {
|
||||
type: 'set-amount';
|
||||
id: TransactionEntity['id'];
|
||||
amount: TransactionEntity['amount'];
|
||||
}
|
||||
| {
|
||||
type: 'set-payee';
|
||||
id: TransactionEntity['id'];
|
||||
payee: TransactionEntity['payee'] | null;
|
||||
}
|
||||
| {
|
||||
type: 'set-category';
|
||||
id: TransactionEntity['id'];
|
||||
category: TransactionEntity['category'] | null;
|
||||
}
|
||||
| {
|
||||
type: 'set-notes';
|
||||
id: TransactionEntity['id'];
|
||||
notes: NonNullable<TransactionEntity['notes']>;
|
||||
}
|
||||
| {
|
||||
type: 'set-account';
|
||||
account: TransactionEntity['account'] | null;
|
||||
}
|
||||
| {
|
||||
type: 'set-date';
|
||||
date: NonNullable<TransactionEntity['date']>;
|
||||
}
|
||||
| {
|
||||
type: 'set-cleared';
|
||||
cleared: NonNullable<TransactionEntity['cleared']>;
|
||||
}
|
||||
| {
|
||||
type: 'split';
|
||||
}
|
||||
| {
|
||||
type: 'add-split';
|
||||
}
|
||||
| {
|
||||
type: 'focus';
|
||||
id: TransactionEntity['id'];
|
||||
}
|
||||
| {
|
||||
type: 'reset';
|
||||
}
|
||||
| {
|
||||
type: 'submit';
|
||||
};
|
||||
|
||||
const TransactionFormStateContext = createContext<TransactionFormState>({
|
||||
transactions: {},
|
||||
focusedTransaction: null,
|
||||
isSubmitting: false,
|
||||
});
|
||||
|
||||
const TransactionFormDispatchContext =
|
||||
createContext<Dispatch<TransactionFormActions> | null>(null);
|
||||
|
||||
type TransactionFormProviderProps = {
|
||||
children: ReactNode;
|
||||
transactions: readonly TransactionEntity[];
|
||||
};
|
||||
|
||||
export function TransactionFormProvider({
|
||||
children,
|
||||
transactions,
|
||||
}: TransactionFormProviderProps) {
|
||||
const unmodifiedTransactions = useMemo(() => {
|
||||
return transactions.reduce(
|
||||
(acc, transaction) => {
|
||||
acc[transaction.id] = {
|
||||
id: transaction.id,
|
||||
amount: transaction.amount,
|
||||
payee: transaction.payee,
|
||||
category: transaction.category,
|
||||
account: transaction.account,
|
||||
date: transaction.date,
|
||||
cleared: transaction.cleared,
|
||||
notes: transaction.notes,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as TransactionFormState['transactions'],
|
||||
);
|
||||
}, [transactions]);
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
(state: TransactionFormState, action: TransactionFormActions) => {
|
||||
switch (action.type) {
|
||||
case 'set-amount':
|
||||
return {
|
||||
...state,
|
||||
transactions: {
|
||||
...state.transactions,
|
||||
[action.id]: {
|
||||
...state.transactions[action.id],
|
||||
amount: action.amount,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'set-payee':
|
||||
return {
|
||||
...state,
|
||||
transactions: {
|
||||
...state.transactions,
|
||||
[action.id]: {
|
||||
...state.transactions[action.id],
|
||||
payee: action.payee,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'set-category':
|
||||
return {
|
||||
...state,
|
||||
transactions: {
|
||||
...state.transactions,
|
||||
[action.id]: {
|
||||
...state.transactions[action.id],
|
||||
category: action.category,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'set-notes':
|
||||
return {
|
||||
...state,
|
||||
transactions: {
|
||||
...state.transactions,
|
||||
[action.id]: {
|
||||
...state.transactions[action.id],
|
||||
notes: action.notes,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'set-account':
|
||||
return {
|
||||
...state,
|
||||
transactions: Object.keys(state.transactions).reduce(
|
||||
(acc, id) => ({
|
||||
...acc,
|
||||
[id]: {
|
||||
...state.transactions[id],
|
||||
account: action.account,
|
||||
},
|
||||
}),
|
||||
{} as TransactionFormState['transactions'],
|
||||
),
|
||||
};
|
||||
case 'set-date':
|
||||
return {
|
||||
...state,
|
||||
transactions: Object.keys(state.transactions).reduce(
|
||||
(acc, id) => ({
|
||||
...acc,
|
||||
[id]: {
|
||||
...state.transactions[id],
|
||||
date: action.date,
|
||||
},
|
||||
}),
|
||||
{} as TransactionFormState['transactions'],
|
||||
),
|
||||
};
|
||||
case 'set-cleared':
|
||||
return {
|
||||
...state,
|
||||
transactions: Object.keys(state.transactions).reduce(
|
||||
(acc, id) => ({
|
||||
...acc,
|
||||
[id]: {
|
||||
...state.transactions[id],
|
||||
cleared: action.cleared,
|
||||
},
|
||||
}),
|
||||
{} as TransactionFormState['transactions'],
|
||||
),
|
||||
};
|
||||
case 'focus':
|
||||
return {
|
||||
...state,
|
||||
focusedTransaction: action.id,
|
||||
};
|
||||
case 'reset':
|
||||
return {
|
||||
...state,
|
||||
transactions: unmodifiedTransactions,
|
||||
isSubmitting: false,
|
||||
};
|
||||
case 'submit':
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: true,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
{
|
||||
transactions: unmodifiedTransactions,
|
||||
focusedTransaction: null,
|
||||
isSubmitting: false,
|
||||
} as TransactionFormState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'reset' });
|
||||
}, [unmodifiedTransactions]);
|
||||
|
||||
const { onBatchSave } = useTransactionBatchActions();
|
||||
|
||||
useEffect(() => {
|
||||
async function saveTransactions() {
|
||||
const transactionsToSave = Object.values(state.transactions);
|
||||
await onBatchSave({
|
||||
transactions: transactionsToSave,
|
||||
onSuccess: () => {
|
||||
dispatch({ type: 'reset' });
|
||||
},
|
||||
});
|
||||
}
|
||||
if (state.isSubmitting) {
|
||||
saveTransactions().catch(console.error);
|
||||
}
|
||||
}, [state.isSubmitting, state.transactions, onBatchSave]);
|
||||
|
||||
return (
|
||||
<TransactionFormStateContext.Provider value={state}>
|
||||
<TransactionFormDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</TransactionFormDispatchContext.Provider>
|
||||
</TransactionFormStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTransactionFormState() {
|
||||
const context = useContext(TransactionFormStateContext);
|
||||
if (context === null) {
|
||||
throw new Error(
|
||||
'useTransactionFormState must be used within a TransactionFormProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTransactionFormDispatch() {
|
||||
const context = useContext(TransactionFormDispatchContext);
|
||||
if (context === null) {
|
||||
throw new Error(
|
||||
'useTransactionFormDispatch must be used within a TransactionFormProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
type TransactionFormProps = {
|
||||
transactions: ReadonlyArray<TransactionEntity>;
|
||||
};
|
||||
|
||||
export function TransactionForm({ transactions }: TransactionFormProps) {
|
||||
const [transaction] = transactions;
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const lastTransaction = useSelector(
|
||||
state => state.transactions.lastTransaction,
|
||||
);
|
||||
const payees = usePayees();
|
||||
const payeesById = useMemo(() => groupById(payees), [payees]);
|
||||
const getPayeeName = useCallback(
|
||||
(payeeId: TransactionEntity['payee']) => {
|
||||
if (!payeeId) {
|
||||
return null;
|
||||
}
|
||||
return payeesById[payeeId]?.name ?? null;
|
||||
},
|
||||
[payeesById],
|
||||
);
|
||||
|
||||
const { list: categories } = useCategories();
|
||||
const categoriesById = useMemo(() => groupById(categories), [categories]);
|
||||
const getCategoryName = useCallback(
|
||||
(categoryId: TransactionEntity['category']) => {
|
||||
if (!categoryId) {
|
||||
return null;
|
||||
}
|
||||
return categoriesById[categoryId]?.name ?? null;
|
||||
},
|
||||
[categoriesById],
|
||||
);
|
||||
|
||||
const accounts = useAccounts();
|
||||
const accountsById = useMemo(() => groupById(accounts), [accounts]);
|
||||
const getAccountName = useCallback(
|
||||
(accountId: TransactionEntity['account']) => {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
return accountsById[accountId]?.name ?? null;
|
||||
},
|
||||
[accountsById],
|
||||
);
|
||||
|
||||
const transactionFormState = useTransactionFormState();
|
||||
|
||||
const getTransactionState = useCallback(
|
||||
(id: TransactionEntity['id']) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return transactionFormState.transactions[id] ?? null;
|
||||
},
|
||||
[transactionFormState.transactions],
|
||||
);
|
||||
|
||||
const transactionFormDispatch = useTransactionFormDispatch();
|
||||
|
||||
const onSelectPayee = (id: TransactionEntity['id']) => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'payee-autocomplete',
|
||||
options: {
|
||||
onSelect: payeeId =>
|
||||
transactionFormDispatch({
|
||||
type: 'set-payee',
|
||||
id,
|
||||
payee: payeeId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSelectCategory = (id: TransactionEntity['id']) => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-autocomplete',
|
||||
options: {
|
||||
onSelect: categoryId =>
|
||||
transactionFormDispatch({
|
||||
type: 'set-category',
|
||||
id,
|
||||
category: categoryId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeNotes = (id: TransactionEntity['id'], notes: string) => {
|
||||
transactionFormDispatch({ type: 'set-notes', id, notes });
|
||||
};
|
||||
|
||||
const onSelectAccount = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'account-autocomplete',
|
||||
options: {
|
||||
onSelect: accountId =>
|
||||
transactionFormDispatch({
|
||||
type: 'set-account',
|
||||
account: accountId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSelectDate = (date: string) => {
|
||||
transactionFormDispatch({ type: 'set-date', date });
|
||||
};
|
||||
|
||||
const onUpdateAmount = (
|
||||
id: TransactionEntity['id'],
|
||||
amount: IntegerAmount,
|
||||
) => {
|
||||
console.log('onUpdateAmount', amount);
|
||||
transactionFormDispatch({ type: 'set-amount', id, amount });
|
||||
};
|
||||
|
||||
const onToggleCleared = (isCleared: boolean) => {
|
||||
transactionFormDispatch({
|
||||
type: 'set-cleared',
|
||||
cleared: isCleared,
|
||||
});
|
||||
};
|
||||
|
||||
if (!transaction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form data-testid="transaction-form">
|
||||
<View style={{ padding: styles.mobileEditingPadding, gap: 40 }}>
|
||||
<View>
|
||||
<TransactionAmount
|
||||
transaction={transaction}
|
||||
onUpdate={amount => onUpdateAmount(transaction.id, amount)}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className={css({
|
||||
gap: 20,
|
||||
'& .view': {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
},
|
||||
'& button,input': {
|
||||
height: styles.mobileMinHeight,
|
||||
textAlign: 'center',
|
||||
...styles.mediumText,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<View>
|
||||
<Label title={t('Payee')} />
|
||||
<Button
|
||||
variant="bare"
|
||||
onClick={() => onSelectPayee(transaction.id)}
|
||||
>
|
||||
<View>
|
||||
{getPayeeName(getTransactionState(transaction.id)?.payee)}
|
||||
<SvgCheveronRight
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: theme.mobileHeaderTextSubdued,
|
||||
}}
|
||||
width="14"
|
||||
height="14"
|
||||
/>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<Label title={t('Category')} />
|
||||
<Button
|
||||
variant="bare"
|
||||
onClick={() => onSelectCategory(transaction.id)}
|
||||
>
|
||||
<View>
|
||||
{getCategoryName(getTransactionState(transaction.id)?.category)}
|
||||
<SvgCheveronRight
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: theme.mobileHeaderTextSubdued,
|
||||
}}
|
||||
width="14"
|
||||
height="14"
|
||||
/>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<Label title={t('Account')} />
|
||||
<Button variant="bare" onClick={onSelectAccount}>
|
||||
<View>
|
||||
{getAccountName(getTransactionState(transaction.id)?.account)}
|
||||
<SvgCheveronRight
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: theme.mobileHeaderTextSubdued,
|
||||
}}
|
||||
width="14"
|
||||
height="14"
|
||||
/>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<Label title={t('Date')} />
|
||||
<Input
|
||||
type="date"
|
||||
value={getTransactionState(transaction.id)?.date ?? currentDay()}
|
||||
onChangeValue={onSelectDate}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Label title={t('Cleared')} />
|
||||
<FormToggle
|
||||
id="Cleared"
|
||||
isOn={getTransactionState(transaction.id)?.cleared ?? false}
|
||||
onToggle={onToggleCleared}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Label title={t('Notes')} />
|
||||
<Input
|
||||
value={getTransactionState(transaction.id)?.notes ?? ''}
|
||||
onChangeValue={notes => onChangeNotes(transaction.id, notes)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionAmountProps = {
|
||||
transaction: TransactionEntity;
|
||||
onUpdate: (amount: IntegerAmount) => void;
|
||||
};
|
||||
|
||||
function TransactionAmount({ transaction, onUpdate }: TransactionAmountProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const [value, setValue] = useState(format(transaction.amount, 'financial'));
|
||||
|
||||
const onChangeValue = useCallback(
|
||||
(value: string) => {
|
||||
setValue(appendDecimals(value));
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const _onUpdate = useCallback(
|
||||
(value: string) => {
|
||||
const parsedAmount = currencyToInteger(value) || 0;
|
||||
setValue(
|
||||
parsedAmount !== 0
|
||||
? format(parsedAmount, 'financial')
|
||||
: format(0, 'financial'),
|
||||
);
|
||||
|
||||
if (parsedAmount !== transaction.amount) {
|
||||
onUpdate(parsedAmount);
|
||||
}
|
||||
},
|
||||
[format],
|
||||
);
|
||||
|
||||
const amountInteger = value ? (currencyToInteger(value) ?? 0) : 0;
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', gap: 10 }}>
|
||||
<Label
|
||||
style={{ textAlign: 'center', ...styles.mediumText }}
|
||||
title={t('Amount')}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
style={{
|
||||
height: '15vh',
|
||||
width: '100vw',
|
||||
textAlign: 'center',
|
||||
...styles.veryLargeText,
|
||||
color: amountInteger > 0 ? theme.noticeText : theme.errorText,
|
||||
}}
|
||||
value={value || ''}
|
||||
onChangeValue={onChangeValue}
|
||||
onUpdate={_onUpdate}
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Text style={styles.largeText}>-</Text>
|
||||
<FormToggle
|
||||
id="TransactionAmountSign"
|
||||
isOn={amountInteger > 0}
|
||||
isDisabled={amountInteger === 0}
|
||||
onToggle={() => _onUpdate(integerToCurrency(-amountInteger))}
|
||||
/>
|
||||
<Text style={styles.largeText}>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type FormToggleProps = ComponentProps<typeof Toggle>;
|
||||
|
||||
function FormToggle({ className, ...restProps }: FormToggleProps) {
|
||||
return (
|
||||
<Toggle
|
||||
className={css({
|
||||
'& [data-toggle-container]': {
|
||||
width: 50,
|
||||
height: 24,
|
||||
},
|
||||
'& [data-toggle]': {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
})}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { SvgSplit } from '@actual-app/components/icons/v0';
|
||||
import { SvgAdd, SvgPiggyBank } from '@actual-app/components/icons/v1';
|
||||
import { SvgPencilWriteAlternate } from '@actual-app/components/icons/v2';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { groupById, integerToCurrency } from 'loot-core/shared/util';
|
||||
import { type TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
TransactionForm,
|
||||
TransactionFormProvider,
|
||||
useTransactionFormDispatch,
|
||||
useTransactionFormState,
|
||||
} from './TransactionForm';
|
||||
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useTransactions } from '@desktop-client/hooks/useTransactions';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function TransactionFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const { transactionId } = useParams();
|
||||
|
||||
const accounts = useAccounts();
|
||||
const accountsById = useMemo(() => groupById(accounts), [accounts]);
|
||||
const payees = usePayees();
|
||||
const payeesById = useMemo(() => groupById(payees), [payees]);
|
||||
|
||||
// const getAccount = useCallback(
|
||||
// trans => {
|
||||
// return trans?.account && accountsById?.[trans.account];
|
||||
// },
|
||||
// [accountsById],
|
||||
// );
|
||||
|
||||
const getPayee = useCallback(
|
||||
(trans: TransactionEntity) => {
|
||||
return trans?.payee ? payeesById?.[trans.payee] : null;
|
||||
},
|
||||
[payeesById],
|
||||
);
|
||||
|
||||
const getTransferAccount = useCallback(
|
||||
(trans: TransactionEntity) => {
|
||||
const payee = trans && getPayee(trans);
|
||||
return payee?.transfer_acct ? accountsById?.[payee.transfer_acct] : null;
|
||||
},
|
||||
[accountsById, getPayee],
|
||||
);
|
||||
|
||||
const transactionsQuery = useMemo(
|
||||
() =>
|
||||
q('transactions')
|
||||
.filter({ id: transactionId })
|
||||
.select('*')
|
||||
.options({ splits: 'all' }),
|
||||
[transactionId],
|
||||
);
|
||||
|
||||
const { transactions, isLoading } = useTransactions({
|
||||
query: transactionsQuery,
|
||||
});
|
||||
const [transaction] = transactions;
|
||||
|
||||
const title = getPrettyPayee({
|
||||
t,
|
||||
transaction,
|
||||
payee: getPayee(transaction),
|
||||
transferAccount: getTransferAccount(transaction),
|
||||
});
|
||||
|
||||
return (
|
||||
<TransactionFormProvider transactions={transactions}>
|
||||
<Page
|
||||
header={
|
||||
<MobilePageHeader
|
||||
title={
|
||||
!transaction?.payee
|
||||
? !transactionId
|
||||
? t('New Transaction')
|
||||
: t('Transaction')
|
||||
: title
|
||||
}
|
||||
leftContent={<MobileBackButton />}
|
||||
/>
|
||||
}
|
||||
footer={<Footer transactions={transactions} />}
|
||||
padding={0}
|
||||
>
|
||||
{isLoading ? (
|
||||
<AnimatedLoading width={15} height={15} />
|
||||
) : (
|
||||
<TransactionForm transactions={transactions} />
|
||||
)}
|
||||
</Page>
|
||||
</TransactionFormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
transactions: ReadonlyArray<TransactionEntity>;
|
||||
};
|
||||
|
||||
function Footer({ transactions }: FooterProps) {
|
||||
const { transactionId } = useParams();
|
||||
const isAdding = !transactionId;
|
||||
const [transaction, ...childTransactions] = transactions;
|
||||
const emptySplitTransaction = childTransactions.find(t => t.amount === 0);
|
||||
|
||||
const transactionFormDispatch = useTransactionFormDispatch();
|
||||
|
||||
const onClickRemainingSplit = () => {
|
||||
if (!transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (childTransactions.length === 0) {
|
||||
transactionFormDispatch({ type: 'split' });
|
||||
} else {
|
||||
if (!emptySplitTransaction) {
|
||||
transactionFormDispatch({ type: 'add-split' });
|
||||
} else {
|
||||
transactionFormDispatch({
|
||||
type: 'focus',
|
||||
id: emptySplitTransaction.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onSelectAccount = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'account-autocomplete',
|
||||
options: {
|
||||
onSelect: (accountId: string) => {
|
||||
transactionFormDispatch({
|
||||
type: 'set-account',
|
||||
account: accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = () => {
|
||||
transactionFormDispatch({ type: 'submit' });
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
data-testid="transaction-form-footer"
|
||||
style={{
|
||||
padding: `10px ${styles.mobileEditingPadding}px`,
|
||||
backgroundColor: theme.tableHeaderBackground,
|
||||
borderTopWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
{transaction?.error?.type === 'SplitTransactionError' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ height: styles.mobileMinHeight }}
|
||||
onPress={onClickRemainingSplit}
|
||||
>
|
||||
<SvgSplit width={17} height={17} />
|
||||
<Text
|
||||
style={{
|
||||
...styles.text,
|
||||
marginLeft: 6,
|
||||
}}
|
||||
>
|
||||
{!emptySplitTransaction ? (
|
||||
<Trans>
|
||||
Add new split -{' '}
|
||||
{{
|
||||
amount: integerToCurrency(
|
||||
transaction.amount > 0
|
||||
? transaction.error.difference
|
||||
: -transaction.error.difference,
|
||||
),
|
||||
}}{' '}
|
||||
left
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Amount left:{' '}
|
||||
{{
|
||||
amount: integerToCurrency(
|
||||
transaction.amount > 0
|
||||
? transaction.error.difference
|
||||
: -transaction.error.difference,
|
||||
),
|
||||
}}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Button>
|
||||
) : !transaction?.account ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ height: styles.mobileMinHeight }}
|
||||
onPress={onSelectAccount}
|
||||
>
|
||||
<SvgPiggyBank width={17} height={17} />
|
||||
<Text
|
||||
style={{
|
||||
...styles.text,
|
||||
marginLeft: 6,
|
||||
}}
|
||||
>
|
||||
<Trans>Select account</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
) : isAdding ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ height: styles.mobileMinHeight }}
|
||||
// onPress={onSubmit}
|
||||
>
|
||||
<SvgAdd width={17} height={17} />
|
||||
<Text
|
||||
style={{
|
||||
...styles.text,
|
||||
marginLeft: 5,
|
||||
}}
|
||||
>
|
||||
<Trans>Add transaction</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ height: styles.mobileMinHeight }}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
<SvgPencilWriteAlternate width={16} height={16} />
|
||||
<Text
|
||||
style={{
|
||||
...styles.text,
|
||||
marginLeft: 6,
|
||||
}}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoSizingInput({
|
||||
children,
|
||||
}: {
|
||||
children: ({ ref }: { ref: Ref<HTMLInputElement> }) => ReactNode;
|
||||
}) {
|
||||
const textRef = useRef<HTMLSpanElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && inputRef.current) {
|
||||
const spanWidth = textRef.current.offsetWidth;
|
||||
inputRef.current.style.width = `${spanWidth + 2}px`; // +2 for caret/padding
|
||||
}
|
||||
}, [inputRef.current?.value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ ref: inputRef })}
|
||||
{/* Hidden span for measuring text width */}
|
||||
<Text
|
||||
ref={textRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
...styles.veryLargeText,
|
||||
padding: '0 5px',
|
||||
}}
|
||||
>
|
||||
{inputRef.current?.value || ''}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -632,22 +632,27 @@ function SelectedTransactionsFloatingActionBar({
|
||||
},
|
||||
});
|
||||
} else if (type === 'transfer') {
|
||||
onSetTransfer?.(selectedTransactionsArray, payees, ids =>
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Successfully marked {{count}} transactions as transfer.',
|
||||
{
|
||||
count: ids.length,
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
onSetTransfer?.({
|
||||
ids: selectedTransactionsArray,
|
||||
payees,
|
||||
onSuccess: ids =>
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Successfully marked {{count}} transactions as transfer.',
|
||||
{
|
||||
count: ids.length,
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
} else if (type === 'merge') {
|
||||
onMerge?.(selectedTransactionsArray, () =>
|
||||
showUndoNotification({
|
||||
message: t('Successfully merged transactions'),
|
||||
}),
|
||||
);
|
||||
onMerge?.({
|
||||
ids: selectedTransactionsArray,
|
||||
onSuccess: () =>
|
||||
showUndoNotification({
|
||||
message: t('Successfully merged transactions'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
setIsMoreOptionsMenuOpen(false);
|
||||
}}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
|
||||
import {
|
||||
SelectedProvider,
|
||||
useSelected,
|
||||
@@ -110,8 +109,8 @@ export function TransactionListWithBalances({
|
||||
const selectedInst = useSelected('transactions', [...transactions], []);
|
||||
|
||||
return (
|
||||
<DisplayPayeeProvider transactions={transactions}>
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
@@ -152,8 +151,8 @@ export function TransactionListWithBalances({
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</SelectedProvider>
|
||||
</DisplayPayeeProvider>
|
||||
</>
|
||||
</SelectedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
|
||||
type GetPrettyPayeeProps = {
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
transaction?: TransactionEntity;
|
||||
payee?: PayeeEntity;
|
||||
transferAccount?: AccountEntity;
|
||||
transaction?: TransactionEntity | null;
|
||||
payee?: PayeeEntity | null;
|
||||
transferAccount?: AccountEntity | null;
|
||||
};
|
||||
|
||||
export function getPrettyPayee({
|
||||
|
||||
@@ -63,7 +63,6 @@ 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';
|
||||
@@ -619,145 +618,139 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<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}
|
||||
/>
|
||||
</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',
|
||||
}}
|
||||
<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' },
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
{!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>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
Suspense,
|
||||
lazy,
|
||||
type ChangeEvent,
|
||||
@@ -92,12 +93,8 @@ function FormulaInner({ widget }: FormulaInnerProps) {
|
||||
error,
|
||||
} = useFormulaExecution(formula, queriesRef.current, queriesVersion);
|
||||
|
||||
// Execute color formula with access to main result via named expression
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
queriesRef.current,
|
||||
queriesVersion,
|
||||
{
|
||||
const colorVariables = useMemo(
|
||||
() => ({
|
||||
RESULT: result ?? 0,
|
||||
...Object.entries(themeColors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
@@ -106,7 +103,14 @@ function FormulaInner({ widget }: FormulaInnerProps) {
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
},
|
||||
}),
|
||||
[result, themeColors],
|
||||
);
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
queriesRef.current,
|
||||
queriesVersion,
|
||||
colorVariables,
|
||||
);
|
||||
|
||||
const handleQueriesChange = useCallback(
|
||||
@@ -387,16 +391,7 @@ function FormulaInner({ widget }: FormulaInnerProps) {
|
||||
<Suspense fallback={<div style={{ height: 32 }} />}>
|
||||
<FormulaEditor
|
||||
value={colorFormula}
|
||||
variables={{
|
||||
RESULT: result ?? 0,
|
||||
...Object.entries(themeColors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[`theme_${key}`] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
}}
|
||||
variables={colorVariables}
|
||||
onChange={setColorFormula}
|
||||
mode="query"
|
||||
queries={queriesRef.current}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { View } from '@actual-app/components/view';
|
||||
@@ -42,12 +42,8 @@ export function FormulaCard({
|
||||
meta?.queriesVersion,
|
||||
);
|
||||
|
||||
// Execute color formula with access to main result via named expression
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
meta?.queries || {},
|
||||
meta?.queriesVersion,
|
||||
{
|
||||
const colorVariables = useMemo(
|
||||
() => ({
|
||||
RESULT: result ?? 0,
|
||||
...Object.entries(themeColors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
@@ -56,7 +52,14 @@ export function FormulaCard({
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
},
|
||||
}),
|
||||
[result, themeColors],
|
||||
);
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
meta?.queries || {},
|
||||
meta?.queriesVersion,
|
||||
colorVariables,
|
||||
);
|
||||
|
||||
// Determine the custom color from color formula result
|
||||
|
||||
@@ -119,12 +119,12 @@ function updateScheduleConditions(
|
||||
};
|
||||
}
|
||||
|
||||
type ScheduleEditModalProps = Extract<
|
||||
type ScheduleDetailsProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'schedule-edit' }
|
||||
>['options'];
|
||||
|
||||
export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) {
|
||||
export function ScheduleDetails({ id, transaction }: ScheduleDetailsProps) {
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -83,7 +83,7 @@ function GlobalFeatureToggle({
|
||||
error,
|
||||
children,
|
||||
}: GlobalFeatureToggleProps) {
|
||||
const [enabled, setEnabled] = useSyncedPref(prefName, { isGlobal: true });
|
||||
const [enabled, setEnabled] = useSyncedPref(prefName);
|
||||
|
||||
return (
|
||||
<label style={{ display: 'flex' }}>
|
||||
|
||||
@@ -30,7 +30,6 @@ 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';
|
||||
@@ -200,32 +199,30 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
|
||||
<AuthProvider>
|
||||
<SpreadsheetProvider>
|
||||
<SchedulesProvider>
|
||||
<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>
|
||||
<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>
|
||||
</SchedulesProvider>
|
||||
</SpreadsheetProvider>
|
||||
</AuthProvider>
|
||||
|
||||
132
packages/desktop-client/src/hooks/useDisplayPayee.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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 '';
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
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 '';
|
||||
}
|
||||
@@ -11,7 +11,6 @@ type SetSyncedPrefAction<K extends keyof SyncedPrefs> = (
|
||||
|
||||
export function useSyncedPref<K extends keyof SyncedPrefs>(
|
||||
prefName: K,
|
||||
options?: { isGlobal?: boolean },
|
||||
): [SyncedPrefs[K], SetSyncedPrefAction<K>] {
|
||||
const dispatch = useDispatch();
|
||||
const setPref = useCallback<SetSyncedPrefAction<K>>(
|
||||
@@ -19,11 +18,10 @@ export function useSyncedPref<K extends keyof SyncedPrefs>(
|
||||
dispatch(
|
||||
saveSyncedPrefs({
|
||||
prefs: { [prefName]: value },
|
||||
isGlobal: options?.isGlobal,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[prefName, dispatch, options?.isGlobal],
|
||||
[prefName, dispatch],
|
||||
);
|
||||
const pref = useSelector(state => state.prefs.synced[prefName]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
deleteTransaction,
|
||||
isTemporaryId,
|
||||
realizeTempTransactions,
|
||||
ungroupTransaction,
|
||||
ungroupTransactions,
|
||||
@@ -58,6 +59,22 @@ type BatchUnlinkScheduleProps = {
|
||||
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
||||
};
|
||||
|
||||
type SetTransferProps = {
|
||||
ids: Array<TransactionEntity['id']>;
|
||||
payees: PayeeEntity[];
|
||||
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
||||
};
|
||||
|
||||
type MergeProps = {
|
||||
ids: Array<TransactionEntity['id']>;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
type BatchSaveProps = {
|
||||
transactions: TransactionEntity[];
|
||||
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
||||
};
|
||||
|
||||
export function useTransactionBatchActions() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
@@ -456,11 +473,11 @@ export function useTransactionBatchActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSetTransfer = async (
|
||||
ids: string[],
|
||||
payees: PayeeEntity[],
|
||||
onSuccess: (ids: string[]) => void,
|
||||
) => {
|
||||
const onSetTransfer = async ({
|
||||
ids,
|
||||
payees,
|
||||
onSuccess,
|
||||
}: SetTransferProps) => {
|
||||
const onConfirmTransfer = async (ids: string[]) => {
|
||||
const { data: transactions } = await aqlQuery(
|
||||
q('transactions')
|
||||
@@ -506,12 +523,60 @@ export function useTransactionBatchActions() {
|
||||
);
|
||||
};
|
||||
|
||||
const onMerge = async (ids: string[], onSuccess: () => void) => {
|
||||
const onMerge = async ({ ids, onSuccess }: MergeProps) => {
|
||||
await send(
|
||||
'transactions-merge',
|
||||
ids.map(id => ({ id })),
|
||||
);
|
||||
onSuccess();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const onBatchSave = async ({ transactions, onSuccess }: BatchSaveProps) => {
|
||||
if (transactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: unmodifiedTransactions } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: transactions.map(t => t.id) } })
|
||||
.select('*'),
|
||||
);
|
||||
|
||||
const changes: Diff<TransactionEntity> = {
|
||||
added: [],
|
||||
deleted: [],
|
||||
updated: [],
|
||||
};
|
||||
|
||||
let transactionsToSave = transactions.some(t => isTemporaryId(t.id))
|
||||
? realizeTempTransactions(transactions)
|
||||
: transactions;
|
||||
|
||||
transactionsToSave.forEach(transaction => {
|
||||
const { diff } = updateTransaction(unmodifiedTransactions, transaction);
|
||||
|
||||
// TODO: We need to keep an updated list of transactions so
|
||||
// the logic in `updateTransaction`, particularly about
|
||||
// updating split transactions, works. This isn't ideal and we
|
||||
// should figure something else out
|
||||
transactionsToSave = applyChanges<TransactionEntity>(
|
||||
diff,
|
||||
transactionsToSave,
|
||||
);
|
||||
|
||||
changes.deleted = changes.deleted
|
||||
? changes.deleted.concat(diff.deleted)
|
||||
: diff.deleted;
|
||||
changes.updated = changes.updated
|
||||
? changes.updated.concat(diff.updated)
|
||||
: diff.updated;
|
||||
changes.added = changes.added
|
||||
? changes.added.concat(diff.added)
|
||||
: diff.added;
|
||||
});
|
||||
|
||||
await send('transactions-batch-update', changes);
|
||||
onSuccess?.(transactionsToSave.map(t => t.id));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -522,5 +587,6 @@ export function useTransactionBatchActions() {
|
||||
onBatchUnlinkSchedule,
|
||||
onSetTransfer,
|
||||
onMerge,
|
||||
onBatchSave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ export type Modal =
|
||||
| {
|
||||
name: 'payee-autocomplete';
|
||||
options: {
|
||||
onSelect: (payeeId: string) => void;
|
||||
onSelect: (payeeId: string, payeeName: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,18 +108,16 @@ export const saveGlobalPrefs = createAppAsyncThunk(
|
||||
|
||||
type SaveSyncedPrefsPayload = {
|
||||
prefs: SyncedPrefs;
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
export const saveSyncedPrefs = createAppAsyncThunk(
|
||||
`${sliceName}/saveSyncedPrefs`,
|
||||
async ({ prefs, isGlobal }: SaveSyncedPrefsPayload, { dispatch }) => {
|
||||
async ({ prefs }: SaveSyncedPrefsPayload, { dispatch }) => {
|
||||
await Promise.all(
|
||||
Object.entries(prefs).map(([prefName, value]) =>
|
||||
send('preferences/save', {
|
||||
id: prefName as keyof SyncedPrefs,
|
||||
value,
|
||||
isGlobal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -159,22 +159,23 @@ export default defineConfig(async ({ mode }) => {
|
||||
? undefined
|
||||
: VitePWA({
|
||||
registerType: 'prompt',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'service-worker',
|
||||
filename: 'plugin-sw.js',
|
||||
manifest: {
|
||||
name: 'Actual',
|
||||
short_name: 'Actual',
|
||||
description: 'A local-first personal finance tool',
|
||||
theme_color: '#8812E1',
|
||||
background_color: '#8812E1',
|
||||
display: 'standalone',
|
||||
start_url: './',
|
||||
},
|
||||
injectManifest: {
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
|
||||
swSrc: `service-worker/plugin-sw.js`,
|
||||
},
|
||||
// TODO: The plugin worker build is currently disabled due to issues with offline support. Fix this
|
||||
// strategies: 'injectManifest',
|
||||
// srcDir: 'service-worker',
|
||||
// filename: 'plugin-sw.js',
|
||||
// manifest: {
|
||||
// name: 'Actual',
|
||||
// short_name: 'Actual',
|
||||
// description: 'A local-first personal finance tool',
|
||||
// theme_color: '#8812E1',
|
||||
// background_color: '#8812E1',
|
||||
// display: 'standalone',
|
||||
// start_url: './',
|
||||
// },
|
||||
// injectManifest: {
|
||||
// maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
|
||||
// swSrc: `service-worker/plugin-sw.js`,
|
||||
// },
|
||||
devOptions: {
|
||||
enabled: true, // We need service worker in dev mode to work with plugins
|
||||
type: 'module',
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 675 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 743 B After Width: | Height: | Size: 779 B |
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 743 B After Width: | Height: | Size: 779 B |
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 20 KiB |