Compare commits

..

13 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
2ededcd854 Implement saving 2025-11-04 11:17:23 -08:00
Joel Jeremy Marquez
fbc6a42662 Change amount input type to number 2025-11-03 14:52:32 -08:00
Joel Jeremy Marquez
247b0d970a Smaller amount input 2025-11-03 14:10:56 -08:00
Joel Jeremy Marquez
b29a12799c Style updates 2025-11-03 14:07:15 -08:00
Joel Jeremy Marquez
b3c62fd69d New mobile transaction page (partial) 2025-11-03 13:56:29 -08:00
Matt Fiddaman
cbac6116d4 fix rerender issue with formula card (#6058)
* fix infinite rerender

* note

* remove React import
2025-11-03 01:30:32 +00:00
Michael Clark
e83cfba357 Remove plugin worker temporarily (#6052)
* remove plugin worker temporarily

* releas enotes

* clarifying comment

* remove plugin worker temporarily

* releas enotes

* clarifying comment
2025-11-02 10:18:49 +00:00
Matiss Janis Aboltins
0cac66b203 Remove isGlobal preference functionality (#6049) 2025-11-01 14:18:08 +00:00
Michael Clark
7983ee45e1 :electron: New appx icons (#6043)
* new appx icons

* updates the windows store appx icons to the new style
2025-10-31 21:45:23 +00:00
Michael Clark
844cd3433a :electron: Flathub MetaInfo (#6033)
* updates to flathub metainfo

* update to summary

* release notes

* omit :light

* Update upcoming-release-notes/6033.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* actual budget

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-30 17:15:47 +00:00
dbequeaith
ae6bea2b15 Import qfx safari mobile (#6020)
* Supports selecting qfx files on safari mobile

Fixes #4283

accept explicit MIME types associated with qfx files

* generated release notes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-28 14:20:58 +00:00
Michael Clark
37481535e7 ☁️ Fix server sync file download when server-files are in .config (#6010)
* fix server sync file download when server-files are in .config directory on linux

* extra security

* release notes

* putting it back after testing

* also accounting for directories

* derp
2025-10-27 20:11:40 +00:00
Matiss Janis Aboltins
45a4f0a40d Add sort_by field to custom reports (#6005) 2025-10-27 19:59:53 +00:00
150 changed files with 1956 additions and 1013 deletions

View File

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

View File

@@ -31,3 +31,6 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

View File

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

View File

@@ -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();
}
}

View File

@@ -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');
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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');
}
}
}

View File

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

View File

@@ -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();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

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

View File

@@ -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';

View File

@@ -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>
}
/>

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 { 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} />;

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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)),
);
},
[],

View File

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

View File

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

View File

@@ -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';

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/hooks/useScrollListener';
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
const COLUMN_COUNT = 3;

View File

@@ -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}
/>
);
}

View File

@@ -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>
</>
);
}

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,
@@ -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);
}}

View File

@@ -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>
);
}

View File

@@ -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({

View File

@@ -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>
);

View File

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

View File

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

View File

@@ -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();

View File

@@ -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' }}>

View File

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

View 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 '';
}

View File

@@ -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 '';
}

View File

@@ -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]);

View File

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

View File

@@ -233,7 +233,7 @@ export type Modal =
| {
name: 'payee-autocomplete';
options: {
onSelect: (payeeId: string) => void;
onSelect: (payeeId: string, payeeName: string) => void;
onClose?: () => void;
};
}

View File

@@ -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,
}),
),
);

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 B

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 B

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More