Compare commits

...

20 Commits

Author SHA1 Message Date
github-actions[bot]
5db5913b86 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
29da17ae76 Cleanup 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
5bf65cb20f Fix typecheck error 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
d487a609ae Coderabbit suggestion 2025-10-27 11:25:26 -07:00
autofix-ci[bot]
8b7d6ba520 [autofix.ci] apply automated fixes 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
153d4e2d18 Update rules test to use pressSequentially 2025-10-27 11:25:26 -07:00
github-actions[bot]
6a77b04ad7 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
fb8f89d411 Fix new payee not being created in tests 2025-10-27 11:25:26 -07:00
github-actions[bot]
874a2cd8cc Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
github-actions[bot]
4dc41356b9 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
e7e2fe28b6 Rename to DisplayPayeeProvider 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
3ef0ea256a Cleanup 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
a99846d3f6 Show search if there are 100 payees 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
987aafd4d4 Fix highlight of Create payee 2025-10-27 11:25:26 -07:00
autofix-ci[bot]
e54a81881c [autofix.ci] apply automated fixes 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
7be77b836c Fix payee autocomplete search 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
2b87e7c388 Set higher page count 2025-10-27 11:25:25 -07:00
Joel Jeremy Marquez
22aed82c39 Add DisplayPayeeContextProvider to TransactionsTable.test.tsx 2025-10-27 11:25:25 -07:00
Joel Jeremy Marquez
5ff59ae3c9 Rename ScrollProvider to useScrollListener and move to hooks folder 2025-10-27 11:25:25 -07:00
Joel Jeremy Marquez
54f4427423 Re-implement useDisplayPayee to use context to minimize SQL queries 2025-10-27 11:25:25 -07:00
29 changed files with 911 additions and 717 deletions

View File

@@ -34,6 +34,7 @@ export const Popover = ({
return (
<ReactAriaPopover
data-popover={true}
ref={ref}
placement="bottom end"
offset={1}

View File

@@ -194,47 +194,71 @@ export class AccountPage {
transaction: TransactionEntry,
) {
if (transaction.debit) {
// double click to ensure the content is selected when adding split transactions
await transactionRow.getByTestId('debit').dblclick();
await this.page.keyboard.type(transaction.debit);
const debitCell = transactionRow.getByTestId('debit');
await debitCell.click();
const debitInput = debitCell.getByRole('textbox');
await this.selectInputText(debitInput);
await debitInput.pressSequentially(transaction.debit);
await this.page.keyboard.press('Tab');
}
if (transaction.credit) {
await transactionRow.getByTestId('credit').click();
await this.page.keyboard.type(transaction.credit);
const creditCell = transactionRow.getByTestId('credit');
await creditCell.click();
const creditInput = creditCell.getByRole('textbox');
await this.selectInputText(creditInput);
await creditInput.pressSequentially(transaction.credit);
await this.page.keyboard.press('Tab');
}
if (transaction.account) {
await transactionRow.getByTestId('account').click();
await this.page.keyboard.type(transaction.account);
const accountCell = transactionRow.getByTestId('account');
await accountCell.click();
const accountInput = accountCell.getByRole('textbox');
await this.selectInputText(accountInput);
await accountInput.pressSequentially(transaction.account);
await this.page.keyboard.press('Tab');
}
if (transaction.payee) {
await transactionRow.getByTestId('payee').click();
await this.page.keyboard.type(transaction.payee);
const payeeCell = transactionRow.getByTestId('payee');
await payeeCell.click();
const payeeInput = payeeCell.getByRole('textbox');
await this.selectInputText(payeeInput);
await payeeInput.pressSequentially(transaction.payee);
await this.page.keyboard.press('Tab');
}
if (transaction.notes) {
await transactionRow.getByTestId('notes').click();
await this.page.keyboard.type(transaction.notes);
const notesCell = transactionRow.getByTestId('notes');
await notesCell.click();
const notesInput = notesCell.getByRole('textbox');
await this.selectInputText(notesInput);
await notesInput.pressSequentially(transaction.notes);
await this.page.keyboard.press('Tab');
}
if (transaction.category) {
await transactionRow.getByTestId('category').click();
const categoryCell = transactionRow.getByTestId('category');
await categoryCell.click();
if (transaction.category === 'split') {
await this.page.getByTestId('split-transaction-button').click();
} else {
await this.page.keyboard.type(transaction.category);
const categoryInput = categoryCell.getByRole('textbox');
await this.selectInputText(categoryInput);
await categoryInput.pressSequentially(transaction.category);
await this.page.keyboard.press('Tab');
}
}
}
async selectInputText(input: Locator) {
const value = await input.inputValue();
if (value) {
await input.selectText();
}
}
}
class FilterTooltip {

View File

@@ -0,0 +1,171 @@
import { type Page, type Locator } from '@playwright/test';
type ConditionsEntry = {
field: string;
op: string;
value: string;
};
type ActionsEntry = {
field: string;
op?: string;
value: string;
};
type SplitsEntry = {
field: string;
op?: string;
value?: string;
};
type RuleEntry = {
conditionsOp?: string | RegExp;
conditions?: ConditionsEntry[];
actions?: ActionsEntry[];
splits?: Array<SplitsEntry[]>;
};
export class EditRuleModal {
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly conditionsOpButton: Locator;
readonly conditionList: Locator;
readonly actionList: Locator;
readonly splitIntoMultipleTransactionsButton: Locator;
readonly saveButton: Locator;
readonly cancelButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.conditionsOpButton = locator
.getByTestId('conditions-op')
.getByRole('button');
this.conditionList = locator.getByTestId('condition-list');
this.actionList = locator.getByTestId('action-list');
this.splitIntoMultipleTransactionsButton = locator.getByTestId(
'add-split-transactions',
);
this.saveButton = locator.getByRole('button', { name: 'Save' });
this.cancelButton = locator.getByRole('button', { name: 'Cancel' });
}
async fill(data: RuleEntry) {
if (data.conditionsOp) {
await this.selectConditionsOp(data.conditionsOp);
}
if (data.conditions) {
await this.fillEditorFields(data.conditions, this.conditionList, true);
}
if (data.actions) {
await this.fillEditorFields(data.actions, this.actionList);
}
if (data.splits) {
let idx = data.actions?.length ?? 0;
for (const splitActions of data.splits) {
await this.splitIntoMultipleTransactionsButton.click();
await this.fillEditorFields(splitActions, this.actionList.nth(idx));
idx++;
}
}
}
async fillEditorFields(
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
rootElement: Locator,
fieldFirst = false,
) {
for (const [idx, entry] of data.entries()) {
const { field, op, value } = entry;
const row = await this.getRow(rootElement, idx);
if (!(await row.isVisible())) {
await this.addEntry(rootElement);
}
if (op && !fieldFirst) {
await this.selectOp(row, op);
}
if (field) {
await this.selectField(row, field);
}
if (op && fieldFirst) {
await this.selectOp(row, op);
}
if (value && value.length > 0) {
const input = row.getByRole('textbox');
const existingValue = await input.inputValue();
if (existingValue) {
await input.selectText();
}
// Using pressSequentially here to simulate user typing.
// When using .fill(...), playwright just "pastes" the entire word onto the input
// and for some reason this breaks the autocomplete highlighting logic
// e.g. "Create payee" option is not being highlighted.
await input.pressSequentially(value);
await this.page.keyboard.press('Enter');
}
}
}
async selectConditionsOp(conditionsOp: string | RegExp) {
await this.conditionsOpButton.click();
const conditionsOpSelectOption =
await this.getPopoverSelectOption(conditionsOp);
await conditionsOpSelectOption.click();
}
async selectOp(row: Locator, op: string) {
await row.getByTestId('op-select').getByRole('button').click();
const opSelectOption = await this.getPopoverSelectOption(op);
await opSelectOption.waitFor({ state: 'visible' });
await opSelectOption.click();
}
async selectField(row: Locator, field: string) {
await row.getByTestId('field-select').getByRole('button').click();
const fieldSelectOption = await this.getPopoverSelectOption(field);
await fieldSelectOption.waitFor({ state: 'visible' });
await fieldSelectOption.click();
}
async getRow(locator: Locator, index: number) {
return locator.getByTestId('editor-row').nth(index);
}
async addEntry(locator: Locator) {
await locator.getByRole('button', { name: 'Add entry' }).click();
}
async getPopoverSelectOption(value: string | RegExp) {
// Need to use page because popover is rendered outside of modal locator
return this.page
.locator('[data-popover]')
.getByRole('button', { name: value, exact: true });
}
async save() {
await this.saveButton.click();
}
async cancel() {
await this.cancelButton.click();
}
async close() {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
}

View File

@@ -1,52 +1,26 @@
import { type Locator, type Page } from '@playwright/test';
type ConditionsEntry = {
field: string;
op: string;
value: string;
};
type ActionsEntry = {
field: string;
op?: string;
value: string;
};
type SplitsEntry = {
field: string;
op?: string;
value?: string;
};
type RuleEntry = {
conditionsOp?: string | RegExp;
conditions?: ConditionsEntry[];
actions?: ActionsEntry[];
splits?: Array<SplitsEntry[]>;
};
import { EditRuleModal } from './edit-rule-modal';
export class RulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly createNewRuleButton: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter rules...');
this.createNewRuleButton = page.getByRole('button', {
name: 'Create new rule',
});
}
/**
* Create a new rule
* Open the edit rule modal to create a new rule.
*/
async createRule(data: RuleEntry) {
await this.page
.getByRole('button', {
name: 'Create new rule',
})
.click();
await this._fillRuleFields(data);
await this.page.getByRole('button', { name: 'Save' }).click();
async createNewRule() {
await this.createNewRuleButton.click();
return new EditRuleModal(this.page.getByTestId('edit-rule-modal'));
}
/**
@@ -65,108 +39,4 @@ export class RulesPage {
async searchFor(text: string) {
await this.searchBox.fill(text);
}
async _fillRuleFields(data: RuleEntry) {
if (data.conditionsOp) {
await this.page
.getByTestId('conditions-op')
.getByRole('button')
.first()
.click();
await this.page
.getByRole('button', { exact: true, name: data.conditionsOp })
.click();
}
if (data.conditions) {
await this._fillEditorFields(
data.conditions,
this.page.getByTestId('condition-list'),
true,
);
}
if (data.actions) {
await this._fillEditorFields(
data.actions,
this.page.getByTestId('action-list'),
);
}
if (data.splits) {
let idx = data.actions?.length ?? 0;
for (const splitActions of data.splits) {
await this.page.getByTestId('add-split-transactions').click();
await this._fillEditorFields(
splitActions,
this.page.getByTestId('action-list').nth(idx),
);
idx++;
}
}
}
async _fillEditorFields(
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
rootElement: Locator,
fieldFirst = false,
) {
for (const [idx, entry] of data.entries()) {
const { field, op, value } = entry;
const row = rootElement.getByTestId('editor-row').nth(idx);
if (!(await row.isVisible())) {
await rootElement.getByRole('button', { name: 'Add entry' }).click();
}
if (op && !fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.click({ force: true });
}
if (field) {
await row
.getByTestId('field-select')
.getByRole('button')
.first()
.click();
await this.page
.getByRole('button', { name: field, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: field, exact: true })
.first()
.click({ force: true });
}
if (op && fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.click({ force: true });
}
if (value) {
await row.getByRole('textbox').fill(value);
await this.page.keyboard.press('Enter');
}
}
}
}

View File

@@ -0,0 +1,78 @@
import { type Page, type Locator } from '@playwright/test';
type ScheduleEntry = {
scheduleName?: string;
payee?: string;
account?: string;
amount?: number;
};
export class ScheduleEditModal {
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly scheduleNameInput: Locator;
readonly payeeInput: Locator;
readonly accountInput: Locator;
readonly amountInput: Locator;
readonly addButton: Locator;
readonly saveButton: Locator;
readonly cancelButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.scheduleNameInput = locator.getByRole('textbox', {
name: 'Schedule name',
});
this.payeeInput = locator.getByRole('textbox', { name: 'Payee' });
this.accountInput = locator.getByRole('textbox', { name: 'Account' });
this.amountInput = locator.getByLabel('Amount');
this.addButton = locator.getByRole('button', { name: 'Add' });
this.saveButton = locator.getByRole('button', { name: 'Save' });
this.cancelButton = locator.getByRole('button', { name: 'Cancel' });
}
async fill(data: ScheduleEntry) {
// Using pressSequentially on autocomplete fields here to simulate user typing.
// When using .fill(...), playwright just "pastes" the entire word onto the input
// and for some reason this breaks the autocomplete highlighting logic
// e.g. "Create payee" option is not being highlighted.
if (data.scheduleName) {
await this.scheduleNameInput.fill(data.scheduleName);
}
if (data.payee) {
await this.payeeInput.pressSequentially(data.payee);
await this.page.keyboard.press('Enter');
}
if (data.account) {
await this.accountInput.pressSequentially(data.account);
await this.page.keyboard.press('Enter');
}
if (data.amount) {
await this.amountInput.fill(String(data.amount));
}
}
async save() {
await this.saveButton.click();
}
async add() {
await this.addButton.click();
}
async cancel() {
await this.cancelButton.click();
}
async close() {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
}

View File

@@ -1,10 +1,6 @@
import { type Locator, type Page } from '@playwright/test';
type ScheduleEntry = {
payee?: string;
account?: string;
amount?: number;
};
import { ScheduleEditModal } from './schedule-edit-modal';
export class SchedulesPage {
readonly page: Page;
@@ -21,17 +17,12 @@ export class SchedulesPage {
}
/**
* Add a new schedule
* Open the schedule edit modal.
*/
async addNewSchedule(data: ScheduleEntry) {
async addNewSchedule() {
await this.addNewScheduleButton.click();
await this._fillScheduleFields(data);
await this.page
.getByTestId('schedule-edit-modal')
.getByRole('button', { name: 'Add' })
.click();
return new ScheduleEditModal(this.page.getByTestId('schedule-edit-modal'));
}
/**
@@ -83,26 +74,4 @@ export class SchedulesPage {
await actions.getByRole('button').click();
await this.page.getByRole('button', { name: actionName }).click();
}
async _fillScheduleFields(data: ScheduleEntry) {
if (data.payee) {
await this.page.getByRole('textbox', { name: 'Payee' }).fill(data.payee);
await this.page.keyboard.press('Enter');
}
if (data.account) {
await this.page
.getByRole('textbox', { name: 'Account' })
.fill(data.account);
await this.page.keyboard.press('Enter');
}
if (data.amount) {
await this.page.getByLabel('Amount').fill(String(data.amount));
// For some readon, the input field does not trigger the change event on tests
// but it works on the browser. We can revisit this once migration to
// react aria components is complete.
await this.page.keyboard.press('Enter');
}
}
}

View File

@@ -35,7 +35,8 @@ test.describe('Rules', () => {
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
await rulesPage.searchFor('Fast Internet');
await rulesPage.createRule({
const editRuleModal = await rulesPage.createNewRule();
await editRuleModal.fill({
conditions: [
{
field: 'payee',
@@ -50,6 +51,7 @@ test.describe('Rules', () => {
},
],
});
await editRuleModal.save();
const rule = rulesPage.getNthRule(0);
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
@@ -73,7 +75,8 @@ test.describe('Rules', () => {
test('creates a split transaction rule and makes sure it is applied when creating a transaction', async () => {
rulesPage = await navigation.goToRulesPage();
await rulesPage.createRule({
const editRuleModal = await rulesPage.createNewRule();
await editRuleModal.fill({
conditions: [
{
field: 'payee',
@@ -110,6 +113,7 @@ test.describe('Rules', () => {
],
],
});
await editRuleModal.save();
const accountPage = await navigation.goToAccountPage(
'Capital One Checking',

View File

@@ -35,11 +35,13 @@ test.describe('Schedules', () => {
test('creates a new schedule, posts the transaction and later completes it', async () => {
test.setTimeout(40000);
await schedulesPage.addNewSchedule({
const scheduleEditModal = await schedulesPage.addNewSchedule();
await scheduleEditModal.fill({
payee: 'Home Depot',
account: 'HSBC',
amount: 25,
});
await scheduleEditModal.add();
const schedule = schedulesPage.getNthSchedule(2);
await expect(schedule.payee).toHaveText('Home Depot');
@@ -91,17 +93,21 @@ test.describe('Schedules', () => {
test.setTimeout(40000);
// Adding two schedules with the same payee and account and amount, mimicking two different subscriptions
await schedulesPage.addNewSchedule({
let scheduleEditModal = await schedulesPage.addNewSchedule();
await scheduleEditModal.fill({
payee: 'Apple',
account: 'HSBC',
amount: 5,
});
await scheduleEditModal.add();
await schedulesPage.addNewSchedule({
scheduleEditModal = await schedulesPage.addNewSchedule();
await scheduleEditModal.fill({
payee: 'Apple',
account: 'HSBC',
amount: 5,
});
await scheduleEditModal.add();
const schedule = schedulesPage.getNthSchedule(2);
await expect(schedule.payee).toHaveText('Apple');
@@ -154,11 +160,13 @@ test.describe('Schedules', () => {
test('creates a "full" list of schedules', async () => {
// Schedules search shouldn't shrink with many schedules
for (let i = 0; i < 10; i++) {
await schedulesPage.addNewSchedule({
const scheduleEditModal = await schedulesPage.addNewSchedule();
await scheduleEditModal.fill({
payee: 'Home Depot',
account: 'HSBC',
amount: 0,
});
await scheduleEditModal.add();
}
await expect(page).toMatchThemeScreenshots();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -11,7 +11,7 @@ export default defineConfig({
userAgent: 'playwright',
screenshot: 'on',
browserName: 'chromium',
baseURL: process.env.E2E_START_URL ?? 'http://localhost:3001',
baseURL: process.env.E2E_START_URL ?? 'https://localhost:3001',
trace: 'on-first-retry',
ignoreHTTPSErrors: true,
},

View File

@@ -21,7 +21,6 @@ import { Reports } from './reports';
import { LoadingIndicator } from './reports/LoadingIndicator';
import { NarrowAlternate, WideComponent } from './responsive';
import { UserDirectoryPage } from './responsive/wide';
import { ScrollProvider } from './ScrollProvider';
import { useMultiuserEnabled } from './ServerContext';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
@@ -36,6 +35,7 @@ import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { ScrollProvider } from '@desktop-client/hooks/useScrollListener';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useSelector, useDispatch } from '@desktop-client/redux';

View File

@@ -72,7 +72,7 @@ import { UnmigrateBudgetAutomationsModal } from './modals/UnmigrateBudgetAutomat
import { CategoryLearning } from './payees/CategoryLearning';
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
import { ScheduleDetails } from './schedules/ScheduleDetails';
import { ScheduleEditModal } from './schedules/ScheduleEditModal';
import { ScheduleLink } from './schedules/ScheduleLink';
import { UpcomingLength } from './schedules/UpcomingLength';
@@ -224,7 +224,7 @@ export function Modals() {
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
case 'schedule-edit':
return <ScheduleDetails key={key} {...modal.options} />;
return <ScheduleEditModal key={key} {...modal.options} />;
case 'schedule-link':
return <ScheduleLink key={key} {...modal.options} />;

View File

@@ -57,6 +57,7 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { usePayees } from '@desktop-client/hooks/usePayees';
@@ -1760,154 +1761,158 @@ class AccountInternal extends PureComponent<
filtered={transactionsFiltered}
>
{(allTransactions, allBalances) => (
<SelectedProviderWithItems
name="transactions"
items={allTransactions}
fetchAllIds={this.fetchAllIds}
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
selectAllFilter={selectAllFilter}
>
<View style={styles.page}>
<AccountHeader
tableRef={this.table}
isNameEditable={isNameEditable ?? false}
workingHard={workingHard ?? false}
accountId={accountId}
account={account}
filterId={filterId}
savedFilters={this.props.savedFilters}
accountName={accountName}
accountsSyncing={accountsSyncing}
failedAccounts={failedAccounts}
accounts={accounts}
transactions={transactions}
showBalances={showBalances ?? false}
showExtraBalances={showExtraBalances ?? false}
showCleared={showCleared ?? false}
showReconciled={showReconciled ?? false}
showEmptyMessage={showEmptyMessage ?? false}
balanceQuery={balanceQuery}
canCalculateBalance={this?.canCalculateBalance ?? undefined}
filteredAmount={filteredAmount}
isFiltered={transactionsFiltered ?? false}
isSorted={this.state.sort !== null}
reconcileAmount={reconcileAmount}
search={this.state.search}
// @ts-expect-error fix me
filterConditions={this.state.filterConditions}
filterConditionsOp={this.state.filterConditionsOp}
onSearch={this.onSearch}
onShowTransactions={this.onShowTransactions}
onMenuSelect={this.onMenuSelect}
onAddTransaction={this.onAddTransaction}
onToggleExtraBalances={this.onToggleExtraBalances}
onSaveName={this.onSaveName}
saveNameError={this.state.nameError}
onReconcile={this.onReconcile}
onDoneReconciling={this.onDoneReconciling}
onCreateReconciliationTransaction={
this.onCreateReconciliationTransaction
}
onSync={this.onSync}
onImport={this.onImport}
onBatchDelete={this.onBatchDelete}
onBatchDuplicate={this.onBatchDuplicate}
onRunRules={this.onRunRules}
onBatchEdit={this.onBatchEdit}
onBatchLinkSchedule={this.onBatchLinkSchedule}
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
onCreateRule={this.onCreateRule}
onUpdateFilter={this.onUpdateFilter}
onClearFilters={this.onClearFilters}
onReloadSavedFilter={this.onReloadSavedFilter}
onConditionsOpChange={this.onConditionsOpChange}
onDeleteFilter={this.onDeleteFilter}
onApplyFilter={this.onApplyFilter}
onScheduleAction={this.onScheduleAction}
onSetTransfer={this.onSetTransfer}
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={this.onMakeAsNonSplitTransactions}
onMergeTransactions={this.onMergeTransactions}
/>
<View style={{ flex: 1 }}>
<TransactionList
headerContent={undefined}
// @ts-ignore TODO
<DisplayPayeeProvider transactions={allTransactions}>
<SelectedProviderWithItems
name="transactions"
items={allTransactions}
fetchAllIds={this.fetchAllIds}
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
selectAllFilter={selectAllFilter}
>
<View style={styles.page}>
<AccountHeader
tableRef={this.table}
isNameEditable={isNameEditable ?? false}
workingHard={workingHard ?? false}
accountId={accountId}
account={account}
transactions={transactions}
allTransactions={allTransactions}
loadMoreTransactions={() =>
this.paged && this.paged.fetchNext()
}
filterId={filterId}
savedFilters={this.props.savedFilters}
accountName={accountName}
accountsSyncing={accountsSyncing}
failedAccounts={failedAccounts}
accounts={accounts}
category={category}
categoryGroups={categoryGroups}
payees={payees}
balances={allBalances}
showBalances={!!allBalances}
showReconciled={showReconciled}
showCleared={!!showCleared}
showAccount={
!accountId ||
accountId === 'offbudget' ||
accountId === 'onbudget' ||
accountId === 'uncategorized'
transactions={transactions}
showBalances={showBalances ?? false}
showExtraBalances={showExtraBalances ?? false}
showCleared={showCleared ?? false}
showReconciled={showReconciled ?? false}
showEmptyMessage={showEmptyMessage ?? false}
balanceQuery={balanceQuery}
canCalculateBalance={this?.canCalculateBalance ?? undefined}
filteredAmount={filteredAmount}
isFiltered={transactionsFiltered ?? false}
isSorted={this.state.sort !== null}
reconcileAmount={reconcileAmount}
search={this.state.search}
// @ts-expect-error fix me
filterConditions={this.state.filterConditions}
filterConditionsOp={this.state.filterConditionsOp}
onSearch={this.onSearch}
onShowTransactions={this.onShowTransactions}
onMenuSelect={this.onMenuSelect}
onAddTransaction={this.onAddTransaction}
onToggleExtraBalances={this.onToggleExtraBalances}
onSaveName={this.onSaveName}
saveNameError={this.state.nameError}
onReconcile={this.onReconcile}
onDoneReconciling={this.onDoneReconciling}
onCreateReconciliationTransaction={
this.onCreateReconciliationTransaction
}
isAdding={this.state.isAdding}
isNew={this.isNew}
isMatched={this.isMatched}
isFiltered={transactionsFiltered}
dateFormat={dateFormat}
hideFraction={hideFraction}
renderEmpty={() =>
showEmptyMessage ? (
<AccountEmptyMessage
onAdd={() =>
this.props.dispatch(
replaceModal({
modal: { name: 'add-account', options: {} },
}),
)
}
/>
) : !loading ? (
<View
style={{
color: theme.tableText,
marginTop: 20,
textAlign: 'center',
fontStyle: 'italic',
}}
>
<Trans>No transactions</Trans>
</View>
) : null
}
onSort={this.onSort}
sortField={this.state.sort?.field ?? ''}
ascDesc={this.state.sort?.ascDesc ?? 'asc'}
onChange={this.onTransactionsChange}
onSync={this.onSync}
onImport={this.onImport}
onBatchDelete={this.onBatchDelete}
onBatchDuplicate={this.onBatchDuplicate}
onRunRules={this.onRunRules}
onBatchEdit={this.onBatchEdit}
onBatchLinkSchedule={this.onBatchLinkSchedule}
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
onCreateRule={this.onCreateRule}
onUpdateFilter={this.onUpdateFilter}
onClearFilters={this.onClearFilters}
onReloadSavedFilter={this.onReloadSavedFilter}
onConditionsOpChange={this.onConditionsOpChange}
onDeleteFilter={this.onDeleteFilter}
onApplyFilter={this.onApplyFilter}
onScheduleAction={this.onScheduleAction}
onSetTransfer={this.onSetTransfer}
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={
this.onMakeAsNonSplitTransactions
}
onRefetch={this.refetchTransactions}
onCloseAddTransaction={() =>
this.setState({ isAdding: false })
}
onCreatePayee={this.onCreatePayee}
onApplyFilter={this.onApplyFilter}
onMergeTransactions={this.onMergeTransactions}
/>
<View style={{ flex: 1 }}>
<TransactionList
headerContent={undefined}
// @ts-ignore TODO
tableRef={this.table}
account={account}
transactions={transactions}
allTransactions={allTransactions}
loadMoreTransactions={() =>
this.paged && this.paged.fetchNext()
}
accounts={accounts}
category={category}
categoryGroups={categoryGroups}
payees={payees}
balances={allBalances}
showBalances={!!allBalances}
showReconciled={showReconciled}
showCleared={!!showCleared}
showAccount={
!accountId ||
accountId === 'offbudget' ||
accountId === 'onbudget' ||
accountId === 'uncategorized'
}
isAdding={this.state.isAdding}
isNew={this.isNew}
isMatched={this.isMatched}
isFiltered={transactionsFiltered}
dateFormat={dateFormat}
hideFraction={hideFraction}
renderEmpty={() =>
showEmptyMessage ? (
<AccountEmptyMessage
onAdd={() =>
this.props.dispatch(
replaceModal({
modal: { name: 'add-account', options: {} },
}),
)
}
/>
) : !loading ? (
<View
style={{
color: theme.tableText,
marginTop: 20,
textAlign: 'center',
fontStyle: 'italic',
}}
>
<Trans>No transactions</Trans>
</View>
) : null
}
onSort={this.onSort}
sortField={this.state.sort?.field ?? ''}
ascDesc={this.state.sort?.ascDesc ?? 'asc'}
onChange={this.onTransactionsChange}
onBatchDelete={this.onBatchDelete}
onBatchDuplicate={this.onBatchDuplicate}
onBatchLinkSchedule={this.onBatchLinkSchedule}
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
onCreateRule={this.onCreateRule}
onScheduleAction={this.onScheduleAction}
onMakeAsNonSplitTransactions={
this.onMakeAsNonSplitTransactions
}
onRefetch={this.refetchTransactions}
onCloseAddTransaction={() =>
this.setState({ isAdding: false })
}
onCreatePayee={this.onCreatePayee}
onApplyFilter={this.onApplyFilter}
/>
</View>
</View>
</View>
</SelectedProviderWithItems>
</SelectedProviderWithItems>
</DisplayPayeeProvider>
)}
</AllTransactions>
);

View File

@@ -265,6 +265,7 @@ export function CategoryAutocomplete({
suggestions: CategoryAutocompleteItem[],
value: string,
): CategoryAutocompleteItem[] => {
const normalizedValue = getNormalisedString(value);
return suggestions
.filter(suggestion => {
if (suggestion.id === 'split') {
@@ -274,11 +275,11 @@ export function CategoryAutocomplete({
if (suggestion.group) {
return (
getNormalisedString(suggestion.group.name).includes(
getNormalisedString(value),
normalizedValue,
) ||
getNormalisedString(
suggestion.group.name + ' ' + suggestion.name,
).includes(getNormalisedString(value))
).includes(normalizedValue)
);
}
@@ -286,8 +287,7 @@ export function CategoryAutocomplete({
})
.sort(
(a, b) =>
customSort(a, getNormalisedString(value)) -
customSort(b, getNormalisedString(value)),
customSort(a, normalizedValue) - customSort(b, normalizedValue),
);
},
[],

View File

@@ -7,7 +7,6 @@ import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import {
PayeeAutocomplete,
type PayeeAutocompleteItem,
type PayeeAutocompleteProps,
} from './PayeeAutocomplete';
@@ -137,7 +136,7 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
test('list with less than the maximum favorites adds common payees', async () => {
//Note that the payees list assumes the payees are already sorted
const payees: PayeeAutocompleteItem[] = [
const payees: PayeeEntity[] = [
makePayee('Alice'),
makePayee('Bob'),
makePayee('Eve', { favorite: true }),

View File

@@ -40,22 +40,22 @@ import {
} from '@desktop-client/payees/payeesSlice';
import { useDispatch } from '@desktop-client/redux';
export type PayeeAutocompleteItem = PayeeEntity;
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
const MAX_AUTO_SUGGESTIONS = 5;
function getPayeeSuggestions(
commonPayees: PayeeAutocompleteItem[],
payees: PayeeAutocompleteItem[],
): (PayeeAutocompleteItem & PayeeItemType)[] {
const favoritePayees = payees
commonPayees: PayeeEntity[],
payees: PayeeEntity[],
): PayeeAutocompleteItem[] {
const favoritePayees: PayeeAutocompleteItem[] = payees
.filter(p => p.favorite)
.map(p => {
return { ...p, itemType: determineItemType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));
let additionalCommonPayees: (PayeeAutocompleteItem & PayeeItemType)[] = [];
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
if (commonPayees?.length > 0) {
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
additionalCommonPayees = commonPayees
@@ -71,10 +71,10 @@ function getPayeeSuggestions(
}
if (favoritePayees.length + additionalCommonPayees.length) {
const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
const filteredPayees: PayeeAutocompleteItem[] = payees
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
.map<PayeeAutocompleteItem>(p => {
return { ...p, itemType: determineItemType(p, false) };
});
@@ -86,14 +86,14 @@ function getPayeeSuggestions(
});
}
function filterActivePayees(
payees: PayeeAutocompleteItem[],
function filterActivePayees<T extends PayeeEntity>(
payees: T[],
accounts: AccountEntity[],
) {
return accounts ? getActivePayees(payees, accounts) : payees;
): T[] {
return accounts ? (getActivePayees(payees, accounts) as T[]) : payees;
}
function filterTransferPayees(payees: PayeeAutocompleteItem[]) {
function filterTransferPayees<T extends PayeeEntity>(payees: T[]): T[] {
return payees.filter(payee => !!payee.transfer_acct);
}
@@ -139,10 +139,7 @@ type PayeeItemType = {
itemType: ItemTypes;
};
function determineItemType(
item: PayeeAutocompleteItem,
isCommon: boolean,
): ItemTypes {
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
@@ -223,7 +220,7 @@ function PayeeList({
// We limit the number of payees shown to 100.
// So we show a hint that more are available via search.
const showSearchForMore = items.length > 100;
const showSearchForMore = items.length >= 100;
return (
<View>
@@ -299,6 +296,17 @@ function PayeeList({
);
}
function customSort(obj: PayeeAutocompleteItem, value: string): number {
const name = getNormalisedString(obj.name);
if (obj.id === 'new') {
return -2;
}
if (name.includes(value)) {
return -1;
}
return 1;
}
export type PayeeAutocompleteProps = ComponentProps<
typeof Autocomplete<PayeeAutocompleteItem>
> & {
@@ -317,7 +325,7 @@ export type PayeeAutocompleteProps = ComponentProps<
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[];
payees?: PayeeAutocompleteItem[];
payees?: PayeeEntity[];
};
export function PayeeAutocomplete({
@@ -370,7 +378,10 @@ export function PayeeAutocomplete({
return filteredSuggestions;
}
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
return [
{ id: 'new', favorite: false, name: '' } as PayeeAutocompleteItem,
...filteredSuggestions,
];
}, [
commonPayees,
payees,
@@ -404,6 +415,40 @@ export function PayeeAutocomplete({
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
const filterSuggestions = (
suggestions: PayeeAutocompleteItem[],
value: string,
) => {
const normalizedValue = getNormalisedString(value);
const filtered = suggestions
.filter(suggestion => {
if (suggestion.id === 'new') {
return !value || value === '' || focusTransferPayees ? false : true;
}
return defaultFilterSuggestion(suggestion, value);
})
.sort(
(a, b) =>
customSort(a, normalizedValue) - customSort(b, normalizedValue),
)
// Only show the first 100 results, users can search to find more.
// If user want to view all payees, it can be done via the manage payees page.
.slice(0, 100);
if (filtered.length >= 2 && filtered[0].id === 'new') {
const firstFiltered = filtered[1];
if (
getNormalisedString(firstFiltered.name) === normalizedValue &&
!firstFiltered.transfer_acct
) {
// Exact match found, remove the 'Create payee` option.
return filtered.slice(1);
}
}
return filtered;
};
return (
<Autocomplete
key={focusTransferPayees ? 'transfers' : 'all'}
@@ -435,66 +480,15 @@ export function PayeeAutocomplete({
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect}
getHighlightedIndex={suggestions => {
if (suggestions.length > 1 && suggestions[0].id === 'new') {
return 1;
if (suggestions.length === 0) {
return null;
} else if (suggestions[0].id === 'new') {
// Highlight the first payee since the create payee option is at index 0.
return suggestions.length > 1 ? 1 : 0;
}
return 0;
}}
filterSuggestions={(suggestions, value) => {
let filtered = suggestions.filter(suggestion => {
if (suggestion.id === 'new') {
return !value || value === '' || focusTransferPayees ? false : true;
}
return defaultFilterSuggestion(suggestion, value);
});
filtered.sort((p1, p2) => {
const r1 = getNormalisedString(p1.name).startsWith(
getNormalisedString(value),
);
const r2 = getNormalisedString(p2.name).startsWith(
getNormalisedString(value),
);
const r1exact = p1.name.toLowerCase() === value.toLowerCase();
const r2exact = p2.name.toLowerCase() === value.toLowerCase();
// (maniacal laughter) mwahaHAHAHAHAH
if (p1.id === 'new') {
return -1;
} else if (p2.id === 'new') {
return 1;
} else {
if (r1exact && !r2exact) {
return -1;
} else if (!r1exact && r2exact) {
return 1;
} else {
if (r1 === r2) {
return 0;
} else if (r1 && !r2) {
return -1;
} else {
return 1;
}
}
}
});
// Only show the first 100 results, users can search to find more.
// If user want to view all payees, it can be done via the manage payees page.
filtered = filtered.slice(0, 100);
if (filtered.length >= 2 && filtered[0].id === 'new') {
if (
filtered[1].name.toLowerCase() === value.toLowerCase() &&
!filtered[1].transfer_acct
) {
return filtered.slice(1);
}
}
return filtered;
}}
filterSuggestions={filterSuggestions}
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList
items={items}

View File

@@ -1,14 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
PayeeAutocomplete,
type PayeeAutocompleteItem,
} from '@desktop-client/components/autocomplete/PayeeAutocomplete';
import { type PayeeEntity } from 'loot-core/types/models';
type PayeeFilterValue =
| PayeeAutocompleteItem['id']
| PayeeAutocompleteItem['id'][];
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
type PayeeFilterValue = PayeeEntity['id'] | PayeeEntity['id'][];
/** This component only supports single- or multi-select operations. */
type PayeeFilterOp = 'is' | 'isNot' | 'oneOf' | 'notOneOf';

View File

@@ -28,7 +28,7 @@ import { useDrag } from '@use-gesture/react';
import * as Platform from 'loot-core/shared/platform';
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
import { useScrollListener } from '@desktop-client/hooks/useScrollListener';
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
const COLUMN_COUNT = 3;

View File

@@ -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,

View File

@@ -16,6 +16,7 @@ import {
CellValue,
CellValueText,
} from '@desktop-client/components/spreadsheet/CellValue';
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
import {
SelectedProvider,
useSelected,
@@ -109,8 +110,8 @@ export function TransactionListWithBalances({
const selectedInst = useSelected('transactions', [...transactions], []);
return (
<SelectedProvider instance={selectedInst}>
<>
<DisplayPayeeProvider transactions={transactions}>
<SelectedProvider instance={selectedInst}>
<View
style={{
flexShrink: 0,
@@ -151,8 +152,8 @@ export function TransactionListWithBalances({
showMakeTransfer={showMakeTransfer}
/>
</PullToRefresh>
</>
</SelectedProvider>
</SelectedProvider>
</DisplayPayeeProvider>
);
}

View File

@@ -63,6 +63,7 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
@@ -618,139 +619,145 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
/>
</View>
</View>
<SelectedProviderWithItems
name="transactions"
items={[]}
fetchAllIds={async () => []}
registerDispatch={() => {}}
selectAllFilter={(item: TransactionEntity) =>
!item._unmatched && !item.is_parent
}
>
<SchedulesProvider query={undefined}>
<View
style={{
width: '100%',
flexGrow: 1,
overflow: isNarrowWidth ? 'auto' : 'hidden',
}}
// TODO: make TableHandleRef conform to HTMLDivEle
ref={table as unknown as Ref<HTMLDivElement>}
>
{!isNarrowWidth ? (
<SplitsExpandedProvider initialMode="collapse">
<TransactionList
tableRef={table}
account={undefined}
transactions={transactionsGrouped}
allTransactions={allTransactions}
loadMoreTransactions={loadMoreTransactions}
accounts={accounts}
category={undefined}
categoryGroups={categoryGroups}
payees={payees}
balances={null}
showBalances={false}
showReconciled={true}
showCleared={false}
showAccount={true}
isAdding={false}
isNew={() => false}
isMatched={() => false}
dateFormat={dateFormat}
hideFraction={false}
renderEmpty={() => (
<View
style={{
color: theme.tableText,
marginTop: 20,
textAlign: 'center',
fontStyle: 'italic',
}}
>
<Trans>No transactions</Trans>
</View>
)}
onSort={onSort}
sortField={sortField}
ascDesc={ascDesc}
onChange={() => {}}
onRefetch={() => setDirty(true)}
onCloseAddTransaction={() => {}}
onCreatePayee={async () => null}
onApplyFilter={() => {}}
onBatchDelete={() => {}}
onBatchDuplicate={() => {}}
onBatchLinkSchedule={() => {}}
onBatchUnlinkSchedule={() => {}}
onCreateRule={() => {}}
onScheduleAction={() => {}}
onMakeAsNonSplitTransactions={() => {}}
showSelection={false}
allowSplitTransaction={false}
/>
</SplitsExpandedProvider>
) : (
<animated.div
{...bind()}
style={{
y,
touchAction: 'pan-x',
backgroundColor: theme.mobileNavBackground,
borderTop: `1px solid ${theme.menuBorder}`,
...styles.shadow,
height: totalHeight + CHEVRON_HEIGHT,
width: '100%',
position: 'fixed',
zIndex: 100,
bottom: 0,
display: isNarrowWidth ? 'flex' : 'none',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Button
variant="bare"
onPress={() =>
!mobileTransactionsOpen
? open({ canceled: false })
: close()
}
className={css({
color: theme.pageTextSubdued,
height: 42,
'&[data-pressed]': { backgroundColor: 'transparent' },
})}
>
{!mobileTransactionsOpen && (
<>
<SvgCheveronUp width={16} height={16} />
<Trans>Show transactions</Trans>
</>
)}
{mobileTransactionsOpen && (
<>
<SvgCheveronDown width={16} height={16} />
<Trans>Hide transactions</Trans>
</>
)}
</Button>
<View
style={{ height: '100%', width: '100%', overflow: 'auto' }}
>
<TransactionListMobile
isLoading={false}
onLoadMore={loadMoreTransactions}
transactions={allTransactions}
onOpenTransaction={onOpenTransaction}
isLoadingMore={false}
<DisplayPayeeProvider transactions={allTransactions}>
<SelectedProviderWithItems
name="transactions"
items={[]}
fetchAllIds={async () => []}
registerDispatch={() => {}}
selectAllFilter={(item: TransactionEntity) =>
!item._unmatched && !item.is_parent
}
>
<SchedulesProvider query={undefined}>
<View
style={{
width: '100%',
flexGrow: 1,
overflow: isNarrowWidth ? 'auto' : 'hidden',
}}
// TODO: make TableHandleRef conform to HTMLDivEle
ref={table as unknown as Ref<HTMLDivElement>}
>
{!isNarrowWidth ? (
<SplitsExpandedProvider initialMode="collapse">
<TransactionList
tableRef={table}
account={undefined}
transactions={transactionsGrouped}
allTransactions={allTransactions}
loadMoreTransactions={loadMoreTransactions}
accounts={accounts}
category={undefined}
categoryGroups={categoryGroups}
payees={payees}
balances={null}
showBalances={false}
showReconciled={true}
showCleared={false}
showAccount={true}
isAdding={false}
isNew={() => false}
isMatched={() => false}
dateFormat={dateFormat}
hideFraction={false}
renderEmpty={() => (
<View
style={{
color: theme.tableText,
marginTop: 20,
textAlign: 'center',
fontStyle: 'italic',
}}
>
<Trans>No transactions</Trans>
</View>
)}
onSort={onSort}
sortField={sortField}
ascDesc={ascDesc}
onChange={() => {}}
onRefetch={() => setDirty(true)}
onCloseAddTransaction={() => {}}
onCreatePayee={async () => null}
onApplyFilter={() => {}}
onBatchDelete={() => {}}
onBatchDuplicate={() => {}}
onBatchLinkSchedule={() => {}}
onBatchUnlinkSchedule={() => {}}
onCreateRule={() => {}}
onScheduleAction={() => {}}
onMakeAsNonSplitTransactions={() => {}}
showSelection={false}
allowSplitTransaction={false}
/>
</View>
</animated.div>
)}
</View>
</SchedulesProvider>
</SelectedProviderWithItems>
</SplitsExpandedProvider>
) : (
<animated.div
{...bind()}
style={{
y,
touchAction: 'pan-x',
backgroundColor: theme.mobileNavBackground,
borderTop: `1px solid ${theme.menuBorder}`,
...styles.shadow,
height: totalHeight + CHEVRON_HEIGHT,
width: '100%',
position: 'fixed',
zIndex: 100,
bottom: 0,
display: isNarrowWidth ? 'flex' : 'none',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Button
variant="bare"
onPress={() =>
!mobileTransactionsOpen
? open({ canceled: false })
: close()
}
className={css({
color: theme.pageTextSubdued,
height: 42,
'&[data-pressed]': { backgroundColor: 'transparent' },
})}
>
{!mobileTransactionsOpen && (
<>
<SvgCheveronUp width={16} height={16} />
<Trans>Show transactions</Trans>
</>
)}
{mobileTransactionsOpen && (
<>
<SvgCheveronDown width={16} height={16} />
<Trans>Hide transactions</Trans>
</>
)}
</Button>
<View
style={{
height: '100%',
width: '100%',
overflow: 'auto',
}}
>
<TransactionListMobile
isLoading={false}
onLoadMore={loadMoreTransactions}
transactions={allTransactions}
onOpenTransaction={onOpenTransaction}
isLoadingMore={false}
/>
</View>
</animated.div>
)}
</View>
</SchedulesProvider>
</SelectedProviderWithItems>
</DisplayPayeeProvider>
</View>
</Page>
);

View File

@@ -119,12 +119,12 @@ function updateScheduleConditions(
};
}
type ScheduleDetailsProps = Extract<
type ScheduleEditModalProps = Extract<
ModalType,
{ name: 'schedule-edit' }
>['options'];
export function ScheduleDetails({ id, transaction }: ScheduleDetailsProps) {
export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) {
const locale = useLocale();
const { t } = useTranslation();

View File

@@ -30,6 +30,7 @@ import { TransactionTable } from './TransactionsTable';
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { DisplayPayeeProvider } from '@desktop-client/hooks/useDisplayPayee';
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
@@ -199,30 +200,32 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
<AuthProvider>
<SpreadsheetProvider>
<SchedulesProvider>
<SelectedProviderWithItems
name="transactions"
items={transactions}
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
>
<SplitsExpandedProvider>
<TransactionTable
{...props}
transactions={transactions}
loadMoreTransactions={() => {}}
// @ts-ignore TODO:
commonPayees={[]}
payees={payees}
addNotification={console.log}
onSave={onSave}
onSplit={onSplit}
onAdd={onAdd}
onAddSplit={onAddSplit}
onCreatePayee={onCreatePayee}
showSelection={true}
allowSplitTransaction={true}
/>
</SplitsExpandedProvider>
</SelectedProviderWithItems>
<DisplayPayeeProvider transactions={transactions}>
<SelectedProviderWithItems
name="transactions"
items={transactions}
fetchAllIds={() => Promise.resolve(transactions.map(t => t.id))}
>
<SplitsExpandedProvider>
<TransactionTable
{...props}
transactions={transactions}
loadMoreTransactions={() => {}}
// @ts-ignore TODO:
commonPayees={[]}
payees={payees}
addNotification={console.log}
onSave={onSave}
onSplit={onSplit}
onAdd={onAdd}
onAddSplit={onAddSplit}
onCreatePayee={onCreatePayee}
showSelection={true}
allowSplitTransaction={true}
/>
</SplitsExpandedProvider>
</SelectedProviderWithItems>
</DisplayPayeeProvider>
</SchedulesProvider>
</SpreadsheetProvider>
</AuthProvider>

View File

@@ -1,132 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { q } from 'loot-core/shared/query';
import {
type AccountEntity,
type PayeeEntity,
type TransactionEntity,
} from 'loot-core/types/models';
import { useAccounts } from './useAccounts';
import { usePayeesById } from './usePayees';
import { useTransactions } from './useTransactions';
type Counts = {
counts: Record<PayeeEntity['id'], number>;
maxCount: number;
mostCommonPayeeTransaction: TransactionEntity | null;
};
type UseDisplayPayeeProps = {
transaction?: TransactionEntity | undefined;
};
export function useDisplayPayee({ transaction }: UseDisplayPayeeProps) {
const { t } = useTranslation();
const subtransactionsQuery = useMemo(
() => q('transactions').filter({ parent_id: transaction?.id }).select('*'),
[transaction?.id],
);
const { transactions: subtransactions = [] } = useTransactions({
query: subtransactionsQuery,
});
const accounts = useAccounts();
const payeesById = usePayeesById();
const payee = payeesById[transaction?.payee || ''];
return useMemo(() => {
if (subtransactions.length === 0) {
return getPrettyPayee({
t,
transaction,
payee,
transferAccount: accounts.find(
a => a.id === payeesById[transaction?.payee || '']?.transfer_acct,
),
});
}
const { counts, mostCommonPayeeTransaction } =
subtransactions?.reduce(
({ counts, ...result }, sub) => {
if (sub.payee) {
counts[sub.payee] = (counts[sub.payee] || 0) + 1;
if (counts[sub.payee] > result.maxCount) {
return {
counts,
maxCount: counts[sub.payee],
mostCommonPayeeTransaction: sub,
};
}
}
return { counts, ...result };
},
{ counts: {}, maxCount: 0, mostCommonPayeeTransaction: null } as Counts,
) || {};
if (!mostCommonPayeeTransaction) {
return t('Split (no payee)');
}
const mostCommonPayee = payeesById[mostCommonPayeeTransaction.payee || ''];
if (!mostCommonPayee) {
return t('Split (no payee)');
}
const numDistinctPayees = Object.keys(counts).length;
return getPrettyPayee({
t,
transaction: mostCommonPayeeTransaction,
payee: mostCommonPayee,
transferAccount: accounts.find(
a =>
a.id ===
payeesById[mostCommonPayeeTransaction.payee || '']?.transfer_acct,
),
numHiddenPayees: numDistinctPayees - 1,
});
}, [subtransactions, payeesById, accounts, transaction, payee, t]);
}
type GetPrettyPayeeProps = {
t: ReturnType<typeof useTranslation>['t'];
transaction?: TransactionEntity | undefined;
payee?: PayeeEntity | undefined;
transferAccount?: AccountEntity | undefined;
numHiddenPayees?: number | undefined;
};
function getPrettyPayee({
t,
transaction,
payee,
transferAccount,
numHiddenPayees = 0,
}: GetPrettyPayeeProps) {
if (!transaction) {
return '';
}
const formatPayeeName = (payeeName: string) =>
numHiddenPayees > 0
? `${payeeName} ${t('(+{{numHiddenPayees}} more)', {
numHiddenPayees,
})}`
: payeeName;
const { payee: payeeId } = transaction;
if (transferAccount) {
return formatPayeeName(transferAccount.name);
} else if (payee) {
return formatPayeeName(payee.name);
} else if (payeeId && payeeId.startsWith('new:')) {
return formatPayeeName(payeeId.slice('new:'.length));
}
return '';
}

View File

@@ -0,0 +1,189 @@
import { createContext, type ReactNode, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { q } from 'loot-core/shared/query';
import {
type AccountEntity,
type PayeeEntity,
type TransactionEntity,
} from 'loot-core/types/models';
import { useAccounts } from './useAccounts';
import { usePayeesById } from './usePayees';
import { useTransactions } from './useTransactions';
type DisplayPayeeContextValue = {
displayPayees: Record<TransactionEntity['id'], string>;
};
const DisplayPayeeContext = createContext<DisplayPayeeContextValue | null>(
null,
);
type DisplayPayeeProviderProps = {
transactions: readonly TransactionEntity[];
children: ReactNode;
};
export function DisplayPayeeProvider({
transactions,
children,
}: DisplayPayeeProviderProps) {
const { t } = useTranslation();
const subtransactionsQuery = useMemo(
() =>
q('transactions')
.filter({ parent_id: { $oneof: transactions.map(t => t.id) } })
.select('*'),
[transactions],
);
const { transactions: allSubtransactions = [] } = useTransactions({
query: subtransactionsQuery,
options: { pageCount: transactions.length * 5 },
});
const accounts = useAccounts();
const payeesById = usePayeesById();
const displayPayees = useMemo(() => {
return transactions.reduce(
(acc, transaction) => {
const subtransactions = allSubtransactions.filter(
st => st.parent_id === transaction.id,
);
if (subtransactions.length === 0) {
acc[transaction.id] = getPrettyPayee({
t,
transaction,
payee: payeesById[transaction?.payee || ''],
transferAccount: accounts.find(
a => a.id === payeesById[transaction?.payee || '']?.transfer_acct,
),
});
return acc;
}
const { counts, mostCommonPayeeTransaction } =
subtransactions.reduce(
({ counts, ...result }, sub) => {
if (sub.payee) {
counts[sub.payee] = (counts[sub.payee] || 0) + 1;
if (counts[sub.payee] > result.maxCount) {
return {
counts,
maxCount: counts[sub.payee],
mostCommonPayeeTransaction: sub,
};
}
}
return { counts, ...result };
},
{
counts: {},
maxCount: 0,
mostCommonPayeeTransaction: null,
} as Counts,
) || {};
if (!mostCommonPayeeTransaction) {
acc[transaction.id] = t('Split (no payee)');
return acc;
}
const mostCommonPayee =
payeesById[mostCommonPayeeTransaction.payee || ''];
if (!mostCommonPayee) {
acc[transaction.id] = t('Split (no payee)');
return acc;
}
const numDistinctPayees = Object.keys(counts).length;
acc[transaction.id] = getPrettyPayee({
t,
transaction: mostCommonPayeeTransaction,
payee: mostCommonPayee,
transferAccount: accounts.find(
a =>
a.id ===
payeesById[mostCommonPayeeTransaction.payee || '']?.transfer_acct,
),
numHiddenPayees: numDistinctPayees - 1,
});
return acc;
},
{} as Record<TransactionEntity['id'], string>,
);
}, [transactions, allSubtransactions, payeesById, accounts, t]);
return (
<DisplayPayeeContext.Provider value={{ displayPayees }}>
{children}
</DisplayPayeeContext.Provider>
);
}
type Counts = {
counts: Record<PayeeEntity['id'], number>;
maxCount: number;
mostCommonPayeeTransaction: TransactionEntity | null;
};
type UseDisplayPayeeProps = {
transaction?: TransactionEntity | undefined;
};
export function useDisplayPayee({ transaction }: UseDisplayPayeeProps) {
const context = useContext(DisplayPayeeContext);
if (!context) {
throw new Error(
'useDisplayPayee must be used within a DisplayPayeeContextProvider.',
);
}
const { displayPayees } = context;
return transaction ? displayPayees[transaction.id] || '' : '';
}
type GetPrettyPayeeProps = {
t: ReturnType<typeof useTranslation>['t'];
transaction?: TransactionEntity | undefined;
payee?: PayeeEntity | undefined;
transferAccount?: AccountEntity | undefined;
numHiddenPayees?: number | undefined;
};
function getPrettyPayee({
t,
transaction,
payee,
transferAccount,
numHiddenPayees = 0,
}: GetPrettyPayeeProps) {
if (!transaction) {
return '';
}
const formatPayeeName = (payeeName: string) =>
numHiddenPayees > 0
? `${payeeName} ${t('(+{{numHiddenPayees}} more)', {
numHiddenPayees,
})}`
: payeeName;
const { payee: payeeId } = transaction;
if (transferAccount) {
return formatPayeeName(transferAccount.name);
} else if (payee) {
return formatPayeeName(payee.name);
} else if (payeeId && payeeId.startsWith('new:')) {
return formatPayeeName(payeeId.slice('new:'.length));
}
return '';
}

View File

@@ -42,7 +42,7 @@ type ScrollProviderProps<T extends Element> = {
export function ScrollProvider<T extends Element>({
scrollableRef,
isDisabled,
delayMs = 250,
delayMs = 100,
children,
}: ScrollProviderProps<T>) {
const previousScrollX = useRef<number | undefined>(undefined);

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Re-implement useDisplayPayee to use context to minimize SQL queries