Compare commits

..

85 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
a767d99534 Release notes 2024-12-10 09:37:49 -08:00
Joel Jeremy Marquez
636153593e Convert useSplitsExpanded.jsx to tsx 2024-12-10 09:37:49 -08:00
Joel Jeremy Marquez
298b734539 Optimize useSheetValue (#3879)
* Optimize useSheetValue

* Fix lint

* Fix mock bind

* Reduce re-renders

* Update useSheetValue

* Update

* Make QueryState immutable

* Release notes
2024-12-10 09:35:23 -08:00
Joel Jeremy Marquez
e96b986ad0 Add loading indicator when loading more transactions in mobile transaction list (#3900)
* Add isLoadingMore property to useTransactions hook

* Release notes

* Start loading more earlier
2024-12-10 09:22:28 -08:00
Joel Jeremy Marquez
5104a1a563 Convert BudgetTable.jsx to tsx (#3899)
* Convert BudgetTable to TypeScript

* Release notes
2024-12-10 09:21:25 -08:00
Travis Lesicka
6ea77324ef Duplicate Budget (#3847)
* Initial Commit

* Create 3847.md

* Removed un-needed comment

* Changed error log text

* Moved budget name validation from DuplicateFileModal to loot-core/server

* Added translation

* Fixed linting error

* Changed delete file hack

Changed from loading and closing the budget file to just opening and closing the database to be able to delete it.

* Removed hard coded english from loot-core server

* Updated wording and style of Duplicate File Modal

* Simpler wording for Duplication text and buttons
2024-12-10 08:55:38 -07:00
lelemm
2b908e9263 Filter account with 'on budget' or 'off budget' (#3891)
* Filter account by on budget / off budget

* small fix

* fix eval for new operations

* code review suggestion

* suggestions

* small fix for rules table

* batch loading the accounts

* Update packages/loot-core/src/server/accounts/transaction-rules.ts

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

* missed this type

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2024-12-10 11:52:38 +00:00
Matiss Janis Aboltins
a2892270d2 🐛 fix condition "notes contains (nothing)" throwing error (#3943) 2024-12-09 22:00:36 +00:00
Matiss Janis Aboltins
d649eec4db 🐛 fix misaligned gocardless credential popover (#3942) 2024-12-09 21:59:55 +00:00
Koen van Staveren
5717d90544 enhance: context menu budget page positioning (#3775)
* enhance: context menu budget page positioning
fix: make popover non selectable

* chore: release note

* Update upcoming-release-notes/3775.md

* chore: improve spelling

* feat: useContextMenu hook for context menus

* fix: linting
2024-12-09 20:32:12 +01:00
Adam Langbert
a35af73023 fix tracking budget docs link (#3944)
* fix tracking budget docs link

* add release notes
2024-12-09 10:00:10 +00:00
Eric Ji
e4b40fb831 Menu Option Disappears Completely On Certain Screen Size (#3880)
* remove use of View component

* created a release note

* rename release note to PR number not issue number

* revert changes to check e2e tests

* redo changes

* indentify exact styling issue

* use style instead of css
2024-12-07 23:20:38 -05:00
Matiss Janis Aboltins
fa8ff79208 (dashboards) piecharts - sorting and label spacing (#3855) 2024-12-07 13:29:40 +00:00
Matt Fiddaman
3ce7ae91d9 Add more logging for GoCardless rate limit information (#3895) 2024-12-06 21:29:37 +00:00
annechoww
1b25235cc7 Category Labels Not Scaling Correctly On Small Screen (#3906)
* Fixed label scaling for smaller screens.

* Added release notes. Minor linting fix added.
2024-12-06 13:53:15 -07:00
Matt Fiddaman
f207803f7a 🔖 (24.12.0) (#3931)
* bump versions

* Remove used release notes

* Remove used release notes

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-12-06 11:03:37 -07:00
Julian Dominguez-Schatz
df7bc5d2f0 Fix a navigation bug and a crash from the account pages (#3932)
* Don't crash when making a txn from the uncat page

* Always navigate consistently from the txn add/edit page

* Add release notes

* Attempt to fix functional tests

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-06 08:34:20 +00:00
lelemm
5e7538fde3 'hasTags' should show only for notes (#3902)
* 'hasTags' should show only for notes

* md

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-05 15:38:10 -08:00
Joel Jeremy Marquez
2c0bd6bafd Use useNavigate instead of accessing window.__navigate (#3904)
* Use useNavigate instead of accessing window.__navigate

* Release notes

* Update packages/desktop-client/src/components/manager/ConfigServer.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-12-05 15:37:52 -08:00
Michael Clark
501c8653ef 🐛 Fix flicker when saving transaction on account page when transactions are scheduled (#3920)
* fix flicker when saving transaction on account page when there are scheduled

* release notes
2024-12-02 08:53:57 +00:00
Joel Jeremy Marquez
22623ce65e Fix mobile transaction edit page's back button behavior (#3905)
* Fix mobile transaction edit page's back button behavior

* Release notes
2024-11-27 08:02:37 -08:00
Michael Clark
c25e3d4163 🐛 Fix performance when navigating between budget/accounts (#3882)
* memo the bindingobj

* release notes

* fix

* a bit heavy handed

* lint
2024-11-24 08:43:01 +00:00
Michael Clark
339fac2806 🔧 Summary Card: Change the font size implementation to be simpler & fix import dashboard (#3871)
* change the font size implementation to be simpler

* release notes

* setting the line height back

* renames

* condtion on the font changed event

* fix type check

* typecheck

* clarifying comment

* remove margin on left and right - not required

* fix import

* fix derp
2024-11-22 15:52:27 +00:00
Joel Jeremy Marquez
2ebaa527be Convert mobile Accounts.jsx to tsx (#3862)
* Convert mobile Accounts.jsx to TS

* Release notes

* Fix lint
2024-11-21 16:01:26 -08:00
Joel Jeremy Marquez
c5411518c4 Use strict typing in useSheetValue and fix bug where query is not being updated when changed (#3864)
* Use strict typing in useSheetValue and fix bug where query is not being updated when changed

* Release notes

* Update VRT

* Fix regression

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-21 16:01:09 -08:00
Joel Jeremy Marquez
36839ff153 Convert ManagePayees components to Typescript (#3867)
* Convert ManagePayees components to Typescript

* Release notes

* Fix lint

* Fix lint

* Fix lint
2024-11-21 15:23:35 -08:00
Joel Jeremy Marquez
9d6db12921 Convert MergeUnusedPayeesModal.jsx to tsx (#3866)
* Convert MergeUnusedPayeesModal.jsx to tsx

* Release notes
2024-11-21 15:23:24 -08:00
Joel Jeremy Marquez
590ac1f95e Convert EditFieldModal.jsx to tsx (#3865)
* Convert EditFieldModal.jsx to tsx

* Release notes

* NoteAmendMode

* Fix lint
2024-11-21 15:23:09 -08:00
Joel Jeremy Marquez
8e76a65e0c Convert SimpleTransactionsTable.jsx to tsx (#3870)
* Convert SimpleTransactionsTable.jsx to tsx

* Release notes
2024-11-21 15:09:23 -08:00
Joel Jeremy Marquez
c3eda4247e Convert PostsOfflineNotification.jsx to tsx (#3868)
* Convert PostsOfflineNotification.jsx to tsx

* Release notes

* Fix lint
2024-11-21 15:09:00 -08:00
Matt Fiddaman
022b9b76b1 Allow the report table columns to grow to fill available space (#3872)
* allow report table columns to grow to fit

* note
2024-11-21 22:36:19 +00:00
Joel Jeremy Marquez
19f0037256 Fix preview transactions not showing on all accounts page and aggregated accounts page (budgeted/offbudget) (#3873)
* Fix preview transactions not showing on all accounts

* Release notes
2024-11-21 14:24:57 -08:00
lelemm
c626fc2f17 Summary report (#3792)
* Summary card report

* Apply suggestions from code rabbit

* Apply suggestions from code review

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* MORE CODE RABBIT SUGGESTIONS

* typecheck fix

* change view form the details page

* added privacy filter

* Apply suggestions from code review

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* debounce

* removed binary search and changed the summary page to not use the card component

* Update packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix on recommended code rabbit commit

* added some padding to number so it fits the window better for big numbers

* accept infinite

* feedback fixes

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

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* translations

* fix on the save, linter and changed "include summary date range" to "all time divisor"

* changed MD from enhancements to feature

* typo

* change card

* typecheck

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

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* typecheck

* changes to fit the number better

* small fix

* fix on filters

* code review

* revert code to check for height

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-21 09:25:09 -07:00
Joel Jeremy Marquez
f523d25052 Convert ManagementApp.jsx to tsx (#3860)
* Convert ManagementApp to tsx

* Release notes
2024-11-20 14:53:23 -08:00
Joel Jeremy Marquez
278ac0c730 Strict TS typing for useResizeObserver (#3859)
* Strict type useResizeObserver

* Release notes

* Fix typecheck error
2024-11-20 14:53:11 -08:00
Ryan Bianchi
0696c8113d Fix category is/is not (nothing) filters (#3669)
* filter out transfers when category is none

* add filter for parent id

* fix Category is not (nothing) as well

* lint fixups

* add special case for 'category is not nothing'

* add release notes

* adding tests

* lint fix

* use $and expression for complex condition special cases

* add tests for condition special cases

* Update packages/loot-core/src/server/accounts/rules.ts

rename subExpression to and

Co-authored-by: Koen van Staveren <koenvanstaveren@hotmail.com>

* update uses of subExpression with and

* remove stray debugger rules

* Update VRT

* Revert "Update VRT"

This reverts commit a450fc7b2457bb578e53f62d5f4201e91e9a93c4.

* make and an internal op to avoid exposing it to the UI

* feedback

---------

Co-authored-by: Koen van Staveren <koenvanstaveren@hotmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: matt <matt@fiddaman.net>
2024-11-20 11:38:52 +00:00
Matiss Janis Aboltins
688de5f604 (dashboards) release as first party feature (#3856) 2024-11-20 08:31:54 +00:00
Joel Jeremy Marquez
881410bc74 Fix wrong scheduled transfer payment direction on PWA (#3402)
* Fix #3230

* Release notes

* Rename hook function

* [chore] Comment

* Coderabbit feedback

* Fix loading states

* Code rabbit

* No payee text

* Update VRT

* Update release notes

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-19 13:57:40 -08:00
Ali Gradina
b4d2d6a884 Marked files for translation (#3548) 2024-11-19 18:52:24 +00:00
youngcw
5cf439883e [Goals] fix limits (#3829)
* fix limits

* cleanup

* fix cases of negative previous balance
2024-11-19 06:53:21 -07:00
Matiss Janis Aboltins
23bb89b96e release tracking budget feature (#3833) 2024-11-18 22:41:50 +00:00
Matt Fiddaman
7010ab1eb6 add simplefin batch sync to api (#3821) 2024-11-18 20:38:45 +00:00
Joel Jeremy Marquez
18f538c54b Convert Mobile Transaction component to TransactionListItem + cleanup (#3761)
* Migration mobile Transaction component to TransactionListItem + cleanup

* Release notes + yarn install

* Fix style

* Padding changes + VRT

* Update useScrollListener

* Code rabbit feedback

* Do not show loading on preview transactions
2024-11-18 06:38:08 -08:00
Joel Jeremy Marquez
e170c0d274 Fix mobile navigation tabs expanding/collapsing when scrolling in modals (#3731)
* Update scroll provider so that it only captures the scroll on div container and not the whole window

* Fix lint + release notes

* Rewrite useScroll to be more performant by being ref based instead of state to avoid re-renders when scrolling

* Check undefined

* Rename to useScrollListener

* Remove small 1px gap under mobile nav tabs when fully open

* Cleanup

* Fix lint

* Coderabbit feedback
2024-11-17 12:23:05 -08:00
Sai Hemanth Beeraka
dad702e5c2 🐛 Fix validation issue for invalid server URLs in /config-server page (#3837)
* Fix breaking /config-server page logic for invalid server URLs

* Handle missing url in 'subscribe-needs-bootstrap' handler
2024-11-15 20:33:29 +00:00
Michael Clark
224d445840 🐛 Fix parameter formatting issues (#3841)
* fix some errors in translation format

* release notes
2024-11-15 12:38:54 +00:00
Awais Alee
670419b087 Added the i18n translation for desktop-client (#3832) 2024-11-14 19:05:15 +00:00
Michael Clark
58baf74992 🐛 Fix translations failing (#3830)
* fix translations failing

* release notes

* blasphemy

* more blasphemy

* putting warning back

* last heretic act

* making the world a better place
2024-11-14 08:58:09 +00:00
Awais Alee
d08be58f95 Marked files for translation (#3827)
* Add translation files for desktop client

* Add backend translation files for i18n integration

* code refactored

* code refactored

* code refactored
2024-11-13 15:08:01 +00:00
Greg Lorenzen
db68170cce Translation: desktop-client/components/payees/PayeeMenu (#3670)
* Translation: desktop-client/components/payees/PayeeMenu

* Add release notes

* Merge master

* Remove dynamic values from selected payee translation
2024-11-12 20:05:35 +01:00
Michael Clark
1e1092e472 :electron: Separate build folders for easier switching between web and electron (#3801)
* seperate build folders for easier switching between web and electron builds

* gitignore

* release notes

* one directory back from electron

* spelling mistake

* Update 3801.md

* test

* making it correct

* huh

* urghh

* again

* account for api bundle which uses electron bundle for some reason

* hu-

* lets go

* remove comment
2024-11-12 18:39:49 +00:00
The-Firexx
d1324408f4 Fixes #2885 - Focus ring getting stuck on last column of /accounts/budgeted (#3571)
* Fixes focus ring getting stuck on last column of /accounts/budgeted screen when creating a new transaction.

* Fix lint problems with the previous commit

* Changed the way the hook is made to the cancel and add button, removing the need to change Button2

* Changed the name of variables as mentioned in PR
2024-11-12 09:13:52 -08:00
Austin Pearce
9e478014c5 fix back button behavior after adding tx on mobile (#3825)
Co-authored-by: Austin Pearce <austin@apearce.dev>
2024-11-12 08:58:43 +00:00
Michael Clark
dd69e539d3 🐛 Fix iphone 13 unable to use regex lookbehinds (#3823)
* fix iphone 13 unable to use regex lookbehinds

* release notes
2024-11-12 08:58:01 +00:00
jotch
2cb668a40c fix: don't clobber server pathname (#3815)
* fix: don't clobber server pathname

The provided server URL may already include a pathname,
so all further segments need to be appended. This also
more closely matches the name `joinURL`.

* add release notes

* use `fs.join` to avoid double slashes
2024-11-12 08:56:44 +00:00
Joel Jeremy Marquez
3cefd98ce9 useTransactions hook to simplify loading of transactions (#3685)
* useTransactions hook to load transactions

* Release notes + lint fix

* Update useQuery

* useTransactions update

* Stabilize tests

* Fx flaky test

* Fix tests

* Fix tests

* Update queries

* Apply coderabbit suggestions

* Fix onlySync

* Update useTransactions

* Code rabbit suggestions

* useTransactionsSearch hook

* Debounce the useTransactionsSearch search method

* usePreviewTransactions debounce fix

* Fix lint

* Coderabbit feedback + make useSchedules consistent with query pattern used in useTransactions

* Code review feedback and improve schedules loading

* Update error handling

* Cancel debounce on unmount

* Fix lint

* set loading state on error

* Fix test

* VRT

* Revert VRT

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2024-11-11 22:41:42 -08:00
youngcw
fa2830a1fd fix remainder goal (#3822) 2024-11-11 15:19:22 -07:00
youngcw
57ac062edc [Goals] fix how goal values are calculated (#3817)
* fix how goals are calculated

* lint, note

* note
2024-11-11 07:46:09 -07:00
Awais Alee
0c94214a8f Marked files for translation (#3752)
* Enhance app with i18n translations

* Code Refactored

* Added translated desktop client files

* Code refactored

* Code refactored

* Fix lint issue

* Removed i18next line from RuleRow.tsx

* Code refactored

* Code refactored

* Code refactored

* Update packages/desktop-client/src/components/settings/Encryption.tsx

Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>

* Added translation for setting encryption

* Update packages/desktop-client/src/components/settings/Themes.tsx

Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>

* Added translation in src/components/settings/Encryption.tsx

* Update packages/desktop-client/src/components/settings/Themes.tsx

Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>

* Update packages/desktop-client/src/components/settings/Themes.tsx

Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>
2024-11-11 14:37:08 +00:00
Koen van Staveren
2b72b2f2f2 Update update-vrt.yml (#3800) 2024-11-07 16:03:15 +00:00
Koen van Staveren
985b653a87 Update update-vrt.yml (#3799) 2024-11-07 15:44:19 +00:00
Koen van Staveren
f14b160e5c [WIP] Update update-vrt.yml (#3798) 2024-11-07 16:29:31 +01:00
A. Schueler
8eafa1e741 fix: check gocardless bank data before setting state (#3793) 2024-11-07 15:01:37 +00:00
Travis Lesicka
aefd9504bf Update Sidebar - Refactor the Budget Name component (#3593)
* Initial Commit

Moved Budget Name to its own component for a cleaner Sidebar component.
Added pencil icon for editing budget name.
Removed Rename Budget from menu.

* Create 3593.md

* Fixed Menu Dropdown Arrow shrinks with long budget name

* Changes recommended by coderabbitai

* Fixed Lint issue

* Remove Help from Menu

* Remove menu from budget name and added Actual logo with menu

* Update VRTs

* Update VRTs

* Fix logo shrinking with long budget name issue

* Update 3593.md

* Removed Logo and pencil icon

* Update VRTs

* Removed unused classnames from SideBar and BudgetName component

* revert to upstream VRTs
2024-11-06 20:53:58 -07:00
Matiss Janis Aboltins
1f6977da81 🐛 (dashboards) remove faulty dashboard widgets (#3785) 2024-11-06 22:14:33 +00:00
Travis Lesicka
290402ee6a Fixes #3729: Typescript and Runtime error after previous merge (#3794) 2024-11-06 20:11:46 +00:00
Koen van Staveren
c3b95886db ci: add reactions to /update-vrt comment (#3789)
* ci: add reactions to /update-vrt comment

* chore: use correct permissions

* chore: don't cancel on non command message

* chore: make workflow not expose github secret

* Update .github/workflows/update-vrt.yml
2024-11-06 09:30:00 +01:00
Joel Jeremy Marquez
e53d444c32 Fix loading of mobile/desktop page on window resize (#3729)
* Fix loading of mobile/desktop page on window resize

* Release notes

* Fix lint

* Use useWindowSize since it matches behavior of documentElement clientWidth and clientHeight

* Debounce so that components are only reloaded when user finishes the resize

* Adjust debounce delay
2024-11-05 15:38:03 -08:00
joel-rich
c0f9073f35 Fixes #3682 - Fix $ne filters incorrectly excluding null values (#3686)
* allow matching null values when not equals query filter is set

* Also fix namedParameter queries with  filter

* improve tests

* release note

---------

Co-authored-by: Joel Rich <joelrich@protonmail.com>
2024-11-05 00:39:43 -08:00
Koen van Staveren
19c6f85f5e Update update-vrt.yml (#3788) 2024-11-04 19:58:22 +01:00
Koen van Staveren
d4f1f703ea fix: /update-vrt (#3787)
fatal: detected dubious ownership in repository at
https://github.com/actions/runner-images/issues/6775
2024-11-04 19:43:49 +01:00
Michael Clark
914f59197f :electron: Removing node-fetch and updating root ca impl for more support (#3782)
* updating root ca impl to use node env variable for more support

* release notes

* removing node-fetch

* clean up

* error message

* Update 3782.md
2024-11-04 18:35:21 +00:00
youngcw
7c24c269e2 ♻️ make templates classy and replace 🍝 code (#3754)
* by check

* minor changes and TS class migration

* good starting point

* very basic testing

* fix

* basic overwrite of simple templates working

* mostly working.  By and schedule don't work

* some cleanup, better async

* add notifications

* add daily weekly limits

* by is working I think

* mostly working

* some fixes, make faster

* lint, note

* note

* cleanup old stuff

* fix paths

* test fixes

* fix test

* fix note

* rabbit, and fix long goal

* lint

* some fixes

* more typing

* fix save error

* last bunny fixes

* fix save, trim schedule names

* lint

* minor fixes

* last fixes

* lint
2024-11-04 11:28:01 -07:00
Matiss Janis Aboltins
c52e5c856d 🐛 (dashboards) save cash flow balance setting (#3745)
Closes #3671
2024-11-04 17:56:29 +00:00
Matiss Janis Aboltins
b08756cc39 ♻️ custom reports - moving to url identifiers (#3744) 2024-11-04 17:56:14 +00:00
Koen van Staveren
29fc22a171 fix: maintain the sort order of the server sync (#3748)
* fix: maintain the sort order of the server sync

* chore: release note

* fix: tests

* chore: release note
2024-11-04 17:56:15 +01:00
Matt Fiddaman
815f69a051 implement SimpleFin batch sync (#3581)
* initial

* remove incorrect automated imports

* fixes

* refactor to mark all transactions new

* clamp latestTransaction to current date

* refactor out temporary placeholder solution

* simplify bank syning logic

* stricter types

* note

* remove debug logging

* better logging

* error handling

* fix handling of SimpleFinBatchSync

* pass errors down

* fix

* another go!

* hopefully the last try...

* fix log

* Update packages/loot-core/src/server/accounts/sync.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* coderabbit: simplify promise construction

* Update packages/loot-core/src/client/actions/account.ts

Co-authored-by: Koen van Staveren <koenvanstaveren@hotmail.com>

* expand types

* month utils

* use aql over sql

* fix types

* fixes

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Koen van Staveren <koenvanstaveren@hotmail.com>
2024-11-04 16:39:04 +00:00
Koen van Staveren
83ceea4250 fix: /update-vrt on forks (#3773)
* fix: /update-vrt on forks

* chore: add missing container for /update-vrt

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2024-11-04 17:01:55 +01:00
JahJoey
59d685fab6 Fix: #3719: Imported payee overflowing on transaction record (#3753)
* Modified tooltip to have set width to avoid overflow issue on imported payee records

* Added release note

* Fixed release note file name
2024-11-04 16:59:19 +01:00
Dreptschar
a267e3abb5 Apply Template to All Categories in Group for Web (#3666)
* add function to apply template to multiple category and add button to group sidebar

* add function to apply template to multiple category and add button to group sidebar

* add correct month

* clean up code

* clean up code

* clean up code

* clean up code

* add notification and clean up

* add notification and clean up

* add notification and clean up

* add notification and clean up

* add notification and clean up

* add release note

* excluded hidden categories

* removed unused method from api

* adjust template to run on already budgeted categories

* fix typecheck

* add apply multiple as budget action and remove from api

* lint clean up

* fix notification and remove log

---------

Co-authored-by: dreptschar <dreptschar@gmail.com>
2024-11-04 07:22:29 -07:00
Joel Jeremy Marquez
e078ed21ba [Typescript migration] Migrate AccountSyncCheck to ts (#3757)
* Migrate AccountSyncCheck to ts

* Release notes

* Fix lint
2024-11-03 13:04:43 -08:00
Koen van Staveren
41d5922635 Add context menu's (#3381)
* feat: context menu on transactions

* feat: context menu's on budget page

* chore: release note

* fix: losing focus on context menu

* feat: schedules context menu

* feat: payees context menu

* feat: rules context menu

* chore: update release note

* chore: lint

* fix: broken balance movement menu

* fix: placement on context menu to be closer to cursor

* feat: context menu on budget field

* chore: lint

* Update packages/desktop-client/src/components/transactions/TransactionsTable.jsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: fix merge

* fix: e2e test

* fix: moving of the popover in the sidebar

* chore: lint

* chore: add feature flag

* chore: fix tsc

* chore: fix test

* Update packages/desktop-client/src/components/settings/Experimental.tsx

Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>

* fix: to budget button

next steps didn't work

* chore: lint

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
2024-11-03 14:26:53 -05:00
Julian Dominguez-Schatz
6f07894be7 Auto-reload on app updates (#3693)
* Auto-reload on app updates

* update #1

* test

* wip

* green

* wip

* ux

* green

* red

* cleanup

* Add release notes

* Unique notification name

* Missing awaits

* Try to fix Electron app

* Simplify update checking

* PR feedback
2024-11-03 13:00:12 -05:00
Koen van Staveren
871de93f2d ci: /update-vrt (#3764)
* ci: /update-vrt

* chore: release note

* chore: code rabbit feedback.

* chore: code rabbit feedback.

* chore: code rabbit feedback.
2024-11-03 10:51:34 -07:00
lelemm
15b2ef1591 Fix #2932: Schedule reset amount to ten (10) when amount is zero (0) (#3732)
* Fix #2932

* md

* e2e update

* Update packages/desktop-client/src/components/util/AmountInput.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-03 16:46:23 +00:00
373 changed files with 11116 additions and 7496 deletions

View File

@@ -161,7 +161,12 @@ module.exports = {
],
'no-with': 'warn',
'no-whitespace-before-property': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useQuery)',
},
],
'require-yield': 'warn',
'rest-spread-spacing': ['warn', 'never'],
strict: ['warn', 'never'],

113
.github/workflows/update-vrt.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: /update-vrt
on:
issue_comment:
types: [ created ]
permissions:
pull-requests: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
cancel-in-progress: true
jobs:
update-vrt:
name: Update VRT
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch
run: |
git config --system --add safe.directory "*"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git reset
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git format-patch -1 HEAD --stdout > Update-VRT.patch
- uses: actions/upload-artifact@v4
with:
name: patch
path: Update-VRT.patch
push-patch:
runs-on: ubuntu-latest
needs: update-vrt
permissions:
contents: write
pull-requests: write
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: actions/download-artifact@v4
continue-on-error: true
with:
name: patch
- name: Apply patch and push
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git apply Update-VRT.patch
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git push origin HEAD:${{ steps.comment-branch.outputs.head_ref }}
- name: Add finished reaction
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: "rocket"
add-starting-reaction:
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
permissions:
pull-requests: write
steps:
- name: React to comment
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: "+1"

View File

@@ -36,7 +36,7 @@ fi
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop
yarn workspace @actual-app/web build --mode=desktop # electron specific build
yarn workspace desktop-electron update-client

View File

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

View File

@@ -10,6 +10,7 @@ playwright-report
# production
build
build-electron
build-stats
stats.json

View File

@@ -27,6 +27,7 @@ test.describe('Mobile Accounts', () => {
test('opens the accounts page and asserts on balances', async () => {
const accountsPage = await navigation.goToAccountsPage();
await accountsPage.waitFor();
const account = await accountsPage.getNthAccount(1);
@@ -37,7 +38,10 @@ test.describe('Mobile Accounts', () => {
test('opens individual account page and checks that filtering is working', async () => {
const accountsPage = await navigation.goToAccountsPage();
await accountsPage.waitFor();
const accountPage = await accountsPage.openNthAccount(0);
await accountPage.waitFor();
await expect(accountPage.heading).toHaveText('Bank of America');
await expect(accountPage.transactionList).toBeVisible();
@@ -50,6 +54,9 @@ test.describe('Mobile Accounts', () => {
await expect(accountPage.transactions).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
await accountPage.clearSearch();
await expect(accountPage.transactions).not.toHaveCount(0);
await accountPage.searchByText('Kroger');
await expect(accountPage.transactions).not.toHaveCount(0);
await expect(page).toMatchThemeScreenshots();

View File

@@ -62,6 +62,8 @@ test.describe('Accounts', () => {
test('creates a transfer from two existing transactions', async () => {
accountPage = await navigation.goToAccountPage('For budget');
await accountPage.waitFor();
await expect(accountPage.accountName).toHaveText('Budgeted Accounts');
await accountPage.filterByNote('Test Acc Transfer');
@@ -109,6 +111,7 @@ test.describe('Accounts', () => {
offBudget: false,
balance: 0,
});
await accountPage.waitFor();
});
async function importCsv(screenshot = false) {

View File

@@ -30,6 +30,10 @@ export class AccountPage {
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
}
async waitFor() {
await this.transactionTable.waitFor();
}
/**
* Enter details of a transaction
*/

View File

@@ -15,6 +15,10 @@ export class MobileAccountPage {
});
}
async waitFor() {
await this.transactionList.waitFor();
}
/**
* Retrieve the balance of the account as a number
*/
@@ -29,6 +33,10 @@ export class MobileAccountPage {
await this.searchBox.fill(term);
}
async clearSearch() {
await this.searchBox.clear();
}
/**
* Go to transaction creation page
*/

View File

@@ -4,9 +4,14 @@ export class MobileAccountsPage {
constructor(page) {
this.page = page;
this.accountList = this.page.getByLabel('Account list');
this.accounts = this.page.getByTestId('account');
}
async waitFor() {
await this.accountList.waitFor();
}
/**
* Get the name and balance of the nth account
*/

View File

@@ -1,3 +1,4 @@
import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBudgetPage } from './mobile-budget-page';
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
@@ -22,6 +23,13 @@ export class MobileNavigation {
return new MobileAccountsPage(this.page);
}
async goToUncategorizedPage() {
const button = this.page.getByRole('button', { name: /uncategorized/ });
await button.click();
return new MobileAccountPage(this.page);
}
async goToTransactionEntryPage() {
const link = this.page.getByRole('link', { name: 'Transaction' });
await link.click();

View File

@@ -22,8 +22,9 @@ export class ReportsPage {
async goToCustomReportPage() {
await this.pageContent
.getByRole('button', { name: 'Create new custom report' })
.getByRole('button', { name: 'Add new widget' })
.click();
await this.page.getByRole('button', { name: 'New custom report' }).click();
return new CustomReportPage(this.page);
}

View File

@@ -8,27 +8,10 @@ export class SettingsPage {
}
async useBudgetType(budgetType) {
await this.enableExperimentalFeature('Budget mode toggle');
const switchBudgetTypeButton = this.page.getByRole('button', {
name: `Switch to ${budgetType} budgeting`,
});
await switchBudgetTypeButton.click();
}
async enableExperimentalFeature(featureName) {
const advancedSettingsButton = this.page.getByTestId('advanced-settings');
await advancedSettingsButton.click();
const experimentalSettingsButton = this.page.getByTestId(
'experimental-settings',
);
await experimentalSettingsButton.click();
const featureCheckbox = this.page.getByRole('checkbox', {
name: featureName,
});
await featureCheckbox.click();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -48,11 +48,8 @@ test.describe('Mobile Transactions', () => {
);
await expect(page).toMatchThemeScreenshots();
const accountPage = await transactionEntryPage.createTransaction();
await expect(accountPage.transactions.nth(0)).toHaveText(
'KrogerClothing-12.34',
);
await transactionEntryPage.createTransaction();
await expect(page.getByLabel('Transaction list')).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
});
@@ -82,4 +79,74 @@ test.describe('Mobile Transactions', () => {
'KrogerClothing-12.34',
);
});
test('creates an uncategorized transaction from `/accounts/uncategorized` page', async () => {
// Create uncategorized transaction
let transactionEntryPage = await navigation.goToTransactionEntryPage();
await transactionEntryPage.amountField.fill('12.35');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
page.getByTestId('account-field'),
'Ally Savings',
);
await transactionEntryPage.createTransaction();
const uncategorizedPage = await navigation.goToUncategorizedPage();
transactionEntryPage = await uncategorizedPage.clickCreateTransaction();
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await transactionEntryPage.amountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
page.getByTestId('payee-field'),
'Kroger',
);
await transactionEntryPage.createTransaction();
await expect(uncategorizedPage.transactions.nth(0)).toHaveText(
'KrogerUncategorized-12.34',
);
await expect(page).toMatchThemeScreenshots();
});
test('creates a categorized transaction from `/accounts/uncategorized` page', async () => {
// Create uncategorized transaction
let transactionEntryPage = await navigation.goToTransactionEntryPage();
await transactionEntryPage.amountField.fill('12.35');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
page.getByTestId('account-field'),
'Ally Savings',
);
await transactionEntryPage.createTransaction();
const uncategorizedPage = await navigation.goToUncategorizedPage();
transactionEntryPage = await uncategorizedPage.clickCreateTransaction();
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await transactionEntryPage.amountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
page.getByTestId('payee-field'),
'Kroger',
);
await transactionEntryPage.fillField(
page.getByTestId('category-field'),
'Clothing',
);
await transactionEntryPage.createTransaction();
await expect(uncategorizedPage.transactions.nth(0)).toHaveText(
'(No payee)Uncategorized-12.35',
);
await expect(page).toMatchThemeScreenshots();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "24.11.0",
"version": "24.12.0",
"license": "MIT",
"files": [
"build"
@@ -50,8 +50,8 @@
"promise-retry": "^2.0.1",
"re-resizable": "^6.9.17",
"react": "18.2.0",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
"react-aria": "^3.35.1",
"react-aria-components": "^1.4.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
@@ -65,7 +65,7 @@
"react-router-dom": "6.21.3",
"react-simple-pull-to-refresh": "^1.3.3",
"react-spring": "^9.7.3",
"react-stately": "^3.10.9",
"react-stately": "^3.33.0",
"react-virtualized-auto-sizer": "^1.0.21",
"recharts": "^2.10.4",
"redux": "^4.2.1",

View File

@@ -1,4 +1,5 @@
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
import { registerSW } from 'virtual:pwa-register';
import * as Platform from 'loot-core/src/client/platform';
@@ -39,6 +40,19 @@ function createBackendWorker() {
createBackendWorker();
let isUpdateReadyForDownload = false;
let markUpdateReadyForDownload;
const isUpdateReadyForDownloadPromise = new Promise(resolve => {
markUpdateReadyForDownload = () => {
isUpdateReadyForDownload = true;
resolve(true);
};
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,
ACTUAL_VERSION,
@@ -140,7 +154,14 @@ global.Actual = {
window.open(url, '_blank');
},
onEventFromMain: () => {},
applyAppUpdate: () => {},
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
applyAppUpdate: async () => {
updateSW();
// Wait for the app to reload
await new Promise(() => {});
},
updateAppMenu: () => {},
ipcConnect: () => {},

View File

@@ -28,7 +28,6 @@ import {
import { useMetadataPref } from '../hooks/useMetadataPref';
import { installPolyfills } from '../polyfills';
import { ResponsiveProvider } from '../ResponsiveProvider';
import { styles, hasHiddenScrollbars, ThemeStyle, useTheme } from '../style';
import { ExposeNavigate } from '../util/router-tools';
@@ -40,7 +39,7 @@ import { FatalError } from './FatalError';
import { FinancesApp } from './FinancesApp';
import { ManagementApp } from './manager/ManagementApp';
import { Modals } from './Modals';
import { ScrollProvider } from './ScrollProvider';
import { ResponsiveProvider } from './responsive/ResponsiveProvider';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { UpdateNotification } from './UpdateNotification';
@@ -51,8 +50,20 @@ function AppInner() {
const { showBoundary: showErrorBoundary } = useErrorBoundary();
const dispatch = useDispatch();
const maybeUpdate = async <T,>(cb?: () => T): Promise<T> => {
if (global.Actual.isUpdateReadyForDownload()) {
dispatch(
setAppState({
loadingText: t('Downloading and applying update...'),
}),
);
await global.Actual.applyAppUpdate();
}
return cb?.();
};
async function init() {
const socketName = await global.Actual.getServerSocket();
const socketName = await maybeUpdate(() => global.Actual.getServerSocket());
dispatch(
setAppState({
@@ -86,14 +97,16 @@ function AppInner() {
loadingText: t('Retrieving remote files...'),
}),
);
send('get-remote-files').then(files => {
if (files) {
const remoteFile = files.find(f => f.fileId === cloudFileId);
if (remoteFile && remoteFile.deleted) {
dispatch(closeBudget());
}
const files = await send('get-remote-files');
if (files) {
const remoteFile = files.find(f => f.fileId === cloudFileId);
if (remoteFile && remoteFile.deleted) {
dispatch(closeBudget());
}
});
}
await maybeUpdate();
}
}
@@ -166,36 +179,34 @@ export function App() {
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={HTML5Backend}>
<ScrollProvider>
<View
data-theme={theme}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<View
data-theme={theme}
key={
hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'
}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
...styles.lightScrollbar,
}}
>
<View
key={
hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'
}
style={{
flexGrow: 1,
overflow: 'hidden',
...styles.lightScrollbar,
}}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{process.env.REACT_APP_REVIEW_ID &&
!Platform.isPlaywright && <DevelopmentTopBar />}
<AppInner />
</ErrorBoundary>
<ThemeStyle />
<Modals />
<UpdateNotification />
</View>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{process.env.REACT_APP_REVIEW_ID &&
!Platform.isPlaywright && <DevelopmentTopBar />}
<AppInner />
</ErrorBoundary>
<ThemeStyle />
<Modals />
<UpdateNotification />
</View>
</ScrollProvider>
</View>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { type ReactElement, useEffect } from 'react';
import React, { type ReactElement, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -18,7 +18,6 @@ import { useAccounts } from '../hooks/useAccounts';
import { useLocalPref } from '../hooks/useLocalPref';
import { useMetaThemeColor } from '../hooks/useMetaThemeColor';
import { useNavigate } from '../hooks/useNavigate';
import { useResponsive } from '../ResponsiveProvider';
import { theme } from '../style';
import { getIsOutdated, getLatestVersion } from '../util/versions';
@@ -34,6 +33,8 @@ import { ManagePayeesPage } from './payees/ManagePayeesPage';
import { Reports } from './reports';
import { LoadingIndicator } from './reports/LoadingIndicator';
import { NarrowAlternate, WideComponent } from './responsive';
import { useResponsive } from './responsive/ResponsiveProvider';
import { ScrollProvider } from './ScrollProvider';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
import { Titlebar } from './Titlebar';
@@ -100,6 +101,29 @@ export function FinancesApp() {
}, 100);
}, []);
useEffect(() => {
async function run() {
await global.Actual.waitForUpdateReadyForDownload();
dispatch(
addNotification({
type: 'message',
title: t('A new version of Actual is available!'),
message: t('Click the button below to reload and apply the update.'),
sticky: true,
id: 'update-reload-notification',
button: {
title: t('Update now'),
action: async () => {
await global.Actual.applyAppUpdate();
},
},
}),
);
}
run();
}, []);
useEffect(() => {
async function run() {
const latestVersion = await getLatestVersion();
@@ -133,6 +157,8 @@ export function FinancesApp() {
run();
}, [lastUsedVersion, setLastUsedVersion]);
const scrollableRef = useRef<HTMLDivElement>(null);
return (
<View style={{ height: '100%' }}>
<RouterBehaviors />
@@ -156,113 +182,119 @@ export function FinancesApp() {
width: '100%',
}}
>
<View
style={{
flex: 1,
overflow: 'auto',
position: 'relative',
}}
<ScrollProvider
isDisabled={!isNarrowWidth}
scrollableRef={scrollableRef}
>
<Titlebar
<View
ref={scrollableRef}
style={{
WebkitAppRegion: 'drag',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
flex: 1,
overflow: 'auto',
position: 'relative',
}}
/>
<Notifications />
<BankSyncStatus />
>
<Titlebar
style={{
WebkitAppRegion: 'drag',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
<Notifications />
<BankSyncStatus />
<Routes>
<Route
path="/"
element={
accountsLoaded ? (
accounts.length > 0 ? (
<Navigate to="/budget" replace />
) : (
// If there are no accounts, we want to redirect the user to
// the All Accounts screen which will prompt them to add an account
<Navigate to="/accounts" replace />
)
) : (
<LoadingIndicator />
)
}
/>
<Route path="/reports/*" element={<Reports />} />
<Route
path="/budget"
element={<NarrowAlternate name="Budget" />}
/>
<Route
path="/schedules"
element={
<NarrowNotSupported>
<WideComponent name="Schedules" />
</NarrowNotSupported>
}
/>
<Route path="/payees" element={<ManagePayeesPage />} />
<Route path="/rules" element={<ManageRulesPage />} />
<Route path="/settings" element={<Settings />} />
<Route
path="/gocardless/link"
element={
<NarrowNotSupported>
<WideComponent name="GoCardlessLink" />
</NarrowNotSupported>
}
/>
<Route
path="/accounts"
element={<NarrowAlternate name="Accounts" />}
/>
<Route
path="/accounts/:id"
element={<NarrowAlternate name="Account" />}
/>
<Route
path="/transactions/:transactionId"
element={
<WideNotSupported>
<TransactionEdit />
</WideNotSupported>
}
/>
<Route
path="/categories/:id"
element={
<WideNotSupported>
<Category />
</WideNotSupported>
}
/>
{/* redirect all other traffic to the budget page */}
<Route path="/*" element={<Navigate to="/budget" replace />} />
</Routes>
</View>
<Routes>
<Route
path="/"
element={
accountsLoaded ? (
accounts.length > 0 ? (
<Navigate to="/budget" replace />
) : (
// If there are no accounts, we want to redirect the user to
// the All Accounts screen which will prompt them to add an account
<Navigate to="/accounts" replace />
)
) : (
<LoadingIndicator />
)
}
/>
<Route path="/reports/*" element={<Reports />} />
<Route
path="/budget"
element={<NarrowAlternate name="Budget" />}
/>
<Route
path="/schedules"
element={
<NarrowNotSupported>
<WideComponent name="Schedules" />
</NarrowNotSupported>
}
/>
<Route path="/payees" element={<ManagePayeesPage />} />
<Route path="/rules" element={<ManageRulesPage />} />
<Route path="/settings" element={<Settings />} />
<Route
path="/gocardless/link"
element={
<NarrowNotSupported>
<WideComponent name="GoCardlessLink" />
</NarrowNotSupported>
}
/>
<Route
path="/accounts"
element={<NarrowAlternate name="Accounts" />}
/>
<Route
path="/accounts/:id"
element={<NarrowAlternate name="Account" />}
/>
<Route
path="/transactions/:transactionId"
element={
<WideNotSupported>
<TransactionEdit />
</WideNotSupported>
}
/>
<Route
path="/categories/:id"
element={
<WideNotSupported>
<Category />
</WideNotSupported>
}
/>
{/* redirect all other traffic to the budget page */}
<Route path="/*" element={<Navigate to="/budget" replace />} />
<Route path="/budget" element={<MobileNavTabs />} />
<Route path="/accounts" element={<MobileNavTabs />} />
<Route path="/settings" element={<MobileNavTabs />} />
<Route path="/reports" element={<MobileNavTabs />} />
<Route path="*" element={null} />
</Routes>
</View>
<Routes>
<Route path="/budget" element={<MobileNavTabs />} />
<Route path="/accounts" element={<MobileNavTabs />} />
<Route path="/settings" element={<MobileNavTabs />} />
<Route path="/reports" element={<MobileNavTabs />} />
<Route path="*" element={null} />
</Routes>
</ScrollProvider>
</View>
</View>
</View>

View File

@@ -6,6 +6,7 @@ import { useSelector } from 'react-redux';
import { type State } from 'loot-core/src/client/state-types';
import { useActions } from '../hooks/useActions';
import { useNavigate } from '../hooks/useNavigate';
import { theme, styles } from '../style';
import { Button } from './common/Button2';
@@ -38,9 +39,11 @@ export function LoggedInUser({
getUserData().then(() => setLoading(false));
}, []);
const navigate = useNavigate();
async function onChangePassword() {
await closeBudget();
window.__navigate('/change-password');
navigate('/change-password');
}
async function onMenuSelect(type) {
@@ -52,14 +55,14 @@ export function LoggedInUser({
break;
case 'sign-in':
await closeBudget();
window.__navigate('/login');
navigate('/login');
break;
case 'sign-out':
signOut();
break;
case 'config-server':
await closeBudget();
window.__navigate('/config-server');
navigate('/config-server');
break;
default:
}

View File

@@ -7,8 +7,11 @@ import React, {
type SetStateAction,
type Dispatch,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useSchedules } from 'loot-core/client/data-hooks/schedules';
import { q } from 'loot-core/shared/query';
import { pushModal } from 'loot-core/src/client/actions/modals';
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
import { send } from 'loot-core/src/platform/client/fetch';
@@ -21,7 +24,6 @@ import { type NewRuleEntity } from 'loot-core/src/types/models';
import { useAccounts } from '../hooks/useAccounts';
import { useCategories } from '../hooks/useCategories';
import { usePayees } from '../hooks/usePayees';
import { useSchedules } from '../hooks/useSchedules';
import { useSelected, SelectedProvider } from '../hooks/useSelected';
import { theme } from '../style';
@@ -113,7 +115,9 @@ export function ManageRules({
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
const { data: schedules = [] } = useSchedules();
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
});
const { list: categories } = useCategories();
const payees = usePayees();
const accounts = useAccounts();
@@ -196,7 +200,9 @@ export function ManageRules({
]);
if (someDeletionsFailed) {
alert('Some rules were not deleted because they are linked to schedules');
alert(
t('Some rules were not deleted because they are linked to schedules'),
);
}
await loadRules();
@@ -204,6 +210,13 @@ export function ManageRules({
setLoading(false);
}
async function onDeleteRule(id: string) {
setLoading(true);
await send('rule-delete', id);
await loadRules();
setLoading(false);
}
const onEditRule = useCallback(rule => {
dispatch(
pushModal('edit-rule', {
@@ -252,6 +265,7 @@ export function ManageRules({
const onHover = useCallback(id => {
setHoveredRule(id);
}, []);
const { t } = useTranslation();
return (
<SelectedProvider instance={selectedInst}>
@@ -273,19 +287,19 @@ export function ManageRules({
}}
>
<Text>
Rules are always run in the order that you see them.{' '}
{t('Rules are always run in the order that you see them.')}{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/budgeting/rules/"
linkColor="muted"
>
Learn more
{t('Learn more')}
</Link>
</Text>
</View>
<View style={{ flex: 1 }} />
<Search
placeholder="Filter rules..."
placeholder={t('Filter rules...')}
value={filter}
onChange={onSearchChange}
/>
@@ -298,7 +312,7 @@ export function ManageRules({
style={{ marginBottom: -1 }}
>
{filteredRules.length === 0 ? (
<EmptyMessage text="No rules" style={{ marginTop: 15 }} />
<EmptyMessage text={t('No rules')} style={{ marginTop: 15 }} />
) : (
<RulesList
rules={filteredRules}
@@ -306,6 +320,7 @@ export function ManageRules({
hoveredRule={hoveredRule}
onHover={onHover}
onEditRule={onEditRule}
onDeleteRule={rule => onDeleteRule(rule.id)}
/>
)}
</SimpleTable>
@@ -325,7 +340,7 @@ export function ManageRules({
</Button>
)}
<Button variant="primary" onPress={onCreateRule}>
Create new rule
{t('Create new rule')}
</Button>
</Stack>
</View>

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { t } from 'i18next';
import { ManageRules } from './ManageRules';
import { Page } from './Page';
export function ManageRulesPage() {
return (
<Page header="Rules">
<Page header={t('Rules')}>
<ManageRules isModal={false} payeeId={null} />
</Page>
);

View File

@@ -1,5 +1,6 @@
// @ts-strict-ignore
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
@@ -44,6 +45,7 @@ import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
import { LoadBackupModal } from './modals/LoadBackupModal';
import { ConfirmChangeDocumentDirModal } from './modals/manager/ConfirmChangeDocumentDir';
import { DeleteFileModal } from './modals/manager/DeleteFileModal';
import { DuplicateFileModal } from './modals/manager/DuplicateFileModal';
import { FilesSettingsModal } from './modals/manager/FilesSettingsModal';
import { ImportActualModal } from './modals/manager/ImportActualModal';
import { ImportModal } from './modals/manager/ImportModal';
@@ -81,6 +83,8 @@ export function Modals() {
}
}, [location]);
const { t } = useTranslation();
const modals = modalStack
.map(({ name, options }) => {
switch (name) {
@@ -287,10 +291,12 @@ export function Modals() {
Header={props => (
<ModalHeader
{...props}
title={<ModalTitle title="New Category" shrinkOnOverflow />}
title={
<ModalTitle title={t('New Category')} shrinkOnOverflow />
}
/>
)}
inputPlaceholder="Category name"
inputPlaceholder={t('Category name')}
buttonText="Add"
onValidate={options.onValidate}
onSubmit={options.onSubmit}
@@ -306,12 +312,15 @@ export function Modals() {
<ModalHeader
{...props}
title={
<ModalTitle title="New Category Group" shrinkOnOverflow />
<ModalTitle
title={t('New Category Group')}
shrinkOnOverflow
/>
}
/>
)}
inputPlaceholder="Category group name"
buttonText="Add"
inputPlaceholder={t('Category group name')}
buttonText={t('Add')}
onValidate={options.onValidate}
onSubmit={options.onSubmit}
/>
@@ -578,6 +587,16 @@ export function Modals() {
return <BudgetListModal key={name} />;
case 'delete-budget':
return <DeleteFileModal key={name} file={options.file} />;
case 'duplicate-budget':
return (
<DuplicateFileModal
key={name}
file={options.file}
managePage={options?.managePage}
loadBudget={options?.loadBudget}
onComplete={options?.onComplete}
/>
);
case 'import':
return <ImportModal key={name} />;
case 'files-settings':

View File

@@ -3,13 +3,14 @@ import React, { useEffect, useRef, type CSSProperties } from 'react';
import ReactMarkdown from 'react-markdown';
import { css } from '@emotion/css';
import { t } from 'i18next';
import remarkGfm from 'remark-gfm';
import { useResponsive } from '../ResponsiveProvider';
import { theme } from '../style';
import { remarkBreaks, sequentialNewlinesPlugin } from '../util/markdown';
import { Text } from './common/Text';
import { useResponsive } from './responsive/ResponsiveProvider';
const remarkPlugins = [sequentialNewlinesPlugin, remarkGfm, remarkBreaks];
@@ -122,7 +123,7 @@ export function Notes({
value={notes || ''}
onChange={e => onChange?.(e.target.value)}
onBlur={e => onBlur?.(e.target.value)}
placeholder="Notes (markdown supported)"
placeholder={t('Notes (markdown supported)')}
/>
) : (
<Text className={css([markdownStyles, getStyle?.(editable)])}>

View File

@@ -6,6 +6,8 @@ import React, {
type CSSProperties,
} from 'react';
import { t } from 'i18next';
import { send } from 'loot-core/src/platform/client/fetch';
import { useNotes } from '../hooks/useNotes';
@@ -59,7 +61,7 @@ export function NotesButton({
<Button
ref={triggerRef}
variant="bare"
aria-label="View notes"
aria-label={t('View notes')}
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
style={{
color: defaultColor,

View File

@@ -9,6 +9,7 @@ import React, {
import { useDispatch, useSelector } from 'react-redux';
import { css } from '@emotion/css';
import { t } from 'i18next';
import { removeNotification } from 'loot-core/client/actions';
import { type State } from 'loot-core/src/client/state-types';
@@ -16,7 +17,6 @@ import type { NotificationWithId } from 'loot-core/src/client/state-types/notifi
import { AnimatedLoading } from '../icons/AnimatedLoading';
import { SvgDelete } from '../icons/v0';
import { useResponsive } from '../ResponsiveProvider';
import { styles, theme } from '../style';
import { Button, ButtonWithLoading } from './common/Button2';
@@ -24,6 +24,7 @@ import { Link } from './common/Link';
import { Stack } from './common/Stack';
import { Text } from './common/Text';
import { View } from './common/View';
import { useResponsive } from './responsive/ResponsiveProvider';
function compileMessage(
message: string,
@@ -231,7 +232,7 @@ function Notification({
</Stack>
<Button
variant="bare"
aria-label="Close"
aria-label={t('Close')}
style={{ flexShrink: 0, color: 'currentColor' }}
onPress={onRemove}
>

View File

@@ -1,10 +1,10 @@
import React, { type ReactNode, type CSSProperties } from 'react';
import { useResponsive } from '../ResponsiveProvider';
import { theme, styles } from '../style';
import { Text } from './common/Text';
import { View } from './common/View';
import { useResponsive } from './responsive/ResponsiveProvider';
const HEADER_HEIGHT = 50;

View File

@@ -8,9 +8,9 @@ import React, {
import { css } from '@emotion/css';
import { usePrivacyMode } from '../hooks/usePrivacyMode';
import { useResponsive } from '../ResponsiveProvider';
import { View } from './common/View';
import { useResponsive } from './responsive/ResponsiveProvider';
type ConditionalPrivacyFilterProps = {
children: ReactNode;

View File

@@ -1,61 +1,204 @@
// @ts-strict-ignore
import React, {
type ReactNode,
type RefObject,
createContext,
useState,
useContext,
useEffect,
useCallback,
useRef,
} from 'react';
import debounce from 'debounce';
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
type ScrollListenerArgs = {
scrollX: number;
scrollY: number;
isScrolling: (direction: ScrollDirection) => boolean;
hasScrolledToEnd: (direction: ScrollDirection, tolerance?: number) => boolean;
};
type ScrollListener = (args: ScrollListenerArgs) => void;
type UnregisterScrollListener = () => void;
type RegisterScrollListener = (
listener: ScrollListener,
) => UnregisterScrollListener;
type IScrollContext = {
scrollY: number | undefined;
hasScrolledToBottom: (tolerance?: number) => boolean;
registerScrollListener: RegisterScrollListener;
};
const ScrollContext = createContext<IScrollContext | undefined>(undefined);
type ScrollProviderProps = {
type ScrollProviderProps<T extends Element> = {
scrollableRef: RefObject<T>;
isDisabled?: boolean;
delayMs?: number;
children?: ReactNode;
};
export function ScrollProvider({ children }: ScrollProviderProps) {
const [scrollY, setScrollY] = useState(undefined);
const [scrollHeight, setScrollHeight] = useState(undefined);
const [clientHeight, setClientHeight] = useState(undefined);
export function ScrollProvider<T extends Element>({
scrollableRef,
isDisabled,
delayMs = 10,
children,
}: ScrollProviderProps<T>) {
const previousScrollX = useRef<number | undefined>(undefined);
const scrollX = useRef<number | undefined>(undefined);
const previousScrollY = useRef<number | undefined>(undefined);
const scrollY = useRef<number | undefined>(undefined);
const scrollWidth = useRef<number | undefined>(undefined);
const scrollHeight = useRef<number | undefined>(undefined);
const clientWidth = useRef<number | undefined>(undefined);
const clientHeight = useRef<number | undefined>(undefined);
const listeners = useRef<ScrollListener[]>([]);
const hasScrolledToBottom = useCallback(
(tolerance = 1) => scrollHeight - scrollY <= clientHeight + tolerance,
[clientHeight, scrollHeight, scrollY],
const hasScrolledToEnd = useCallback(
(direction: ScrollDirection, tolerance = 1) => {
const isAtStart = (currentCoordinate?: number) =>
currentCoordinate !== undefined && currentCoordinate <= tolerance;
const isAtEnd = (
totalSize?: number,
currentCoordinate?: number,
viewportSize?: number,
) =>
totalSize !== undefined &&
currentCoordinate !== undefined &&
viewportSize !== undefined &&
totalSize - currentCoordinate <= viewportSize + tolerance;
switch (direction) {
case 'up': {
return isAtStart(scrollY.current);
}
case 'down': {
return isAtEnd(
scrollHeight.current,
scrollY.current,
clientHeight.current,
);
}
case 'left': {
return isAtStart(scrollX.current);
}
case 'right': {
return isAtEnd(
scrollWidth.current,
scrollX.current,
clientWidth.current,
);
}
default:
return false;
}
},
[],
);
useEffect(() => {
const listenToScroll = debounce(e => {
const target = e.target;
setScrollY(target?.scrollTop || 0);
setScrollHeight(target?.scrollHeight || 0);
setClientHeight(target?.clientHeight || 0);
}, 10);
const isScrolling = useCallback((direction: ScrollDirection) => {
switch (direction) {
case 'up':
return (
previousScrollY.current !== undefined &&
scrollY.current !== undefined &&
previousScrollY.current > scrollY.current
);
case 'down':
return (
previousScrollY.current !== undefined &&
scrollY.current !== undefined &&
previousScrollY.current < scrollY.current
);
case 'left':
return (
previousScrollX.current !== undefined &&
scrollX.current !== undefined &&
previousScrollX.current > scrollX.current
);
case 'right':
return (
previousScrollX.current !== undefined &&
scrollX.current !== undefined &&
previousScrollX.current < scrollX.current
);
default:
return false;
}
}, []);
window.addEventListener('scroll', listenToScroll, {
useEffect(() => {
if (isDisabled) {
return;
}
const listenToScroll = debounce((e: Event) => {
const target = e.target;
if (target instanceof Element) {
previousScrollX.current = scrollX.current;
scrollX.current = target.scrollLeft;
scrollHeight.current = target.scrollHeight;
previousScrollY.current = scrollY.current;
scrollY.current = target.scrollTop;
clientHeight.current = target.clientHeight;
const currentScrollX = scrollX.current;
const currentScrollY = scrollY.current;
if (currentScrollX !== undefined && currentScrollY !== undefined) {
listeners.current.forEach(listener =>
listener({
scrollX: currentScrollX,
scrollY: currentScrollY,
isScrolling,
hasScrolledToEnd,
}),
);
}
}
}, delayMs);
const ref = scrollableRef.current;
ref?.addEventListener('scroll', listenToScroll, {
capture: true,
passive: true,
});
return () =>
window.removeEventListener('scroll', listenToScroll, {
ref?.removeEventListener('scroll', listenToScroll, {
capture: true,
});
}, []);
}, [delayMs, hasScrolledToEnd, isDisabled, isScrolling, scrollableRef]);
const registerScrollListener: RegisterScrollListener = useCallback(
listener => {
listeners.current.push(listener);
return () => {
listeners.current = listeners.current.filter(l => l !== listener);
};
},
[],
);
return (
<ScrollContext.Provider value={{ scrollY, hasScrolledToBottom }}>
<ScrollContext.Provider value={{ registerScrollListener }}>
{children}
</ScrollContext.Provider>
);
}
export function useScroll(): IScrollContext {
return useContext(ScrollContext);
export function useScrollListener(listener: ScrollListener) {
const context = useContext(ScrollContext);
if (!context) {
throw new Error('useScrollListener must be used within a ScrollProvider');
}
const { registerScrollListener } = context;
useEffect(() => {
return registerScrollListener(listener);
}, [listener, registerScrollListener]);
}

View File

@@ -1,14 +1,16 @@
import React, { useRef, useState, type CSSProperties } from 'react';
import { t } from 'i18next';
import type { Theme } from 'loot-core/src/types/prefs';
import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
import { useResponsive } from '../ResponsiveProvider';
import { themeOptions, useTheme } from '../style';
import { Button } from './common/Button2';
import { Menu } from './common/Menu';
import { Popover } from './common/Popover';
import { useResponsive } from './responsive/ResponsiveProvider';
type ThemeSelectorProps = {
style?: CSSProperties;
@@ -45,7 +47,7 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
<Button
ref={triggerRef}
variant="bare"
aria-label="Switch theme"
aria-label={t('Switch theme')}
onPress={() => setMenuOpen(true)}
style={style}
>

View File

@@ -3,6 +3,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { Routes, Route, useLocation } from 'react-router-dom';
import { css } from '@emotion/css';
import { t } from 'i18next';
import * as Platform from 'loot-core/src/client/platform';
import * as queries from 'loot-core/src/client/queries';
@@ -24,7 +25,6 @@ import {
SvgViewHide,
SvgViewShow,
} from '../icons/v2';
import { useResponsive } from '../ResponsiveProvider';
import { theme, styles } from '../style';
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
@@ -37,6 +37,7 @@ import { Text } from './common/Text';
import { View } from './common/View';
import { HelpMenu } from './HelpMenu';
import { LoggedInUser } from './LoggedInUser';
import { useResponsive } from './responsive/ResponsiveProvider';
import { useServerURL } from './ServerContext';
import { useSidebar } from './sidebar/SidebarProvider';
import { useSheetValue } from './spreadsheet/useSheetValue';
@@ -206,7 +207,7 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
return (
<Button
variant="bare"
aria-label="Sync"
aria-label={t('Sync')}
className={css({
...(isMobile
? {
@@ -290,7 +291,7 @@ export function Titlebar({ style }: TitlebarProps) {
>
{(floatingSidebar || sidebar.alwaysFloats) && (
<Button
aria-label="Sidebar menu"
aria-label={t('Sidebar menu')}
variant="bare"
style={{ marginRight: 8 }}
onHoverStart={e => {
@@ -322,7 +323,7 @@ export function Titlebar({ style }: TitlebarProps) {
height={10}
style={{ marginRight: 5, color: 'currentColor' }}
/>{' '}
Back
{t('Back')}
</Button>
) : null
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { type State } from 'loot-core/src/client/state-types';
@@ -13,6 +14,7 @@ import { Text } from './common/Text';
import { View } from './common/View';
export function UpdateNotification() {
const { t } = useTranslation();
const updateInfo = useSelector((state: State) => state.app.updateInfo);
const showUpdateNotification = useSelector(
(state: State) => state.app.showUpdateNotification,
@@ -40,7 +42,9 @@ export function UpdateNotification() {
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ marginRight: 10, fontWeight: 700 }}>
<Text>App updated to {updateInfo.version}</Text>
<Text>
{t('App updated to {{version}}', { version: updateInfo.version })}
</Text>
</View>
<View style={{ flex: 1 }} />
<View style={{ marginTop: -1 }}>
@@ -53,7 +57,7 @@ export function UpdateNotification() {
textDecoration: 'underline',
}}
>
Restart
{t('Restart')}
</Link>{' '}
(
<Link
@@ -68,12 +72,12 @@ export function UpdateNotification() {
)
}
>
notes
{t('notes')}
</Link>
)
<Button
variant="bare"
aria-label="Close"
aria-label={t('Close')}
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
onPress={() => {
// Set a flag to never show an update notification again for this session

View File

@@ -5,6 +5,7 @@ import React, {
createRef,
useMemo,
type ReactElement,
useEffect,
} from 'react';
import { Trans } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -19,14 +20,17 @@ import { type UndoState } from 'loot-core/server/undo';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import {
SchedulesProvider,
useDefaultSchedulesQueryTransform,
accountSchedulesQuery,
} 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 {
runQuery,
pagedQuery,
type PagedQuery,
} from 'loot-core/src/client/query-helpers';
import { send, listen } from 'loot-core/src/platform/client/fetch';
import { currentDay } from 'loot-core/src/shared/months';
import { q, type Query } from 'loot-core/src/shared/query';
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
import {
updateTransaction,
realizeTempTransactions,
@@ -46,6 +50,7 @@ import {
type TransactionFilterEntity,
} from 'loot-core/src/types/models';
import { useAccountPreviewTransactions } from '../../hooks/useAccountPreviewTransactions';
import { useAccounts } from '../../hooks/useAccounts';
import { useActions } from '../../hooks/useActions';
import { useCategories } from '../../hooks/useCategories';
@@ -53,7 +58,6 @@ import { useDateFormat } from '../../hooks/useDateFormat';
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
import { useLocalPref } from '../../hooks/useLocalPref';
import { usePayees } from '../../hooks/usePayees';
import { usePreviewTransactions } from '../../hooks/usePreviewTransactions';
import {
SelectedProviderWithItems,
type Actions,
@@ -143,7 +147,6 @@ type AllTransactionsProps = {
transactions: TransactionEntity[],
balances: Record<string, { balance: number }> | null,
) => ReactElement;
collapseTransactions: (ids: string[]) => void;
};
function AllTransactions({
@@ -153,14 +156,24 @@ function AllTransactions({
showBalances,
filtered,
children,
collapseTransactions,
}: AllTransactionsProps) {
const accountId = account?.id;
const prependTransactions: (TransactionEntity & { _inverse?: boolean })[] =
usePreviewTransactions(collapseTransactions).map(trans => ({
...trans,
_inverse: accountId ? accountId !== trans.account : false,
}));
const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
const { previewTransactions, isLoading: isPreviewTransactionsLoading } =
useAccountPreviewTransactions({ accountId });
useEffect(() => {
if (!isPreviewTransactionsLoading) {
splitsExpandedDispatch({
type: 'close-splits',
ids: previewTransactions.filter(t => t.is_parent).map(t => t.id),
});
}
}, [
isPreviewTransactionsLoading,
previewTransactions,
splitsExpandedDispatch,
]);
transactions ??= [];
@@ -180,29 +193,26 @@ function AllTransactions({
}
// Reverse so we can calculate from earliest upcoming schedule.
const scheduledBalances = [...prependTransactions]
const previewBalances = [...previewTransactions]
.reverse()
.map(scheduledTransaction => {
const amount =
(scheduledTransaction._inverse ? -1 : 1) *
getScheduledAmount(scheduledTransaction.amount);
.map(previewTransaction => {
return {
// TODO: fix me
// eslint-disable-next-line react-hooks/exhaustive-deps
balance: (runningBalance += amount),
id: scheduledTransaction.id,
balance: (runningBalance += previewTransaction.amount),
id: previewTransaction.id,
};
});
return groupById(scheduledBalances);
}, [showBalances, prependTransactions, runningBalance]);
return groupById(previewBalances);
}, [showBalances, previewTransactions, runningBalance]);
const allTransactions = useMemo(() => {
// Don't prepend scheduled transactions if we are filtering
if (!filtered && prependTransactions.length > 0) {
return prependTransactions.concat(transactions);
if (!filtered && previewTransactions.length > 0) {
return previewTransactions.concat(transactions);
}
return transactions;
}, [filtered, prependTransactions, transactions]);
}, [filtered, previewTransactions, transactions]);
const allBalances = useMemo(() => {
// Don't prepend scheduled transactions if we are filtering
@@ -212,7 +222,7 @@ function AllTransactions({
return balances;
}, [filtered, prependBalances, balances]);
if (!prependTransactions) {
if (!previewTransactions?.length || filtered) {
return children(transactions, balances);
}
return children(allTransactions, allBalances);
@@ -240,7 +250,7 @@ function getField(field?: string) {
}
type AccountInternalProps = {
accountId?: string;
accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized';
filterConditions: RuleConditionEntity[];
showBalances?: boolean;
setShowBalances: (newValue: boolean) => void;
@@ -256,8 +266,8 @@ type AccountInternalProps = {
accounts: AccountEntity[];
getPayees: () => Promise<PayeeEntity[]>;
updateAccount: (newAccount: AccountEntity) => void;
newTransactions: string[];
matchedTransactions: string[];
newTransactions: Array<TransactionEntity['id']>;
matchedTransactions: Array<TransactionEntity['id']>;
splitsExpandedDispatch: ReturnType<typeof useSplitsExpanded>['dispatch'];
expandSplits?: boolean;
savedFilters: TransactionFilterEntity[];
@@ -322,7 +332,7 @@ class AccountInternal extends PureComponent<
AccountInternalProps,
AccountInternalState
> {
paged: ReturnType<typeof pagedQuery> | null;
paged: PagedQuery<TransactionEntity> | null;
rootQuery: Query;
currentQuery: Query;
table: TableRef;
@@ -457,7 +467,7 @@ class AccountInternal extends PureComponent<
}
fetchAllIds = async () => {
const { data } = await runQuery(this.paged?.getQuery().select('id'));
const { data } = await runQuery(this.paged?.query.select('id'));
// Remember, this is the `grouped` split type so we need to deal
// with the `subtransactions` property
return data.reduce((arr: string[], t: TransactionEntity) => {
@@ -472,7 +482,7 @@ class AccountInternal extends PureComponent<
};
fetchTransactions = (filterConditions?: ConditionEntity[]) => {
const query = this.makeRootQuery();
const query = this.makeRootTransactionsQuery();
this.rootQuery = this.currentQuery = query;
if (filterConditions) this.applyFilters(filterConditions);
else this.updateQuery(query);
@@ -482,10 +492,10 @@ class AccountInternal extends PureComponent<
}
};
makeRootQuery = () => {
makeRootTransactionsQuery = () => {
const accountId = this.props.accountId;
return queries.makeTransactionsQuery(accountId);
return queries.transactions(accountId);
};
updateQuery(query: Query, isFiltered: boolean = false) {
@@ -502,12 +512,9 @@ class AccountInternal extends PureComponent<
query = query.filter({ reconciled: { $eq: false } });
}
this.paged = pagedQuery(
query.select('*'),
async (
data: TransactionEntity[],
prevData: TransactionEntity[] | null,
) => {
this.paged = pagedQuery(query.select('*'), {
onData: async (groupedData, prevData) => {
const data = ungroupTransactions([...groupedData]);
const firstLoad = prevData == null;
if (firstLoad) {
@@ -529,7 +536,7 @@ class AccountInternal extends PureComponent<
this.setState(
{
transactions: data,
transactionCount: this.paged?.getTotalCount(),
transactionCount: this.paged?.totalCount,
transactionsFiltered: isFiltered,
loading: false,
workingHard: false,
@@ -549,12 +556,11 @@ class AccountInternal extends PureComponent<
},
);
},
{
options: {
pageCount: 150,
onlySync: true,
mapper: ungroupTransactions,
},
);
});
}
UNSAFE_componentWillReceiveProps(nextProps: AccountInternalProps) {
@@ -590,7 +596,7 @@ class AccountInternal extends PureComponent<
);
} else {
this.updateQuery(
queries.makeTransactionSearchQuery(
queries.transactionsSearch(
this.currentQuery,
this.state.search,
this.props.dateFormat,
@@ -652,27 +658,19 @@ class AccountInternal extends PureComponent<
);
};
onTransactionsChange = (
newTransaction: TransactionEntity,
data: TransactionEntity[],
) => {
onTransactionsChange = (updatedTransaction: TransactionEntity) => {
// Apply changes to pagedQuery data
this.paged?.optimisticUpdate(
(data: TransactionEntity[]) => {
if (newTransaction._deleted) {
return data.filter(t => t.id !== newTransaction.id);
} else {
return data.map(t => {
return t.id === newTransaction.id ? newTransaction : t;
});
}
},
() => {
return data;
},
);
this.paged?.optimisticUpdate(data => {
if (updatedTransaction._deleted) {
return data.filter(t => t.id !== updatedTransaction.id);
} else {
return data.map(t => {
return t.id === updatedTransaction.id ? updatedTransaction : t;
});
}
});
this.props.updateNewTransactions(newTransaction.id);
this.props.updateNewTransactions(updatedTransaction.id);
};
canCalculateBalance = () => {
@@ -696,8 +694,7 @@ class AccountInternal extends PureComponent<
}
const { data } = await runQuery(
this.paged
?.getQuery()
this.paged?.query
.options({ splits: 'none' })
.select([{ balance: { $sumOver: '$amount' } }]),
);
@@ -862,22 +859,22 @@ class AccountInternal extends PureComponent<
getBalanceQuery(id?: string) {
return {
name: `balance-query-${id}`,
query: this.makeRootQuery().calculate({ $sum: '$amount' }),
query: this.makeRootTransactionsQuery().calculate({ $sum: '$amount' }),
} as const;
}
getFilteredAmount = async () => {
const { data: amount } = await runQuery(
this.paged?.getQuery().calculate({ $sum: '$amount' }),
this.paged?.query.calculate({ $sum: '$amount' }),
);
return amount;
};
isNew = (id: string) => {
isNew = (id: TransactionEntity['id']) => {
return this.props.newTransactions.includes(id);
};
isMatched = (id: string) => {
isMatched = (id: TransactionEntity['id']) => {
return this.props.matchedTransactions.includes(id);
};
@@ -1678,9 +1675,6 @@ class AccountInternal extends PureComponent<
balances={balances}
showBalances={showBalances}
filtered={transactionsFiltered}
collapseTransactions={ids =>
this.props.splitsExpandedDispatch({ type: 'close-splits', ids })
}
>
{(allTransactions, allBalances) => (
<SelectedProviderWithItems
@@ -1803,6 +1797,15 @@ class AccountInternal extends PureComponent<
sortField={this.state.sort?.field}
ascDesc={this.state.sort?.ascDesc}
onChange={this.onTransactionsChange}
onBatchDelete={this.onBatchDelete}
onBatchDuplicate={this.onBatchDuplicate}
onBatchLinkSchedule={this.onBatchLinkSchedule}
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
onCreateRule={this.onCreateRule}
onScheduleAction={this.onScheduleAction}
onMakeAsNonSplitTransactions={
this.onMakeAsNonSplitTransactions
}
onRefetch={this.refetchTransactions}
onCloseAddTransaction={() =>
this.setState({ isAdding: false })
@@ -1887,10 +1890,13 @@ export function Account() {
const savedFiters = useFilters();
const actionCreators = useActions();
const transform = useDefaultSchedulesQueryTransform(params.id);
const schedulesQuery = useMemo(
() => accountSchedulesQuery(params.id),
[params.id],
);
return (
<SchedulesProvider transform={transform}>
<SchedulesProvider query={schedulesQuery}>
<SplitsExpandedProvider
initialMode={expandSplits ? 'collapse' : 'expand'}
>

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Trans } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
@@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom';
import { t } from 'i18next';
import { unlinkAccount } from 'loot-core/client/actions';
import { type AccountEntity } from 'loot-core/types/models';
import { authorizeBank } from '../../gocardless';
import { useAccounts } from '../../hooks/useAccounts';
@@ -16,7 +17,7 @@ import { Link } from '../common/Link';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
function getErrorMessage(type, code) {
function getErrorMessage(type: string, code: string) {
switch (type.toUpperCase()) {
case 'ITEM_ERROR':
switch (code.toUpperCase()) {
@@ -81,7 +82,29 @@ export function AccountSyncCheck() {
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
if (!failedAccounts) {
const reauth = useCallback(
(acc: AccountEntity) => {
setOpen(false);
if (acc.account_id) {
authorizeBank(dispatch, { upgradingAccountId: acc.account_id });
}
},
[dispatch],
);
const unlink = useCallback(
(acc: AccountEntity) => {
if (acc.id) {
dispatch(unlinkAccount(acc.id));
}
setOpen(false);
},
[dispatch],
);
if (!failedAccounts || !id) {
return null;
}
@@ -91,22 +114,15 @@ export function AccountSyncCheck() {
}
const account = accounts.find(account => account.id === id);
if (!account) {
return null;
}
const { type, code } = error;
const showAuth =
(type === 'ITEM_ERROR' && code === 'ITEM_LOGIN_REQUIRED') ||
(type === 'INVALID_INPUT' && code === 'INVALID_ACCESS_TOKEN');
function reauth() {
setOpen(false);
authorizeBank(dispatch, { upgradingAccountId: account.account_id });
}
async function unlink() {
dispatch(unlinkAccount(account.id));
setOpen(false);
}
return (
<View>
<Button
@@ -148,20 +164,20 @@ export function AccountSyncCheck() {
<View style={{ justifyContent: 'flex-end', flexDirection: 'row' }}>
{showAuth ? (
<>
<Button onPress={unlink}>
<Button onPress={() => unlink(account)}>
<Trans>Unlink</Trans>
</Button>
<Button
variant="primary"
autoFocus
onPress={reauth}
onPress={() => reauth(account)}
style={{ marginLeft: 5 }}
>
<Trans>Reauthorize</Trans>
</Button>
</>
) : (
<Button onPress={unlink}>
<Button onPress={() => unlink(account)}>
<Trans>Unlink account</Trans>
</Button>
)}

View File

@@ -68,8 +68,13 @@ function SelectedBalance({ selectedItems, account }) {
});
let scheduleBalance = null;
const scheduleData = useCachedSchedules();
const schedules = scheduleData ? scheduleData.schedules : [];
const { isLoading, schedules = [] } = useCachedSchedules();
if (isLoading) {
return null;
}
const previewIds = [...selectedItems]
.filter(id => isPreviewId(id))
.map(id => id.slice(8));

View File

@@ -369,7 +369,7 @@ export function AccountHeader({
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
/>
)}
<View>
<View style={{ flex: '0 0 auto' }}>
{account && (
<>
<Button
@@ -427,7 +427,7 @@ export function AccountHeader({
</View>
</Button>
{account ? (
<View>
<View style={{ flex: '0 0 auto' }}>
<MenuButton
aria-label="Account menu"
ref={triggerRef}
@@ -456,7 +456,7 @@ export function AccountHeader({
</Popover>
</View>
) : (
<View>
<View style={{ flex: '0 0 auto' }}>
<MenuButton
aria-label="Account menu"
ref={triggerRef}

View File

@@ -29,11 +29,13 @@ export function ReconcilingMessage({
onDone,
onCreateTransaction,
}: ReconcilingMessageProps) {
const cleared = useSheetValue<'balance', `balance-query-${string}-cleared`>({
name: (balanceQuery.name + '-cleared') as `balance-query-${string}-cleared`,
value: 0,
query: balanceQuery.query.filter({ cleared: true }),
});
const cleared =
useSheetValue<'balance', `balance-query-${string}-cleared`>({
name: (balanceQuery.name +
'-cleared') as `balance-query-${string}-cleared`,
value: 0,
query: balanceQuery.query.filter({ cleared: true }),
}) ?? 0;
const format = useFormat();
const targetDiff = targetBalance - cleared;

View File

@@ -13,10 +13,10 @@ import { css, cx } from '@emotion/css';
import { type AccountEntity } from 'loot-core/src/types/models';
import { useAccounts } from '../../hooks/useAccounts';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
import { Autocomplete } from './Autocomplete';
import { ItemHeader } from './ItemHeader';

View File

@@ -17,12 +17,12 @@ import Downshift, { type StateChangeTypes } from 'downshift';
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
import { SvgRemove } from '../../icons/v2';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Input } from '../common/Input';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
type CommonAutocompleteProps<T extends Item> = {
focused?: boolean;

View File

@@ -25,13 +25,13 @@ import {
import { useCategories } from '../../hooks/useCategories';
import { useSyncedPref } from '../../hooks/useSyncedPref';
import { SvgSplit } from '../../icons/v0';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { useEnvelopeSheetValue } from '../budget/envelope/EnvelopeBudgetComponents';
import { makeAmountFullStyle } from '../budget/util';
import { Text } from '../common/Text';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
import { useSheetValue } from '../spreadsheet/useSheetValue';
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
@@ -393,7 +393,7 @@ function CategoryItem({
>(balanceBinding);
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
return (
<div
@@ -429,10 +429,13 @@ function CategoryItem({
display: !showBalances ? 'none' : undefined,
marginLeft: 5,
flexShrink: 0,
...makeAmountFullStyle(isToBeBudgetedItem ? toBudget : balance, {
positiveColor: theme.noticeTextMenu,
negativeColor: theme.errorTextMenu,
}),
...makeAmountFullStyle(
(isToBeBudgetedItem ? toBudget : balance) || 0,
{
positiveColor: theme.noticeTextMenu,
negativeColor: theme.errorTextMenu,
},
),
}}
>
{isToBeBudgetedItem

View File

@@ -1,7 +1,7 @@
import React, { type CSSProperties } from 'react';
import { useResponsive } from '../../ResponsiveProvider';
import { styles, theme } from '../../style';
import { useResponsive } from '../responsive/ResponsiveProvider';
type ItemHeaderProps = {
title: string;

View File

@@ -7,7 +7,7 @@ import { TestProvider } from 'loot-core/src/mocks/redux';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import { useCommonPayees } from '../../hooks/usePayees';
import { ResponsiveProvider } from '../../ResponsiveProvider';
import { ResponsiveProvider } from '../responsive/ResponsiveProvider';
import {
PayeeAutocomplete,

View File

@@ -27,11 +27,11 @@ import {
import { useAccounts } from '../../hooks/useAccounts';
import { useCommonPayees, usePayees } from '../../hooks/usePayees';
import { SvgAdd, SvgBookmark } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { Button } from '../common/Button';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
import {
Autocomplete,

View File

@@ -11,10 +11,10 @@ import { css } from '@emotion/css';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { SvgArrowThinRight } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { Tooltip } from '../common/Tooltip';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
import { type Binding } from '../spreadsheet';
import { CellValue, CellValueText } from '../spreadsheet/CellValue';
import { useFormat } from '../spreadsheet/useFormat';

View File

@@ -28,6 +28,7 @@ export const BudgetCategories = memo(
onSaveGroup,
onDeleteCategory,
onDeleteGroup,
onApplyBudgetTemplatesInGroup,
onReorderCategory,
onReorderGroup,
}) => {
@@ -245,6 +246,7 @@ export const BudgetCategories = memo(
onReorderCategory={onReorderCategory}
onToggleCollapse={onToggleCollapse}
onShowNewCategory={onShowNewCategory}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
);
break;

View File

@@ -31,7 +31,7 @@ export function BudgetSummaries({ SummaryComponent }: BudgetSummariesProps) {
config: { mass: 3, tension: 600, friction: 80 },
}));
const containerRef = useResizeObserver(
const containerRef = useResizeObserver<HTMLDivElement>(
useCallback(rect => {
setWidthState(rect.width);
}, []),

View File

@@ -1,14 +1,24 @@
import React, { useState } from 'react';
import React, {
type ComponentPropsWithoutRef,
type KeyboardEvent,
useState,
} from 'react';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { useCategories } from '../../hooks/useCategories';
import { useLocalPref } from '../../hooks/useLocalPref';
import { theme, styles } from '../../style';
import { View } from '../common/View';
import { type DropPosition } from '../sort';
import { BudgetCategories } from './BudgetCategories';
import { BudgetSummaries } from './BudgetSummaries';
import { BudgetTotals } from './BudgetTotals';
import { MonthsProvider } from './MonthsContext';
import { type MonthBounds, MonthsProvider } from './MonthsContext';
import {
findSortDown,
findSortUp,
@@ -16,7 +26,39 @@ import {
separateGroups,
} from './util';
export function BudgetTable(props) {
type BudgetTableProps = {
type: string;
prewarmStartMonth: string;
startMonth: string;
numMonths: number;
monthBounds: MonthBounds;
dataComponents: {
SummaryComponent: ComponentPropsWithoutRef<
typeof BudgetSummaries
>['SummaryComponent'];
BudgetTotalsComponent: ComponentPropsWithoutRef<
typeof BudgetTotals
>['MonthComponent'];
};
onSaveCategory: (category: CategoryEntity) => void;
onDeleteCategory: (id: CategoryEntity['id']) => void;
onSaveGroup: (group: CategoryGroupEntity) => void;
onDeleteGroup: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup: (groupId: CategoryGroupEntity['id']) => void;
onReorderCategory: (params: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => void;
onReorderGroup: (params: {
id: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => void;
onShowActivity: (id: CategoryEntity['id'], month?: string) => void;
onBudgetAction: (month: string, type: string, args: unknown) => void;
};
export function BudgetTable(props: BudgetTableProps) {
const {
type,
prewarmStartMonth,
@@ -28,29 +70,36 @@ export function BudgetTable(props) {
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onApplyBudgetTemplatesInGroup,
onReorderCategory,
onReorderGroup,
onShowActivity,
onBudgetAction,
} = props;
const { grouped: categoryGroups } = useCategories();
const { grouped: categoryGroups = [] } = useCategories();
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
useLocalPref('budget.collapsed');
const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref(
'budget.showHiddenCategories',
);
const [editing, setEditing] = useState(null);
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
null,
);
const onEditMonth = (id, month) => {
const onEditMonth = (id: string, month: string) => {
setEditing(id ? { id, cell: month } : null);
};
const onEditName = id => {
const onEditName = (id: string) => {
setEditing(id ? { id, cell: 'name' } : null);
};
const _onReorderCategory = (id, dropPos, targetId) => {
const _onReorderCategory = (
id: string,
dropPos: DropPosition,
targetId: string,
) => {
const isGroup = !!categoryGroups.find(g => g.id === targetId);
if (isGroup) {
@@ -62,7 +111,7 @@ export function BudgetTable(props) {
const group = categoryGroups.find(g => g.id === groupId);
if (group) {
const { categories } = group;
const { categories = [] } = group;
onReorderCategory({
id,
groupId: group.id,
@@ -76,7 +125,7 @@ export function BudgetTable(props) {
let targetGroup;
for (const group of categoryGroups) {
if (group.categories.find(cat => cat.id === targetId)) {
if (group.categories?.find(cat => cat.id === targetId)) {
targetGroup = group;
break;
}
@@ -84,13 +133,17 @@ export function BudgetTable(props) {
onReorderCategory({
id,
groupId: targetGroup.id,
...findSortDown(targetGroup.categories, dropPos, targetId),
groupId: targetGroup?.id,
...findSortDown(targetGroup?.categories || [], dropPos, targetId),
});
}
};
const _onReorderGroup = (id, dropPos, targetId) => {
const _onReorderGroup = (
id: string,
dropPos: DropPosition,
targetId: string,
) => {
const [expenseGroups] = separateGroups(categoryGroups); // exclude Income group from sortable groups to fix off-by-one error
onReorderGroup({
id,
@@ -98,13 +151,21 @@ export function BudgetTable(props) {
});
};
const moveVertically = dir => {
const flattened = categoryGroups.reduce((all, group) => {
if (collapsedGroupIds.includes(group.id)) {
return all.concat({ id: group.id, isGroup: true });
}
return all.concat([{ id: group.id, isGroup: true }, ...group.categories]);
}, []);
const moveVertically = (dir: 1 | -1) => {
const flattened = categoryGroups.reduce(
(all, group) => {
if (collapsedGroupIds.includes(group.id)) {
return all.concat({ id: group.id, isGroup: true });
}
return all.concat([
{ id: group.id, isGroup: true },
...(group?.categories || []),
]);
},
[] as Array<
{ id: CategoryGroupEntity['id']; isGroup: boolean } | CategoryEntity
>,
);
if (editing) {
const idx = flattened.findIndex(item => item.id === editing.id);
@@ -113,10 +174,13 @@ export function BudgetTable(props) {
while (nextIdx >= 0 && nextIdx < flattened.length) {
const next = flattened[nextIdx];
if (next.isGroup) {
if ('isGroup' in next && next.isGroup) {
nextIdx += dir;
continue;
} else if (type === 'report' || !next.is_income) {
} else if (
type === 'report' ||
('is_income' in next && !next.is_income)
) {
onEditMonth(next.id, editing.cell);
return;
} else {
@@ -126,7 +190,7 @@ export function BudgetTable(props) {
}
};
const onKeyDown = e => {
const onKeyDown = (e: KeyboardEvent) => {
if (!editing) {
return null;
}
@@ -137,7 +201,7 @@ export function BudgetTable(props) {
}
};
const onCollapse = collapsedIds => {
const onCollapse = (collapsedIds: string[]) => {
setCollapsedGroupIdsPref(collapsedIds);
};
@@ -222,6 +286,7 @@ export function BudgetTable(props) {
onKeyDown={onKeyDown}
>
<BudgetCategories
// @ts-expect-error Fix when migrating BudgetCategories to ts
categoryGroups={categoryGroups}
editingCell={editing}
dataComponents={dataComponents}
@@ -235,6 +300,7 @@ export function BudgetTable(props) {
onReorderGroup={_onReorderGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
/>
</View>
</View>

View File

@@ -131,6 +131,7 @@ const DynamicBudgetTableInner = ({
onMonthSelect={_onMonthSelect}
/>
<BudgetTable
type={type}
prewarmStartMonth={prewarmStartMonth}
startMonth={startMonth}
numMonths={numMonths}
@@ -144,7 +145,13 @@ const DynamicBudgetTableInner = ({
DynamicBudgetTableInner.displayName = 'DynamicBudgetTableInner';
type DynamicBudgetTableProps = ComponentProps<typeof BudgetTable>;
type DynamicBudgetTableProps = Omit<
ComponentProps<typeof BudgetTable>,
'numMonths'
> & {
maxMonths: number;
onMonthSelect: (month: string, numMonths: number) => void;
};
export const DynamicBudgetTable = (props: DynamicBudgetTableProps) => {
return (

View File

@@ -25,6 +25,9 @@ type ExpenseGroupProps = {
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
onApplyBudgetTemplatesInGroup?: ComponentProps<
typeof SidebarGroup
>['onApplyBudgetTemplatesInGroup'];
onDragChange: OnDragChangeCallback<
ComponentProps<typeof SidebarGroup>['group']
>;
@@ -43,6 +46,7 @@ export function ExpenseGroup({
onEditName,
onSave,
onDelete,
onApplyBudgetTemplatesInGroup,
onDragChange,
onReorderGroup,
onReorderCategory,
@@ -125,6 +129,7 @@ export function ExpenseGroup({
onEdit={onEditName}
onSave={onSave}
onDelete={onDelete}
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
onShowNewCategory={onShowNewCategory}
/>
<RenderMonths component={MonthComponent} args={{ group }} />

View File

@@ -7,12 +7,12 @@ import { useResizeObserver } from '../../hooks/useResizeObserver';
import { styles, theme } from '../../style';
import { View } from '../common/View';
import { type BoundsProps } from './MonthsContext';
import { type MonthBounds } from './MonthsContext';
type MonthPickerProps = {
startMonth: string;
numDisplayed: number;
monthBounds: BoundsProps;
monthBounds: MonthBounds;
style: CSSProperties;
onSelect: (month: string) => void;
};

View File

@@ -3,13 +3,13 @@ import React, { createContext, type ReactNode } from 'react';
import * as monthUtils from 'loot-core/src/shared/months';
export type BoundsProps = {
export type MonthBounds = {
start: string;
end: string;
};
export function getValidMonthBounds(
bounds: BoundsProps,
bounds: MonthBounds,
startMonth: undefined | string,
endMonth: string,
) {
@@ -29,7 +29,7 @@ export const MonthsContext = createContext<MonthsContextProps>(null);
type MonthsProviderProps = {
startMonth: string | undefined;
numMonths: number;
monthBounds: BoundsProps;
monthBounds: MonthBounds;
type: string;
children: ReactNode;
};

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { type CSSProperties, type Ref, useRef, useState } from 'react';
import React, { type CSSProperties, type Ref, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -7,6 +7,7 @@ import {
type CategoryEntity,
} from 'loot-core/src/types/models';
import { useContextMenu } from '../../hooks/useContextMenu';
import { SvgCheveronDown } from '../../icons/v1';
import { theme } from '../../style';
import { Button } from '../common/Button2';
@@ -49,7 +50,8 @@ export function SidebarCategory({
const { t } = useTranslation();
const temporary = category.id === 'new';
const [menuOpen, setMenuOpen] = useState(false);
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
useContextMenu();
const triggerRef = useRef(null);
const displayed = (
@@ -61,7 +63,10 @@ export function SidebarCategory({
WebkitUserSelect: 'none',
opacity: category.hidden || categoryGroup?.hidden ? 0.33 : undefined,
backgroundColor: 'transparent',
height: 20,
}}
ref={triggerRef}
onContextMenu={handleContextMenu}
>
<div
data-testid="category-name"
@@ -74,12 +79,15 @@ export function SidebarCategory({
>
{category.name}
</div>
<View style={{ flexShrink: 0, marginLeft: 5 }} ref={triggerRef}>
<View style={{ flexShrink: 0, marginLeft: 5 }}>
<Button
variant="bare"
className="hover-visible"
style={{ color: 'currentColor', padding: 3 }}
onPress={() => setMenuOpen(true)}
onPress={() => {
resetPosition();
setMenuOpen(true);
}}
>
<SvgCheveronDown
width={14}
@@ -93,7 +101,9 @@ export function SidebarCategory({
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
style={{ width: 200 }}
style={{ width: 200, margin: 1 }}
isNonModal
{...position}
>
<Menu
onMenuSelect={type => {

View File

@@ -1,8 +1,10 @@
// @ts-strict-ignore
import React, { type CSSProperties, useRef, useState } from 'react';
import React, { type CSSProperties, useRef } from 'react';
import { type ConnectDragSource } from 'react-dnd';
import { useTranslation } from 'react-i18next';
import { useContextMenu } from '../../hooks/useContextMenu';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { SvgExpandArrow } from '../../icons/v0';
import { SvgCheveronDown } from '../../icons/v1';
import { theme } from '../../style';
@@ -32,6 +34,7 @@ type SidebarGroupProps = {
onEdit?: (id: string) => void;
onSave?: (group: object) => Promise<void>;
onDelete?: (id: string) => Promise<void>;
onApplyBudgetTemplatesInGroup?: (categories: object[]) => void;
onShowNewCategory?: (groupId: string) => void;
onHideNewGroup?: () => void;
onToggleCollapse?: (id: string) => void;
@@ -47,14 +50,17 @@ export function SidebarGroup({
onEdit,
onSave,
onDelete,
onApplyBudgetTemplatesInGroup,
onShowNewCategory,
onHideNewGroup,
onToggleCollapse,
}: SidebarGroupProps) {
const { t } = useTranslation();
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const temporary = group.id === 'new';
const [menuOpen, setMenuOpen] = useState(false);
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
useContextMenu();
const triggerRef = useRef(null);
const displayed = (
@@ -64,10 +70,13 @@ export function SidebarGroup({
alignItems: 'center',
userSelect: 'none',
WebkitUserSelect: 'none',
height: 20,
}}
ref={triggerRef}
onClick={() => {
onToggleCollapse(group.id);
}}
onContextMenu={handleContextMenu}
>
{!dragPreview && (
<SvgExpandArrow
@@ -95,11 +104,14 @@ export function SidebarGroup({
</div>
{!dragPreview && (
<>
<View style={{ marginLeft: 5, flexShrink: 0 }} ref={triggerRef}>
<View style={{ marginLeft: 5, flexShrink: 0 }}>
<Button
variant="bare"
className="hover-visible"
onPress={() => setMenuOpen(true)}
onPress={() => {
resetPosition();
setMenuOpen(true);
}}
style={{ padding: 3 }}
>
<SvgCheveronDown width={14} height={14} />
@@ -110,7 +122,9 @@ export function SidebarGroup({
placement="bottom start"
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
style={{ width: 200 }}
style={{ width: 200, margin: 1 }}
isNonModal
{...position}
>
<Menu
onMenuSelect={type => {
@@ -122,6 +136,12 @@ export function SidebarGroup({
onDelete(group.id);
} else if (type === 'toggle-visibility') {
onSave({ ...group, hidden: !group.hidden });
} else if (type === 'apply-multiple-category-template') {
onApplyBudgetTemplatesInGroup?.(
group.categories
.filter(c => !c['hidden'])
.map(c => c['id']),
);
}
setMenuOpen(false);
}}
@@ -130,9 +150,17 @@ export function SidebarGroup({
{ name: 'rename', text: t('Rename') },
!group.is_income && {
name: 'toggle-visibility',
text: group.hidden ? t('Show') : t('Hide'),
text: group.hidden ? 'Show' : 'Hide',
},
onDelete && { name: 'delete', text: t('Delete') },
...(isGoalTemplatesEnabled
? [
{
name: 'apply-multiple-category-template',
text: t('Apply budget templates'),
},
]
: []),
]}
/>
</Popover>

View File

@@ -29,7 +29,8 @@ export function BalanceMenu({
const carryover = useEnvelopeSheetValue(
envelopeBudget.catCarryover(categoryId),
);
const balance = useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId));
const balance =
useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)) ?? 0;
return (
<Menu

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