Compare commits

..

71 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
58b57aefe1 arial-labels 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
e25683f130 Fix typecheck error 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
496c76c7f9 vrt 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b7d4964539 Add pillBackgroundHover 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
7479df359a Fix OpButton hover 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b1b14d0813 VRT 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b710b9675e Remove title 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
f8fb4a9ba7 Release notes 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
9f738956d7 React Aria Button on autocomplete and filters 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
dc9ce974a5 VRT 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
27974c63fd VRT 2024-07-15 12:56:46 -07:00
Matiss Janis Aboltins
f81c452ba5 ♻️ (tooltip) refactoring to react-aria (vol.10) (#2828) 2024-07-15 18:18:40 +01:00
Robert Dyer
7072674111 Add help modal for keyboard shortcuts. (#3033)
* Add help modal for keyboard shortcuts.

* add release note

* fix linter

* fix typecheck

* fix linter

* use component syntax for GroupHeading

* use component syntax for Shortcut

* fix linter

* use component syntax for KeyIcon

* refactor to support different dialogs

* show different help based on current page

* fix linter

* reword help

* capitalize letters

* show cmd on mac

* stop event propagation

* dont show if a modal is already open

* remove unused import

* rename modal

* move where location check happens

* dont stop event

* allow typing '?' in inputs

* better filter

* extract function

* fix linter

* dont show if filter popover is visible

* fix linter

* fix wrong shortcut, support SHIFT

* fix linter

* fix conditional
2024-07-15 08:06:09 -07:00
Michael Clark
16e887c917 :electron: Build electron for Mac Universal arch (#3015) 2024-07-13 22:03:57 +01:00
Tom Crasset
572033debe migrate BudgetList to typescript (#3026) 2024-07-13 18:02:01 +01:00
Michael Clark
b85f9102ce :electron: Convert window-state.js ➡️ window-state.ts (#3027) 2024-07-13 13:05:42 +01:00
Yusef Ouda
942aebedd0 fixes alignment of notifications on mobile to be centered (#3046) 2024-07-13 12:45:51 +01:00
Chris Tozlowski
32d830440a Fix the position of the separator in the operator menu when editing a rule (#3037)
* Fix line separator position in operations menu

* release note
2024-07-12 08:20:44 -07:00
youngcw
4575616961 [Goals]: don't reset goals when using "apply template" (#3011)
* fix apply budget

* un change

* note

* lint
2024-07-12 07:05:05 -07:00
DJ Mountney
4a0e2ea306 Remove Trafico workflow in favour of our new GitHub bot (#3023)
* Remove Trafico workflow in favour of our new GitHub bot

* Add release note
2024-07-11 07:30:12 -07:00
Robert Dyer
14ec9a9089 Dim hidden income category rows (#3032)
* Dim hidden income category rows

* add release note

* fix linter
2024-07-11 05:47:12 -07:00
Matt Fiddaman
e91b4070aa Add mergePayees method to the API (#3028) 2024-07-11 10:01:41 +01:00
Robert Dyer
6dd34b0c63 Perform bank sync in same order as accounts shown in sidebar. (#3029) 2024-07-11 09:32:57 +01:00
Robert Dyer
ab4639f48f API: add getBudgets() methods to list all budgets in the local cache or remote server. (#2928) 2024-07-11 09:27:32 +01:00
Austin Pearce
aa3cbd881b Fix alignment of reports` (#3007) 2024-07-10 20:17:48 -07:00
Robert Dyer
8681c9c3e6 Fix editing transactions on mobile not going back. (#2968) 2024-07-10 22:00:50 +01:00
Matiss Janis Aboltins
9ec9aef632 (budget-type) moving the selector to settings page (#3017)
*  (budget-type) moving the selector to settings page

* Feedback: move the block down
2024-07-10 21:58:20 +01:00
Robert Dyer
3be7dd753d Add getAccountBalance() API. (#2930) 2024-07-10 21:52:21 +01:00
Robert Dyer
259e84cea5 Expose bank sync account data in AQL. (#3022)
* Expose bank sync account data in AQL.

* add release note
2024-07-10 07:05:20 -07:00
Austin Pearce
f9014f0e19 Fix mobile payee creation (#3019) 2024-07-09 16:06:25 -07:00
Julian Wachholz
e59f5c9af8 Add apostrophe-dot number format (#2982) 2024-07-09 19:14:38 +01:00
Matt Fiddaman
771c01c8b4 Move bank sync payee name normalisation from actual to actual-server (#2721) 2024-07-09 19:05:15 +01:00
Michael Clark
9f72b43826 :electron: Remove unneded files (#3014) 2024-07-09 18:08:23 +01:00
Julian Wachholz
ec3475d834 Fix number formatting with non-breaking space (#2981) 2024-07-09 18:02:40 +01:00
Wizmaster
5ea9c587a8 Explicitly ask when reconciling transactions on manual import (#2717)
- Added import preview in transaction import list
- Added checkboxes to selectively prevent merging transactions

Co-authored-by: youngcw <calebyoung94@gmail.com>
2024-07-09 06:39:08 -07:00
Matiss Janis Aboltins
1e38055376 🐛 (popover) fix date popover closing when editing a filter (#3009) 2024-07-08 19:57:00 +01:00
Julian Dominguez-Schatz
0ee9126820 Disable interactivity on preview status icons (#2924)
* Disable interactivity on preview statuses

These have no click action but have a focus effect of a purple circle
(residual from the "Cleared" checkbox styling) that looks a bit glitchy.

* Add release notes

* Exclude status field from keyboard navigation
2024-07-08 10:39:00 -07:00
Joel Jeremy Marquez
9e455e4c1e Fix cover modal title (#3008)
* Fix cover modal title

* Release notes
2024-07-08 09:59:36 -07:00
Yusef Ouda
d77b54f27b reorders 'Rename' above 'Hide' in menu popovers, adds debounce to sidebar animation (#3001)
* reorders 'Rename' above 'Hide' in menu popovers

* release notes

* adds debounce to sidebar animation

* bump debounce time

* release notes

* release notes

* Update debounce import

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* removes event listener on titlebar, changes margins
2024-07-08 09:51:28 -07:00
Sreetam Das
ff36d1efbe Add computed padding for handling clipped large Net worth amounts (#2818)
* Add computed padding for handling clipped Net worth amounts

* Add comment, early handle 5 character case

* Add release note

* Update packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx

Co-authored-by: Robert Dyer <rdyer@unl.edu>

* Update vrt snapshots

* Fix NetWorthGraph cutoff when `compact` is true

This happens in case of `ReportCard`

* Update VRT snapshots to revert to original

* Revert snapshots to original

* vrt

---------

Co-authored-by: Robert Dyer <rdyer@unl.edu>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2024-07-08 08:13:47 -07:00
youngcw
cbbbaf65cf remove version from electron build names (#3000)
* remove version from electron build names

* note

* fix
2024-07-07 14:54:14 -07:00
Yusef Ouda
f129b07dc9 Adds ability to resize sidebar (#2993)
* Adds ability to resize sidebar

* Adds release notes

* Changes to feature

* lint

* change translateX to use % for both states

* vrt

* set max sidebar width, cleanup

* set min and max widths

* min width to 200px

* changes resizable sidebar to use re-resizable instead off css resize

* vrt

* vrt
2024-07-07 14:10:41 -07:00
Joel Jeremy Marquez
f1caf21deb Assign schedule to both transactions if schedule is a transfer (#2990)
* Assign schedule to both transactions if schedule is a transfer

* Release notes

* Migration for old scheduled transfer transactions
2024-07-07 09:29:27 -07:00
Michael Clark
a28ea6be8f :electron: server.js ➡️ server.ts (#2995) 2024-07-07 13:31:26 +01:00
Michael Clark
f36c5e002b :electron: Remove "About" screen and broken updater (#2983) 2024-07-05 21:35:52 +01:00
Michael Clark
803289ee1f :electron: Electron menu.js ➡️ menu.ts (#2978) 2024-07-04 20:12:22 +01:00
Joel Jeremy Marquez
76cdad4fe6 React Aria Button on Accounts and Payees page (#2914)
* React Aria Button on payees and accounts page

* Release notes

* Fix Reconcile

* VRT

* VRT

* Fix balance hover

* VRT

* Update packages/desktop-client/src/components/accounts/Balance.jsx

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Fix lint

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-07-04 11:56:14 -07:00
Julian Wachholz
d03b30bc00 Add PWA shortcuts (#2980)
* Add PWA shortcuts

* Add release note
2024-07-04 08:27:57 -07:00
dymanoid
710d9ab8ac fix: use Unicode-aware SQLite LIKE filtering (#2903)
* fix: use Unicode-aware SQLite filtering

* Add release notes, fix type check

* Fix code styling
2024-07-04 08:24:58 -07:00
DJ Mountney
d008944022 Remove the trafico pr review triggers (#2942)
* Remove the trafico pr review triggers

- We loose triggers for Approved and Changes Requested
- They weren't working on forks anyways

* Add release note
2024-07-04 07:34:38 -07:00
Robert Dyer
f18bce6094 Fix warning modal not showing a second time. (#2956)
* Fix warning modal not showing a second time.

* add release note
2024-07-03 18:13:57 -07:00
Michael Clark
31eb00a155 :electron: Electron package Typescript starting point (#2880) 2024-07-03 21:28:04 +01:00
Joel Jeremy Marquez
a67c969189 React Aria Button on Settings and Rules Page (#2913)
* React Aria Button on rules and settings page

* Release notes

* VRT
2024-07-03 13:04:26 -07:00
dymanoid
58e6c6f23a Fix rollover arrow display for mobile and desktop (#2943)
* Fix rollover arrow display for mobile and desktop

* Add release notes

* Implement review suggestions

* Fix rollover indicator without goals
2024-07-03 11:00:03 -07:00
Matiss Janis Aboltins
f046d75b75 ♻️ refactoring Select component to use existing Menu (#2905) 2024-07-03 18:25:41 +01:00
Joel Jeremy Marquez
30bcfedc86 React Aria Button as base of Button component (#2904)
* React Aria Button as base of Button component

* Release notes

* AmountInput sign button

* Fix tests

* Comment

* Fix disabled/pressed style

* Update react-aria-components version

* yarn.lock

* Apply defaultStyle

* Fix button props type
2024-07-03 09:33:57 -07:00
Matiss Janis Aboltins
866b4d6cd4 🐛 (bank-sync) fix account highlight dissapearing (#2898) 2024-07-03 17:15:36 +01:00
Julian Dominguez-Schatz
a42938fa64 Reapply rules to split transactions when the parent changes (#2834)
* Reapply rules to split transactions when the parent changes

Concretely, this enables the "standard" workflow for
split-transaction-entry on desktop, where you enter the payee first, and
then edit the amount afterwards (and expect splits in a rule to apply when
you edit the amount).

* Add release notes

* Fix bug in first field below parent transaction

* Update vrt
2024-07-02 21:31:14 -07:00
Michael Clark
e02b0f9bc7 :electron: Fix backup time format in electron (#2960)
* fix backup time in electron

* release notes

* bugfix not maintenance
2024-07-02 14:16:16 -07:00
Matiss Janis Aboltins
049a41f366 🔖 (24.7.0) custom reports, splits in rules, tags and more (#2955) 2024-07-02 21:29:31 +01:00
Matiss Janis Aboltins
7f30680fb3 Revert "🐛 fix the app randomly navigating back (closing) (#2772)" (#2966)
This reverts commit 247e3e8d93.
2024-07-02 21:23:33 +01:00
Robert Dyer
2d4256b239 Fix wording of split rule. (#2927)
* Fix wording of slit rule.

* add release note

* fix typo
2024-06-27 13:08:30 -07:00
Matiss Janis Aboltins
247e3e8d93 🐛 fix the app randomly navigating back (closing) (#2772) 2024-06-27 19:00:17 +01:00
Joel Jeremy Marquez
5951b92668 Group and ungroup split transactions (#2805)
* Group and ungroup split transactions

* Release note

* Fix release note category

* Do not allow on reconciled transactions

* Add account validation, fix undo behavior, set split payee

* Fix lint errors

* Allow extracting some child transactions

* Disabled split/unsplit selected items menu

* Fix lint error

* Fix typecheck error

* Special Split payee

* "Split" payee on parent transaction

* Show manage payees on payee autocomplete modal

* Fix typecheck error + cleanup

* Fix typecheck error + cleanup

* VRT

* Fix tests

* VRT

* Only show split/unsplit when applicable
2024-06-26 12:39:36 -07:00
youngcw
a9ee670eb4 change the mobile budget balance colors to be the same as desktop (#2940)
* change the mobile budget colors to be the same as desktop

* fix

* some lint and note

* actual note

* fix mad lint

* another lint
2024-06-26 10:03:37 -07:00
lelemm
3990aaf38f Fix: Transaction table constantly resizing (#2941) 2024-06-26 17:27:47 +01:00
Michael Clark
48f5880f1d :electron: Fix "Export" function on desktop app (#2925)
* fix electron export file

* add release notes
2024-06-25 09:38:39 -07:00
Neil
3332f58376 Custom Reports: Adjust Net values (#2871)
* Add Net value

* notes

* fix

* revert changes

* balanceTypeOpType

* lint fix

* add net numbers

* bar fix

* nit fixes and fix clicks

* remove abs
2024-06-24 20:08:59 +01:00
Michael Clark
46ea8fbf72 :electron: Fix regex filters on electron app (#2929)
* fix regex on desktop app

* add release notes
2024-06-24 08:08:15 -07:00
Matiss Janis Aboltins
6a21f8e3de Revert "fix(#2562): Prevent transaction deduplication for imported transactio…" (#2910) 2024-06-22 21:32:51 +01:00
Joel Jeremy Marquez
f02ca4e3d2 Format transaction notes as clickable tags (#2670)
* Format notes as clickable tags

* Release notes

* Fix tests - extract the handler to higher level component

* Update colors

* Fix filtering

* Rename variables

* Remove font weight

* Cleanup style

* Append note tag filters

* Fix overlapping UI

* Revert pill colors

* Rename prop

* Rename notes

* Delete filter margin

* Fix typecheck error

* VRT + typecheck fix

* Add matches op in rules + use it to match tags

* Fix database types

* Fix typecheck error

* Fix typecheck

* Move create_function call

* VRT

* Update tag regex

* Escape regex input

* Update tag regex

* Use onApplyFilter

* Update tag formatting

* Fix tag formatting

* Update regex

* VRT

* Update packages/desktop-client/src/components/modals/EditRule.jsx

Co-authored-by: Robert Dyer <rdyer@unl.edu>

* VRT

* Fix error

* Fix filtered balance

* VRT

---------

Co-authored-by: Robert Dyer <rdyer@unl.edu>
2024-06-21 10:47:21 -07:00
316 changed files with 5756 additions and 4504 deletions

View File

@@ -1,39 +0,0 @@
##########################################################################################
# WARNING! This workflow uses the 'pull_request_target' event. That mans that it will #
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
# a fork. This is necessary to get access to a GitHub token that can modify the PR. #
# Be VERY CAREFUL about adding things to this workflow, since forks can inject #
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
# code execution in this workflow could lead to a compromise of the main repo. #
##########################################################################################
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
##########################################################################################
name: Trafico Reviews
on:
pull_request_target:
types:
- opened
- closed
- reopened
- synchronize
- edited
- review_requested
- review_request_removed
pull_request_review:
types: [submitted, edited, dismissed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
manage-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actualbudget/trafico@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,27 +0,0 @@
name: Add WIP
on:
pull_request_target:
types:
- opened
jobs:
add_wip_prefix:
if: |
join(github.event.pull_request.requested_reviewers) == ''
&& !contains(github.event.pull_request.title, 'WIP')
&& !contains(github.event.pull_request.labels.*.name, 'WIP')
&& github.event.pull_request.draft != true
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add WIP
env:
TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: |
echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token
gh pr edit ${{ github.event.pull_request.number }} -t "[WIP] ${TITLE}"

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ packages/api/dist
packages/api/@types
packages/crdt/dist
packages/desktop-electron/client-build
packages/desktop-electron/build
packages/desktop-electron/.electron-symbols
packages/desktop-electron/dist
packages/desktop-electron/loot-core

View File

@@ -58,6 +58,19 @@ describe('API CRUD operations', () => {
await api.loadBudget(budgetName);
});
// api: getBudgets
test('getBudgets', async () => {
const budgets = await api.getBudgets();
expect(budgets).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'test-budget',
name: 'Default Test Db',
}),
]),
);
});
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
test('CategoryGroups: successfully update category groups', async () => {
const month = '2023-10';
@@ -251,7 +264,7 @@ describe('API CRUD operations', () => {
);
});
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount, getAccountBalance
test('Accounts: successfully complete account operators', async () => {
const accountId1 = await api.createAccount(
{ name: 'test-account1', offbudget: true },
@@ -272,6 +285,9 @@ describe('API CRUD operations', () => {
]),
);
expect(await api.getAccountBalance(accountId1)).toEqual(1000);
expect(await api.getAccountBalance(accountId2)).toEqual(0);
await api.updateAccount(accountId1, { offbudget: false });
await api.closeAccount(accountId1, accountId2, null);
await api.deleteAccount(accountId2);
@@ -569,6 +585,11 @@ describe('API CRUD operations', () => {
});
expect(addResult).toBe('ok');
expect(await api.getAccountBalance(accountId)).toEqual(200);
expect(
await api.getAccountBalance(accountId, new Date(2023, 10, 2)),
).toEqual(0);
// confirm added transactions exist
let transactions = await api.getTransactions(
accountId,

View File

@@ -31,6 +31,10 @@ export async function downloadBudget(syncId, { password }: { password? } = {}) {
return send('api/download-budget', { syncId, password });
}
export async function getBudgets() {
return send('api/get-budgets');
}
export async function sync() {
return send('api/sync');
}
@@ -125,6 +129,10 @@ export function deleteAccount(id) {
return send('api/account-delete', { id });
}
export function getAccountBalance(id, cutoff?) {
return send('api/account-balance', { id, cutoff });
}
export function getCategoryGroups() {
return send('api/category-groups-get');
}
@@ -173,6 +181,10 @@ export function deletePayee(id) {
return send('api/payee-delete', { id });
}
export function mergePayees(targetId, mergeIds) {
return send('api/payees-merge', { targetId, mergeIds });
}
export function getRules() {
return send('api/rules-get');
}

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "6.8.1",
"version": "6.8.2",
"license": "MIT",
"description": "An API for Actual",
"engines": {

View File

@@ -44,7 +44,7 @@ export class RulesPage {
.first()
.click();
await this.page
.getByRole('option', { exact: true, name: data.conditionsOp })
.getByRole('button', { exact: true, name: data.conditionsOp })
.click();
}
@@ -97,13 +97,13 @@ export class RulesPage {
if (field) {
await row.getByRole('button').first().click();
await this.page
.getByRole('option', { exact: true, name: field })
.getByRole('button', { exact: true, name: field })
.click();
}
if (op) {
await row.getByRole('button', { name: 'is' }).click();
await this.page.getByRole('option', { name: op, exact: true }).click();
await this.page.getByRole('button', { name: op, exact: true }).click();
}
if (value) {

View File

@@ -84,6 +84,10 @@ export class SchedulesPage {
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');
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -89,7 +89,7 @@ test.describe('Rules', () => {
splitActions: [
[
{
field: 'a fixed percent',
field: 'a fixed percent of the remainder',
value: '90',
},
{
@@ -120,7 +120,7 @@ test.describe('Rules', () => {
});
const transaction = accountPage.getNthTransaction(0);
await expect(transaction.payee).toHaveText('Ikea');
await expect(transaction.payee).toHaveText('Split');
await expect(transaction.notes).toHaveText('food / entertainment');
await expect(transaction.category).toHaveText('Split');
await expect(transaction.debit).toHaveText('100.00');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -120,7 +120,7 @@ test.describe('Transactions', () => {
]);
const firstTransaction = accountPage.getNthTransaction(0);
await expect(firstTransaction.payee).toHaveText('Krogger');
await expect(firstTransaction.payee).toHaveText('Split');
await expect(firstTransaction.notes).toHaveText('Notes');
await expect(firstTransaction.category).toHaveText('Split');
await expect(firstTransaction.debit).toHaveText('333.33');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Submodule packages/desktop-client/locale deleted from a0e122296a

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "24.6.0",
"version": "24.7.0",
"license": "MIT",
"files": [
"build"
@@ -8,7 +8,6 @@
"devDependencies": {
"@juggle/resize-observer": "^3.4.0",
"@playwright/test": "1.41.1",
"@reach/listbox": "^0.18.0",
"@react-aria/focus": "^3.16.0",
"@react-aria/listbox": "^3.11.3",
"@react-aria/utils": "^3.23.0",
@@ -48,8 +47,9 @@
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"promise-retry": "^2.0.1",
"re-resizable": "^6.9.17",
"react": "18.2.0",
"react-aria-components": "^1.1.1",
"react-aria-components": "^1.2.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M1.857 7.612H.98a.98.98 0 0 0-.98.98v2.224c0 .449.184.857.51 1.163.612.551 1.816 1.49 3.735 2.368a.571.571 0 0 1 .306.346l.939 3.286a.484.484 0 0 0 .469.347h1.47a.479.479 0 0 0 .469-.367l.47-1.817a.262.262 0 0 1 .326-.183c.571.122 1.183.163 1.795.163.613 0 1.225-.061 1.796-.163.143-.02.286.06.327.183l.47 1.817a.502.502 0 0 0 .468.367h1.47a.484.484 0 0 0 .47-.347l1.224-4.245a.487.487 0 0 1 .122-.224c.919-.98 1.53-2.163 1.735-3.47h.49c.53 0 .959-.448.938-.979-.02-.51-.47-.918-.98-.918h-.448C18.06 4.673 14.632 2 10.489 2c-1.775 0-3.428.49-4.755 1.326-.591-.408-1.469-.734-2.693-.632-.49.04-.715.612-.388.959.408.429.796 1 .877 1.735L1.857 7.612Zm3.122.98a.862.862 0 0 1-.857-.858c0-.469.388-.857.857-.857.47 0 .858.388.858.857 0 .47-.388.858-.858.858Z" />
</svg>

After

Width:  |  Height:  |  Size: 838 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M19 18h-1v-8c0-.6-.4-1-1-1s-1 .4-1 1v8h-3V1c0-.6-.4-1-1-1s-1 .4-1 1v17H8V7c0-.6-.4-1-1-1s-1 .4-1 1v11H3V3c0-.6-.4-1-1-1s-1 .4-1 1v15c-.6 0-1 .4-1 1s.4 1 1 1h18c.6 0 1-.4 1-1s-.4-1-1-1z" />
</svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M23 11.5a1.5 1.5 0 0 1-1.5 1.5h-20a1.5 1.5 0 0 1 0-3h20a1.5 1.5 0 0 1 1.5 1.5Z" />
<path d="M11.5 23a1.5 1.5 0 0 1-1.5-1.5v-20a1.5 1.5 0 0 1 3 0v20a1.5 1.5 0 0 1-1.5 1.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@@ -28,6 +28,44 @@
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Add Transaction",
"short_name": "Add Transaction",
"description": "Add a new transaction",
"url": "/transactions/new",
"icons": [
{
"src": "/shortcut-transaction.svg",
"sizes": "150x150"
}
]
},
{
"name": "Accounts",
"short_name": "Accounts",
"description": "View all accounts",
"url": "/accounts",
"icons": [
{
"src": "/shortcut-accounts.svg",
"sizes": "150x150"
}
]
},
{
"name": "Reports",
"short_name": "Reports",
"description": "View reports",
"url": "/reports",
"icons": [
{
"src": "/shortcut-reports.svg",
"sizes": "150x150"
}
]
}
],
"screenshots": [
{
"src": "/screenshot_wide.png",
@@ -43,7 +81,7 @@
"type": "image/png",
"sizes": "350x600"
}
],
],
"theme_color": "#8812E1",
"background_color": "#ffffff",
"display": "standalone",

View File

@@ -135,6 +135,14 @@ global.Actual = {
},
};
function inputFocused(e) {
return (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
);
}
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey) {
// Cmd/Ctrl+o
@@ -144,11 +152,7 @@ document.addEventListener('keydown', e => {
}
// Cmd/Ctrl+z
else if (e.key.toLowerCase() === 'z') {
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
) {
if (inputFocused(e)) {
return;
}
e.preventDefault();
@@ -160,5 +164,10 @@ document.addEventListener('keydown', e => {
window.__actionsForMenu.undo();
}
}
} else if (e.key === '?') {
if (inputFocused(e)) {
return;
}
window.__actionsForMenu.pushModal('keyboard-shortcuts');
}
});

View File

@@ -42,7 +42,7 @@ import { ScrollProvider } from './ScrollProvider';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { Titlebar, TitlebarProvider } from './Titlebar';
import { Titlebar } from './Titlebar';
function NarrowNotSupported({
redirectTo = '/budget',
@@ -246,15 +246,13 @@ export function FinancesApp() {
return (
<SpreadsheetProvider>
<TitlebarProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</TitlebarProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</SpreadsheetProvider>
);
}

View File

@@ -35,6 +35,7 @@ import { GoCardlessExternalMsg } from './modals/GoCardlessExternalMsg';
import { GoCardlessInitialise } from './modals/GoCardlessInitialise';
import { HoldBufferModal } from './modals/HoldBufferModal';
import { ImportTransactions } from './modals/ImportTransactions';
import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
import { LoadBackup } from './modals/LoadBackup';
import { ManageRulesModal } from './modals/ManageRulesModal';
import { MergeUnusedPayees } from './modals/MergeUnusedPayees';
@@ -53,7 +54,6 @@ import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenu
import { SelectLinkedAccounts } from './modals/SelectLinkedAccounts';
import { SimpleFinInitialise } from './modals/SimpleFinInitialise';
import { SingleInputModal } from './modals/SingleInputModal';
import { SwitchBudgetTypeModal } from './modals/SwitchBudgetTypeModal';
import { TransferModal } from './modals/TransferModal';
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
@@ -96,6 +96,9 @@ export function Modals() {
};
switch (name) {
case 'keyboard-shortcuts':
return <KeyboardShortcutModal modalProps={modalProps} />;
case 'import-transactions':
return (
<ImportTransactions
@@ -173,6 +176,7 @@ export function Modals() {
<ConfirmTransactionEdit
key={name}
modalProps={modalProps}
onCancel={options.onCancel}
onConfirm={options.onConfirm}
confirmReason={options.confirmReason}
/>
@@ -401,7 +405,6 @@ export function Modals() {
actions={actions}
transactionIds={options?.transactionIds}
getTransaction={options?.getTransaction}
pushModal={options?.pushModal}
/>
);
@@ -423,15 +426,6 @@ export function Modals() {
/>
);
case 'switch-budget-type':
return (
<SwitchBudgetTypeModal
key={name}
modalProps={modalProps}
onSwitch={options.onSwitch}
/>
);
case 'account-menu':
return (
<AccountMenuModal
@@ -623,7 +617,6 @@ export function Modals() {
onAddCategoryGroup={options.onAddCategoryGroup}
onToggleHiddenCategories={options.onToggleHiddenCategories}
onSwitchBudgetFile={options.onSwitchBudgetFile}
onSwitchBudgetType={options.onSwitchBudgetType}
/>
);

View File

@@ -13,6 +13,7 @@ import type { NotificationWithId } from 'loot-core/src/client/state-types/notifi
import { useActions } from '../hooks/useActions';
import { AnimatedLoading } from '../icons/AnimatedLoading';
import { SvgDelete } from '../icons/v0';
import { useResponsive } from '../ResponsiveProvider';
import { styles, theme, type CSSProperties } from '../style';
import { Button, ButtonWithLoading } from './common/Button';
@@ -245,6 +246,7 @@ function Notification({
export function Notifications({ style }: { style?: CSSProperties }) {
const { removeNotification } = useActions();
const { isNarrowWidth } = useResponsive();
const notifications = useSelector(
(state: State) => state.notifications.notifications,
);
@@ -254,6 +256,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
position: 'fixed',
bottom: 20,
right: 13,
left: isNarrowWidth ? 13 : undefined,
zIndex: 10000,
...style,
}}

View File

@@ -1,11 +1,4 @@
import React, {
createContext,
useState,
useEffect,
useRef,
useContext,
type ReactNode,
} from 'react';
import React, { useState, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Routes, Route, useLocation } from 'react-router-dom';
@@ -13,10 +6,8 @@ import * as Platform from 'loot-core/src/client/platform';
import * as queries from 'loot-core/src/client/queries';
import { listen } from 'loot-core/src/platform/client/fetch';
import { isDevelopmentEnvironment } from 'loot-core/src/shared/environment';
import { type LocalPrefs } from 'loot-core/src/types/prefs';
import { useActions } from '../hooks/useActions';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
import { useGlobalPref } from '../hooks/useGlobalPref';
import { useLocalPref } from '../hooks/useLocalPref';
import { useNavigate } from '../hooks/useNavigate';
@@ -33,10 +24,8 @@ import { theme, type CSSProperties, styles } from '../style';
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
import { AnimatedRefresh } from './AnimatedRefresh';
import { MonthCountSelector } from './budget/MonthCountSelector';
import { Button, ButtonWithLoading } from './common/Button';
import { Button } from './common/Button';
import { Link } from './common/Link';
import { Paragraph } from './common/Paragraph';
import { Popover } from './common/Popover';
import { Text } from './common/Text';
import { View } from './common/View';
import { LoggedInUser } from './LoggedInUser';
@@ -45,55 +34,6 @@ import { useSidebar } from './sidebar/SidebarProvider';
import { useSheetValue } from './spreadsheet/useSheetValue';
import { ThemeSelector } from './ThemeSelector';
export const SWITCH_BUDGET_MESSAGE_TYPE = 'budget/switch-type';
type SwitchBudgetTypeMessage = {
type: typeof SWITCH_BUDGET_MESSAGE_TYPE;
payload: {
newBudgetType: LocalPrefs['budgetType'];
};
};
export type TitlebarMessage = SwitchBudgetTypeMessage;
type Listener = (msg: TitlebarMessage) => void;
export type TitlebarContextValue = {
sendEvent: (msg: TitlebarMessage) => void;
subscribe: (listener: Listener) => () => void;
};
export const TitlebarContext = createContext<TitlebarContextValue>({
sendEvent() {
throw new Error('TitlebarContext not initialized');
},
subscribe() {
throw new Error('TitlebarContext not initialized');
},
});
type TitlebarProviderProps = {
children?: ReactNode;
};
export function TitlebarProvider({ children }: TitlebarProviderProps) {
const listeners = useRef<Listener[]>([]);
function sendEvent(msg: TitlebarMessage) {
listeners.current.forEach(func => func(msg));
}
function subscribe(listener: Listener) {
listeners.current.push(listener);
return () =>
(listeners.current = listeners.current.filter(func => func !== listener));
}
return (
<TitlebarContext.Provider value={{ sendEvent, subscribe }}>
{children}
</TitlebarContext.Provider>
);
}
function UncategorizedButton() {
const count: number | null = useSheetValue(queries.uncategorizedCount());
if (count === null || count <= 0) {
@@ -287,31 +227,6 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
function BudgetTitlebar() {
const [maxMonths, setMaxMonthsPref] = useGlobalPref('maxMonths');
const [budgetType] = useLocalPref('budgetType');
const { sendEvent } = useContext(TitlebarContext);
const [loading, setLoading] = useState(false);
const [showPopover, setShowPopover] = useState(false);
const triggerRef = useRef(null);
const reportBudgetEnabled = useFeatureFlag('reportBudget');
function onSwitchType() {
setLoading(true);
if (!loading) {
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
sendEvent({
type: SWITCH_BUDGET_MESSAGE_TYPE,
payload: {
newBudgetType,
},
});
}
}
useEffect(() => {
setLoading(false);
}, [budgetType]);
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@@ -319,61 +234,6 @@ function BudgetTitlebar() {
maxMonths={maxMonths || 1}
onChange={value => setMaxMonthsPref(value)}
/>
{reportBudgetEnabled && (
<View style={{ marginLeft: -5 }}>
<ButtonWithLoading
ref={triggerRef}
type="bare"
loading={loading}
style={{
alignSelf: 'flex-start',
padding: '4px 7px',
}}
title="Learn more about budgeting"
onClick={() => setShowPopover(true)}
>
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}
</ButtonWithLoading>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={showPopover}
onOpenChange={() => setShowPopover(false)}
style={{
padding: 10,
maxWidth: 400,
}}
>
<Paragraph>
You are currently using a{' '}
<Text style={{ fontWeight: 600 }}>
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}.
</Text>{' '}
Switching will not lose any data and you can always switch back.
</Paragraph>
<Paragraph>
<ButtonWithLoading
type="primary"
loading={loading}
onClick={onSwitchType}
>
Switch to a{' '}
{budgetType === 'report' ? 'Rollover budget' : 'Report budget'}
</ButtonWithLoading>
</Paragraph>
<Paragraph isLast={true}>
<Link
variant="external"
to="https://actualbudget.org/docs/experimental/report-budget"
linkColor="muted"
>
How do these types of budgeting work?
</Link>
</Paragraph>
</Popover>
</View>
)}
</View>
);
}
@@ -416,11 +276,6 @@ export function Titlebar({ style }: TitlebarProps) {
sidebar.setHidden(false);
}
}}
onPointerLeave={e => {
if (e.pointerType === 'mouse') {
sidebar.setHidden(true);
}
}}
onPointerUp={e => {
if (e.pointerType !== 'mouse') {
sidebar.setHidden(!sidebar.hidden);

View File

@@ -1,14 +1,16 @@
import React, { PureComponent, createRef, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { Navigate, useParams, useLocation } from 'react-router-dom';
import { debounce } from 'debounce';
import { bindActionCreators } from 'redux';
import { v4 as uuidv4 } from 'uuid';
import { validForTransfer } from 'loot-core/client/transfer';
import * as actions from 'loot-core/src/client/actions';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules';
import {
SchedulesProvider,
useDefaultSchedulesQueryTransform,
} from 'loot-core/src/client/data-hooks/schedules';
import * as queries from 'loot-core/src/client/queries';
import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
import { send, listen } from 'loot-core/src/platform/client/fetch';
@@ -22,10 +24,13 @@ import {
realizeTempTransactions,
ungroupTransaction,
ungroupTransactions,
makeChild,
makeAsNonChildTransactions,
} from 'loot-core/src/shared/transactions';
import { applyChanges, groupById } from 'loot-core/src/shared/util';
import { useAccounts } from '../../hooks/useAccounts';
import { useActions } from '../../hooks/useActions';
import { useCategories } from '../../hooks/useCategories';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
@@ -38,7 +43,7 @@ import {
useSplitsExpanded,
} from '../../hooks/useSplitsExpanded';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { TransactionList } from '../transactions/TransactionList';
@@ -71,7 +76,7 @@ function EmptyMessage({ onAdd }) {
manage it locally yourself.
</Text>
<Button type="primary" style={{ marginTop: 20 }} onClick={onAdd}>
<Button variant="primary" style={{ marginTop: 20 }} onPress={onAdd}>
Add account
</Button>
@@ -179,7 +184,9 @@ class AccountInternal extends PureComponent {
this.state = {
search: '',
filters: props.conditions || [],
filterConditions: props.filterConditions || [],
filterId: [],
filterConditionsOp: 'and',
loading: true,
workingHard: false,
reconcileAmount: null,
@@ -192,9 +199,8 @@ class AccountInternal extends PureComponent {
editingName: false,
isAdding: false,
latestDate: null,
filterId: [],
conditionsOp: 'and',
sort: [],
filteredAmount: null,
};
}
@@ -256,7 +262,7 @@ class AccountInternal extends PureComponent {
// Important that any async work happens last so that the
// listeners are set up synchronously
await this.props.initiallyLoadPayees();
await this.fetchTransactions(this.state.filters);
await this.fetchTransactions(this.state.filterConditions);
// If there is a pending undo, apply it immediately (this happens
// when an undo changes the location to this page)
@@ -285,7 +291,7 @@ class AccountInternal extends PureComponent {
//Resest sort/filter/search on account change
if (this.props.accountId !== prevProps.accountId) {
this.setState({ sort: [], search: '', filters: [] });
this.setState({ sort: [], search: '', filterConditions: [] });
}
}
@@ -313,10 +319,10 @@ class AccountInternal extends PureComponent {
this.paged?.run();
};
fetchTransactions = filters => {
fetchTransactions = filterConditions => {
const query = this.makeRootQuery();
this.rootQuery = this.currentQuery = query;
if (filters) this.applyFilters(filters);
if (filterConditions) this.applyFilters(filterConditions);
else this.updateQuery(query);
if (this.props.accountId) {
@@ -371,6 +377,7 @@ class AccountInternal extends PureComponent {
balances: this.state.showBalances
? await this.calculateBalances()
: null,
filteredAmount: await this.getFilteredAmount(),
},
() => {
if (firstLoad) {
@@ -418,7 +425,10 @@ class AccountInternal extends PureComponent {
onSearchDone = debounce(() => {
if (this.state.search === '') {
this.updateQuery(this.currentQuery, this.state.filters.length > 0);
this.updateQuery(
this.currentQuery,
this.state.filterConditions.length > 0,
);
} else {
this.updateQuery(
queries.makeTransactionSearchQuery(
@@ -511,7 +521,7 @@ class AccountInternal extends PureComponent {
return (
account &&
this.state.search === '' &&
this.state.filters.length === 0 &&
this.state.filterConditions.length === 0 &&
(this.state.sort.length === 0 ||
(this.state.sort.field === 'date' &&
this.state.sort.ascDesc === 'desc'))
@@ -599,7 +609,7 @@ class AccountInternal extends PureComponent {
{
transactions: [],
transactionCount: 0,
filters: [],
filterConditions: [],
search: '',
sort: [],
showBalances: true,
@@ -612,9 +622,9 @@ class AccountInternal extends PureComponent {
break;
case 'remove-sorting': {
this.setState({ sort: [] }, () => {
const filters = this.state.filters;
if (filters.length > 0) {
this.applyFilters([...filters]);
const filterConditions = this.state.filterConditions;
if (filterConditions.length > 0) {
this.applyFilters([...filterConditions]);
} else {
this.fetchTransactions();
}
@@ -637,12 +647,12 @@ class AccountInternal extends PureComponent {
if (this.state.showReconciled) {
this.props.savePrefs({ ['hide-reconciled-' + accountId]: true });
this.setState({ showReconciled: false }, () =>
this.fetchTransactions(this.state.filters),
this.fetchTransactions(this.state.filterConditions),
);
} else {
this.props.savePrefs({ ['hide-reconciled-' + accountId]: false });
this.setState({ showReconciled: true }, () =>
this.fetchTransactions(this.state.filters),
this.fetchTransactions(this.state.filterConditions),
);
}
break;
@@ -680,24 +690,11 @@ class AccountInternal extends PureComponent {
};
}
getFilteredAmount = async (filters, conditionsOpKey) => {
const filter = queries.getAccountFilter(this.props.accountId);
let query = q('transactions').filter({
[conditionsOpKey]: [...filters],
});
if (filter) {
query = query.filter(filter);
}
const filteredQuery = await runQuery(
query.select([{ amount: { $sum: '$amount' } }]),
getFilteredAmount = async () => {
const { data: amount } = await runQuery(
this.paged.getQuery().calculate({ $sum: '$amount' }),
);
const filteredAmount = filteredQuery.data.reduce(
(a, v) => (a = a + v.amount),
0,
);
return filteredAmount;
return amount;
};
isNew = id => {
@@ -817,7 +814,7 @@ class AccountInternal extends PureComponent {
onShowTransactions = async ids => {
this.onApplyFilter({
customName: 'Selected transactions',
filter: { id: { $oneof: ids } },
queryFilter: { id: { $oneof: ids } },
});
};
@@ -1058,6 +1055,114 @@ class AccountInternal extends PureComponent {
);
};
onMakeAsSplitTransaction = async ids => {
this.setState({ workingHard: true });
const { data: transactions } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'none' }),
);
if (!transactions || transactions.length === 0) {
return;
}
const [firstTransaction] = transactions;
const parentTransaction = {
id: uuidv4(),
is_parent: true,
cleared: transactions.every(t => !!t.cleared),
date: firstTransaction.date,
account: firstTransaction.account,
amount: transactions
.map(t => t.amount)
.reduce((total, amount) => total + amount, 0),
};
const childTransactions = transactions.map(t =>
makeChild(parentTransaction, t),
);
await send('transactions-batch-update', {
added: [parentTransaction],
updated: childTransactions,
});
this.refetchTransactions();
};
onMakeAsNonSplitTransactions = async ids => {
this.setState({ workingHard: true });
const { data: groupedTransactions } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
let changes = {
updated: [],
deleted: [],
};
const groupedTransactionsToUpdate = groupedTransactions.filter(
t => t.is_parent,
);
for (const groupedTransaction of groupedTransactionsToUpdate) {
const transactions = ungroupTransaction(groupedTransaction);
const [parentTransaction, ...childTransactions] = transactions;
if (ids.includes(parentTransaction.id)) {
// Unsplit all child transactions.
const diff = makeAsNonChildTransactions(
childTransactions,
transactions,
);
changes = {
updated: [...changes.updated, ...diff.updated],
deleted: [...changes.deleted, ...diff.deleted],
};
// Already processed the child transactions above, no need to process them below.
continue;
}
// Unsplit selected child transactions.
const selectedChildTransactions = childTransactions.filter(t =>
ids.includes(t.id),
);
if (selectedChildTransactions.length === 0) {
continue;
}
const diff = makeAsNonChildTransactions(
selectedChildTransactions,
transactions,
);
changes = {
updated: [...changes.updated, ...diff.updated],
deleted: [...changes.deleted, ...diff.deleted],
};
}
await send('transactions-batch-update', changes);
this.refetchTransactions();
const transactionsToSelect = changes.updated.map(t => t.id);
this.dispatchSelected({
type: 'select-all',
ids: transactionsToSelect,
});
};
checkForReconciledTransactions = async (ids, confirmReason, onConfirm) => {
const { data } = await runQuery(
q('transactions')
@@ -1209,10 +1314,10 @@ class AccountInternal extends PureComponent {
);
};
onCondOpChange = (value, filters) => {
this.setState({ conditionsOp: value });
onConditionsOpChange = (value, conditions) => {
this.setState({ filterConditionsOp: value });
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
this.applyFilters([...filters]);
this.applyFilters([...conditions]);
if (this.state.search !== '') {
this.onSearch(this.state.search);
}
@@ -1220,14 +1325,14 @@ class AccountInternal extends PureComponent {
onReloadSavedFilter = (savedFilter, item) => {
if (item === 'reload') {
const [getFilter] = this.props.filtersList.filter(
const [savedFilter] = this.props.savedFilters.filter(
f => f.id === this.state.filterId.id,
);
this.setState({ conditionsOp: getFilter.conditionsOp });
this.applyFilters([...getFilter.conditions]);
this.setState({ filterConditionsOp: savedFilter.conditionsOp });
this.applyFilters([...savedFilter.conditions]);
} else {
if (savedFilter.status) {
this.setState({ conditionsOp: savedFilter.conditionsOp });
this.setState({ filterConditionsOp: savedFilter.conditionsOp });
this.applyFilters([...savedFilter.conditions]);
}
}
@@ -1235,7 +1340,7 @@ class AccountInternal extends PureComponent {
};
onClearFilters = () => {
this.setState({ conditionsOp: 'and' });
this.setState({ filterConditionsOp: 'and' });
this.setState({ filterId: [] });
this.applyFilters([]);
if (this.state.search !== '') {
@@ -1243,9 +1348,11 @@ class AccountInternal extends PureComponent {
}
};
onUpdateFilter = (oldFilter, updatedFilter) => {
onUpdateFilter = (oldCondition, updatedCondition) => {
this.applyFilters(
this.state.filters.map(f => (f === oldFilter ? updatedFilter : f)),
this.state.filterConditions.map(c =>
c === oldCondition ? updatedCondition : c,
),
);
this.setState({
filterId: {
@@ -1258,11 +1365,11 @@ class AccountInternal extends PureComponent {
}
};
onDeleteFilter = filter => {
this.applyFilters(this.state.filters.filter(f => f !== filter));
if (this.state.filters.length === 1) {
onDeleteFilter = condition => {
this.applyFilters(this.state.filterConditions.filter(c => c !== condition));
if (this.state.filterConditions.length === 1) {
this.setState({ filterId: [] });
this.setState({ conditionsOp: 'and' });
this.setState({ filterConditionsOp: 'and' });
} else {
this.setState({
filterId: {
@@ -1276,23 +1383,31 @@ class AccountInternal extends PureComponent {
}
};
onApplyFilter = async cond => {
let filters = this.state.filters;
if (cond.customName) {
filters = filters.filter(f => f.customName !== cond.customName);
onApplyFilter = async conditionOrSavedFilter => {
let filterConditions = this.state.filterConditions;
if (conditionOrSavedFilter.customName) {
filterConditions = filterConditions.filter(
c => c.customName !== conditionOrSavedFilter.customName,
);
}
if (cond.conditions) {
this.setState({ filterId: { ...cond, status: 'saved' } });
this.setState({ conditionsOp: cond.conditionsOp });
this.applyFilters([...cond.conditions]);
if (conditionOrSavedFilter.conditions) {
// A saved filter was passed in.
const savedFilter = conditionOrSavedFilter;
this.setState({
filterId: { ...savedFilter, status: 'saved' },
});
this.setState({ filterConditionsOp: savedFilter.conditionsOp });
this.applyFilters([...savedFilter.conditions]);
} else {
// A condition was passed in.
const condition = conditionOrSavedFilter;
this.setState({
filterId: {
...this.state.filterId,
status: this.state.filterId && 'changed',
},
});
this.applyFilters([...filters, cond]);
this.applyFilters([...filterConditions, condition]);
}
if (this.state.search !== '') {
this.onSearch(this.state.search);
@@ -1320,30 +1435,35 @@ class AccountInternal extends PureComponent {
applyFilters = async conditions => {
if (conditions.length > 0) {
const customFilters = conditions
const customQueryFilters = conditions
.filter(cond => !!cond.customName)
.map(f => f.filter);
const { filters } = await send('make-filters-from-conditions', {
conditions: conditions.filter(cond => !cond.customName),
});
const conditionsOpKey = this.state.conditionsOp === 'or' ? '$or' : '$and';
this.filteredAmount = await this.getFilteredAmount(
filters,
conditionsOpKey,
.map(f => f.queryFilter);
const { filters: queryFilters } = await send(
'make-filters-from-conditions',
{
conditions: conditions.filter(cond => !cond.customName),
},
);
const conditionsOpKey =
this.state.filterConditionsOp === 'or' ? '$or' : '$and';
this.currentQuery = this.rootQuery.filter({
[conditionsOpKey]: [...filters, ...customFilters],
[conditionsOpKey]: [...queryFilters, ...customQueryFilters],
});
this.setState({ filters: conditions }, () => {
this.updateQuery(this.currentQuery, true);
});
this.setState(
{
filterConditions: conditions,
},
() => {
this.updateQuery(this.currentQuery, true);
},
);
} else {
this.setState(
{
transactions: [],
transactionCount: 0,
filters: conditions,
filterConditions: conditions,
},
() => {
this.fetchTransactions();
@@ -1357,8 +1477,8 @@ class AccountInternal extends PureComponent {
};
applySort = (field, ascDesc, prevField, prevAscDesc) => {
const filters = this.state.filters;
const isFiltered = filters.length > 0;
const filterConditions = this.state.filterConditions;
const isFiltered = filterConditions.length > 0;
const sortField = getField(!field ? this.state.sort.field : field);
const sortAscDesc = !ascDesc ? this.state.sort.ascDesc : ascDesc;
const sortPrevField = getField(
@@ -1425,7 +1545,7 @@ class AccountInternal extends PureComponent {
// called directly from UI by sorting a column.
// active filters need to be applied before sorting
case isFiltered:
this.applyFilters([...filters]);
this.applyFilters([...filterConditions]);
sortCurrentQuery(this, sortField, sortAscDesc);
break;
@@ -1487,7 +1607,6 @@ class AccountInternal extends PureComponent {
addNotification,
accountsSyncing,
failedAccounts,
pushModal,
replaceModal,
showExtraBalances,
accountId,
@@ -1505,6 +1624,7 @@ class AccountInternal extends PureComponent {
balances,
showCleared,
showReconciled,
filteredAmount,
} = this.state;
const account = accounts.find(account => account.id === accountId);
@@ -1548,14 +1668,13 @@ class AccountInternal extends PureComponent {
>
<View style={styles.page}>
<AccountHeader
filteredAmount={this.filteredAmount}
tableRef={this.table}
editingName={editingName}
isNameEditable={isNameEditable}
workingHard={workingHard}
account={account}
filterId={filterId}
filtersList={this.props.filtersList}
savedFilters={this.props.savedFilters}
location={this.props.location}
accountName={accountName}
accountsSyncing={accountsSyncing}
@@ -1569,11 +1688,13 @@ class AccountInternal extends PureComponent {
showEmptyMessage={showEmptyMessage}
balanceQuery={balanceQuery}
canCalculateBalance={this.canCalculateBalance}
filteredAmount={filteredAmount}
isFiltered={transactionsFiltered}
isSorted={this.state.sort.length !== 0}
reconcileAmount={reconcileAmount}
search={this.state.search}
filters={this.state.filters}
conditionsOp={this.state.conditionsOp}
filterConditions={this.state.filterConditions}
filterConditionsOp={this.state.filterConditionsOp}
savePrefs={this.props.savePrefs}
pushModal={this.props.pushModal}
onSearch={this.onSearch}
@@ -1598,11 +1719,13 @@ class AccountInternal extends PureComponent {
onUpdateFilter={this.onUpdateFilter}
onClearFilters={this.onClearFilters}
onReloadSavedFilter={this.onReloadSavedFilter}
onCondOpChange={this.onCondOpChange}
onConditionsOpChange={this.onConditionsOpChange}
onDeleteFilter={this.onDeleteFilter}
onApplyFilter={this.onApplyFilter}
onScheduleAction={this.onScheduleAction}
onSetTransfer={this.onSetTransfer}
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={this.onMakeAsNonSplitTransactions}
/>
<View style={{ flex: 1 }}>
@@ -1631,9 +1754,7 @@ class AccountInternal extends PureComponent {
isAdding={this.state.isAdding}
isNew={this.isNew}
isMatched={this.isMatched}
isFiltered={
this.state.search !== '' || this.state.filters.length > 0
}
isFiltered={transactionsFiltered}
dateFormat={dateFormat}
hideFraction={hideFraction}
addNotification={addNotification}
@@ -1653,7 +1774,6 @@ class AccountInternal extends PureComponent {
</View>
) : null
}
pushModal={pushModal}
onSort={this.onSort}
sortField={this.state.sort.field}
ascDesc={this.state.sort.ascDesc}
@@ -1669,6 +1789,7 @@ class AccountInternal extends PureComponent {
this.setState({ isAdding: false })
}
onCreatePayee={this.onCreatePayee}
onApplyFilter={this.onApplyFilter}
/>
</View>
</View>
@@ -1681,13 +1802,11 @@ class AccountInternal extends PureComponent {
function AccountHack(props) {
const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
const match = useMatch(props.location.pathname);
return (
<AccountInternal
{...props}
match={match}
splitsExpandedDispatch={splitsExpandedDispatch}
{...props}
/>
);
}
@@ -1716,75 +1835,41 @@ export function Account() {
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
const lastUndoState = useSelector(state => state.app.lastUndoState);
const conditions =
location.state && location.state.conditions
? location.state.conditions
: [];
const filterConditions = location?.state?.filterConditions || [];
const state = {
newTransactions,
matchedTransactions,
accounts,
failedAccounts,
dateFormat,
hideFraction,
expandSplits,
showBalances,
showCleared: !hideCleared,
showReconciled: !hideReconciled,
showExtraBalances,
payees,
modalShowing,
accountsSyncing,
lastUndoState,
conditions,
};
const savedFiters = useFilters();
const actionCreators = useActions();
const dispatch = useDispatch();
const filtersList = useFilters();
const actionCreators = useMemo(
() => bindActionCreators(actions, dispatch),
[dispatch],
);
const transform = useMemo(() => {
const filterByAccount = queries.getAccountFilter(params.id, '_account');
const filterByPayee = queries.getAccountFilter(
params.id,
'_payee.transfer_acct',
);
return q => {
q = q.filter({
$and: [{ '_account.closed': false }],
});
if (params.id) {
if (params.id === 'uncategorized') {
q = q.filter({ next_date: null });
} else {
q = q.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return q.orderBy({ next_date: 'desc' });
};
}, [params.id]);
const transform = useDefaultSchedulesQueryTransform(params.id);
return (
<SchedulesProvider transform={transform}>
<SplitsExpandedProvider
initialMode={state.expandSplits ? 'collapse' : 'expand'}
initialMode={expandSplits ? 'collapse' : 'expand'}
>
<AccountHack
{...state}
newTransactions={newTransactions}
matchedTransactions={matchedTransactions}
accounts={accounts}
failedAccounts={failedAccounts}
dateFormat={dateFormat}
hideFraction={hideFraction}
expandSplits={expandSplits}
showBalances={showBalances}
showCleared={!hideCleared}
showReconciled={!hideReconciled}
showExtraBalances={showExtraBalances}
payees={payees}
modalShowing={modalShowing}
accountsSyncing={accountsSyncing}
lastUndoState={lastUndoState}
filterConditions={filterConditions}
categoryGroups={categoryGroups}
{...actionCreators}
modalShowing={state.modalShowing}
accountId={params.id}
categoryId={location?.state?.categoryId}
location={location}
filtersList={filtersList}
savedFilters={savedFiters}
/>
</SplitsExpandedProvider>
</SchedulesProvider>

View File

@@ -7,7 +7,7 @@ import { useAccounts } from '../../hooks/useAccounts';
import { useActions } from '../../hooks/useActions';
import { SvgExclamationOutline } from '../../icons/v1';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Link } from '../common/Link';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
@@ -94,7 +94,7 @@ export function AccountSyncCheck() {
<View>
<Button
ref={triggerRef}
type="bare"
variant="bare"
style={{
flexDirection: 'row',
alignItems: 'center',
@@ -103,7 +103,7 @@ export function AccountSyncCheck() {
padding: '4px 8px',
borderRadius: 4,
}}
onClick={() => setOpen(true)}
onPress={() => setOpen(true)}
>
<SvgExclamationOutline
style={{ width: 14, height: 14, marginRight: 5 }}
@@ -129,13 +129,17 @@ export function AccountSyncCheck() {
<View style={{ justifyContent: 'flex-end', flexDirection: 'row' }}>
{showAuth ? (
<>
<Button onClick={unlink}>Unlink</Button>
<Button type="primary" onClick={reauth} style={{ marginLeft: 5 }}>
<Button onPress={unlink}>Unlink</Button>
<Button
variant="primary"
onPress={reauth}
style={{ marginLeft: 5 }}
>
Reauthorize
</Button>
</>
) : (
<Button onClick={unlink}>Unlink account</Button>
<Button onPress={unlink}>Unlink account</Button>
)}
</View>
</Popover>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import React, { useRef } from 'react';
import { useHover } from 'usehooks-ts';
import { isPreviewId } from 'loot-core/shared/transactions';
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
@@ -8,7 +10,7 @@ import { getScheduledAmount } from 'loot-core/src/shared/schedules';
import { useSelectedItems } from '../../hooks/useSelected';
import { SvgArrowButtonRight1 } from '../../icons/v2';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { PrivacyFilter } from '../PrivacyFilter';
@@ -137,10 +139,12 @@ export function Balances({
showExtraBalances,
onToggleExtraBalances,
account,
filteredItems,
isFiltered,
filteredAmount,
}) {
const selectedItems = useSelectedItems();
const buttonRef = useRef(null);
const isButtonHovered = useHover(buttonRef);
return (
<View
@@ -152,14 +156,11 @@ export function Balances({
}}
>
<Button
ref={buttonRef}
data-testid="account-balance"
type="bare"
onClick={onToggleExtraBalances}
variant="bare"
onPress={onToggleExtraBalances}
style={{
'& svg': {
opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0,
},
'&:hover svg': { opacity: 1 },
paddingTop: 1,
paddingBottom: 1,
}}
@@ -188,6 +189,10 @@ export function Balances({
marginLeft: 10,
color: theme.pillText,
transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)',
opacity:
isButtonHovered || selectedItems.size > 0 || showExtraBalances
? 1
: 0,
}}
/>
</Button>
@@ -196,9 +201,7 @@ export function Balances({
{selectedItems.size > 0 && (
<SelectedBalance selectedItems={selectedItems} account={account} />
)}
{filteredItems.length > 0 && (
<FilteredBalance filteredAmount={filteredAmount} />
)}
{isFiltered && <FilteredBalance filteredAmount={filteredAmount} />}
</View>
);
}

View File

@@ -14,7 +14,7 @@ import {
} from '../../icons/v2';
import { theme, styles } from '../../style';
import { AnimatedRefresh } from '../AnimatedRefresh';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Menu } from '../common/Menu';
@@ -26,13 +26,12 @@ import { View } from '../common/View';
import { FilterButton } from '../filters/FiltersMenu';
import { FiltersStack } from '../filters/FiltersStack';
import { NotesButton } from '../NotesButton';
import { SelectedTransactionsButton } from '../transactions/SelectedTransactions';
import { SelectedTransactionsButton } from '../transactions/SelectedTransactionsButton';
import { Balances } from './Balance';
import { ReconcilingMessage, ReconcileMenu } from './Reconcile';
export function AccountHeader({
filteredAmount,
tableRef,
editingName,
isNameEditable,
@@ -40,7 +39,7 @@ export function AccountHeader({
accountName,
account,
filterId,
filtersList,
savedFilters,
accountsSyncing,
failedAccounts,
accounts,
@@ -53,10 +52,12 @@ export function AccountHeader({
balanceQuery,
reconcileAmount,
canCalculateBalance,
isFiltered,
filteredAmount,
isSorted,
search,
filters,
conditionsOp,
filterConditions,
filterConditionsOp,
pushModal,
onSearch,
onAddTransaction,
@@ -79,10 +80,12 @@ export function AccountHeader({
onUpdateFilter,
onClearFilters,
onReloadSavedFilter,
onCondOpChange,
onConditionsOpChange,
onDeleteFilter,
onScheduleAction,
onSetTransfer,
onMakeAsSplitTransaction,
onMakeAsNonSplitTransactions,
}) {
const [menuOpen, setMenuOpen] = useState(false);
const searchInput = useRef(null);
@@ -211,10 +214,10 @@ export function AccountHeader({
/>
)}
<Button
type="bare"
variant="bare"
aria-label="Edit account name"
className="hover-visible"
onClick={() => onExposeName(true)}
onPress={() => onExposeName(true)}
>
<SvgPencil1
style={{
@@ -243,7 +246,7 @@ export function AccountHeader({
showExtraBalances={showExtraBalances}
onToggleExtraBalances={onToggleExtraBalances}
account={account}
filteredItems={filters}
isFiltered={isFiltered}
filteredAmount={filteredAmount}
/>
@@ -255,9 +258,9 @@ export function AccountHeader({
>
{((account && !account.closed) || canSync) && (
<Button
type="bare"
onClick={canSync ? onSync : onImport}
disabled={canSync && isServerOffline}
variant="bare"
onPress={canSync ? onSync : onImport}
isDisabled={canSync && isServerOffline}
>
{canSync ? (
<>
@@ -286,7 +289,7 @@ export function AccountHeader({
</Button>
)}
{!showEmptyMessage && (
<Button type="bare" onClick={onAddTransaction}>
<Button variant="bare" onPress={onAddTransaction}>
<SvgAdd width={10} height={10} style={{ marginRight: 3 }} /> Add
New
</Button>
@@ -318,24 +321,34 @@ export function AccountHeader({
onScheduleAction={onScheduleAction}
pushModal={pushModal}
showMakeTransfer={showMakeTransfer}
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
/>
)}
<Button
type="bare"
disabled={search !== '' || filters.length > 0}
style={{ padding: 6, marginLeft: 10 }}
onClick={onToggleSplits}
title={
variant="bare"
aria-label={
splitsExpanded.state.mode === 'collapse'
? 'Collapse split transactions'
: 'Expand split transactions'
}
isDisabled={search !== '' || filterConditions.length > 0}
style={{ padding: 6, marginLeft: 10 }}
onPress={onToggleSplits}
>
{splitsExpanded.state.mode === 'collapse' ? (
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
) : (
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
)}
<View
title={
splitsExpanded.state.mode === 'collapse'
? 'Collapse split transactions'
: 'Expand split transactions'
}
>
{splitsExpanded.state.mode === 'collapse' ? (
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
) : (
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
)}
</View>
</Button>
{account ? (
<View>
@@ -391,17 +404,17 @@ export function AccountHeader({
)}
</Stack>
{filters && filters.length > 0 && (
{filterConditions?.length > 0 && (
<FiltersStack
filters={filters}
conditionsOp={conditionsOp}
conditions={filterConditions}
conditionsOp={filterConditionsOp}
onUpdateFilter={onUpdateFilter}
onDeleteFilter={onDeleteFilter}
onClearFilters={onClearFilters}
onReloadSavedFilter={onReloadSavedFilter}
filterId={filterId}
filtersList={filtersList}
onCondOpChange={onCondOpChange}
savedFilters={savedFilters}
onConditionsOpChange={onConditionsOpChange}
/>
)}
</View>

View File

@@ -1,11 +1,11 @@
import React from 'react';
import React, { useState } from 'react';
import * as queries from 'loot-core/src/client/queries';
import { currencyToInteger } from 'loot-core/src/shared/util';
import { SvgCheckCircle1 } from '../../icons/v2';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Text } from '../common/Text';
@@ -78,13 +78,13 @@ export function ReconcilingMessage({
</View>
)}
<View style={{ marginLeft: 15 }}>
<Button type="primary" onClick={onDone}>
<Button variant="primary" onPress={onDone}>
Done Reconciling
</Button>
</View>
{targetDiff !== 0 && (
<View style={{ marginLeft: 15 }}>
<Button onClick={() => onCreateTransaction(targetDiff)}>
<Button onPress={() => onCreateTransaction(targetDiff)}>
Create Reconciliation Transaction
</Button>
</View>
@@ -102,17 +102,20 @@ export function ReconcileMenu({ account, onReconcile, onClose }) {
query: balanceQuery.query.filter({ cleared: true }),
});
const format = useFormat();
const [inputValue, setInputValue] = useState(null);
const [inputFocused, setInputFocused] = useState(false);
function onSubmit(e) {
e.preventDefault();
const input = e.target.elements[0];
const amount = currencyToInteger(input.value);
if (amount != null) {
onReconcile(amount == null ? clearedBalance : amount);
onClose();
} else {
input.select();
function onSubmit() {
if (inputValue === '') {
setInputFocused(true);
return;
}
const amount =
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
onReconcile(amount);
onClose();
}
return (
@@ -121,17 +124,20 @@ export function ReconcileMenu({ account, onReconcile, onClose }) {
Enter the current balance of your bank account that you want to
reconcile with:
</Text>
<form onSubmit={onSubmit}>
{clearedBalance != null && (
<InitialFocus>
<Input
defaultValue={format(clearedBalance, 'financial')}
style={{ margin: '7px 0' }}
/>
</InitialFocus>
)}
<Button type="primary">Reconcile</Button>
</form>
{clearedBalance != null && (
<InitialFocus>
<Input
defaultValue={format(clearedBalance, 'financial')}
onChangeValue={setInputValue}
style={{ margin: '7px 0' }}
focused={inputFocused}
onEnter={onSubmit}
/>
</InitialFocus>
)}
<Button variant="primary" onPress={onSubmit}>
Reconcile
</Button>
</View>
);
}

View File

@@ -17,7 +17,7 @@ import { css } from 'glamor';
import { SvgRemove } from '../../icons/v2';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Input } from '../common/Input';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
@@ -621,7 +621,12 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
}}
>
{name}
<Button type="bare" style={{ marginLeft: 1 }} onClick={onRemove}>
<Button
variant="bare"
aria-label="Remove autocomplete item"
style={{ marginLeft: 1 }}
onPress={onRemove}
>
<SvgRemove style={{ width: 8, height: 8 }} />
</Button>
</View>

View File

@@ -26,7 +26,7 @@ import { usePayees } from '../../hooks/usePayees';
import { SvgAdd } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
@@ -366,9 +366,10 @@ export function PayeeAutocomplete({
<AutocompleteFooter embedded={embedded}>
{showMakeTransfer && (
<Button
type={focusTransferPayees ? 'menuSelected' : 'menu'}
variant={focusTransferPayees ? 'menuSelected' : 'menu'}
aria-label="Make transfer"
style={showManagePayees && { marginBottom: 5 }}
onClick={() => {
onPress={() => {
onUpdate?.(null, null);
setFocusTransferPayees(!focusTransferPayees);
}}
@@ -377,7 +378,11 @@ export function PayeeAutocomplete({
</Button>
)}
{showManagePayees && (
<Button type="menu" onClick={() => onManagePayees()}>
<Button
variant="menu"
aria-label="Manage payees"
onPress={() => onManagePayees()}
>
Manage Payees
</Button>
)}
@@ -420,7 +425,7 @@ export function CreatePayeeButton({
data-testid="create-payee-button"
style={{
display: 'block',
flexShrink: 0,
flex: '1 0',
color: highlighted
? theme.menuAutoCompleteTextHover
: theme.noticeTextMenu,

View File

@@ -3,7 +3,6 @@ import React, { type ComponentPropsWithoutRef } from 'react';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { SvgArrowThinRight } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties } from '../../style';
import { View } from '../common/View';
import { type Binding } from '../spreadsheet';
@@ -12,24 +11,51 @@ import { useSheetValue } from '../spreadsheet/useSheetValue';
import { makeBalanceAmountStyle } from './util';
type CarryoverIndicatorProps = {
style?: CSSProperties;
};
type BalanceWithCarryoverProps = Omit<
ComponentPropsWithoutRef<typeof CellValue>,
'binding'
> & {
carryover: Binding;
balance: Binding;
goal?: Binding;
budgeted?: Binding;
goal: Binding;
budgeted: Binding;
disabled?: boolean;
carryoverStyle?: CSSProperties;
carryoverIndicator?: ({ style }: CarryoverIndicatorProps) => JSX.Element;
};
export function DefaultCarryoverIndicator({ style }: CarryoverIndicatorProps) {
return (
<View
style={{
marginLeft: 2,
position: 'absolute',
right: '-4px',
alignSelf: 'center',
top: 0,
bottom: 0,
...style,
}}
>
<SvgArrowThinRight
width={style?.width || 7}
height={style?.height || 7}
style={style}
/>
</View>
);
}
export function BalanceWithCarryover({
carryover,
balance,
goal,
budgeted,
disabled,
carryoverStyle,
carryoverIndicator = DefaultCarryoverIndicator,
...props
}: BalanceWithCarryoverProps) {
const carryoverValue = useSheetValue(carryover);
@@ -37,11 +63,21 @@ export function BalanceWithCarryover({
const goalValue = useSheetValue(goal);
const budgetedValue = useSheetValue(budgeted);
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const { isNarrowWidth } = useResponsive();
const valueStyle = makeBalanceAmountStyle(
balanceValue,
isGoalTemplatesEnabled ? goalValue : null,
budgetedValue,
);
return (
<>
<span
style={{
alignItems: 'center',
display: 'inline-flex',
justifyContent: 'right',
maxWidth: '100%',
}}
>
<CellValue
{...props}
binding={balance}
@@ -54,6 +90,8 @@ export function BalanceWithCarryover({
)
}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'right',
...(!disabled && {
cursor: 'pointer',
@@ -61,30 +99,7 @@ export function BalanceWithCarryover({
...props.style,
}}
/>
{carryoverValue && (
<View
style={{
alignSelf: 'center',
marginLeft: 2,
position: 'absolute',
right: isNarrowWidth ? '-8px' : '-4px',
top: 0,
bottom: 0,
justifyContent: 'center',
...carryoverStyle,
}}
>
<SvgArrowThinRight
width={carryoverStyle?.width || 7}
height={carryoverStyle?.height || 7}
style={makeBalanceAmountStyle(
balanceValue,
goalValue,
budgetedValue,
)}
/>
</View>
)}
</>
{carryoverValue && carryoverIndicator({ style: valueStyle })}
</span>
);
}

View File

@@ -37,6 +37,7 @@ export const BudgetCategories = memo(
function onCollapse(value) {
setCollapsedGroupIdsPref(value);
}
const [isAddingGroup, setIsAddingGroup] = useState(false);
const [newCategoryForGroup, setNewCategoryForGroup] = useState(null);
const items = useMemo(() => {

View File

@@ -1,5 +1,4 @@
import React, { useRef, useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import React, { useState } from 'react';
import { useCategories } from '../../hooks/useCategories';
import { useLocalPref } from '../../hooks/useLocalPref';
@@ -30,38 +29,12 @@ export function BudgetTable(props) {
onBudgetAction,
} = props;
const budgetCategoriesRef = useRef();
const scrollableDivRef = useRef();
const location = useLocation();
const { grouped: categoryGroups } = useCategories();
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
useLocalPref('budget.collapsed');
const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref(
'budget.showHiddenCategories',
);
const getCurrentScrollPosition = () => {
return scrollableDivRef.current?.scrollTop || 0;
};
const onShowActivityWithScroll = (categoryId, month) => {
const scrollPosition = getCurrentScrollPosition();
onShowActivity(categoryId, month, scrollPosition);
};
useEffect(() => {
const savedScrollPosition = location.state?.scrollPosition;
if (savedScrollPosition && scrollableDivRef.current) {
// Use requestAnimationFrame to ensure the DOM is ready
requestAnimationFrame(() => {
if (scrollableDivRef.current) {
scrollableDivRef.current.scrollTop = savedScrollPosition;
}
});
}
}, [location.state?.scrollPosition]);
const [editing, setEditing] = useState(null);
const onEditMonth = (id, month) => {
@@ -228,7 +201,6 @@ export function BudgetTable(props) {
collapseAllCategories={collapseAllCategories}
/>
<View
id="scrollableDiv"
style={{
overflowY: 'scroll',
overflowAnchor: 'none',
@@ -236,7 +208,6 @@ export function BudgetTable(props) {
paddingLeft: 5,
paddingRight: 5,
}}
innerRef={scrollableDivRef}
>
<View
style={{
@@ -257,7 +228,7 @@ export function BudgetTable(props) {
onReorderCategory={_onReorderCategory}
onReorderGroup={_onReorderGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivityWithScroll}
onShowActivity={onShowActivity}
/>
</View>
</View>

View File

@@ -58,7 +58,13 @@ export function IncomeCategory({
});
return (
<Row innerRef={dropRef} collapsed={true}>
<Row
innerRef={dropRef}
collapsed={true}
style={{
opacity: cat.hidden ? 0.5 : undefined,
}}
>
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
<SidebarCategory

View File

@@ -106,11 +106,11 @@ export function SidebarCategory({
setMenuOpen(false);
}}
items={[
{ name: 'rename', text: 'Rename' },
!categoryGroup?.hidden && {
name: 'toggle-visibility',
text: category.hidden ? 'Show' : 'Hide',
},
{ name: 'rename', text: 'Rename' },
{ name: 'delete', text: 'Delete' },
]}
/>

View File

@@ -127,11 +127,11 @@ export function SidebarGroup({
}}
items={[
{ name: 'add-category', text: 'Add category' },
{ name: 'rename', text: 'Rename' },
!group.is_income && {
name: 'toggle-visibility',
text: group.hidden ? 'Show' : 'Hide',
},
{ name: 'rename', text: 'Rename' },
onDelete && { name: 'delete', text: 'Delete' },
]}
/>

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { memo, useContext, useMemo, useState, useEffect } from 'react';
import React, { memo, useMemo, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import {
@@ -10,7 +10,6 @@ import {
deleteCategory,
deleteGroup,
getCategories,
loadPrefs,
moveCategory,
moveCategoryGroup,
pushModal,
@@ -28,19 +27,13 @@ import { useNavigate } from '../../hooks/useNavigate';
import { styles } from '../../style';
import { View } from '../common/View';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
import {
SWITCH_BUDGET_MESSAGE_TYPE,
TitlebarContext,
type TitlebarContextValue,
type TitlebarMessage,
} from '../Titlebar';
import { DynamicBudgetTable } from './DynamicBudgetTable';
import * as report from './report/ReportComponents';
import { ReportProvider } from './report/ReportContext';
import * as rollover from './rollover/RolloverComponents';
import { RolloverProvider } from './rollover/RolloverContext';
import { prewarmAllMonths, prewarmMonth, switchBudgetType } from './util';
import { prewarmAllMonths, prewarmMonth } from './util';
type ReportComponents = {
SummaryComponent: typeof report.BudgetSummary;
@@ -66,7 +59,6 @@ type BudgetInnerProps = {
accountId?: string;
reportComponents: ReportComponents;
rolloverComponents: RolloverComponents;
titlebar: TitlebarContextValue;
};
function BudgetInner(props: BudgetInnerProps) {
@@ -95,8 +87,6 @@ function BudgetInner(props: BudgetInnerProps) {
}
useEffect(() => {
const { titlebar } = props;
async function run() {
loadCategories();
@@ -132,8 +122,6 @@ function BudgetInner(props: BudgetInnerProps) {
loadCategories();
}
}),
titlebar.subscribe(onTitlebarEvent),
];
return () => {
@@ -276,8 +264,8 @@ function BudgetInner(props: BudgetInnerProps) {
dispatch(applyBudgetAction(month, type, args));
};
const onShowActivity = (categoryId, month, scrollPosition) => {
const conditions = [
const onShowActivity = (categoryId, month) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
@@ -287,16 +275,10 @@ function BudgetInner(props: BudgetInnerProps) {
type: 'date',
},
];
navigate('/budget', {
replace: true,
state: { scrollPosition }
});
navigate('/accounts', {
state: {
goBack: true,
conditions,
filterConditions,
categoryId,
},
});
@@ -329,24 +311,6 @@ function BudgetInner(props: BudgetInnerProps) {
setSummaryCollapsedPref(!summaryCollapsed);
};
const onTitlebarEvent = async ({ type, payload }: TitlebarMessage) => {
switch (type) {
case SWITCH_BUDGET_MESSAGE_TYPE: {
await switchBudgetType(
payload.newBudgetType,
spreadsheet,
bounds,
startMonth,
async () => {
dispatch(loadPrefs());
},
);
break;
}
default:
}
};
const { reportComponents, rolloverComponents } = props;
if (!initialized || !categoryGroups) {
@@ -422,8 +386,6 @@ const RolloverBudgetSummary = memo<{ month: string }>(props => {
RolloverBudgetSummary.displayName = 'RolloverBudgetSummary';
export function Budget() {
const titlebar = useContext(TitlebarContext);
const reportComponents = useMemo<ReportComponents>(
() => ({
SummaryComponent: report.BudgetSummary,
@@ -466,7 +428,6 @@ export function Budget() {
<BudgetInner
reportComponents={reportComponents}
rolloverComponents={rolloverComponents}
titlebar={titlebar}
/>
</View>
);

View File

@@ -302,9 +302,7 @@ export const CategoryMonth = memo(function CategoryMonth({
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
<span
data-testid="category-month-spent"
onClick={() => {
onShowActivity(category.id, month);
}}
onClick={() => onShowActivity(category.id, month)}
>
<CellValue
binding={reportBudget.catSumAmount(category.id)}
@@ -323,7 +321,6 @@ export const CategoryMonth = memo(function CategoryMonth({
{!category.is_income && (
<Field
name="balance"
truncate={false}
width="flex"
style={{ paddingRight: styles.monthRightPadding, textAlign: 'right' }}
>

View File

@@ -295,9 +295,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
<span
data-testid="category-month-spent"
onClick={() => {
onShowActivity(category.id, month);
}}
onClick={() => onShowActivity(category.id, month)}
>
<CellValue
binding={rolloverBudget.catSumAmount(category.id)}
@@ -312,7 +310,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
</Field>
<Field
name="balance"
truncate={false}
width="flex"
style={{ paddingRight: styles.monthRightPadding, textAlign: 'right' }}
>

View File

@@ -0,0 +1,229 @@
import React, { forwardRef, type ComponentPropsWithoutRef } from 'react';
import {
Button as ReactAriaButton,
type ButtonProps as ReactAriaButtonProps,
} from 'react-aria-components';
import { AnimatedLoading } from '../../icons/AnimatedLoading';
import { type CSSProperties, styles, theme } from '../../style';
import { View } from './View';
const backgroundColor: {
[key in ButtonVariant | `${ButtonVariant}Disabled`]?: string;
} = {
normal: theme.buttonNormalBackground,
normalDisabled: theme.buttonNormalDisabledBackground,
primary: theme.buttonPrimaryBackground,
primaryDisabled: theme.buttonPrimaryDisabledBackground,
bare: theme.buttonBareBackground,
bareDisabled: theme.buttonBareDisabledBackground,
menu: theme.buttonMenuBackground,
menuSelected: theme.buttonMenuSelectedBackground,
};
const backgroundColorHover: Record<
ButtonVariant | `${ButtonVariant}Disabled`,
CSSProperties['backgroundColor']
> = {
normal: theme.buttonNormalBackgroundHover,
primary: theme.buttonPrimaryBackgroundHover,
bare: theme.buttonBareBackgroundHover,
menu: theme.buttonMenuBackgroundHover,
menuSelected: theme.buttonMenuSelectedBackgroundHover,
normalDisabled: 'transparent',
primaryDisabled: 'transparent',
bareDisabled: 'transparent',
menuDisabled: 'transparent',
menuSelectedDisabled: 'transparent',
};
const borderColor: {
[key in
| ButtonVariant
| `${ButtonVariant}Disabled`]?: CSSProperties['borderColor'];
} = {
normal: theme.buttonNormalBorder,
normalDisabled: theme.buttonNormalDisabledBorder,
primary: theme.buttonPrimaryBorder,
primaryDisabled: theme.buttonPrimaryDisabledBorder,
menu: theme.buttonMenuBorder,
menuSelected: theme.buttonMenuSelectedBorder,
};
const textColor: {
[key in ButtonVariant | `${ButtonVariant}Disabled`]?: CSSProperties['color'];
} = {
normal: theme.buttonNormalText,
normalDisabled: theme.buttonNormalDisabledText,
primary: theme.buttonPrimaryText,
primaryDisabled: theme.buttonPrimaryDisabledText,
bare: theme.buttonBareText,
bareDisabled: theme.buttonBareDisabledText,
menu: theme.buttonMenuText,
menuSelected: theme.buttonMenuSelectedText,
};
const textColorHover: {
[key in ButtonVariant]?: string;
} = {
normal: theme.buttonNormalTextHover,
primary: theme.buttonPrimaryTextHover,
bare: theme.buttonBareTextHover,
menu: theme.buttonMenuTextHover,
menuSelected: theme.buttonMenuSelectedTextHover,
};
const _getBorder = (
variant: ButtonVariant,
variantWithDisabled: keyof typeof borderColor,
): string => {
switch (variant) {
case 'bare':
return 'none';
default:
return '1px solid ' + borderColor[variantWithDisabled];
}
};
const _getPadding = (variant: ButtonVariant): string => {
switch (variant) {
case 'bare':
return '5px';
default:
return '5px 10px';
}
};
const _getActiveStyles = (
variant: ButtonVariant,
bounce: boolean,
): CSSProperties => {
switch (variant) {
case 'bare':
return { backgroundColor: theme.buttonBareBackgroundActive };
default:
return {
transform: bounce ? 'translateY(1px)' : undefined,
boxShadow: `0 1px 4px 0 ${
variant === 'primary'
? theme.buttonPrimaryShadow
: theme.buttonNormalShadow
}`,
transition: 'none',
};
}
};
type ButtonProps = ComponentPropsWithoutRef<typeof ReactAriaButton> & {
variant?: ButtonVariant;
bounce?: boolean;
};
type ButtonVariant = 'normal' | 'primary' | 'bare' | 'menu' | 'menuSelected';
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
children,
variant = 'normal',
bounce = true,
style,
isDisabled,
...restProps
} = props;
const variantWithDisabled: ButtonVariant | `${ButtonVariant}Disabled` =
isDisabled ? `${variant}Disabled` : variant;
const hoveredStyle = {
...(variant !== 'bare' && styles.shadow),
backgroundColor: backgroundColorHover[variant],
color: textColorHover[variant],
};
const pressedStyle = {
..._getActiveStyles(variant, bounce),
};
const buttonStyle: ComponentPropsWithoutRef<
typeof Button
>['style'] = props => ({
...props.defaultStyle,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
padding: _getPadding(variant),
margin: 0,
overflow: 'hidden',
display: 'flex',
borderRadius: 4,
backgroundColor: backgroundColor[variantWithDisabled],
border: _getBorder(variant, variantWithDisabled),
color: textColor[variantWithDisabled],
transition: 'box-shadow .25s',
WebkitAppRegion: 'no-drag',
...styles.smallText,
...(props.isHovered && !isDisabled ? hoveredStyle : {}),
...(props.isPressed && !isDisabled ? pressedStyle : {}),
...(typeof style === 'function' ? style(props) : style),
});
return (
<ReactAriaButton ref={ref} style={buttonStyle} {...restProps}>
{children}
</ReactAriaButton>
);
},
);
Button.displayName = 'Button';
type ButtonWithLoadingProps = ButtonProps & {
isLoading?: boolean;
};
export const ButtonWithLoading = forwardRef<
HTMLButtonElement,
ButtonWithLoadingProps
>((props, ref) => {
const { isLoading, children, ...buttonProps } = props;
return (
<Button
{...buttonProps}
ref={ref}
style={{ position: 'relative', ...buttonProps.style }}
>
{renderProps => (
<>
{isLoading && (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
)}
<View
style={{
opacity: isLoading ? 0 : 1,
flexDirection: 'row',
alignItems: 'center',
}}
>
{typeof children === 'function' ? children(renderProps) : children}
</View>
</>
)}
</Button>
);
});
ButtonWithLoading.displayName = 'ButtonWithLoading';

View File

@@ -23,14 +23,6 @@ export const Popover = ({
...style,
})}`}
shouldCloseOnInteractOutside={element => {
// Disable closing the popover when a reach listbox is clicked (Select component)
if (
element.getAttribute('data-reach-listbox-list') !== null ||
element.getAttribute('data-reach-listbox-option') !== null
) {
return false;
}
if (shouldCloseOnInteractOutside) {
return shouldCloseOnInteractOutside(element);
}

View File

@@ -1,26 +1,28 @@
import {
ListboxInput,
ListboxButton,
ListboxPopover,
ListboxList,
ListboxOption,
} from '@reach/listbox';
import { css } from 'glamor';
import { useRef, useState } from 'react';
import { SvgExpandArrow } from '../../icons/v0';
import { theme, styles, type CSSProperties } from '../../style';
import { type CSSProperties } from '../../style';
import { Button } from './Button';
import { Menu } from './Menu';
import { Popover } from './Popover';
import { View } from './View';
function isValueOption<Value extends string>(
option: [Value, string] | typeof Menu.line,
): option is [Value, string] {
return option !== Menu.line;
}
type SelectProps<Value extends string> = {
bare?: boolean;
options: Array<[Value, string]>;
options: Array<[Value, string] | typeof Menu.line>;
value: Value;
defaultLabel?: string;
onChange?: (newValue: Value) => void;
style?: CSSProperties;
wrapperStyle?: CSSProperties;
line?: number;
disabled?: boolean;
disabledKeys?: Value[];
buttonStyle?: CSSProperties;
};
/**
@@ -29,7 +31,6 @@ type SelectProps<Value extends string> = {
* @param {string} [defaultLabel] - The label to display when the selected value is not in the options.
* @param {function} [onChange] - A callback function invoked when the selected value changes.
* @param {CSSProperties} [style] - Custom styles to apply to the selected button.
* @param {CSSProperties} [wrapperStyle] - Custom style to apply to the select wrapper.
* @param {string[]} [disabledKeys] - An array of option values to disable.
*
* @example
@@ -37,128 +38,96 @@ type SelectProps<Value extends string> = {
* // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="1" onChange={handleOnChange} />
* // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="3" defaultLabel="Select an option" onChange={handleOnChange} />
*/
export function Select<Value extends string>({
bare,
options,
value,
defaultLabel = '',
onChange,
style,
wrapperStyle,
line,
disabled,
disabled = false,
disabledKeys = [],
buttonStyle = {},
}: SelectProps<Value>) {
const arrowSize = 7;
const minHeight = style?.minHeight ? style.minHeight : '18px';
const targetOption = options.filter(option => option[0] === value);
const targetOption = options
.filter(isValueOption)
.find(option => option[0] === value);
const triggerRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
return (
<ListboxInput
value={value}
onChange={onChange}
disabled={disabled}
style={{
color: bare ? 'inherit' : theme.formInputText,
backgroundColor: bare ? 'transparent' : theme.cardBackground,
borderRadius: styles.menuBorderRadius,
border: bare ? 'none' : '1px solid ' + theme.formInputBorder,
lineHeight: '1em',
...wrapperStyle,
}}
>
<ListboxButton
className={`${css([
{ borderWidth: 0, padding: 5, borderRadius: 4 },
style,
])}`}
arrow={
<>
<Button
ref={triggerRef}
type={bare ? 'bare' : 'normal'}
disabled={disabled}
onClick={() => {
setIsOpen(true);
}}
style={buttonStyle}
hoveredStyle={{
backgroundColor: bare ? 'transparent' : undefined,
...buttonStyle,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 5,
width: '100%',
}}
>
<span
style={{
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: 'calc(100% - 7px)',
}}
>
{targetOption ? targetOption[1] : defaultLabel}
</span>
<SvgExpandArrow
style={{
width: arrowSize,
height: arrowSize,
width: 7,
height: 7,
color: 'inherit',
}}
/>
}
</View>
</Button>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={isOpen}
onOpenChange={() => setIsOpen(false)}
>
<span
style={{
display: 'flex',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: `calc(100% - ${arrowSize + 5}px)`,
alignItems: 'center',
minHeight,
<Menu
onMenuSelect={item => {
onChange?.(item);
setIsOpen(false);
}}
>
{targetOption.length !== 0 ? targetOption[0][1] : defaultLabel}
</span>
</ListboxButton>
<ListboxPopover
style={{
zIndex: 100000,
outline: 0,
borderRadius: styles.menuBorderRadius,
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
boxShadow: styles.cardShadow,
border: '1px solid ' + theme.menuBorder,
}}
className={`${css({
'[data-reach-listbox-option]': {
background: theme.menuItemBackground,
color: theme.menuItemText,
},
'[data-reach-listbox-option][data-current-nav]': {
background: theme.menuItemBackgroundHover,
color: theme.menuItemTextHover,
},
})}`}
>
{!line ? (
<ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
{options.map(([value, label]) => (
<ListboxOption
key={value}
value={value}
disabled={disabledKeys.includes(value)}
>
{label}
</ListboxOption>
))}
</ListboxList>
) : (
<ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
{options.slice(0, line).map(([value, label]) => (
<ListboxOption
key={value}
value={value}
disabled={disabledKeys.includes(value)}
>
{label}
</ListboxOption>
))}
<div
style={{
padding: '2px',
marginTop: 5,
borderTop: '1px solid ' + theme.menuBorder,
}}
/>
{options.slice(line, options.length).map(([value, label]) => (
<ListboxOption
key={value}
value={value}
disabled={disabledKeys.includes(value)}
>
{label}
</ListboxOption>
))}
</ListboxList>
)}
</ListboxPopover>
</ListboxInput>
items={options.map(item =>
item === Menu.line
? Menu.line
: {
name: item[0],
text: item[1],
disabled: disabledKeys.includes(item[0]),
},
)}
getItemStyle={option => {
if (targetOption && targetOption[0] === option.name) {
return { fontWeight: 'bold' };
}
return {};
}}
/>
</Popover>
</>
);
}

View File

@@ -4,26 +4,29 @@ import { type RuleConditionEntity } from 'loot-core/src/types/models';
import { View } from '../common/View';
import { CondOpMenu } from './CondOpMenu';
import { ConditionsOpMenu } from './ConditionsOpMenu';
import { FilterExpression } from './FilterExpression';
type AppliedFiltersProps = {
filters: RuleConditionEntity[];
conditions: RuleConditionEntity[];
onUpdate: (
filter: RuleConditionEntity,
newFilter: RuleConditionEntity,
) => void;
onDelete: (filter: RuleConditionEntity) => void;
conditionsOp: string;
onCondOpChange: (value: string, filters: RuleConditionEntity[]) => void;
onConditionsOpChange: (
value: string,
conditions: RuleConditionEntity[],
) => void;
};
export function AppliedFilters({
filters,
conditions,
onUpdate,
onDelete,
conditionsOp,
onCondOpChange,
onConditionsOpChange,
}: AppliedFiltersProps) {
return (
<View
@@ -33,12 +36,12 @@ export function AppliedFilters({
flexWrap: 'wrap',
}}
>
<CondOpMenu
<ConditionsOpMenu
conditionsOp={conditionsOp}
onCondOpChange={onCondOpChange}
filters={filters}
onChange={onConditionsOpChange}
conditions={conditions}
/>
{filters.map((filter: RuleConditionEntity, i: number) => (
{conditions.map((filter: RuleConditionEntity, i: number) => (
<FilterExpression
key={i}
customName={filter.customName}

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { SvgFilter } from '../../icons/v1';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
export function CompactFiltersButton({ onClick }: { onClick: () => void }) {
return (
<Button type="bare" onClick={onClick}>
<Button variant="bare" onPress={onClick}>
<SvgFilter width={15} height={15} />
</Button>
);

View File

@@ -7,16 +7,16 @@ import { Text } from '../common/Text';
import { View } from '../common/View';
import { FieldSelect } from '../modals/EditRule';
export function CondOpMenu({
export function ConditionsOpMenu({
conditionsOp,
onCondOpChange,
filters,
onChange,
conditions,
}: {
conditionsOp: string;
onCondOpChange: (value: string, filters: RuleConditionEntity[]) => void;
filters: RuleConditionEntity[];
onChange: (value: string, conditions: RuleConditionEntity[]) => void;
conditions: RuleConditionEntity[];
}) {
return filters.length > 1 ? (
return conditions.length > 1 ? (
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>
<FieldSelect
style={{ display: 'inline-flex' }}
@@ -25,9 +25,7 @@ export function CondOpMenu({
['or', 'any'],
]}
value={conditionsOp}
onChange={(name: string, value: string) =>
onCondOpChange(value, filters)
}
onChange={(name: string, value: string) => onChange(value, conditions)}
/>
of:
</Text>

View File

@@ -9,7 +9,7 @@ import {
import { SvgDelete } from '../../icons/v0';
import { type CSSProperties, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -18,6 +18,8 @@ import { Value } from '../rules/Value';
import { FilterEditor } from './FiltersMenu';
import { subfieldFromFilter } from './subfieldFromFilter';
let isDatepickerClick = false;
type FilterExpressionProps = {
field: string | undefined;
customName: string | undefined;
@@ -43,6 +45,8 @@ export function FilterExpression({
const triggerRef = useRef(null);
const field = subfieldFromFilter({ field: originalField, value });
const displayField = mapField(field, options);
const displayOp = friendlyOp(op, null);
return (
<View
@@ -58,10 +62,10 @@ export function FilterExpression({
>
<Button
ref={triggerRef}
type="bare"
disabled={customName != null}
onClick={() => setEditing(true)}
style={{ marginRight: -7 }}
variant="bare"
aria-label={`${displayField} ${displayOp} ${value} filter`}
isDisabled={customName != null}
onPress={() => setEditing(true)}
>
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
{customName ? (
@@ -69,26 +73,29 @@ export function FilterExpression({
) : (
<>
<Text style={{ color: theme.pageTextPositive }}>
{mapField(field, options)}
{displayField}
</Text>{' '}
<Text>{friendlyOp(op, null)}</Text>{' '}
<Text>{displayOp}</Text>{' '}
<Value
value={value}
field={field}
inline={true}
valueIsRaw={op === 'contains' || op === 'doesNotContain'}
valueIsRaw={
op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain'
}
/>
</>
)}
</div>
</Button>
<Button type="bare" onClick={onDelete} aria-label="Delete filter">
<Button variant="bare" onPress={onDelete} aria-label="Delete filter">
<SvgDelete
style={{
width: 8,
height: 8,
margin: 5,
marginLeft: 3,
margin: 4,
}}
/>
</Button>
@@ -98,6 +105,21 @@ export function FilterExpression({
placement="bottom start"
isOpen={editing}
onOpenChange={() => setEditing(false)}
shouldCloseOnInteractOutside={element => {
// Datepicker selections for some reason register 2x clicks
// We want to keep the popover open after selecting a date.
// So we ignore the "close" event on selection + the subsequent event.
if (element instanceof HTMLElement && element.dataset.pikaYear) {
isDatepickerClick = true;
return false;
}
if (isDatepickerClick) {
isDatepickerClick = false;
return false;
}
return true;
}}
style={{ width: 275, padding: 15, color: theme.menuItemText }}
data-testid="filters-menu-tooltip"
>

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { SvgFilter } from '../../icons/v1/Filter';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
export function FiltersButton({ onClick }: { onClick: () => void }) {
return (
<Button type="bare" onClick={onClick} title="Filters">
<Button variant="bare" onPress={onClick} aria-label="Filters">
<SvgFilter style={{ width: 12, height: 12, marginRight: 5 }} /> Filter
</Button>
);

View File

@@ -22,7 +22,7 @@ import { titleFirst } from 'loot-core/src/shared/util';
import { useDateFormat } from '../../hooks/useDateFormat';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
import { Select } from '../common/Select';
@@ -87,7 +87,6 @@ function ConfigureField({
<Stack direction="row" align="flex-start">
{field === 'amount' || field === 'date' ? (
<Select
bare
options={
field === 'amount'
? [
@@ -111,7 +110,6 @@ function ConfigureField({
dispatch({ type: 'set-op', op: 'is' });
}
}}
style={{ borderWidth: 1 }}
/>
) : (
titleFirst(mapField(field))
@@ -199,7 +197,8 @@ function ConfigureField({
field={field}
subfield={subfield}
type={
type === 'id' && (op === 'contains' || op === 'doesNotContain')
type === 'id' &&
(op === 'contains' || op === 'matches' || op === 'doesNotContain')
? 'string'
: type
}
@@ -220,9 +219,9 @@ function ConfigureField({
>
<View style={{ flex: 1 }} />
<Button
type="primary"
onClick={e => {
e.preventDefault();
variant="primary"
aria-label="Apply"
onPress={() => {
onApply({
field,
op,

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { type TransactionFilterEntity } from 'loot-core/types/models';
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
import { Stack } from '../common/Stack';
@@ -12,17 +13,17 @@ import {
} from './SavedFilterMenuButton';
export function FiltersStack({
filters,
conditions,
conditionsOp,
onUpdateFilter,
onDeleteFilter,
onClearFilters,
onReloadSavedFilter,
filterId,
filtersList,
onCondOpChange,
savedFilters,
onConditionsOpChange,
}: {
filters: RuleConditionEntity[];
conditions: RuleConditionEntity[];
conditionsOp: string;
onUpdateFilter: (
filter: RuleConditionEntity,
@@ -32,8 +33,8 @@ export function FiltersStack({
onClearFilters: () => void;
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
filterId: SavedFilter;
filtersList: RuleConditionEntity[];
onCondOpChange: () => void;
savedFilters: TransactionFilterEntity[];
onConditionsOpChange: () => void;
}) {
return (
<View>
@@ -44,20 +45,20 @@ export function FiltersStack({
align="flex-start"
>
<AppliedFilters
filters={filters}
conditions={conditions}
conditionsOp={conditionsOp}
onCondOpChange={onCondOpChange}
onConditionsOpChange={onConditionsOpChange}
onUpdate={onUpdateFilter}
onDelete={onDeleteFilter}
/>
<View style={{ flex: 1 }} />
<SavedFilterMenuButton
filters={filters}
conditions={conditions}
conditionsOp={conditionsOp}
filterId={filterId}
onClearFilters={onClearFilters}
onReloadSavedFilter={onReloadSavedFilter}
filtersList={filtersList}
savedFilters={savedFilters}
/>
</Stack>
</View>

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect } from 'react';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Input } from '../common/Input';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
@@ -54,12 +54,10 @@ export function NameFilter({
/>
</FormField>
<Button
type="primary"
variant="primary"
aria-label={adding ? 'Add' : 'Update'}
style={{ marginTop: 18 }}
onClick={e => {
e.preventDefault();
onAddUpdate();
}}
onPress={onAddUpdate}
>
{adding ? 'Add' : 'Update'}
</Button>

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { friendlyOp } from 'loot-core/src/shared/rules';
import { type CSSProperties, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
type OpButtonProps = {
op: string;
@@ -13,24 +13,30 @@ type OpButtonProps = {
};
export function OpButton({ op, selected, style, onClick }: OpButtonProps) {
const displayOp = friendlyOp(op);
return (
<Button
type="bare"
style={{
backgroundColor: theme.pillBackground,
variant="bare"
aria-label={`${displayOp} op`}
style={({ isHovered, isPressed }) => ({
marginBottom: 5,
...style,
...(selected && {
color: theme.buttonNormalSelectedText,
'&,:hover,:active': {
backgroundColor: theme.buttonNormalSelectedBackground,
color: theme.buttonNormalSelectedText,
},
}),
}}
onClick={onClick}
...(selected
? {
color: theme.pillTextSelected,
backgroundColor: theme.pillBackgroundSelected,
}
: isHovered || isPressed
? {
backgroundColor: theme.pillBackgroundHover,
}
: {
backgroundColor: theme.pillBackground,
}),
})}
onPress={onClick}
>
{friendlyOp(op)}
{displayOp}
</Button>
);
}

View File

@@ -1,10 +1,11 @@
import React, { useRef, useState } from 'react';
import { send, sendCatch } from 'loot-core/src/platform/client/fetch';
import { type TransactionFilterEntity } from 'loot-core/types/models';
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
import { SvgExpandArrow } from '../../icons/v0';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -21,19 +22,19 @@ export type SavedFilter = {
};
export function SavedFilterMenuButton({
filters,
conditions,
conditionsOp,
filterId,
onClearFilters,
onReloadSavedFilter,
filtersList,
savedFilters,
}: {
filters: RuleConditionEntity[];
conditions: RuleConditionEntity[];
conditionsOp: string;
filterId: SavedFilter;
onClearFilters: () => void;
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
filtersList: RuleConditionEntity[];
savedFilters: TransactionFilterEntity[];
}) {
const [nameOpen, setNameOpen] = useState(false);
const [adding, setAdding] = useState(false);
@@ -64,7 +65,7 @@ export function SavedFilterMenuButton({
setAdding(false);
setMenuOpen(false);
savedFilter = {
conditions: filters,
conditions,
conditionsOp,
id: filterId.id,
name: filterId.name,
@@ -72,7 +73,7 @@ export function SavedFilterMenuButton({
};
const response = await sendCatch('filter-update', {
state: savedFilter,
filters: [...filtersList],
filters: [...savedFilters],
});
if (response.error) {
@@ -108,7 +109,7 @@ export function SavedFilterMenuButton({
async function onAddUpdate() {
if (adding) {
const newSavedFilter = {
conditions: filters,
conditions,
conditionsOp,
name,
status: 'saved',
@@ -116,7 +117,7 @@ export function SavedFilterMenuButton({
const response = await sendCatch('filter-create', {
state: newSavedFilter,
filters: [...filtersList],
filters: [...savedFilters],
});
if (response.error) {
@@ -142,7 +143,7 @@ export function SavedFilterMenuButton({
const response = await sendCatch('filter-update', {
state: updatedFilter,
filters: [...filtersList],
filters: [...savedFilters],
});
if (response.error) {
@@ -157,12 +158,13 @@ export function SavedFilterMenuButton({
return (
<View>
{filters.length > 0 && (
{conditions.length > 0 && (
<Button
ref={triggerRef}
type="bare"
variant="bare"
aria-label="Saved filter menu"
style={{ marginTop: 10 }}
onClick={() => {
onPress={() => {
setMenuOpen(true);
}}
>

View File

@@ -12,6 +12,7 @@ export function updateFilterReducer(
if (
(type === 'id' || type === 'string') &&
(action.op === 'contains' ||
action.op === 'matches' ||
action.op === 'is' ||
action.op === 'doesNotContain' ||
action.op === 'isNot')

View File

@@ -105,6 +105,17 @@ export const Checkbox = (props: CheckboxProps) => {
content: ' ',
},
},
':disabled': {
border: '1px solid ' + theme.buttonNormalDisabledBorder,
backgroundColor: theme.buttonNormalDisabledBorder,
},
':checked:disabled': {
border: '1px solid ' + theme.buttonNormalDisabledBorder,
backgroundColor: theme.buttonNormalDisabledBorder,
'::after': {
backgroundColor: theme.buttonNormalDisabledBorder,
},
},
'&.focus-visible:focus': {
'::before': {
position: 'absolute',

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react';
import type React from 'react';
import { useState, useRef, type CSSProperties } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -12,6 +13,12 @@ import {
pushModal,
} from 'loot-core/client/actions';
import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
import {
type File,
type LocalFile,
type SyncableLocalFile,
type SyncedLocalFile,
} from 'loot-core/types/file';
import { useInitialMount } from '../../hooks/useInitialMount';
import { useLocalPref } from '../../hooks/useLocalPref';
@@ -32,7 +39,7 @@ import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
function getFileDescription(file) {
function getFileDescription(file: File) {
if (file.state === 'unknown') {
return (
'This is a cloud-based file but its state is unknown because you ' +
@@ -50,8 +57,14 @@ function getFileDescription(file) {
return null;
}
function FileMenu({ onDelete, onClose }) {
function onMenuSelect(type) {
function FileMenu({
onDelete,
onClose,
}: {
onDelete: () => void;
onClose: () => void;
}) {
function onMenuSelect(type: string) {
onClose();
switch (type) {
@@ -83,7 +96,7 @@ function FileMenu({ onDelete, onClose }) {
);
}
function FileMenuButton({ state, onDelete }) {
function FileMenuButton({ onDelete }: { onDelete: () => void }) {
const triggerRef = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -106,17 +119,13 @@ function FileMenuButton({ state, onDelete }) {
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
>
<FileMenu
state={state}
onDelete={onDelete}
onClose={() => setMenuOpen(false)}
/>
<FileMenu onDelete={onDelete} onClose={() => setMenuOpen(false)} />
</Popover>
</View>
);
}
function FileState({ file }) {
function FileState({ file }: { file: File }) {
let Icon;
let status;
let color;
@@ -164,10 +173,20 @@ function FileState({ file }) {
);
}
function File({ file, quickSwitchMode, onSelect, onDelete }) {
function FileItem({
file,
quickSwitchMode,
onSelect,
onDelete,
}: {
file: File;
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
}) {
const selecting = useRef(false);
async function _onSelect(file) {
async function _onSelect(file: File) {
// Never allow selecting the file while uploading/downloading, and
// make sure to never allow duplicate clicks
if (!selecting.current) {
@@ -180,7 +199,7 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
return (
<View
onClick={() => _onSelect(file)}
title={getFileDescription(file)}
title={getFileDescription(file) || ''}
style={{
flexDirection: 'row',
justifyContent: 'space-between',
@@ -193,7 +212,7 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
flexShrink: 0,
cursor: 'pointer',
':hover': {
backgroundColor: theme.hover,
backgroundColor: theme.menuItemBackgroundHover,
},
}}
>
@@ -219,15 +238,27 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
/>
)}
{!quickSwitchMode && (
<FileMenuButton state={file.state} onDelete={() => onDelete(file)} />
)}
{!quickSwitchMode && <FileMenuButton onDelete={() => onDelete(file)} />}
</View>
</View>
);
}
function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
function BudgetFiles({
files,
quickSwitchMode,
onSelect,
onDelete,
}: {
files: File[];
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
}) {
function isLocalFile(file: File): file is LocalFile {
return file.state === 'local';
}
return (
<View
style={{
@@ -252,8 +283,8 @@ function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
</Text>
) : (
files.map(file => (
<File
key={file.id || file.cloudFileId}
<FileItem
key={isLocalFile(file) ? file.id : file.cloudFileId}
file={file}
quickSwitchMode={quickSwitchMode}
onSelect={onSelect}
@@ -265,7 +296,13 @@ function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
);
}
function RefreshButton({ style, onRefresh }) {
function RefreshButton({
style,
onRefresh,
}: {
style?: CSSProperties;
onRefresh: () => void;
}) {
const [loading, setLoading] = useState(false);
async function _onRefresh() {
@@ -288,7 +325,13 @@ function RefreshButton({ style, onRefresh }) {
);
}
function BudgetListHeader({ quickSwitchMode, onRefresh }) {
function BudgetListHeader({
quickSwitchMode,
onRefresh,
}: {
quickSwitchMode: boolean;
onRefresh: () => void;
}) {
return (
<View
style={{
@@ -314,7 +357,14 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
const allFiles = useSelector(state => state.budgets.allFiles || []);
const [id] = useLocalPref('id');
const files = id ? allFiles.filter(f => f.id !== id) : allFiles;
// Remote files do not have the 'id' field
function isNonRemoteFile(
file: File,
): file is LocalFile | SyncableLocalFile | SyncedLocalFile {
return file.state !== 'remote';
}
const nonRemoteFiles = allFiles.filter(isNonRemoteFile);
const files = id ? nonRemoteFiles.filter(f => f.id !== id) : allFiles;
const [creating, setCreating] = useState(false);
const { isNarrowWidth } = useResponsive();
@@ -324,7 +374,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
}
: {};
const onCreate = ({ testMode } = {}) => {
const onCreate = ({ testMode = false } = {}) => {
if (!creating) {
setCreating(true);
dispatch(createBudget({ testMode }));
@@ -341,6 +391,22 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
refresh();
}
const onSelect = (file: File): void => {
const isRemoteFile = file.state === 'remote';
if (!id) {
if (isRemoteFile) {
dispatch(downloadBudget(file.cloudFileId));
} else {
dispatch(loadBudget(file.id));
}
} else if (!isRemoteFile && file.id !== id) {
dispatch(closeAndLoadBudget(file.id));
} else if (isRemoteFile) {
dispatch(closeAndDownloadBudget(file.cloudFileId));
}
};
return (
<View
style={{
@@ -365,21 +431,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
<BudgetFiles
files={files}
quickSwitchMode={quickSwitchMode}
onSelect={file => {
if (!id) {
if (file.state === 'remote') {
dispatch(downloadBudget(file.cloudFileId));
} else {
dispatch(loadBudget(file.id));
}
} else if (file.id !== id) {
if (file.state === 'remote') {
dispatch(closeAndDownloadBudget(file.cloudFileId));
} else {
dispatch(closeAndLoadBudget(file.id));
}
}
}}
onSelect={onSelect}
onDelete={file => dispatch(pushModal('delete-budget', { file }))}
/>
{!quickSwitchMode && (
@@ -408,7 +460,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
<Button
type="primary"
onClick={onCreate}
onClick={() => onCreate()}
style={{
...narrowButtonStyle,
marginLeft: 10,

View File

@@ -7,7 +7,6 @@ import React, {
} from 'react';
import { useDispatch } from 'react-redux';
import memoizeOne from 'memoize-one';
import { useDebounceCallback } from 'usehooks-ts';
import {
@@ -20,7 +19,10 @@ import {
syncAndDownload,
updateAccount,
} from 'loot-core/client/actions';
import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules';
import {
SchedulesProvider,
useDefaultSchedulesQueryTransform,
} from 'loot-core/client/data-hooks/schedules';
import * as queries from 'loot-core/client/queries';
import { pagedQuery } from 'loot-core/client/query-helpers';
import { listen, send } from 'loot-core/platform/client/fetch';
@@ -39,6 +41,7 @@ import { AddTransactionButton } from '../transactions/AddTransactionButton';
import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances';
export function AccountTransactions({ account, pending, failed }) {
const schedulesTransform = useDefaultSchedulesQueryTransform(account.id);
return (
<Page
header={
@@ -52,7 +55,7 @@ export function AccountTransactions({ account, pending, failed }) {
}
padding={0}
>
<SchedulesProvider transform={getSchedulesTransform(account.id)}>
<SchedulesProvider transform={schedulesTransform}>
<TransactionListWithPreviews account={account} />
</SchedulesProvider>
</Page>
@@ -132,15 +135,6 @@ function AccountName({ account, pending, failed }) {
);
}
const getSchedulesTransform = memoizeOne(id => {
const filter = queries.getAccountFilter(id, '_account');
return q => {
q = q.filter({ $and: [filter, { '_account.closed': false }] });
return q.orderBy({ next_date: 'desc' });
};
});
function TransactionListWithPreviews({ account }) {
const [currentQuery, setCurrentQuery] = useState();
const [isSearching, setIsSearching] = useState(false);

View File

@@ -1,6 +1,5 @@
import React, { memo, useRef, useEffect } from 'react';
import React, { memo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { AutoTextSize } from 'auto-text-size';
import memoizeOne from 'memoize-one';
@@ -9,6 +8,7 @@ import { collapseModals, pushModal } from 'loot-core/client/actions';
import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries';
import * as monthUtils from 'loot-core/src/shared/months';
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
import { useLocalPref } from '../../../hooks/useLocalPref';
import { useNavigate } from '../../../hooks/useNavigate';
import { SvgLogo } from '../../../icons/logo';
@@ -16,13 +16,14 @@ import { SvgExpandArrow } from '../../../icons/v0';
import {
SvgArrowThinLeft,
SvgArrowThinRight,
SvgArrowThickRight,
SvgCheveronRight,
} from '../../../icons/v1';
import { SvgViewShow } from '../../../icons/v2';
import { useResponsive } from '../../../ResponsiveProvider';
import { theme, styles } from '../../../style';
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
import { makeAmountFullStyle, makeAmountGrey } from '../../budget/util';
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
import { Button } from '../../common/Button';
import { Card } from '../../common/Card';
import { Label } from '../../common/Label';
@@ -329,11 +330,15 @@ const ExpenseCategory = memo(function ExpenseCategory({
onBudgetAction,
show3Cols,
showBudgetedCol,
setScrollPosition,
onShowActivityWithScroll,
}) {
const opacity = blank ? 0 : 1;
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemp = useSheetValue(goal);
const goalValue = isGoalTemplatesEnabled ? goalTemp : null;
const budgetedTemp = useSheetValue(budgeted);
const budgetedValue = isGoalTemplatesEnabled ? budgetedTemp : null;
const [budgetType = 'rollover'] = useLocalPref('budgetType');
const dispatch = useDispatch();
@@ -373,7 +378,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
const onCover = () => {
dispatch(
pushModal('cover', {
categoryId: category.id,
title: category.name,
month,
onSubmit: fromCategoryId => {
onBudgetAction(month, 'cover-overspending', {
@@ -399,6 +404,10 @@ const ExpenseCategory = memo(function ExpenseCategory({
const listItemRef = useRef();
const format = useFormat();
const navigate = useNavigate();
const onShowActivity = () => {
navigate(`/categories/${category.id}?month=${month}`);
};
const sidebarColumnWidth = getColumnWidth({ show3Cols, isSidebar: true });
const columnWidth = getColumnWidth({ show3Cols });
@@ -516,9 +525,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
binding={spent}
getStyle={makeAmountGrey}
type="financial"
onClick={() => {
onShowActivityWithScroll(category.id, month);
}}
onClick={onShowActivity}
formatter={value => (
<Button
type="bare"
@@ -575,9 +582,11 @@ const ExpenseCategory = memo(function ExpenseCategory({
mode="oneline"
style={{
maxWidth: columnWidth,
...makeAmountFullStyle(value, {
zeroColor: theme.pillTextSubdued,
}),
...makeBalanceAmountStyle(
value,
goalValue,
budgetedValue,
),
textAlign: 'right',
fontSize: 12,
}}
@@ -586,6 +595,23 @@ const ExpenseCategory = memo(function ExpenseCategory({
</AutoTextSize>
</Button>
)}
carryoverIndicator={({ style }) => (
<View
style={{
position: 'absolute',
right: '-3px',
top: '-5px',
borderRadius: '50%',
backgroundColor: style?.color ?? theme.pillText,
}}
>
<SvgArrowThickRight
width={11}
height={11}
style={{ color: theme.pillBackgroundLight }}
/>
</View>
)}
/>
</span>
</View>
@@ -1236,8 +1262,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
showHiddenCategories,
collapsed,
onToggleCollapse,
setScrollPosition,
onShowActivityWithScroll,
}) {
function editable(content) {
if (!editMode) {
@@ -1350,8 +1374,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
month={month}
// onReorder={onReorderCategory}
onBudgetAction={onBudgetAction}
setScrollPosition={setScrollPosition}
onShowActivityWithScroll={onShowActivityWithScroll}
/>
);
})}
@@ -1463,8 +1485,6 @@ function BudgetGroups({
showBudgetedCol,
show3Cols,
showHiddenCategories,
setScrollPosition,
onShowActivityWithScroll,
}) {
const separateGroups = memoizeOne(groups => {
return {
@@ -1514,8 +1534,6 @@ function BudgetGroups({
showHiddenCategories={showHiddenCategories}
collapsed={collapsedGroupIds.includes(group.id)}
onToggleCollapse={onToggleCollapse}
setScrollPosition={setScrollPosition}
onShowActivityWithScroll={onShowActivityWithScroll}
/>
);
})}
@@ -1566,8 +1584,6 @@ export function BudgetTable({
}) {
const { width } = useResponsive();
const show3Cols = width >= 360;
const location = useLocation();
const navigate = useNavigate();
// let editMode = false; // neuter editMode -- sorry, not rewriting drag-n-drop right now
@@ -1575,44 +1591,6 @@ export function BudgetTable({
'mobile.showSpentColumn',
);
const getCurrentScrollPosition = () => {
const scrollableDiv = document.getElementById('scrollableDiv');
return scrollableDiv?.scrollTop || 0;
};
const setScrollPosition = () => {
const scrollPosition = getCurrentScrollPosition();
navigate('/budget', {
replace: true,
state: { scrollPosition }
});
};
const onShowActivityWithScroll = (categoryId, month) => {
const scrollPosition = getCurrentScrollPosition();
navigate('/budget', {
replace: true,
state: { scrollPosition }
});
navigate(`/categories/${categoryId}?month=${month}`);
};
useEffect(() => {
const savedScrollPosition = location.state?.scrollPosition;
if (savedScrollPosition) {
const scrollableDiv = document.getElementById('scrollableDiv');
if (scrollableDiv) {
requestAnimationFrame(() => {
scrollableDiv.scrollTop = savedScrollPosition;
});
}
}
}, [location.state?.scrollPosition]);
function toggleSpentColumn() {
setShowSpentColumnPref(!showSpentColumn);
}
@@ -1672,12 +1650,9 @@ export function BudgetTable({
<PullToRefresh onRefresh={onRefresh}>
<View
data-testid="budget-table"
id="scrollableDiv"
style={{
backgroundColor: theme.pageBackground,
paddingBottom: MOBILE_NAV_HEIGHT,
overflowY: 'auto',
height: '100%',
}}
>
<BudgetGroups
@@ -1699,8 +1674,6 @@ export function BudgetTable({
onReorderCategory={onReorderCategory}
onReorderGroup={onReorderGroup}
onBudgetAction={onBudgetAction}
setScrollPosition={setScrollPosition}
onShowActivityWithScroll={onShowActivityWithScroll}
/>
</View>
</PullToRefresh>

View File

@@ -16,7 +16,6 @@ import {
updateCategory,
updateGroup,
sync,
loadPrefs,
} from 'loot-core/client/actions';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import { send, listen } from 'loot-core/src/platform/client/fetch';
@@ -31,7 +30,7 @@ import { useLocalPref } from '../../../hooks/useLocalPref';
import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
import { AnimatedLoading } from '../../../icons/AnimatedLoading';
import { theme } from '../../../style';
import { prewarmMonth, switchBudgetType } from '../../budget/util';
import { prewarmMonth } from '../../budget/util';
import { View } from '../../common/View';
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
import { SyncRefresh } from '../../SyncRefresh';
@@ -289,23 +288,6 @@ function BudgetInner(props: BudgetInnerProps) {
// );
// };
const onSwitchBudgetType = async () => {
setInitialized(false);
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
await switchBudgetType(
newBudgetType,
spreadsheet,
bounds,
startMonth,
async () => {
dispatch(loadPrefs());
},
);
setInitialized(true);
};
const onSaveNotes = async (id, notes) => {
await send('notes-save', { id, note: notes });
};
@@ -358,17 +340,6 @@ function BudgetInner(props: BudgetInnerProps) {
);
};
const onOpenSwitchBudgetTypeModal = () => {
dispatch(
pushModal('switch-budget-type', {
onSwitch: () => {
onSwitchBudgetType();
dispatch(collapseModals('budget-page-menu'));
},
}),
);
};
const [showHiddenCategories, setShowHiddenCategoriesPref] = useLocalPref(
'budget.showHiddenCategories',
);
@@ -408,7 +379,6 @@ function BudgetInner(props: BudgetInnerProps) {
onAddCategoryGroup: onOpenNewCategoryGroupModal,
onToggleHiddenCategories,
onSwitchBudgetFile,
onSwitchBudgetType: onOpenSwitchBudgetTypeModal,
}),
);
};

View File

@@ -362,7 +362,7 @@ const ChildTransactionEdit = forwardRef(
<View>
<FieldLabel title="Category" />
<TapField
style={{
textStyle={{
...((isOffBudget || isBudgetTransfer(transaction)) && {
fontStyle: 'italic',
color: theme.pageTextSubdued,
@@ -490,6 +490,9 @@ const TransactionEditInner = memo(function TransactionEditInner({
};
const getPrettyPayee = trans => {
if (trans && trans.is_parent) {
return 'Split';
}
const transPayee = trans && getPayee(trans);
const transTransferAcct = trans && getTransferAcct(trans);
return getDescriptionPretty(trans, transPayee, transTransferAcct);
@@ -520,16 +523,20 @@ const TransactionEditInner = memo(function TransactionEditInner({
const [unserializedTransaction] = unserializedTransactions;
const onConfirmSave = async () => {
const { account: accountId } = unserializedTransaction;
const account = accountsById[accountId];
let transactionsToSave = unserializedTransactions;
if (adding) {
transactionsToSave = realizeTempTransactions(unserializedTransactions);
}
props.onSave(transactionsToSave);
navigate(`/accounts/${account.id}`, { replace: true });
if (adding) {
const { account: accountId } = unserializedTransaction;
const account = accountsById[accountId];
navigate(`/accounts/${account.id}`, { replace: true });
} else {
navigate(-1);
}
};
if (unserializedTransaction.reconciled) {
@@ -636,12 +643,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
return;
}
const { account: accountId } = unserializedTransaction;
if (accountId) {
navigate(`/accounts/${accountId}`, { replace: true });
} else {
navigate(-1);
}
navigate(-1);
},
}),
);
@@ -763,11 +765,17 @@ const TransactionEditInner = memo(function TransactionEditInner({
<View>
<FieldLabel title="Payee" />
<TapField
textStyle={{
...(transaction.is_parent && {
fontStyle: 'italic',
fontWeight: 300,
}),
}}
value={getPrettyPayee(transaction)}
disabled={
editingField &&
editingField !== getFieldName(transaction.id, 'payee')
}
value={getPrettyPayee(transaction)}
onClick={() => onEditField(transaction.id, 'payee')}
data-testid="payee-field"
/>

View File

@@ -1,6 +1,5 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { useLocalPref } from '../../hooks/useLocalPref';
import { type CSSProperties, theme, styles } from '../../style';
import { Menu } from '../common/Menu';
@@ -18,7 +17,6 @@ export function BudgetPageMenuModal({
onAddCategoryGroup,
onToggleHiddenCategories,
onSwitchBudgetFile,
onSwitchBudgetType,
}: BudgetPageMenuModalProps) {
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
@@ -34,7 +32,6 @@ export function BudgetPageMenuModal({
onAddCategoryGroup={onAddCategoryGroup}
onToggleHiddenCategories={onToggleHiddenCategories}
onSwitchBudgetFile={onSwitchBudgetFile}
onSwitchBudgetType={onSwitchBudgetType}
/>
</Modal>
);
@@ -47,17 +44,14 @@ type BudgetPageMenuProps = Omit<
onAddCategoryGroup: () => void;
onToggleHiddenCategories: () => void;
onSwitchBudgetFile: () => void;
onSwitchBudgetType: () => void;
};
function BudgetPageMenu({
onAddCategoryGroup,
onToggleHiddenCategories,
onSwitchBudgetFile,
onSwitchBudgetType,
...props
}: BudgetPageMenuProps) {
const isReportBudgetEnabled = useFeatureFlag('reportBudget');
const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories');
const onMenuSelect = (name: string) => {
@@ -74,9 +68,6 @@ function BudgetPageMenu({
case 'switch-budget-file':
onSwitchBudgetFile?.();
break;
case 'switch-budget-type':
onSwitchBudgetType?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
@@ -99,14 +90,6 @@ function BudgetPageMenu({
name: 'switch-budget-file',
text: 'Switch budget file',
},
...(isReportBudgetEnabled
? [
{
name: 'switch-budget-type',
text: 'Switch budget type',
},
]
: []),
]}
/>
);

View File

@@ -9,12 +9,14 @@ import { type CommonModalProps } from '../Modals';
type ConfirmTransactionEditProps = {
modalProps: Partial<CommonModalProps>;
onCancel?: () => void;
onConfirm: () => void;
confirmReason: string;
};
export function ConfirmTransactionEdit({
modalProps,
onCancel,
onConfirm,
confirmReason,
}: ConfirmTransactionEditProps) {
@@ -71,7 +73,13 @@ export function ConfirmTransactionEdit({
justifyContent: 'flex-end',
}}
>
<Button style={{ marginRight: 10 }} onClick={modalProps.onClose}>
<Button
style={{ marginRight: 10 }}
onClick={() => {
modalProps.onClose();
onCancel();
}}
>
Cancel
</Button>
<Button

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { send } from 'loot-core/src/platform/client/fetch';
@@ -16,10 +16,10 @@ import { Link } from '../common/Link';
import { Menu } from '../common/Menu';
import { Modal } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { type CommonModalProps } from '../Modals';
import { Tooltip } from '../tooltips';
type CreateAccountProps = {
modalProps: CommonModalProps;
@@ -38,6 +38,7 @@ export function CreateAccountModal({
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] =
useState(null);
const [menuGoCardlessOpen, setGoCardlessMenuOpen] = useState<boolean>(false);
const triggerRef = useRef(null);
const [menuSimplefinOpen, setSimplefinMenuOpen] = useState<boolean>(false);
const onConnectGoCardless = () => {
@@ -235,39 +236,40 @@ export function CreateAccountModal({
: 'Set up GoCardless for bank sync'}
</ButtonWithLoading>
{isGoCardlessSetupComplete && (
<Button
type="bare"
onClick={() => setGoCardlessMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
{menuGoCardlessOpen && (
<Tooltip
position="bottom-right"
width={200}
style={{ padding: 0 }}
onClose={() => setGoCardlessMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onGoCardlessReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset GoCardless credentials',
},
]}
/>
</Tooltip>
)}
</Button>
<>
<Button
ref={triggerRef}
type="bare"
onClick={() => setGoCardlessMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuGoCardlessOpen}
onOpenChange={() => setGoCardlessMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onGoCardlessReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset GoCardless credentials',
},
]}
/>
</Popover>
</>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
@@ -303,39 +305,39 @@ export function CreateAccountModal({
: 'Set up SimpleFIN for bank sync'}
</ButtonWithLoading>
{isSimpleFinSetupComplete && (
<Button
type="bare"
onClick={() => setSimplefinMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
{menuSimplefinOpen && (
<Tooltip
position="bottom-right"
width={200}
style={{ padding: 0 }}
onClose={() => setSimplefinMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onSimpleFinReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset SimpleFIN credentials',
},
]}
/>
</Tooltip>
)}
</Button>
<>
<Button
ref={triggerRef}
type="bare"
onClick={() => setSimplefinMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuSimplefinOpen}
onOpenChange={() => setSimplefinMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onSimpleFinReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset SimpleFIN credentials',
},
]}
/>
</Popover>
</>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>

View File

@@ -1,4 +1,10 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import { useDispatch } from 'react-redux';
import { v4 as uuid } from 'uuid';
@@ -35,6 +41,7 @@ import { SvgDelete, SvgAdd, SvgSubtract } from '../../icons/v0';
import { SvgInformationOutline } from '../../icons/v1';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Menu } from '../common/Menu';
import { Modal } from '../common/Modal';
import { Select } from '../common/Select';
import { Stack } from '../common/Stack';
@@ -82,12 +89,13 @@ function getTransactionFields(conditions, actions) {
export function FieldSelect({ fields, style, value, onChange }) {
return (
<View style={{ color: theme.pageTextPositive, ...style }}>
<View style={style}>
<Select
bare
options={fields}
value={value}
onChange={value => onChange('field', value)}
buttonStyle={{ color: theme.pageTextPositive }}
/>
</View>
);
@@ -97,31 +105,36 @@ export function OpSelect({
ops,
type,
style,
wrapperStyle,
value,
formatOp = friendlyOp,
onChange,
}) {
let line;
// We don't support the `contains` operator for the id type for
// rules yet
if (type === 'id') {
ops = ops.filter(op => op !== 'contains' && op !== 'doesNotContain');
line = ops.length / 2;
}
if (type === 'string') {
line = ops.length / 2;
}
const opOptions = useMemo(() => {
const options = ops
// We don't support the `contains`, `doesNotContain`, `matches` operators
// for the id type rules yet
// TODO: Add matches op support for payees, accounts, categories.
.filter(op =>
type === 'id'
? !['contains', 'matches', 'doesNotContain'].includes(op)
: true,
)
.map(op => [op, formatOp(op, type)]);
if (type === 'string' || type === 'id') {
options.splice(Math.ceil(options.length / 2), 0, Menu.line);
}
return options;
}, [ops, type]);
return (
<Select
bare
options={ops.map(op => [op, formatOp(op, type)])}
options={opOptions}
value={value}
onChange={value => onChange('op', value)}
line={line}
style={{ minHeight: '1px', ...style }}
wrapperStyle={wrapperStyle}
buttonStyle={style}
/>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import * as d from 'date-fns';
@@ -12,6 +12,7 @@ import {
import { useActions } from '../../hooks/useActions';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useLocalPrefs } from '../../hooks/useLocalPrefs';
import { SvgDownAndRightArrow } from '../../icons/v2';
import { theme, styles } from '../../style';
import { Button, ButtonWithLoading } from '../common/Button';
import { Input } from '../common/Input';
@@ -19,6 +20,7 @@ import { Modal } from '../common/Modal';
import { Select } from '../common/Select';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
import { Tooltip } from '../common/Tooltip';
import { View } from '../common/View';
import { Checkbox, SectionLabel } from '../forms';
import { TableHeader, TableWithNavigator, Row, Field } from '../table';
@@ -245,6 +247,12 @@ function applyFieldMappings(transaction, mappings) {
result[field] = transaction[target || field];
}
// Keep preview fields on the mapped transactions
result.trx_id = transaction.trx_id;
result.existing = transaction.existing;
result.ignored = transaction.ignored;
result.selected = transaction.selected;
result.selected_merge = transaction.selected_merge;
return result;
}
@@ -335,33 +343,124 @@ function Transaction({
flipAmount,
multiplierAmount,
categories,
onCheckTransaction,
reconcile,
}) {
const categoryList = categories.map(category => category.name);
const transaction = useMemo(
() =>
fieldMappings
fieldMappings && !rawTransaction.isMatchedTransaction
? applyFieldMappings(rawTransaction, fieldMappings)
: rawTransaction,
[rawTransaction, fieldMappings],
);
const { amount, outflow, inflow } = parseAmountFields(
transaction,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
);
let amount, outflow, inflow;
if (rawTransaction.isMatchedTransaction) {
amount = rawTransaction.amount;
if (splitMode) {
outflow = amount < 0 ? -amount : 0;
inflow = amount > 0 ? amount : 0;
}
} else {
({ amount, outflow, inflow } = parseAmountFields(
transaction,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
));
}
return (
<Row
style={{
backgroundColor: theme.tableBackground,
color:
(transaction.isMatchedTransaction && !transaction.selected_merge) ||
!transaction.selected
? theme.tableTextInactive
: theme.tableText,
}}
>
{reconcile && (
<Field width={31}>
{!transaction.isMatchedTransaction && (
<Tooltip
content={
!transaction.existing && !transaction.ignored
? 'New transaction. You can import it, or skip it.'
: transaction.ignored
? 'Already imported transaction. You can skip it, or import it again.'
: transaction.existing
? 'Updated transaction. You can update it, import it again, or skip it.'
: ''
}
placement="right top"
>
<Checkbox
checked={transaction.selected}
onChange={() => onCheckTransaction(transaction.trx_id)}
style={
transaction.selected_merge
? {
':checked': {
'::after': {
background:
theme.checkboxBackgroundSelected +
// update sign from packages/desktop-client/src/icons/v1/layer.svg
// eslint-disable-next-line rulesdir/typography
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="white" d="M10 1l10 6-10 6L0 7l10-6zm6.67 10L20 13l-10 6-10-6 3.33-2L10 15l6.67-4z" /></svg>\') 9px 9px',
},
},
}
: {
'&': {
border:
'1px solid ' + theme.buttonNormalDisabledBorder,
backgroundColor: theme.buttonNormalDisabledBorder,
'::after': {
display: 'block',
background:
theme.buttonNormalDisabledBorder +
// minus sign adapted from packages/desktop-client/src/icons/v1/add.svg
// eslint-disable-next-line rulesdir/typography
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /></svg>\') 9px 9px',
width: 9,
height: 9,
content: ' ',
},
},
':checked': {
border: '1px solid ' + theme.checkboxBorderSelected,
backgroundColor: theme.checkboxBackgroundSelected,
'::after': {
background:
theme.checkboxBackgroundSelected +
// plus sign from packages/desktop-client/src/icons/v1/add.svg
// eslint-disable-next-line rulesdir/typography
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /><path fill="white" className="path" d="M11.5,23 C10.6715729,23 10,22.3284271 10,21.5 L10,1.5 C10,0.671572875 10.6715729,1.52179594e-16 11.5,0 C12.3284271,-1.52179594e-16 13,0.671572875 13,1.5 L13,21.5 C13,22.3284271 12.3284271,23 11.5,23 Z" /></svg>\') 9px 9px',
},
},
}
}
/>
</Tooltip>
)}
</Field>
)}
<Field width={200}>
{showParsed ? (
{transaction.isMatchedTransaction ? (
<View>
<Stack direction="row" align="flex-start">
<View>
<SvgDownAndRightArrow width={16} height={16} />
</View>
<View>{formatDate(transaction.date, dateFormat)}</View>
</Stack>
</View>
) : showParsed ? (
<ParsedDate
parseDateFormat={parseDateFormat}
dateFormat={dateFormat}
@@ -487,9 +586,8 @@ function SelectField({
]),
]}
value={value === null ? 'choose-field' : value}
style={{ width: '100%' }}
wrapperStyle={style}
onChange={value => onChange(value)}
onChange={onChange}
buttonStyle={style}
/>
);
}
@@ -520,8 +618,7 @@ function DateFormatSelect({
f.label.replace(/ /g, delimiter),
])}
value={parseDateFormat || ''}
onChange={value => onChange(value)}
style={{ width: '100%' }}
onChange={onChange}
/>
</View>
);
@@ -627,7 +724,9 @@ function FieldMappings({
return null;
}
const options = Object.keys(transactions[0]);
const { existing, ignored, selected, selected_merge, trx_id, ...trans } =
transactions[0];
const options = Object.keys(trans);
mappings = mappings || {};
return (
@@ -740,8 +839,13 @@ function FieldMappings({
export function ImportTransactions({ modalProps, options }) {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const prefs = useLocalPrefs();
const { parseTransactions, importTransactions, getPayees, savePrefs } =
useActions();
const {
parseTransactions,
importTransactions,
importPreviewTransactions,
getPayees,
savePrefs,
} = useActions();
const [multiplierAmount, setMultiplierAmount] = useState('');
const [loadingState, setLoadingState] = useState('parsing');
@@ -756,6 +860,7 @@ export function ImportTransactions({ modalProps, options }) {
const [flipAmount, setFlipAmount] = useState(false);
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
const [reconcile, setReconcile] = useState(true);
const [previewTrigger, setPreviewTrigger] = useState(0);
const { accountId, categories, onImported } = options;
// This cannot be set after parsing the file, because changing it
@@ -785,7 +890,18 @@ export function ImportTransactions({ modalProps, options }) {
setFilename(filename);
setFileType(filetype);
const { errors, transactions } = await parseTransactions(filename, options);
const { errors, transactions: parsedTransactions } =
await parseTransactions(filename, options);
let index = 0;
const transactions = parsedTransactions.map(trans => {
// Add a transient transaction id to match preview with imported transactions
trans.trx_id = index++;
// Select all parsed transactions before first preview run
trans.selected = true;
return trans;
});
setLoadingState(null);
setError(null);
@@ -796,8 +912,14 @@ export function ImportTransactions({ modalProps, options }) {
message: errors[0].message || 'Internal error',
});
} else {
let flipAmount = false;
let fieldMappings = null;
let splitMode = false;
let parseDateFormat = null;
if (filetype === 'csv' || filetype === 'qif') {
setFlipAmount(prefs[`flip-amount-${accountId}-${filetype}`] || false);
flipAmount = prefs[`flip-amount-${accountId}-${filetype}`] || false;
setFlipAmount(flipAmount);
}
if (filetype === 'csv') {
@@ -806,21 +928,22 @@ export function ImportTransactions({ modalProps, options }) {
? JSON.parse(mappings)
: getInitialMappings(transactions);
fieldMappings = mappings;
setFieldMappings(mappings);
// Set initial split mode based on any saved mapping
const initialSplitMode = !!(mappings.outflow || mappings.inflow);
setSplitMode(initialSplitMode);
splitMode = !!(mappings.outflow || mappings.inflow);
setSplitMode(splitMode);
setParseDateFormat(
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, mappings),
);
getInitialDateFormat(transactions, mappings);
setParseDateFormat(parseDateFormat);
} else if (filetype === 'qif') {
setParseDateFormat(
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, { date: 'date' }),
);
getInitialDateFormat(transactions, { date: 'date' });
setParseDateFormat(parseDateFormat);
} else {
setFieldMappings(null);
setParseDateFormat(null);
@@ -829,7 +952,18 @@ export function ImportTransactions({ modalProps, options }) {
// Reverse the transactions because it's very common for them to
// be ordered ascending, but we show transactions descending by
// date. This is purely cosmetic.
setTransactions(transactions.reverse());
const transactionPreview = await getImportPreview(
transactions.reverse(),
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
);
setTransactions(transactionPreview);
}
}
@@ -837,6 +971,7 @@ export function ImportTransactions({ modalProps, options }) {
const amt = e;
if (!amt || amt.match(/^\d{1,}(\.\d{0,4})?$/)) {
setMultiplierAmount(amt);
runImportPreview();
}
}
@@ -904,7 +1039,54 @@ export function ImportTransactions({ modalProps, options }) {
}
function onUpdateFields(field, name) {
setFieldMappings({ ...fieldMappings, [field]: name === '' ? null : name });
const newFieldMappings = {
...fieldMappings,
[field]: name === '' ? null : name,
};
setFieldMappings(newFieldMappings);
runImportPreview();
}
function onCheckTransaction(trx_id) {
const newTransactions = transactions.map(trans => {
if (trans.trx_id === trx_id) {
if (trans.existing) {
// 3-states management for transactions with existing (merged transactions)
// flow of states:
// (selected true && selected_merge true)
// => (selected true && selected_merge false)
// => (selected false)
// => back to (selected true && selected_merge true)
if (!trans.selected) {
return {
...trans,
selected: true,
selected_merge: true,
};
} else if (trans.selected_merge) {
return {
...trans,
selected: true,
selected_merge: false,
};
} else {
return {
...trans,
selected: false,
selected_merge: false,
};
}
} else {
return {
...trans,
selected: !trans.selected,
};
}
}
return trans;
});
setTransactions(newTransactions);
}
async function onImport() {
@@ -914,6 +1096,16 @@ export function ImportTransactions({ modalProps, options }) {
let errorMessage;
for (let trans of transactions) {
if (
trans.isMatchedTransaction ||
(reconcile && !trans.selected && !trans.ignored)
) {
// skip transactions that are
// - matched transaction (existing transaction added to show update changes)
// - unselected transactions that are not ignored by the reconcilation algorithm (only when reconcilation is enabled)
continue;
}
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
const date =
@@ -943,7 +1135,29 @@ export function ImportTransactions({ modalProps, options }) {
const category_id = parseCategoryFields(trans, categories.list);
trans.category = category_id;
const { inflow, outflow, inOut, ...finalTransaction } = trans;
const {
inflow,
outflow,
inOut,
existing,
ignored,
selected,
selected_merge,
trx_id,
...finalTransaction
} = trans;
if (
reconcile &&
((trans.ignored && trans.selected) ||
(trans.existing && trans.selected && !trans.selected_merge))
) {
// in reconcile mode, force transaction add for
// - ignored transactions (aleardy existing) that are checked
// - transactions with existing (merged transactions) that are not selected_merge
finalTransaction.forceAddTransaction = true;
}
finalTransactions.push({
...finalTransaction,
date,
@@ -996,6 +1210,156 @@ export function ImportTransactions({ modalProps, options }) {
modalProps.onClose();
}
const runImportPreviewCallback = useCallback(async () => {
const transactionPreview = await getImportPreview(
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
);
setTransactions(transactionPreview);
}, [
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
]);
useEffect(() => {
runImportPreviewCallback();
}, [previewTrigger]);
function runImportPreview() {
setPreviewTrigger(value => value + 1);
}
async function getImportPreview(
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
) {
const previewTransactions = [];
for (let trans of transactions) {
if (trans.isMatchedTransaction) {
// skip transactions that are matched transaction (existing transaction added to show update changes)
continue;
}
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
const date = isOfxFile(filetype)
? trans.date
: parseDate(trans.date, parseDateFormat);
if (date == null) {
console.log(
`Unable to parse date ${
trans.date || '(empty)'
} with given date format`,
);
break;
}
const { amount } = parseAmountFields(
trans,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
);
if (amount == null) {
console.log(`Transaction on ${trans.date} has no amount`);
break;
}
const category_id = parseCategoryFields(trans, categories.list);
if (category_id != null) {
trans.category = category_id;
}
const {
inflow,
outflow,
inOut,
existing,
ignored,
selected,
selected_merge,
...finalTransaction
} = trans;
previewTransactions.push({
...finalTransaction,
date,
amount: amountToInteger(amount),
cleared: clearOnImport,
});
}
// Retreive the transactions that would be updated (along with the existing trx)
const previewTrx = await importPreviewTransactions(
accountId,
previewTransactions,
);
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
map[entry.transaction.trx_id] = entry;
return map;
}, {});
return transactions
.filter(trans => !trans.isMatchedTransaction)
.reduce((previous, current_trx) => {
let next = previous;
const entry = matchedUpdateMap[current_trx.trx_id];
const existing_trx = entry?.existing;
// if the transaction is matched with an existing one for update
current_trx.existing = !!existing_trx;
// if the transaction is an update that will be ignored
// (reconciled transactions or no change detected)
current_trx.ignored = entry?.ignored || false;
current_trx.selected = !current_trx.ignored;
current_trx.selected_merge = current_trx.existing;
next = next.concat({ ...current_trx });
if (existing_trx) {
// add the updated existing transaction in the list, with the
// isMatchedTransaction flag to identify it in display and not send it again
existing_trx.isMatchedTransaction = true;
existing_trx.category = categories.list.find(
cat => cat.id === existing_trx.category,
)?.name;
// add parent transaction attribute to mimic behaviour
existing_trx.trx_id = current_trx.trx_id;
existing_trx.existing = current_trx.existing;
existing_trx.selected = current_trx.selected;
existing_trx.selected_merge = current_trx.selected_merge;
next = next.concat({ ...existing_trx });
}
return next;
}, []);
}
const headers = [
{ name: 'Date', width: 200 },
{ name: 'Payee', width: 'flex' },
@@ -1003,6 +1367,9 @@ export function ImportTransactions({ modalProps, options }) {
{ name: 'Category', width: 'flex' },
];
if (reconcile) {
headers.unshift({ name: ' ', width: 31 });
}
if (inOutMode) {
headers.push({ name: 'In/Out', width: 90, style: { textAlign: 'left' } });
}
@@ -1040,7 +1407,11 @@ export function ImportTransactions({ modalProps, options }) {
<TableHeader headers={headers} />
<TableWithNavigator
items={transactions}
items={transactions.filter(
trans =>
!trans.isMatchedTransaction ||
(trans.isMatchedTransaction && reconcile),
)}
fields={['payee', 'category', 'amount']}
style={{ backgroundColor: theme.tableHeaderBackground }}
getItemKey={index => index}
@@ -1072,6 +1443,8 @@ export function ImportTransactions({ modalProps, options }) {
flipAmount={flipAmount}
multiplierAmount={multiplierAmount}
categories={categories.list}
onCheckTransaction={onCheckTransaction}
reconcile={reconcile}
/>
</View>
)}
@@ -1096,7 +1469,7 @@ export function ImportTransactions({ modalProps, options }) {
)}
{filetype === 'csv' && (
<View style={{ marginTop: 25 }}>
<View style={{ marginTop: 10 }}>
<FieldMappings
transactions={transactions}
onChange={onUpdateFields}
@@ -1130,16 +1503,16 @@ export function ImportTransactions({ modalProps, options }) {
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(state => !state);
setReconcile(!reconcile);
}}
>
Reconcile transactions
Merge with existing transactions
</CheckboxOption>
)}
{/*Import Options */}
{(filetype === 'qif' || filetype === 'csv') && (
<View style={{ marginTop: 25 }}>
<View style={{ marginTop: 10 }}>
<Stack
direction="row"
align="flex-start"
@@ -1153,14 +1526,17 @@ export function ImportTransactions({ modalProps, options }) {
transactions={transactions}
fieldMappings={fieldMappings}
parseDateFormat={parseDateFormat}
onChange={setParseDateFormat}
onChange={value => {
setParseDateFormat(value);
runImportPreview();
}}
/>
)}
</View>
{/* CSV Options */}
{filetype === 'csv' && (
<View style={{ marginLeft: 25, gap: 5 }}>
<View style={{ marginLeft: 10, gap: 5 }}>
<SectionLabel title="CSV OPTIONS" />
<label
style={{
@@ -1221,23 +1597,26 @@ export function ImportTransactions({ modalProps, options }) {
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(state => !state);
setReconcile(!reconcile);
}}
>
Reconcile transactions
Merge with existing transactions
</CheckboxOption>
</View>
)}
<View style={{ flex: 1 }} />
<View style={{ marginRight: 25, gap: 5 }}>
<View style={{ marginRight: 10, gap: 5 }}>
<SectionLabel title="AMOUNT OPTIONS" />
<CheckboxOption
id="form_flip"
checked={flipAmount}
disabled={splitMode || inOutMode}
onChange={() => setFlipAmount(!flipAmount)}
onChange={() => {
setFlipAmount(!flipAmount);
runImportPreview();
}}
>
Flip amount
</CheckboxOption>
@@ -1247,7 +1626,10 @@ export function ImportTransactions({ modalProps, options }) {
id="form_split"
checked={splitMode}
disabled={inOutMode || flipAmount}
onChange={onSplitMode}
onChange={() => {
onSplitMode();
runImportPreview();
}}
>
Split amount into separate inflow/outflow columns
</CheckboxOption>
@@ -1255,7 +1637,10 @@ export function ImportTransactions({ modalProps, options }) {
inOutMode={inOutMode}
outValue={outValue}
disabled={splitMode || flipAmount}
onToggle={() => setInOutMode(!inOutMode)}
onToggle={() => {
setInOutMode(!inOutMode);
runImportPreview();
}}
onChangeText={setOutValue}
/>
</>
@@ -1266,6 +1651,7 @@ export function ImportTransactions({ modalProps, options }) {
onToggle={() => {
setMultiplierEnabled(!multiplierEnabled);
setMultiplierAmount('');
runImportPreview();
}}
onChangeAmount={onMultiplierChange}
/>
@@ -1281,15 +1667,21 @@ export function ImportTransactions({ modalProps, options }) {
alignSelf: 'flex-end',
flexDirection: 'row',
alignItems: 'center',
gap: '1em',
}}
>
<ButtonWithLoading
type="primary"
disabled={transactions.length === 0}
disabled={
transactions?.filter(trans => !trans.isMatchedTransaction)
.length === 0
}
loading={loadingState === 'importing'}
onClick={onImport}
>
Import {transactions.length} transactions
Import{' '}
{transactions?.filter(trans => !trans.isMatchedTransaction).length}{' '}
transactions
</ButtonWithLoading>
</View>
</View>

View File

@@ -0,0 +1,262 @@
import { useLocation } from 'react-router-dom';
import * as Platform from 'loot-core/src/client/platform';
import { Modal, type ModalProps } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
type KeyboardShortcutsModalProps = {
modalProps?: Partial<ModalProps>;
};
type KeyIconProps = {
shortcut: string;
};
type GroupHeadingProps = {
group: string;
};
type ShortcutProps = {
shortcut: string;
description: string;
meta?: string;
shift?: boolean;
};
function KeyIcon({ shortcut }: KeyIconProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
backgroundColor: '#fff',
color: '#000',
border: '1px solid #000',
borderRadius: 8,
minWidth: 35,
minHeight: 35,
filter: 'drop-shadow(1px 1px)',
padding: 5,
}}
>
{shortcut}
</div>
);
}
function GroupHeading({ group }: GroupHeadingProps) {
return (
<Text
style={{
fontWeight: 'bold',
fontSize: 16,
marginTop: 20,
marginBottom: 10,
}}
>
{group}:
</Text>
);
}
function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
return (
<div
style={{
display: 'flex',
marginBottom: 10,
marginLeft: 20,
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
marginRight: 10,
}}
>
{meta && (
<>
<KeyIcon shortcut={meta} />
<Text
style={{
display: 'flex',
alignItems: 'center',
textAlign: 'center',
fontSize: 16,
paddingLeft: 2,
paddingRight: 2,
}}
>
+
</Text>
</>
)}
{shift && (
<>
<KeyIcon shortcut="Shift" />
<Text
style={{
display: 'flex',
alignItems: 'center',
textAlign: 'center',
fontSize: 16,
paddingLeft: 2,
paddingRight: 2,
}}
>
+
</Text>
</>
)}
<KeyIcon shortcut={shortcut} />
</div>
<div
style={{
display: 'flex',
flexDirection: 'row',
flex: 1,
}}
/>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
maxWidth: 300,
}}
>
{description}
</div>
</div>
);
}
export function KeyboardShortcutModal({
modalProps,
}: KeyboardShortcutsModalProps) {
const location = useLocation();
const onAccounts = location.pathname.startsWith('/accounts');
const ctrl = Platform.OS === 'mac' ? '⌘' : 'Ctrl';
return (
<Modal title="Keyboard Shortcuts" {...modalProps}>
<View
style={{
flexDirection: 'row',
}}
>
<View>
<Shortcut
shortcut="O"
description="Close the current budget and open another"
meta={ctrl}
/>
<Shortcut shortcut="?" description="Show this help dialog" />
{onAccounts && (
<>
<Shortcut shortcut="Enter" description="Move down when editing" />
<Shortcut shortcut="Tab" description="Move right when editing" />
<GroupHeading group="Select a transaction, then" />
<Shortcut
shortcut="J"
description="Move to the next transaction down"
/>
<Shortcut
shortcut="K"
description="Move to the next transaction up"
/>
<Shortcut
shortcut="↑"
description="Move to the next transaction down and scroll"
/>
<Shortcut
shortcut="↓"
description="Move to the next transaction up and scroll"
/>
<Shortcut
shortcut="Space"
description="Toggle selection of current transaction"
/>
<Shortcut
shortcut="Space"
description="Toggle all transactions between current and most recently selected transaction"
shift={true}
/>
</>
)}
</View>
<View
style={{
marginLeft: 20,
marginRight: 20,
}}
>
<Shortcut
shortcut="Z"
description="Undo the last change"
meta={ctrl}
/>
<Shortcut
shortcut="Z"
description="Redo the last undone change"
shift={true}
meta={ctrl}
/>
{onAccounts && (
<>
<Shortcut
shortcut="Enter"
description="Move up when editing"
shift={true}
/>
<Shortcut
shortcut="Tab"
description="Move left when editing"
shift={true}
/>
<GroupHeading group="With transaction(s) selected" />
<Shortcut
shortcut="F"
description="Filter to the selected transactions"
/>
<Shortcut
shortcut="D"
description="Delete selected transactions"
/>
<Shortcut
shortcut="A"
description="Set account for selected transactions"
/>
<Shortcut
shortcut="P"
description="Set payee for selected transactions"
/>
<Shortcut
shortcut="N"
description="Set notes for selected transactions"
/>
<Shortcut
shortcut="C"
description="Set category for selected transactions"
/>
<Shortcut
shortcut="L"
description="Toggle cleared for current transaction"
/>
</>
)}
</View>
</View>
</Modal>
);
}

View File

@@ -1,6 +1,7 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { useAccounts } from '../../hooks/useAccounts';
import { useNavigate } from '../../hooks/useNavigate';
import { usePayees } from '../../hooks/usePayees';
import { useResponsive } from '../../ResponsiveProvider';
import { theme } from '../../style';
@@ -21,6 +22,7 @@ export function PayeeAutocompleteModal({
}: PayeeAutocompleteModalProps) {
const payees = usePayees() || [];
const accounts = useAccounts() || [];
const navigate = useNavigate();
const _onClose = () => {
modalProps.onClose();
@@ -32,6 +34,8 @@ export function PayeeAutocompleteModal({
containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } },
};
const onManagePayees = () => navigate('/payees');
return (
<Modal
title={
@@ -56,20 +60,19 @@ export function PayeeAutocompleteModal({
/>
)}
>
{() => (
<PayeeAutocomplete
payees={payees}
accounts={accounts}
focused={true}
embedded={true}
closeOnBlur={false}
onClose={_onClose}
showManagePayees={false}
showMakeTransfer={!isNarrowWidth}
{...defaultAutocompleteProps}
{...autocompleteProps}
/>
)}
<PayeeAutocomplete
payees={payees}
accounts={accounts}
focused={true}
embedded={true}
closeOnBlur={false}
onClose={_onClose}
onManagePayees={onManagePayees}
showManagePayees={!isNarrowWidth}
showMakeTransfer={!isNarrowWidth}
{...defaultAutocompleteProps}
{...autocompleteProps}
/>
</Modal>
);
}

View File

@@ -4,7 +4,10 @@ import { reportBudget } from 'loot-core/client/queries';
import { useCategory } from '../../hooks/useCategory';
import { type CSSProperties, theme, styles } from '../../style';
import { BalanceWithCarryover } from '../budget/BalanceWithCarryover';
import {
BalanceWithCarryover,
DefaultCarryoverIndicator,
} from '../budget/BalanceWithCarryover';
import { BalanceMenu } from '../budget/report/BalanceMenu';
import { Modal, ModalTitle } from '../common/Modal';
import { Text } from '../common/Text';
@@ -63,11 +66,21 @@ export function ReportBalanceMenuModal({
textAlign: 'center',
...styles.veryLargeText,
}}
carryoverStyle={{ right: -20, width: 15, height: 15 }}
carryover={reportBudget.catCarryover(categoryId)}
balance={reportBudget.catBalance(categoryId)}
goal={reportBudget.catGoal(categoryId)}
budgeted={reportBudget.catBudgeted(categoryId)}
carryoverIndicator={({ style }) =>
DefaultCarryoverIndicator({
style: {
width: 15,
height: 15,
display: 'inline-flex',
position: 'relative',
...style,
},
})
}
/>
</View>
<BalanceMenu

View File

@@ -4,7 +4,10 @@ import { rolloverBudget } from 'loot-core/client/queries';
import { useCategory } from '../../hooks/useCategory';
import { type CSSProperties, theme, styles } from '../../style';
import { BalanceWithCarryover } from '../budget/BalanceWithCarryover';
import {
BalanceWithCarryover,
DefaultCarryoverIndicator,
} from '../budget/BalanceWithCarryover';
import { BalanceMenu } from '../budget/rollover/BalanceMenu';
import { Modal, ModalTitle } from '../common/Modal';
import { Text } from '../common/Text';
@@ -65,11 +68,21 @@ export function RolloverBalanceMenuModal({
textAlign: 'center',
...styles.veryLargeText,
}}
carryoverStyle={{ right: -20, width: 15, height: 15 }}
carryover={rolloverBudget.catCarryover(categoryId)}
balance={rolloverBudget.catBalance(categoryId)}
goal={rolloverBudget.catGoal(categoryId)}
budgeted={rolloverBudget.catBudgeted(categoryId)}
carryoverIndicator={({ style }) =>
DefaultCarryoverIndicator({
style: {
width: 15,
height: 15,
display: 'inline-flex',
position: 'relative',
...style,
},
})
}
/>
</View>
<BalanceMenu

View File

@@ -1,73 +0,0 @@
// @ts-strict-ignore
import React from 'react';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useResponsive } from '../../ResponsiveProvider';
import { styles } from '../../style';
import { Button } from '../common/Button';
import { Link } from '../common/Link';
import { Modal, ModalTitle } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { type CommonModalProps } from '../Modals';
type SwitchBudgetTypeModalProps = {
modalProps: CommonModalProps;
onSwitch: () => void;
};
export function SwitchBudgetTypeModal({
modalProps,
onSwitch,
}: SwitchBudgetTypeModalProps) {
const [budgetType] = useLocalPref('budgetType');
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
height: styles.mobileMinHeight,
}
: {};
return (
<Modal
title={<ModalTitle title="Switch budget type?" shrinkOnOverflow />}
{...modalProps}
>
<>
<Paragraph>
You are currently using a{' '}
<Text style={{ fontWeight: 600 }}>
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}.
</Text>{' '}
Switching will not lose any data and you can always switch back.
</Paragraph>
<Button
type="primary"
style={{
...narrowStyle,
}}
onClick={() => {
onSwitch?.();
modalProps.onClose?.();
}}
>
Switch to a{' '}
{budgetType === 'report' ? 'Rollover budget' : 'Report budget'}
</Button>
<Paragraph
isLast={true}
style={{
marginTop: 10,
}}
>
<Link
variant="external"
to="https://actualbudget.org/docs/experimental/report-budget"
linkColor="muted"
>
How do these types of budgeting work?
</Link>
</Paragraph>
</>
</Modal>
);
}

View File

@@ -22,7 +22,7 @@ import {
import { useStableCallback } from '../../hooks/useStableCallback';
import { SvgExpandArrow } from '../../icons/v0';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Popover } from '../common/Popover';
import { Search } from '../common/Search';
import { View } from '../common/View';
@@ -236,10 +236,10 @@ export const ManagePayees = forwardRef(
<View style={{ flexShrink: 0 }}>
<Button
ref={triggerRef}
type="bare"
variant="bare"
style={{ marginRight: 10 }}
disabled={buttonsDisabled}
onClick={() => setMenuOpen(true)}
isDisabled={buttonsDisabled}
onPress={() => setMenuOpen(true)}
>
{buttonsDisabled
? 'No payees selected'
@@ -273,9 +273,9 @@ export const ManagePayees = forwardRef(
{(orphanedOnly ||
(orphanedPayees && orphanedPayees.length > 0)) && (
<Button
type="bare"
variant="bare"
style={{ marginRight: 10 }}
onClick={() => {
onPress={() => {
setOrphanedOnly(!orphanedOnly);
applyFilter(filter);
tableNavigator.onEdit(null);

View File

@@ -27,7 +27,7 @@ export function Header({
onApply,
onUpdateFilter,
onDeleteFilter,
onCondOpChange,
onConditionsOpChange,
headerPrefixItems,
children,
}) {
@@ -151,11 +151,11 @@ export function Header({
align="flex-start"
>
<AppliedFilters
filters={filters}
conditions={filters}
onUpdate={onUpdateFilter}
onDelete={onDeleteFilter}
conditionsOp={conditionsOp}
onCondOpChange={onCondOpChange}
onConditionsOpChange={onConditionsOpChange}
/>
</View>
)}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { css } from 'glamor';
import { useReports } from 'loot-core/src/client/data-hooks/reports';
import { useAccounts } from '../../hooks/useAccounts';
@@ -56,16 +58,21 @@ export function Overview() {
style={{ paddingBottom: MOBILE_NAV_HEIGHT }}
>
<View
style={{
flexDirection: isNarrowWidth ? 'column' : 'row',
className={`${css({
flex: '0 0 auto',
}}
flexDirection: isNarrowWidth ? 'column' : 'row',
flexWrap: isNarrowWidth ? 'nowrap' : 'wrap',
padding: '10',
'> a, > div': {
margin: '10',
},
})}`}
>
<NetWorthCard accounts={accounts} />
<CashFlowCard />
{spendingReportFeatureFlag && <SpendingCard />}
<CustomReportListCards reports={customReports} />
</View>
<CustomReportListCards reports={customReports} />
</Page>
);
}

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