Compare commits

..

129 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
44375e72ad 🔖 (24.9.0) (#3348) 2024-09-03 18:02:45 +01:00
Michael Clark
6454c10e63 🐛 Fix tooltip when using touch devices (#3342)
* fix tooltip when using touch devices

* release notes
2024-09-02 17:23:22 +01:00
Matiss Janis Aboltins
2a9546ced1 🐛 fix reconciliation closing on enter click (#3338) 2024-09-01 20:08:19 +01:00
Matiss Janis Aboltins
8926ff69b1 🐛 fix long payee name overflow (#3340) 2024-09-01 18:26:57 +01:00
Matiss Janis Aboltins
340169bfb6 🐛 fix schedules modal closing when selecting link transactions (#3337) 2024-09-01 18:26:41 +01:00
Tim
3a905d3f9a [WIP] fix toggleSpentColumn being called on every render (#3333)
* fix toggleSpentColumn being called on every render

* release notes
2024-08-31 20:10:02 +01:00
Ryan Bianchi
7738ea0c00 Fix suggested payee issues #3317 #3316 (#3318) 2024-08-30 19:04:50 +01:00
Michael Clark
8e077e0282 :electron: Desktop app to work with self signed certificates (#3308)
* solves the problem but creates a vulnerability

* sake...

* working but need to specify rootca.pem

* works

* being flexible on the cert names, as long as its a crt or pem

* remove console logs

* initial setup for adding cert

* caps

* comments

* fix ts strict

* rewrote it

* release notes

* remove unneeded

* https no polyfill

* removing the cert reference if it is not found

* moving full stop
2024-08-28 21:39:14 +01:00
Matiss Janis Aboltins
ae608f0cb8 🐛 (dashboards) add back spending report if dashboards feature is not enabled (#3323) 2024-08-28 21:02:47 +01:00
Matiss Janis Aboltins
f1c0d0b8a6 🐛 fix 's' hotkey not working in transaction table (#3324) 2024-08-28 20:21:24 +01:00
Matt Fiddaman
d9adb750d4 🧹 optimise GoCardless bank sync to use fewer api calls (#3279)
* optimise

* lint

* release note

* psybers feedback
2024-08-26 11:03:57 +01:00
lelemm
1750cd9081 Filter fix when alternating all <-> any (#3278) 2024-08-24 09:49:36 +01:00
lelemm
7769d0303e "has tags" filter (#3290)
* new tag filter

* fixes

* release notes

* fixes for the rules modal

* more fixes

* linter

* visual regression fixes

* review suggestions

* missing this change
2024-08-23 08:21:09 -07:00
Michael Clark
9108b63355 :electron: Notarize the mac desktop app (#3300)
* adding notarization stuff back in

* win csc settings so win build doesnt try to sign with mac stuff

* windows doesnt need to know about mac build

* teamid env var instead of config val as per docs

* for testing purposes only

* probably wont work

* yet, didnt work

* try this

* update notarize

* removing test code

* add release notes
2024-08-22 17:43:51 +01:00
Robert Dyer
1b70e59bde Apply regular expression conditions to imported transactions. (#3287) 2024-08-22 17:25:16 +01:00
Robert Dyer
b48d256ec4 Translation: desktop-client/components/sidebar (#3302) 2024-08-22 17:23:23 +01:00
Robert Dyer
9c0e6a307b Translation: desktop-client/components/reports/graphs (#3299) 2024-08-21 20:41:25 +01:00
Michael Clark
3e5ce72e27 :electron: Packaging different architectures and installers for windows (#3185)
* packaging different architectures for windows

* appx for the windows store app hosting

* remove unneeded applicationid

* adding windows store assets

* adding images

* appx added to artifacts

* add appx to release

* remove override build params

* being specific about the mac build - default is dmg

* are these all needed.. Seems so

* removing appx from the release notes as its only for the windows store

* moving appxs to a different artifact for smaller download

* Update electron-pr.yml

* update version

* update release process to remove actual-windows.exe because it can possible be the wrong arch
2024-08-20 12:01:36 -07:00
Matiss Janis Aboltins
b347f03fbb (dashboards) ability to rename all widgets (#3284) 2024-08-20 17:25:18 +01:00
Matiss Janis Aboltins
f3660c166f ⬆️ upgrade typescript, eslint, prettier (#3289) 2024-08-20 17:18:54 +01:00
Robert Dyer
aaf96bbc2c Better debug logs for bank sync errors. (#3296)
* Better debug logs for bank sync errors.

* add release note

* force CI update
2024-08-20 07:44:35 -07:00
Crazypkr1099
6d84b0e371 Fix wrong month on spending card (#3295)
* Create 2881.md

* Fix spending card reading wrong month

* Revert "Create 2881.md"

This reverts commit de26f690e8.

* Create 3295.md
2024-08-20 07:38:01 -07:00
Matiss Janis Aboltins
db4b504e53 typescript: migrate cards from jsx to tsx (#3285) 2024-08-19 18:07:20 +01:00
Matiss Janis Aboltins
d6afc85a8c adding feedback links besides the feature flags (#3283) 2024-08-18 11:17:42 +01:00
Julian Dominguez-Schatz
ee21155d1a Display splits in previews (#2923)
* Display splits in mobile previews

* Display splits in desktop previews

* UI fix: hide checkboxes on preview child transactions

* UI fix: show notes on preview transactions

* Add release notes

* Update vrt for mobile padding fix

* Fix status display

* Collapse split previews by default

* PR feedback: fix issues with split payee

* Update new VRT with spacing from this PR
2024-08-17 16:30:27 -04:00
Robert Dyer
65a7c58441 Translation: desktop-client/components/budget/report (#3280) 2024-08-17 18:33:03 +01:00
Matiss Janis Aboltins
51ec600de2 customizable reports homepage - drag-able and resizable widgets (#3231) 2024-08-17 17:53:35 +01:00
Robert Dyer
af5fd5b3ef Translation: desktop-client/components/autocomplete (#3275) 2024-08-17 11:44:37 +01:00
wdpk
eccdc52342 feat: [localization] Update README to mention weblate (#3271)
* weblate mention in readme

part of terms for libre plan

* Create 3271.md

* add link to internationalization section of documentation
2024-08-16 19:47:54 +01:00
Robert Dyer
4c192d7e1e Translation: desktop-client/components/filters (#3270)
* Translation: desktop-client/components/filters

* add release note

* fix linter

* add missing t definition
2024-08-16 13:35:57 +01:00
Matt Fiddaman
f715ceafc9 hide target category from cover overspending list (#3115)
* hide target category from cover overspending list

* release note

* suggestions from joel-jeremy

* useMemo and better types

* add change to mobile

* fix build

* fix mobile
2024-08-15 14:40:20 +01:00
Robert Dyer
af73dcd722 Add rule actions to prepend/append to transaction notes. (#3215)
* Add rule action to append to transaction notes.

* add release note

* support prepending

* fix linter

* update release note

* fix typecheck error

* update VRT test code

* revising VRT code

* select by row

* fix missing delete button

* fix VRT tests

* fix linter

* empty commit for CI

* avoid 'undefined' appearing in notes

* fix linter
2024-08-15 14:36:44 +01:00
Robert Dyer
5e3485a8e2 Automatically focus inputs, or the primary button, in modals. (#2974)
* Automatically focus inputs, or the primary button, in modals.

* Set focus on more modals.

* focus mobile transaction edits

* add release note

* fix linter

* fix linter
2024-08-15 14:36:09 +01:00
Julian Dominguez-Schatz
1458dbc307 Support type-checking on spreadsheet fields (part 3—last part) (#3097)
* Add report budget typing

* Remove default `any` types

* Add release notes

* Fix lint

* Attempt to fix unrelated test error

* fix: changed type name

* More correct types

* fix types
2024-08-15 08:32:41 -04:00
Stefan Wilkes
9ac77af077 Added configuration to CSV importer that allows to skip lines (#3234)
* Added configuration to CSV importer that allows to skip lines

* Fixed several type / parser check when import is executed with a different CSV structure on accounts with transactions

* Fixed linter warning

* Reverted changes on sync.ts as initial error is not occuring anymore.
This will also fix the tests again

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
2024-08-14 22:16:33 +01:00
Neil
3e07d18acd Update ModeButton.tsx (#3257)
* Update ModeButton.tsx

* Update upcoming-release-notes

* Update 3257.md

* Update ModeButton.tsx
2024-08-14 21:45:59 +01:00
Matiss Janis Aboltins
fa6cc26416 ♻️ cleanup iterableTopologicalSort feature flag (#3262) 2024-08-14 19:42:04 +01:00
Robert Dyer
a1ca871b24 Allow escaping tags with double ##. (#3246)
* Allow escaping tags with double ##.

* add release note

* convert '#' to '##' in bank sync-generated notes
2024-08-14 18:40:06 +01:00
Julian Dominguez-Schatz
d9066a49c4 Remove some any types from the API (#3238)
* Remove some `any` types from the API

* Add release notes

* No backwards-incompatible changes

* Update tests to reflect API changes

* PR feedback: standardize on id for deletes

* PR feedback: restore partial updates
2024-08-14 13:28:09 -04:00
Julian Dominguez-Schatz
63ad6dadf2 fix(mobile): show category for on-to-off-budget transfers (#3258)
* fix(mobile): show category for on-to-off-budget transfers

* Add release notes
2024-08-14 13:27:34 -04:00
Michael Clark
89b096aa65 :electron: Fix export on mac (#3250)
* fix export on mac

* add release notes
2024-08-14 13:38:57 +01:00
Eirik Reksten
ee0156d35d 3211: Fix broken transaction import on new accounts (#3251)
* 3211: Added check for string before matching any object.

* Release notes for 3251.

* Fix release notes authors.
2024-08-14 10:13:07 +01:00
Alex Walker
9c17d55e0d Extract out note template logic from goaltemplates.ts, refactor and add unit tests (#3221)
Signed-off-by: Alex Walker <walker.c.alex@gmail.com>
2024-08-13 06:15:51 -07:00
Julian Dominguez-Schatz
411a6791b2 Fix transfer category in temporary transactions (#3239)
* Fix transfer category in temporary transactions

* Add visual regression tests to prevent this issue in the future

* Add release notes
2024-08-12 21:33:21 -04:00
Michael Clark
6f3af7b609 :electron: Fix "Unknown problem opening <local actual file>" on Electron (#3220)
* add electron logging to main browser process console

* add logging

* removing old way

* release notes

* adding some logs in to test mac build

* repent satan

* i caste yeee oooott sinner

* derp

* hmmm<

* forcing nodegyprebuild

* not like this.... Not like this... 😢

* hmm

* dunno

* will it recognise it if i link it manually.. 👀

* give up

* rebuild

* merge asars fasle

* update package

* manually do it ffs work damnit

* remove the cmd

* dont rebuild cause i build it manually

* dafuq is this, two bettersqlite modules installed huhhhhh

* test

* does this work?

* bloody hell

* couple more logs

* test this out

* arch in name

* adding the rebuild step back into first build

* try rebuild before pack - so we know what arch we need

* having a laugh

* tidying up

* release notes

* move package up a bit

* exit process if no electron verison
2024-08-12 22:50:48 +01:00
Joel Jeremy Marquez
43ff1c033e [React Aria Button] Reports page buttons (#3159)
* More components to use react aria Button

* vrt

* Fix account menu test

* vrt

* vrt

* VRT

* VRT

* React Aria Button - Reports page

* Release notes

* Fix typecheck error

* Fix typecheck error + VRT (Create new custom report button got slightly bigger)

* Fix payee icon color
2024-08-12 13:53:58 -07:00
Joel Jeremy Marquez
09c44d351d [Mobile] Long press transaction to reveal floating action bar with bulk actions (#2892)
* Mobile transaction long press

* Floating action bar

* Styling

* Add functionality

* Fix typecheck error

* Release notes

* Undo notifications

* Fix schedules and update transaction delete confirmation message

* Use react-aria useLongPress

* Bulk edit amount display

* Themes

* Do not clear on batch update

* useUndo hook

* Fix typecheck error

* Update useUndo

* Fix typecheck error

* Handle batch deleted transactions

* useMemo

* Make onClearSelectedTransactions mandatory

* Extract FloatingActionBar to a separate component

* Require onAddSelectedTransaction and onClearSelectedTransactions if there are  any selectedTransactions

* Fix schedule link

* Undo notification timeout

* Use useSelected

* Fix typecheck error

* Category transactions batch updates

* Remove undo notification title

* Fix types

* Fix notes undo notification

* Move SelectedProvider to TransactionListWithBalances

* Remove NewPayeeEntity

* Disable support for amount batch edit for now

* Fix lint error

* Notification inset + reuse useTransactionBatchActions

* Always show notification close button regardless if sticky or not

* Allow clicking action bar when notifications are present

* Fix typecheck error

* Remove inset on addNotification calls

* Use PressResponder

* Fix mobile transaction border

* VRT

* VRT

* VRT

* VRT
2024-08-12 13:53:14 -07:00
Julian Wachholz
a22160579d fix: i18n: Use 'en' as default language (#3242)
* fix: i18n: Use 'en' as default language

Using 'cimode' as default language code will return the
translation keys verbatim without doing interpolation.
This is unwanted unless working directly on localization.

Fixes #3240

* Add release note
2024-08-12 10:54:48 +01:00
Matiss Janis Aboltins
81df2ce7fd ♻️ (prefs) initial type implementation for synced/local/metadata prefs (#3236) 2024-08-12 08:12:14 +01:00
Jordan Lees
119d0b339d Fix off-by-one error when placing category into 2nd-to-last place (#3241)
* Fix off-by-one error when placing category into 2nd-to-last place

Signed-off-by: JL102 <jordanlees@mailbox.org>

* Add changelog file

Signed-off-by: JL102 <jordanlees@mailbox.org>

---------

Signed-off-by: JL102 <jordanlees@mailbox.org>
2024-08-11 22:49:17 -07:00
Julian Dominguez-Schatz
d1362c3d74 Correct type for to-budget field (#3237)
* Correct type for `to-budget` field

* Add release notes

* Don't enable `strictNullChecks` for now
2024-08-11 16:52:35 -04:00
Julian Wachholz
8142dd1ec9 feat: introduce i18n framework (#3036)
* feat: introduce i18n framework

* Incorporate review feedback

* Patch demo

* remove unnecessary arguments

* Consistently use t() function

* Fix typing issue

* Fix e2e tests

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-08-11 15:37:33 -04:00
pmoon
2afd6967b4 Fix reconcileTransactions Invokation To Use strictIdChecking (#3232) 2024-08-11 18:37:20 +01:00
Robert Dyer
fe922ec22e Highlight current month in budgets. (#3111) 2024-08-10 20:00:46 +01:00
pmoon
30a70f5627 fix(#2562): Prevent transaction deduplication for imported transactions (#2991)
* fix(#2562): Prevent transaction deduplication for imported transactions (#2770)

* fix(#2562): Prevent transaction deduplication for imported transactions

* chore(): eslint fixes

* chore(): Add release note file

* fix(#2562): Allow transaction deduplication if transaction being imported is null

* chore: Rename release note, add strazto as author

* test(loot-core): Add test case for new logic

* docs(release-notes.loot-core): Add pmoon00 as author

* test(loot-core): Update test case to not be affected by unrelated bug

* test(loot-core): fix linter

---------

Co-authored-by: Mohamed El Mahdali <mohamed.elmahdali.developer@gmail.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Add Handling For goCardless Fuzzy Search

* Rename Release Note File

* Rename Release Notes File

* Fix UseFuzzySearchV2 After Merge Conflict

* Update Fuzzy Search Query To Include New Columns

* Update useFuzzyMatchV2 Variable To useStrictIdChecking

* Update useStrictIdChecking To Only Be Used If It's Not Syncing From External Sources

---------

Co-authored-by: Matthew Strasiotto <39424834+strazto@users.noreply.github.com>
Co-authored-by: Mohamed El Mahdali <mohamed.elmahdali.developer@gmail.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-08-09 01:44:37 +01:00
Robert Dyer
65c5f2c559 Filter by account when linking schedules (#3188)
* Add "S" hotkey for viewing/linking schedules.

* Default to filtering by account name when linking a schedule to a transaction.

* Work on 'all accounts' page.

* Update help modal

* add release note
2024-08-08 13:16:19 -07:00
Michael Clark
1abca7619d :electron: Making Electron server logs visible in devtools (#3219)
* add electron logging to main browser process console

* add logging

* removing old way

* release notes
2024-08-08 20:45:57 +01:00
Michael Clark
6a85f84565 :electron: Fix gocardless "Linking account" integration (#3212)
* fix gocardless call - forked process didnt know its origin

* add release notes

* mamma mia

* remove old change
2024-08-07 20:33:23 +01:00
Julian Dominguez-Schatz
65329398fd Support type-checking on spreadsheet fields (part 2) (#3095)
* Add rollover budget typing

* Fix lint

* Add release notes

* Fix strict typechecking
2024-08-07 07:55:54 -04:00
Matiss Janis Aboltins
a2e434a1fb ♻️ (reports) improve useReports data fetching hook to return loading state (#3198) 2024-08-06 20:47:43 +01:00
Neil
d2bbe6a98e Spending Report: Mobile UI (#3209)
* SpendingMobile UI

* notes
2024-08-06 20:36:45 +01:00
Matiss Janis Aboltins
2c1967d788 ♻️ (reports) add 'showTooltip' prop to various graphs (#3200) 2024-08-06 20:31:35 +01:00
Spencer Murray
798aee78c3 Identify Payee and Notes fields by name if they exist in CSV import (#3203)
* Use csv fields with name payee/notes as defaults for Payee/Notes in getInitialMappings

* Add release notes

* Run lint

* Use or instead of nullish coalesce to match other fields
2024-08-06 15:41:48 +01:00
Matt Fiddaman
2807c98c2c fix "unkown" typo in error message (#3205) 2024-08-06 10:09:21 +01:00
Joel Jeremy Marquez
5e9b976676 [React Aria Button] - Migrate desktop and mobile budget page buttons (#3156)
* More components to use react aria Button

* Release notes

* vrt

* Fix typecheck error

* Fix account menu test

* Fix typecheck error

* Fix typecheck error

* Remove unnecessary aria-labels

* Fix payee icons and category notes

* vrt

* vrt

* Fix notes button

* Fix typecheck error

* Fix lint error

* VRT

* Remove default :focus on Button2

* Add Button2 defaultClassName

* Update className

* Fix typecheck error

* Cleanup

* VRT

* Fix typecheck error

* Fix typecheck error

* Fix typecheck error

* react-aria Button for budget pages

* Release notes
2024-08-05 20:45:30 -06:00
Joel Jeremy Marquez
44ce976ffa [React Aria Button] - Migrate sidebar, notifications, transactions, recurring schedule picker buttons (#2984)
* More components to use react aria Button

* Release notes

* vrt

* Fix typecheck error

* Fix account menu test

* Fix typecheck error

* Fix typecheck error

* Remove unnecessary aria-labels

* Fix payee icons and category notes

* vrt

* vrt

* Fix notes button

* Fix typecheck error

* Fix lint error

* VRT

* Remove default :focus on Button2

* Add Button2 defaultClassName

* Update className

* Fix typecheck error

* Cleanup

* VRT

* Fix typecheck error

* Fix typecheck error

* Fix typecheck error
2024-08-05 20:44:51 -06:00
Robert Dyer
5ba80fcbdc Fix mobile status indicators cutting off. (#3206) 2024-08-06 02:10:10 +01:00
Matt Fiddaman
7b77f60458 Adds a tooltip to the transaction tables to show the imported payee (#3018) 2024-08-05 20:24:49 +01:00
Alex Walker
81f59ff776 Add unit tests for goal template types (#3183)
* Add unit tests for each goal type

* Add test for goals schedule template

* Update release notes
2024-08-05 11:58:49 +01:00
Matiss Janis Aboltins
63d9547e7c ♻️ (reports) unify selectedCategories and conditions (#3178) 2024-08-04 20:09:54 +01:00
Neil
d18fd36ae1 Spending Report: UI Adjustments (#3166)
* adjust UI

* notes

* revert cashflow change

* merge fixes

* remove bold, adjust style code
2024-08-04 20:06:48 +01:00
rodriguestiago0
2b1ba88983 Add Reset Hold and Hold For Next Month api (#3140) 2024-08-04 19:56:11 +01:00
youngcw
8be867f884 Fix a few number parsing issues (#3044)
* revert built in number parse

* note

* tests

* remove trivial test

* more tests

---------

Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
2024-08-04 10:21:53 -07:00
Tim Quelch
cafe480ba4 Update monthly spending report option name (#3181)
* Update monthly spending report name

* Add change note

---------

Co-authored-by: DJ Mountney <david@twkie.net>
2024-08-03 11:33:22 -07:00
alcroito
6472c70960 Shorten hidden category names imported from YNAB4 (#3122)
Imported hidden categories had the parent category entity id (UUID)
appended to the end of the name, making the category name quite long
and somewhat hard to read.

Remove the entity id from the end, and replace the apostrophe that
divides the master category from the sub category with a forward slash.

This shortens the name, ensuring a better chance that the full
category name fits into the default table cell width.

Co-authored-by: DJ Mountney <david@twkie.net>
2024-08-03 11:18:33 -07:00
Sebastian Civarolo
56c5a533e7 Fix false positives for duplicate filters error when saving a new filter (#2970)
* update conditionExists function to compare filter options

* rename method

* array.some instead of for loop

* release note

* refactor the condition search
2024-08-03 11:09:25 -07:00
Matiss Janis Aboltins
7e3ff1ad03 (vrt) improving stability - rules test (#3186) 2024-08-03 19:06:48 +01:00
Julian Dominguez-Schatz
e0d7233b40 Support type-checking on spreadsheet fields (part 1) (#3093)
* Correct table usage of `onBlur`

* Add basic spreadsheet typing structure

* Move to different module

* Add account typing

* Add release notes

* Fix lint

* Remove unneeded diff

* PR feedback
2024-08-03 11:06:47 -04:00
Julian Dominguez-Schatz
1b4c4319e1 Disable typography linter in tests (#3114)
* Disable typography linter in tests

* Add release notes

* Remove unused type ignore
2024-08-03 11:05:30 -04:00
Matiss Janis Aboltins
14f29941b0 ♻️ (typescript) make category and rule types stricter (#3180) 2024-08-03 15:24:01 +01:00
Matiss Janis Aboltins
4389329bfa 🔖 (24.8.0) (#3179) 2024-08-03 14:30:33 +01:00
Matiss Janis Aboltins
3a38c32b4c 🐛 (modals) allow content to be vertically scrollable (#3161)
* 🐛 (modals) allow content to be vertically scrollable

Closes #3079

* Feedback: overfloww auto

* fix modal scrollbar style

* dont need to spread

* lint 😞

* Update 3161.md

* adding width 100% to inputwithcontent

---------

Co-authored-by: Michael Clark <5285928+MikesGlitch@users.noreply.github.com>
2024-08-02 19:16:32 +01:00
Yusef Ouda
c3c6acd37c Fix budget type toggle not working the first time (#3169)
* use default 'rollover' for budgetType localPref

* release notes

---------

Co-authored-by: Yusef Ouda <5180063+YusefOuda@users.noreply.github.com>
2024-08-01 17:59:04 +01:00
wnklmnn
8de0f6a72a Set minWidth on <input /> to allow it to shrink. (#3168)
* Set minWidth on <input /> to allow it to shrink.
Fixes #3126

* fix formatting

* add release-notes markdown
2024-07-31 18:31:49 -04:00
Neil
2799dbee3e Spending Report: Add Last Month (#3132)
* AddLastMonth

* notes

* Add data for last month a year ago

* Adjustments for save button

* spending card fix

* notes

* fix typecheck

* fix averages
2024-07-31 21:35:23 +01:00
Matt Fiddaman
58eeee825e add goal tooltip to balance in budget table (#3123)
* add goal tooltip to balance in budget table

* release note

* fix long goals over multiple months
2024-07-29 23:49:25 +01:00
Matiss Janis Aboltins
6653dca776 🐛 (modals) remove focus outline (#3160)
Closes #3141
2024-07-29 22:00:27 +01:00
Joel Jeremy Marquez
77ba15f54c Fix local playwright reporter config (#3158)
* Fix local playwright reporter config

* Release notes
2024-07-29 12:26:51 -07:00
Matiss Janis Aboltins
653a0ab104 🐛 (rules) fix error handling (#3149) 2024-07-29 18:35:49 +01:00
Joel Jeremy Marquez
2c26fa51a3 Add Modal2 backdrop (#3147)
* Add Modal2 backdrop

* Release notes

* VRT

* Use HTML reported when running tests locally
2024-07-29 00:41:44 -07:00
Michael Clark
dff9911a15 [Fix] "Enter" key should save on "Cover Overspending" popup (#3153)
* allow autocomplete events to fire when dropdown is closed

* release notes

* simpler solution

* release notes
2024-07-28 21:47:13 +01:00
Michael Clark
3d5818f017 :electron: Fix electron "Data Dir" picker on Settings page (#3133)
* fix electron file location picker

* add release notes

* update message and style
2024-07-28 21:32:20 +01:00
Matiss Janis Aboltins
efd294dcef 🐛 fix plain-text link in simplefin error (#3151) 2024-07-28 14:57:24 +01:00
Matiss Janis Aboltins
0eb62a09bc 🔧 improve unit test stability while using uuid (#3144) 2024-07-27 21:24:19 +01:00
Joel Jeremy Marquez
73d52fa0d0 Fix Button2 isDisabled prop (#3146)
* Fix Button2 isDisabled prop

* Release notes

* Update 3146.md
2024-07-27 13:06:15 -07:00
Matiss Janis Aboltins
5b0cc63f73 🐛 add missing underline to links (#3143)
* 🐛 add missing underline to links

* Update accounts screenshots
2024-07-27 16:44:14 +01:00
Matiss Janis Aboltins
26a591f07f 🐛 add missing hover cursor to new button component (#3142) 2024-07-27 16:41:59 +01:00
Robert Dyer
fe8851c797 Fix running balances thick header. (#3082)
* Update running balances width to display large numbers.

* add release note

* update width

* update width

* Fix running balances thick header.

* add release note

* fix alignment

* fix lint

* dont make header clickable

* refactor so HeaderCell can be unclickable
2024-07-26 12:07:10 -07:00
youngcw
511f677ae4 [Goals]: Add a long term goal template (#3012)
* update the parser

* can set goal value, but there are errors and it still needs to look for a balance and not month amount

* fix apply budget

* un change

* note

* lint

* working processing, need to set colors based on month somehow

* add long goal option to the gui and db

* note, lint

* fix cleanup

* fix

* make mobile work, lint

* fix bindings

* more proper

* lint

* fix single category run

* don't unset goal values if they don't have a template too

* lint

* more lint

* fix check when no template exists

* rearrange to get around the issue of inconsistent colors

* lint

* typecheck

* add field to aql schema

* fixes

* cleanup

* migration date
2024-07-26 09:04:44 -07:00
Joel Jeremy Marquez
1cef0d11ee Fix menus autoclosed when clicked element on top of the menu item (#3131)
* Fix menus autoclosed when clicked element on top of the menu item

* Release notes
2024-07-25 13:50:23 -07:00
Neil
536cabb75b Spending Report: card fix (#3135)
* spending card fix

* notes
2024-07-25 18:07:43 +01:00
Neil
cceda03905 Spending Report: add save button (#3112)
* add save button

* notes

* rerun checks

* add time

* tooltips

* change entity to string

* lint fix

* adjust defaults

* lint
2024-07-25 09:35:44 +01:00
Neil
982f555a21 Custom Reports: updateReport db Schema fix (#3127)
* updateReport db Schema fix

* notes

* error
2024-07-24 19:20:41 +01:00
Fran González
fe70ecb635 Fixes #3089: Dismiss pop-over on budget action (#3092)
* Fix no dismissal on budget action

* Add release notes file

* Correct tag

* Remove unnecessary callback

* Linting

* Apply same strategy on reportcomponents

* Rename to onMenuAction
2024-07-23 10:38:30 -07:00
Neil
5c0bee6031 Custom Reports: PlusOne (#3117)
* PlusOne

* notes

* add daterange filters
2024-07-22 19:02:21 +01:00
Bruno Ribeiro
4439bb6abe Enhance Autocomplete sorting, Payees tab filter, and Schedules tab filter to ignore characters with accents / diacritics (#3045)
* Autocomplete sort now ignores diacritics

* Payees tab now takes into account diacritics

* Category Autocomplete now ignores diacritics

* Schedules filter now ignore diacritics

* Added release note

* Fixed type error

* Added normalisation to manage rules filter

* Added normalisation to $like operator

* Added normalisation function to loot-core

* Fixed type error & added normalisation to notlike

* Fixed unit tests

* Changed normalise to use loot-core/shared on desktop

* Linting fix
2024-07-22 16:13:25 +01:00
Reece
b432204b4b modify fly.io url to point to correct documentation (#3113)
* modify fly.io url to point to correct documentation

* Create 3113.md
2024-07-21 15:27:13 -04:00
Matiss Janis Aboltins
9a85a72089 🔧 ping WIP PRs that have been inactive for a week (#3107)
* 🔧 ping WIP PRs that have been inactive for a week

* Bump the stale action version
2024-07-20 21:17:00 +01:00
Julian Dominguez-Schatz
a970a78932 Include more information in payee of split parent (#3049)
* Use dicts to look up common information

* Show abbreviated payees in split payee section

* Update vrt

* fix: missing transfer icon

* Add release notes

* fix: update vrt again

* bugfix: failing edge cases

* fix: stale test

* fix: stale test, p2 (+ vrt)
2024-07-20 11:43:24 -07:00
Joel Jeremy Marquez
ed65805d53 Port finance modals to use new Modal component based on React Aria Modal (#2946)
* React Aria Modal POC

* Fix imports

* Use composition

* Fix typecheck and lint errors

* VRT

* Fix schedule details modal header

* Fix typecheck error + VRT

* Fix typecheck error

* Release notes

* Update release note

* Fix props

* Update modal props

* useModalState hook

* VRT

* VRT

* Fix typecheck error

* Fix modal close

* VRT

* Fix test

* Fix typecheck error
2024-07-20 09:03:58 -07:00
Matiss Janis Aboltins
88ae7e9375 📝 update some information in the README (#3106) 2024-07-20 17:03:25 +01:00
Matiss Janis Aboltins
0135a4d1b9 🔥 (preferences) remove unused user prefs (#3104) 2024-07-20 09:53:54 +01:00
Matiss Janis Aboltins
4af2c4f214 ⬆️ upgrade yarn to 4.3.1 (#3105) 2024-07-20 09:53:42 +01:00
Ryan Bianchi
89a8f102dc Recently used and favorite payees (#2814)
* add idea of common payee, a top 10 frequently used payee

* add button in payee to mark as favorite

* cleanup

* minor fixes

* add release notes and make favorite optional

* fix TransactionsTable test

* lint and release notes

* rename section, resort list to ensure both are sorted

* don't show common, move bookmarked to menu

* add a limit on adding common payees

* linting

* reduce to 5 commonly used payees by default

* linting

* more linting

* update migrate timestamp

* more linting

* fix api name, bump migrate timestamp

* Add star to payee dropdown and rename section to 'Suggested Payees'

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
2024-07-19 19:44:29 -06:00
Sreetam Das
d032fce7ea fix: number format config not being respected for graphs (#2832)
* Add computed padding for handling clipped Net worth amounts

* Add comment, early handle 5 character case

* Add release note

* Use currency decimal preference for Net Worth graph Y-axis ticks

* use number format preference for `BarLineGraph` ticks

* use number format preference for checking `NetWorthGraph` tick overflow

* prevent `numberFormatConfig` overwrite

`getNumberFormat` uses `numberFormatConfig` as the default argument;
passing just `{ hideFraction: true }` caused `numberFormatConfig.format`
to be ignored

* add release note

* use `amountToCurrencyNoDecimal` instead for `{BarLine,NetWorth}Graph` ticks
2024-07-18 13:02:01 -06:00
Joel Jeremy Marquez
2fdc7fef32 Add callouts to starting fresh and migration documentations in README (#3101)
* Add callouts to starting fresh and migration documentations

* README

* Update README
2024-07-18 09:24:21 -07:00
Michael Clark
1e41d695c5 :electron: Added keyboard shortcuts reference to Help menu (#3100)
* adding keyboard shortcuts reference to help menu

* add release notes
2024-07-18 09:13:50 -06:00
wdpk
12f91f7d86 update split transaction handling for csv export (#2973)
* Split transaction handling for csv export (#2973)

* refactor, zero-amt parents and add split_amount

change how child/parents are counted to remove dependence on `sort_order` being negative. for export, give parent transactions a 0 in the `amount` column, but add a new column with their `split_amount` for other programs to possibly import

---------

Co-authored-by: Zeus\Herb <herb@win.dows>
Co-authored-by: DJ Mountney <david.mountney@twkie.net>
2024-07-18 07:57:36 -07:00
Michael Clark
f75d0f8099 :electron: security.js and preload.js ➡️ .ts (#3066) 2024-07-17 22:31:09 +01:00
Robert Dyer
07bbe00059 Add additional hotkeys (#3061)
* Add account page hotkeys

* add release note

* fix linter

* change shortcut

* change hotkey

* fix lint

* add budget shortcuts

* update help page

* fix linter

* add privacy filter hotkey p

* update help modal

* fix deps

* slash the zero

* bound the month picker

* change privacy shortcut to ctrl+p

* remap keys to ctrl

* add select all hotkey

* fix linter

* change add hotkey to T

* update help modal

* resize help modal

* fix linter

* shrink modal size more

* change budget reset behavior

* change privacy to shift+ctrl+p

* move shift to front
2024-07-17 14:41:50 -06:00
Simon Schmidt
be0d363576 CAMT.053: Handle missing ValDt on entries with Sts Book (#3086)
* Replicate seen-in-the-wild camt.053 example

* Handle missing ValDt in camt

* Release notes for #3086
2024-07-17 14:41:06 -06:00
Joel Jeremy Marquez
c2e648c9d5 React Aria Button on Modals (#2918)
* React Aria Button on modals

* Release notes

* Remove tabIndex

* Remove aria-label
2024-07-17 13:38:41 -07:00
Joel Jeremy Marquez
33049a77e7 Fix rules not being applied in mobile transaction entry (#3073)
* Fix rules in mobile transaction entry

* Release notes

* Remove alert
2024-07-17 13:38:19 -07:00
Joel Jeremy Marquez
89241623f3 React Aria Button on Management App (#2916)
* React Aria Button on management app

* Release notes

* Update release notes

* Fix typecheck error

* Remove aria labels

* Apply suggestions from code review

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

* Remove aria-labels

* Remove aria-label

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-07-17 12:20:12 -07:00
Robert Dyer
8434e8f5ce Fix "?" crashing on budget selection page. (#3084)
* Fix ? crashing on load budget page.

* add release note
2024-07-17 11:48:28 -06:00
Robert Dyer
9b99debacc Update running balances width to display large numbers. (#3080)
* Update running balances width to display large numbers.

* add release note

* update width

* update width
2024-07-17 10:20:22 -06:00
Austin Pearce
a23ec33591 Add autocapitalize (#3056) 2024-07-16 17:57:01 -07:00
Matiss Janis Aboltins
aaea04fc00 🔧 add lint-staged and husky to auto-patch formatting issues (#3058) 2024-07-16 19:10:57 +01:00
Austin Pearce
b4f0087eef increase mobile header label font weight (#3062) 2024-07-16 00:07:21 -07:00
566 changed files with 19417 additions and 11702 deletions

View File

@@ -1,4 +1,3 @@
/* eslint-disable rulesdir/typography */
const path = require('path');
const rulesDirPlugin = require('eslint-plugin-rulesdir');
@@ -34,9 +33,23 @@ const restrictedImportColors = [
];
module.exports = {
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
root: true,
env: {
browser: true,
commonjs: true,
es6: true,
jest: true,
node: true,
},
plugins: [
'prettier',
'import',
'rulesdir',
'@typescript-eslint',
'jsx-a11y',
'react-hooks',
],
extends: [
'react-app',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:prettier/recommended',
@@ -51,6 +64,184 @@ module.exports = {
vi: true,
},
rules: {
// http://eslint.org/docs/rules/
'array-callback-return': 'warn',
'default-case': ['warn', { commentPattern: '^no default$' }],
'dot-location': ['warn', 'property'],
eqeqeq: ['warn', 'smart'],
'new-parens': 'warn',
'no-array-constructor': 'warn',
'no-caller': 'warn',
'no-cond-assign': ['warn', 'except-parens'],
'no-const-assign': 'warn',
'no-control-regex': 'warn',
'no-delete-var': 'warn',
'no-dupe-args': 'warn',
'no-dupe-class-members': 'warn',
'no-dupe-keys': 'warn',
'no-duplicate-case': 'warn',
'no-empty-character-class': 'warn',
'no-empty-pattern': 'warn',
'no-eval': 'warn',
'no-ex-assign': 'warn',
'no-extend-native': 'warn',
'no-extra-bind': 'warn',
'no-extra-label': 'warn',
'no-fallthrough': 'warn',
'no-func-assign': 'warn',
'no-implied-eval': 'warn',
'no-invalid-regexp': 'warn',
'no-iterator': 'warn',
'no-label-var': 'warn',
'no-labels': ['warn', { allowLoop: true, allowSwitch: false }],
'no-lone-blocks': 'warn',
'no-mixed-operators': [
'warn',
{
groups: [
['&', '|', '^', '~', '<<', '>>', '>>>'],
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof'],
],
allowSamePrecedence: false,
},
],
'no-multi-str': 'warn',
'no-global-assign': 'warn',
'no-unsafe-negation': 'warn',
'no-new-func': 'warn',
'no-new-object': 'warn',
'no-new-symbol': 'warn',
'no-new-wrappers': 'warn',
'no-obj-calls': 'warn',
'no-octal': 'warn',
'no-octal-escape': 'warn',
'no-redeclare': 'warn',
'no-regex-spaces': 'warn',
'no-script-url': 'warn',
'no-self-assign': 'warn',
'no-self-compare': 'warn',
'no-sequences': 'warn',
'no-shadow-restricted-names': 'warn',
'no-sparse-arrays': 'warn',
'no-template-curly-in-string': 'warn',
'no-this-before-super': 'warn',
'no-throw-literal': 'warn',
'no-undef': 'error',
'no-unreachable': 'warn',
'no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
allowTaggedTemplates: true,
},
],
'no-unused-labels': 'warn',
'no-use-before-define': [
'warn',
{
functions: false,
classes: false,
variables: false,
},
],
'no-useless-computed-key': 'warn',
'no-useless-concat': 'warn',
'no-useless-constructor': 'warn',
'no-useless-escape': 'warn',
'no-useless-rename': [
'warn',
{
ignoreDestructuring: false,
ignoreImport: false,
ignoreExport: false,
},
],
'no-with': 'warn',
'no-whitespace-before-property': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'require-yield': 'warn',
'rest-spread-spacing': ['warn', 'never'],
strict: ['warn', 'never'],
'unicode-bom': ['warn', 'never'],
'use-isnan': 'warn',
'valid-typeof': 'warn',
'no-restricted-properties': [
'error',
{
object: 'require',
property: 'ensure',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
{
object: 'System',
property: 'import',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
],
'getter-return': 'warn',
// https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules
'import/first': 'error',
'import/no-amd': 'error',
'import/no-anonymous-default-export': 'warn',
'import/no-webpack-loader-syntax': 'error',
// https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
'react/forbid-foreign-prop-types': ['warn', { allowInPropTypes: true }],
'react/jsx-no-comment-textnodes': 'warn',
'react/jsx-no-duplicate-props': 'warn',
'react/jsx-no-target-blank': 'warn',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': [
'warn',
{
allowAllCaps: true,
ignore: [],
},
],
'react/no-danger-with-children': 'warn',
// Disabled because of undesirable warnings
// See https://github.com/facebook/create-react-app/issues/5204 for
// blockers until its re-enabled
// 'react/no-deprecated': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-is-mounted': 'warn',
'react/no-typos': 'error',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
// https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
'jsx-a11y/alt-text': 'warn',
'jsx-a11y/anchor-has-content': 'warn',
'jsx-a11y/anchor-is-valid': [
'warn',
{
aspects: ['noHref', 'invalidHref'],
},
],
'jsx-a11y/aria-activedescendant-has-tabindex': 'warn',
'jsx-a11y/aria-props': 'warn',
'jsx-a11y/aria-proptypes': 'warn',
'jsx-a11y/aria-role': ['warn', { ignoreNonDOM: true }],
'jsx-a11y/aria-unsupported-elements': 'warn',
'jsx-a11y/heading-has-content': 'warn',
'jsx-a11y/iframe-has-title': 'warn',
'jsx-a11y/img-redundant-alt': 'warn',
'jsx-a11y/no-access-key': 'warn',
'jsx-a11y/no-distracting-elements': 'warn',
'jsx-a11y/no-redundant-roles': 'warn',
'jsx-a11y/role-has-required-aria-props': 'warn',
'jsx-a11y/role-supports-aria-props': 'warn',
'jsx-a11y/scope': 'warn',
// https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
'react-hooks/rules-of-hooks': 'error',
'prettier/prettier': 'warn',
// Note: base rule explicitly disabled in favor of the TS one
@@ -60,6 +251,7 @@ module.exports = {
{
varsIgnorePattern: '^(_|React)',
ignoreRestSiblings: true,
caughtErrors: 'none',
},
],
@@ -166,9 +358,63 @@ module.exports = {
'prefer-const': 'warn',
'prefer-spread': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-require-imports': 'off',
'import/no-default-export': 'warn',
},
overrides: [
{
files: ['**/*.ts?(x)'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
// typescript-eslint specific options
warnOnUnsupportedTypeScriptVersion: true,
},
plugins: ['@typescript-eslint'],
// If adding a typescript-eslint version of an existing ESLint rule,
// make sure to disable the ESLint rule here.
rules: {
// TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906)
'default-case': 'off',
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/291)
'no-dupe-class-members': 'off',
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/477)
'no-undef': 'off',
// Add TypeScript specific rules (and turn off ESLint equivalents)
'@typescript-eslint/consistent-type-assertions': 'warn',
'no-array-constructor': 'off',
'@typescript-eslint/no-array-constructor': 'warn',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'warn',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [
'warn',
{
functions: false,
classes: false,
variables: false,
typedefs: false,
},
],
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
allowTaggedTemplates: true,
},
],
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'warn',
},
},
{
files: ['.eslintrc.js', './**/.eslintrc.js'],
parserOptions: { project: null },
@@ -189,7 +435,7 @@ module.exports = {
'warn',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/ban-types': [
'@typescript-eslint/no-restricted-types': [
'warn',
{
types: {
@@ -197,7 +443,6 @@ module.exports = {
FunctionComponent: { message: ruleFCMsg },
FC: { message: ruleFCMsg },
},
extendDefaults: true,
},
],
},
@@ -332,8 +577,23 @@ module.exports = {
'react-hooks/exhaustive-deps': 'off',
},
},
{
files: [
'.eslintrc.js',
'*.test.js',
'*.test.ts',
'*.test.jsx',
'*.test.tsx',
],
rules: {
'rulesdir/typography': 'off',
},
},
],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,

View File

@@ -48,13 +48,17 @@ jobs:
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
env:
# CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
# CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@v4
with:
@@ -62,13 +66,22 @@ jobs:
path: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx
- name: Add to Release
uses: softprops/action-gh-release@v2
with:
files: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak

View File

@@ -52,5 +52,13 @@ jobs:
path: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx

View File

@@ -7,10 +7,20 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
- uses: actions/stale@v9
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
days-before-stale: 30
days-before-close: 5
days-before-issue-stale: -1
stale-wip:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
days-before-stale: 7
any-of-labels: ':construction: WIP'
days-before-close: -1
days-before-issue-stale: -1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
yarn lint-staged

File diff suppressed because one or more lines are too long

894
.yarn/releases/yarn-4.3.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -4,4 +4,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.0.2.cjs
yarnPath: .yarn/releases/yarn-4.3.1.cjs

View File

@@ -14,22 +14,40 @@ Want to say thanks? Click the ⭐ at the top of the page.
- Actual [discord](https://discord.gg/pRYNYr4W5A) community.
- Actual [Community Documentation](https://actualbudget.org/docs)
- [Frequently asked questions](https://actualbudget.org/docs/faq)
## Installation
If you are only interested in running the latest version and not contributing to the source code, you don't need to clone this repo. You can get the latest version through npm.
There are four ways to deploy Actual:
### The easy way: using a server (recommended)
1. One-click deployment [via PikaPods](https://www.pikapods.com/pods?run=actual) (~1.40 $/month) - recommended for non-technical users
1. Managed hosting [via Fly.io](https://actualbudget.org/docs/install/fly) (~1.50 $/month)
1. Self-hosted by using [a Docker image](https://actualbudget.org/docs/install/docker)
1. Local-only apps - [downloadable Windows, Mac and Linux apps](https://actualbudget.org/download/) you can run on your device
The easiest way to get Actual running is to use the [actual-server](https://github.com/actualbudget/actual-server) project. That is the server for syncing changes across devices, and it comes with the latest version of Actual. The server will provide both the web project and a server for syncing.
Learn more in the [installation instructions docs](https://actualbudget.org/docs/install/).
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.org/docs/install/local)
## Ready to Start Budgeting?
Read about [Envelope budgeting](https://actualbudget.org/docs/getting-started/envelope-budgeting) to know more about the idea behind Actual Budget.
### Are you new to budgeting or want to start fresh?
Check out the community's [Starting Fresh](https://actualbudget.org/docs/getting-started/starting-fresh) guide so you can quickly get up and running!
### Are you migrating from other budgeting apps?
Check out the community's [Migration](https://actualbudget.org/docs/migration/) guide to start jumping on the Actual Budget train!
## Documentation
We have a wide range of documentation on how to use Actual, this is all available in our [Community Documentation](https://actualbudget.org/docs), this includes topics on Budgeting, Account Management, Tips & Tricks and some documentation for developers.
## Code structure
## Contributing
Actual is a community driven product. Learn more about [contributing to Actual](https://actualbudget.org/docs/contributing/).
### Code structure
The Actual app is split up into a few packages:
@@ -39,13 +57,21 @@ The Actual app is split up into a few packages:
More information on the project structure is available in our [community documentation](https://actualbudget.org/docs/contributing/project-details).
## Feature Requests
### Feature Requests
Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc).
Vote for your favorite requests by reacting :+1: to the top comment of the request.
To add new feature requests, open a new Issue of the "Feature Request" type.
### Translation
Make Actual Budget accessible to more people by helping with the [Internationalization](https://actualbudget.org/docs/contributing/i18n/) of Actual. We are using a crowd sourcing tool to manage the translations, see our [Weblate Project](https://hosted.weblate.org/projects/actualbudget/). Weblate proudly supports open-source software projects through their [Libre plan](https://weblate.org/en/hosting/#libre).
## Repo Activity
![Alt](https://repobeats.axiom.co/api/embed/e20537dd8b74956f86736726ccfbc6f0565bec22.svg 'Repobeats analytics image')
## Sponsors
Thanks to our wonderful sponsors who make Actual budget possible!

View File

@@ -34,8 +34,6 @@ if [ "$OSTYPE" == "msys" ]; then
fi
fi
yarn rebuild-electron
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop
@@ -50,10 +48,10 @@ yarn workspace desktop-electron update-client
if [ -f ../../.secret-tokens ]; then
source ../../.secret-tokens
fi
yarn build --publish never --arm64 --x64
yarn build
echo "\nCreated release"
else
SKIP_NOTARIZATION=true yarn build --publish never --x64
SKIP_NOTARIZATION=true yarn build
fi
)

View File

@@ -30,6 +30,7 @@
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"test": "yarn workspaces foreach --all --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --parallel --verbose run e2e",
@@ -40,24 +41,31 @@
"lint": "eslint . --max-warnings 0 --ext .js,.jsx,.ts,.tsx",
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
"typecheck": "yarn tsc && tsc-strict",
"jq": "./node_modules/node-jq/bin/jq"
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.1.0",
"confusing-browser-globals": "^1.0.11",
"cross-env": "^7.0.3",
"eslint": "^8.37.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "7.0.1",
"eslint-import-resolver-typescript": "3.5.5",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-react": "7.32.2",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "^6.9.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-rulesdir": "^0.2.2",
"husky": "^9.0.11",
"lint-staged": "^15.2.9",
"node-jq": "^4.0.1",
"npm-run-all": "^4.1.5",
"prettier": "3.2.4",
"prettier": "3.3.3",
"source-map-support": "^0.5.21",
"typescript": "^5.0.2",
"typescript-strict-plugin": "^2.2.2-beta.2"
"typescript": "^5.5.4",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
"rollup": "4.9.4"
@@ -65,7 +73,10 @@
"engines": {
"node": ">=18.0.0"
},
"packageManager": "yarn@4.0.2",
"lint-staged": {
"*.{js,jsx,ts,tsx,md,json}": "prettier --write"
},
"packageManager": "yarn@4.3.1",
"browserslist": [
"electron 24.0",
"defaults"

View File

@@ -81,28 +81,22 @@ describe('API CRUD operations', () => {
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({
hidden: 0,
hidden: false,
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
is_income: 0,
is_income: false,
name: 'Usual Expenses',
sort_order: 16384,
tombstone: 0,
}),
expect.objectContaining({
hidden: 0,
hidden: false,
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
is_income: 0,
is_income: false,
name: 'Investments and Savings',
sort_order: 32768,
tombstone: 0,
}),
expect.objectContaining({
hidden: 0,
hidden: false,
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
is_income: 1,
is_income: true,
name: 'Income',
sort_order: 32768,
tombstone: 0,
}),
]),
);
@@ -563,10 +557,10 @@ describe('API CRUD operations', () => {
);
// delete rules
await api.deleteRule(rules[1]);
await api.deleteRule(rules[1].id);
expect(await api.getRules()).toHaveLength(1);
await api.deleteRule(rules[0]);
await api.deleteRule(rules[0].id);
expect(await api.getRules()).toHaveLength(0);
});

View File

@@ -165,6 +165,10 @@ export function deleteCategory(id, transferCategoryId?) {
return send('api/category-delete', { id, transferCategoryId });
}
export function getCommonPayees() {
return send('api/common-payees-get');
}
export function getPayees() {
return send('api/payees-get');
}
@@ -201,6 +205,14 @@ export function updateRule(rule) {
return send('api/rule-update', { rule });
}
export function deleteRule(id) {
return send('api/rule-delete', { id });
export function deleteRule(id: string) {
return send('api/rule-delete', id);
}
export function holdBudgetForNextMonth(month, amount) {
return send('api/budget-hold-for-next-month', { month, amount });
}
export function resetBudgetHold(month) {
return send('api/budget-reset-hold', { month });
}

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "6.8.2",
"version": "6.10.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -35,6 +35,6 @@
"@types/uuid": "^9.0.2",
"jest": "^27.5.1",
"tsc-alias": "^1.8.8",
"typescript": "^5.0.2"
"typescript": "^5.5.4"
}
}

View File

@@ -26,6 +26,6 @@
"@types/uuid": "^9.0.2",
"jest": "^27.5.1",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.0.2"
"typescript": "^5.5.4"
}
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as merkle from './merkle';
import { Timestamp } from './timestamp';

View File

@@ -134,6 +134,7 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
node2 = node2[diffkey] || emptyTrie();
}
// eslint-disable-next-line no-unreachable
return null;
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Timestamp } from './timestamp';
describe('Timestamp', function () {

View File

@@ -154,7 +154,7 @@ export class Timestamp {
/**
* maximum timestamp
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
static max = Timestamp.parse(
'9999-12-31T23:59:59.999Z-FFFF-FFFFFFFFFFFFFFFF',
)!;
@@ -294,7 +294,7 @@ export class Timestamp {
/**
* zero/minimum timestamp
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
static zero = Timestamp.parse(
'1970-01-01T00:00:00.000Z-0000-0000000000000000',
)!;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -16,8 +16,8 @@ export class AccountPage {
this.cancelTransactionButton = this.page.getByRole('button', {
name: 'Cancel',
});
this.menuButton = this.page.getByRole('button', {
name: 'Menu',
this.accountMenuButton = this.page.getByRole('button', {
name: 'Account menu',
});
this.transactionTable = this.page.getByTestId('transaction-table');
@@ -30,16 +30,28 @@ export class AccountPage {
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
}
/**
* Enter details of a transaction
*/
async enterSingleTransaction(transaction) {
await this.addNewTransactionButton.click();
await this._fillTransactionFields(this.newTransactionRow, transaction);
}
/**
* Finish adding a transaction
*/
async addEnteredTransaction() {
await this.addTransactionButton.click();
await this.cancelTransactionButton.click();
}
/**
* Create a single transaction
*/
async createSingleTransaction(transaction) {
await this.addNewTransactionButton.click();
await this._fillTransactionFields(this.newTransactionRow, transaction);
await this.addTransactionButton.click();
await this.cancelTransactionButton.click();
await this.enterSingleTransaction(transaction);
await this.addEnteredTransaction();
}
/**
@@ -82,6 +94,15 @@ export class AccountPage {
*/
getNthTransaction(index) {
const row = this.transactionTableRow.nth(index);
return this._getTransactionDetails(row);
}
getEnteredTransaction() {
return this._getTransactionDetails(this.newTransactionRow);
}
_getTransactionDetails(row) {
const account = row.getByTestId('account');
return {
@@ -103,10 +124,10 @@ export class AccountPage {
* Open the modal for closing the account.
*/
async clickCloseAccount() {
await this.menuButton.click();
await this.accountMenuButton.click();
await this.page.getByRole('button', { name: 'Close Account' }).click();
return new CloseAccountModal(
this.page.locator('css=[aria-modal]'),
this.page.getByTestId('close-account-modal'),
this.page,
);
}

View File

@@ -5,22 +5,22 @@ export class ReportsPage {
}
async waitToLoad() {
return this.pageContent.getByRole('link', { name: /^Net/ }).waitFor();
return this.pageContent.getByRole('button', { name: /^Net/ }).waitFor();
}
async goToNetWorthPage() {
await this.pageContent.getByRole('link', { name: /^Net/ }).click();
await this.pageContent.getByRole('button', { name: /^Net/ }).click();
return new ReportsPage(this.page);
}
async goToCashFlowPage() {
await this.pageContent.getByRole('link', { name: /^Cash/ }).click();
await this.pageContent.getByRole('button', { name: /^Cash/ }).click();
return new ReportsPage(this.page);
}
async getAvailableReportList() {
return this.pageContent
.getByRole('link')
.getByRole('button')
.getByRole('heading')
.allTextContents();
}

View File

@@ -52,6 +52,7 @@ export class RulesPage {
await this._fillEditorFields(
data.conditions,
this.page.getByTestId('condition-list'),
true,
);
}
@@ -63,28 +64,19 @@ export class RulesPage {
}
if (data.splits) {
if (data.splits.beforeSplitActions) {
let idx = data.actions?.length ?? 0;
for (const splitActions of data.splits) {
await this.page.getByTestId('add-split-transactions').click();
await this._fillEditorFields(
data.splits.beforeSplitActions,
this.page.getByTestId('action-list'),
splitActions,
this.page.getByTestId('action-list').nth(idx),
);
}
if (data.splits.splitActions) {
let idx = data.splits?.beforeSplitActions.length ?? 0;
for (const splitActions of data.splits.splitActions) {
await this.page.getByTestId('add-split-transactions').click();
await this._fillEditorFields(
splitActions,
this.page.getByTestId('action-list').nth(idx),
);
idx++;
}
idx++;
}
}
}
async _fillEditorFields(data, rootElement) {
async _fillEditorFields(data, rootElement, fieldFirst = false) {
for (const idx in data) {
const { field, op, value } = data[idx];
@@ -94,15 +86,24 @@ export class RulesPage {
await rootElement.getByRole('button', { name: 'Add entry' }).click();
}
if (op && !fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page.getByRole('button', { name: op, exact: true }).click();
}
if (field) {
await row.getByRole('button').first().click();
await row
.getByTestId('field-select')
.getByRole('button')
.first()
.click();
await this.page
.getByRole('button', { exact: true, name: field })
.getByRole('button', { name: field, exact: true })
.click();
}
if (op) {
await row.getByRole('button', { name: 'is' }).click();
if (op && fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page.getByRole('button', { name: op, exact: true }).click();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -32,6 +32,7 @@ test.describe('Rules', () => {
});
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
await rulesPage.searchFor('Fast Internet');
await rulesPage.createRule({
conditions: [
{
@@ -48,7 +49,6 @@ test.describe('Rules', () => {
],
});
await rulesPage.searchFor('Fast Internet');
const rule = rulesPage.getNthRule(0);
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
await expect(rule.actions).toHaveText(['set category to General']);
@@ -79,35 +79,34 @@ test.describe('Rules', () => {
value: 'Ikea',
},
],
splits: {
beforeSplitActions: [
actions: [
{
op: 'set',
field: 'notes',
value: 'food / entertainment',
},
],
splits: [
[
{
field: 'notes',
value: 'food / entertainment',
field: 'a fixed percent of the remainder',
value: '90',
},
{
field: 'category',
value: 'Entertainment',
},
],
splitActions: [
[
{
field: 'a fixed percent of the remainder',
value: '90',
},
{
field: 'category',
value: 'Entertainment',
},
],
[
{
field: 'an equal portion of the remainder',
},
{
field: 'category',
value: 'Food',
},
],
[
{
field: 'an equal portion of the remainder',
},
{
field: 'category',
value: 'Food',
},
],
},
],
});
const accountPage = await navigation.goToAccountPage(
@@ -120,7 +119,7 @@ test.describe('Rules', () => {
});
const transaction = accountPage.getNthTransaction(0);
await expect(transaction.payee).toHaveText('Split');
await expect(transaction.payee).toHaveText('Ikea');
await expect(transaction.notes).toHaveText('food / entertainment');
await expect(transaction.category).toHaveText('Split');
await expect(transaction.debit).toHaveText('100.00');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -120,7 +120,7 @@ test.describe('Transactions', () => {
]);
const firstTransaction = accountPage.getNthTransaction(0);
await expect(firstTransaction.payee).toHaveText('Split');
await expect(firstTransaction.payee).toHaveText('Krogger');
await expect(firstTransaction.notes).toHaveText('Notes');
await expect(firstTransaction.category).toHaveText('Split');
await expect(firstTransaction.debit).toHaveText('333.33');
@@ -141,4 +141,26 @@ test.describe('Transactions', () => {
await expect(thirdTransaction.credit).toHaveText('');
await expect(page).toMatchThemeScreenshots();
});
test('creates a transfer test transaction', async () => {
await accountPage.enterSingleTransaction({
payee: 'Bank of America',
notes: 'Notes field',
debit: '12.34',
});
let transaction = accountPage.getEnteredTransaction();
await expect(transaction.category.locator('input')).toHaveValue('Transfer');
await expect(page).toMatchThemeScreenshots();
await accountPage.addEnteredTransaction();
transaction = accountPage.getNthTransaction(0);
await expect(transaction.payee).toHaveText('Bank of America');
await expect(transaction.notes).toHaveText('Notes field');
await expect(transaction.category).toHaveText('Transfer');
await expect(transaction.debit).toHaveText('12.34');
await expect(transaction.credit).toHaveText('');
await expect(page).toMatchThemeScreenshots();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,14 @@
module.exports = {
input: ['src/**/*.{js,jsx,ts,tsx}', '../loot-core/src/**/*.{js,jsx,ts,tsx}'],
output: 'src/locale/$LOCALE.json',
locales: ['en'],
sort: true,
keySeparator: false,
namespaceSeparator: false,
defaultValue: (locale, ns, key, value) => {
if (locale === 'en') {
return value || key;
}
return '';
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "24.7.0",
"version": "24.9.0",
"license": "MIT",
"files": [
"build"
@@ -24,6 +24,7 @@
"@types/promise-retry": "^1.1.6",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.1",
"@types/react-grid-layout": "^1",
"@types/react-modal": "^3.16.0",
"@types/react-redux": "^7.1.25",
"@types/uuid": "^9.0.2",
@@ -39,6 +40,9 @@
"downshift": "7.6.2",
"focus-visible": "^4.1.5",
"glamor": "^2.20.40",
"i18next": "^23.11.5",
"i18next-parser": "^9.0.0",
"i18next-resources-to-backend": "^1.2.1",
"inter-ui": "^3.19.3",
"jest": "^27.5.1",
"jest-watch-typeahead": "^2.2.2",
@@ -49,12 +53,15 @@
"promise-retry": "^2.0.1",
"re-resizable": "^6.9.17",
"react": "18.2.0",
"react-aria": "^3.33.1",
"react-aria-components": "^1.2.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-error-boundary": "^4.0.12",
"react-grid-layout": "^1.4.4",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.2",
"react-markdown": "^8.0.7",
"react-modal": "3.16.1",
"react-redux": "7.2.9",
@@ -86,6 +93,7 @@
"build": "vite build",
"build:browser": "cross-env ./bin/build-browser",
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
"generate:i18n": "i18next",
"test": "vitest",
"e2e": "npx playwright test --browser=chromium",
"vrt": "cross-env VRT=true npx playwright test --browser=chromium"

View File

@@ -57,6 +57,9 @@ export default defineConfig({
timeout: 20000, // 20 seconds
retries: 1,
testDir: 'e2e/',
reporter: !process.env.CI
? [['html', { open: 'never', outputFolder: 'test-results/html' }]]
: undefined,
use: {
userAgent: 'playwright',
screenshot: 'on',

View File

@@ -6,6 +6,7 @@ import {
type FallbackProps,
} from 'react-error-boundary';
import { HotkeysProvider } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -22,7 +23,7 @@ import {
send,
} from 'loot-core/src/platform/client/fetch';
import { useLocalPref } from '../hooks/useLocalPref';
import { useMetadataPref } from '../hooks/useMetadataPref';
import { installPolyfills } from '../polyfills';
import { ResponsiveProvider } from '../ResponsiveProvider';
import { styles, hasHiddenScrollbars, ThemeStyle } from '../style';
@@ -33,7 +34,6 @@ import { DevelopmentTopBar } from './DevelopmentTopBar';
import { FatalError } from './FatalError';
import { FinancesApp } from './FinancesApp';
import { ManagementApp } from './manager/ManagementApp';
import { MobileWebMessage } from './mobile/MobileWebMessage';
import { UpdateNotification } from './UpdateNotification';
type AppInnerProps = {
@@ -42,6 +42,7 @@ type AppInnerProps = {
};
function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
const { t } = useTranslation();
const [initializing, setInitializing] = useState(true);
const { showBoundary: showErrorBoundary } = useErrorBoundary();
const loadingText = useSelector((state: State) => state.app.loadingText);
@@ -52,7 +53,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
dispatch(
setAppState({
loadingText: 'Initializing the connection to the local database...',
loadingText: t('Initializing the connection to the local database...'),
}),
);
await initConnection(socketName);
@@ -60,7 +61,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
// Load any global prefs
dispatch(
setAppState({
loadingText: 'Loading global preferences...',
loadingText: t('Loading global preferences...'),
}),
);
await dispatch(loadGlobalPrefs());
@@ -68,18 +69,20 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
// Open the last opened budget, if any
dispatch(
setAppState({
loadingText: 'Opening last budget...',
loadingText: t('Opening last budget...'),
}),
);
const budgetId = await send('get-last-opened-backup');
if (budgetId) {
await dispatch(loadBudget(budgetId, 'Loading the last budget file...'));
await dispatch(
loadBudget(budgetId, t('Loading the last budget file...')),
);
// Check to see if this file has been remotely deleted (but
// don't block on this in case they are offline or something)
dispatch(
setAppState({
loadingText: 'Retrieving remote files...',
loadingText: t('Retrieving remote files...'),
}),
);
send('get-remote-files').then(files => {
@@ -124,7 +127,6 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
))}
<UpdateNotification />
<MobileWebMessage />
</>
);
}
@@ -139,8 +141,8 @@ function ErrorFallback({ error }: FallbackProps) {
}
export function App() {
const [budgetId] = useLocalPref('id');
const [cloudFileId] = useLocalPref('cloudFileId');
const [budgetId] = useMetadataPref('id');
const [cloudFileId] = useMetadataPref('cloudFileId');
const [hiddenScrollbars, setHiddenScrollbars] = useState(
hasHiddenScrollbars(),
);

View File

@@ -3,7 +3,7 @@ import React, { useState, type ReactNode } from 'react';
import { LazyLoadFailedError } from 'loot-core/src/shared/errors';
import { Block } from './common/Block';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Link } from './common/Link';
import { Modal } from './common/Modal';
import { Paragraph } from './common/Paragraph';
@@ -149,8 +149,8 @@ function SharedArrayBufferOverride() {
I understand the risks, run Actual in the unsupported fallback mode
</label>
<Button
disabled={!understand}
onClick={() => {
isDisabled={!understand}
onPress={() => {
window.localStorage.setItem('SharedArrayBufferOverride', 'true');
window.location.reload();
}}
@@ -191,7 +191,7 @@ export function FatalError({ error }: FatalErrorProps) {
)}
<Paragraph>
<Button onClick={() => window.Actual?.relaunch()}>Restart app</Button>
<Button onPress={() => window.Actual?.relaunch()}>Restart app</Button>
</Paragraph>
<Paragraph isLast={true} style={{ fontSize: 11 }}>
<Link variant="text" onClick={() => setShowError(state => !state)}>

View File

@@ -7,7 +7,7 @@ import { type State } from 'loot-core/src/client/state-types';
import { useActions } from '../hooks/useActions';
import { theme, styles, type CSSProperties } from '../style';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Menu } from './common/Menu';
import { Popover } from './common/Popover';
import { Text } from './common/Text';
@@ -97,8 +97,8 @@ export function LoggedInUser({
<View style={{ flexDirection: 'row', alignItems: 'center', ...style }}>
<Button
ref={triggerRef}
type="bare"
onClick={() => setMenuOpen(true)}
variant="bare"
onPress={() => setMenuOpen(true)}
style={color && { color }}
>
{serverMessage()}

View File

@@ -13,6 +13,7 @@ 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';
import * as undo from 'loot-core/src/platform/client/undo';
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
import { describeSchedule } from 'loot-core/src/shared/schedules';
import { type NewRuleEntity } from 'loot-core/src/types/models';
@@ -23,7 +24,7 @@ import { usePayees } from '../hooks/usePayees';
import { useSelected, SelectedProvider } from '../hooks/useSelected';
import { theme } from '../style';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Link } from './common/Link';
import { Search } from './common/Search';
import { Stack } from './common/Stack';
@@ -78,6 +79,11 @@ function ruleToString(rule, data) {
data.payees.find(p => p.id === schedule._payee),
),
];
} else if (action.op === 'prepend-notes' || action.op === 'append-notes') {
return [
friendlyOp(action.op),
'“' + mapValue(action.field, action.value, data) + '”',
];
} else {
return [];
}
@@ -125,9 +131,9 @@ function ManageRulesContent({
(filter === ''
? allRules
: allRules.filter(rule =>
ruleToString(rule, filterData)
.toLowerCase()
.includes(filter.toLowerCase()),
getNormalisedString(ruleToString(rule, filterData)).includes(
getNormalisedString(filter),
),
)
).slice(0, 100 + page * 50),
[allRules, filter, filterData, page],
@@ -313,11 +319,11 @@ function ManageRulesContent({
>
<Stack direction="row" align="center" justify="flex-end" spacing={2}>
{selectedInst.items.size > 0 && (
<Button onClick={onDeleteSelected}>
<Button onPress={onDeleteSelected}>
Delete {selectedInst.items.size} rules
</Button>
)}
<Button type="primary" onClick={onCreateRule}>
<Button variant="primary" onPress={onCreateRule}>
Create new rule
</Button>
</Stack>

View File

@@ -1,17 +1,17 @@
// @ts-strict-ignore
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { type State } from 'loot-core/src/client/state-types';
import { closeModal } from 'loot-core/client/actions';
import { type PopModalAction } from 'loot-core/src/client/state-types/modals';
import { send } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months';
import { useActions } from '../hooks/useActions';
import { useModalState } from '../hooks/useModalState';
import { useSyncServerStatus } from '../hooks/useSyncServerStatus';
import { ModalTitle } from './common/Modal';
import { ModalTitle, ModalHeader } from './common/Modal2';
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
import { AccountMenuModal } from './modals/AccountMenuModal';
import { BudgetListModal } from './modals/BudgetListModal';
@@ -71,67 +71,43 @@ export type CommonModalProps = {
};
export function Modals() {
const modalStack = useSelector((state: State) => state.modals.modalStack);
const isHidden = useSelector((state: State) => state.modals.isHidden);
const actions = useActions();
const location = useLocation();
const dispatch = useDispatch();
const { modalStack } = useModalState();
useEffect(() => {
if (modalStack.length > 0) {
actions.closeModal();
dispatch(closeModal());
}
}, [location]);
const syncServerStatus = useSyncServerStatus();
const modals = modalStack
.map(({ name, options }, idx) => {
const modalProps: CommonModalProps = {
onClose: actions.popModal,
onBack: actions.popModal,
showBack: idx > 0,
isCurrent: idx === modalStack.length - 1,
isHidden,
stackIndex: idx,
};
.map(({ name, options }) => {
switch (name) {
case 'keyboard-shortcuts':
return <KeyboardShortcutModal modalProps={modalProps} />;
return <KeyboardShortcutModal />;
case 'import-transactions':
return (
<ImportTransactions
key={name}
modalProps={modalProps}
options={options}
/>
);
return <ImportTransactions key={name} options={options} />;
case 'add-account':
return (
<CreateAccountModal
key={name}
modalProps={modalProps}
syncServerStatus={syncServerStatus}
upgradingAccountId={options?.upgradingAccountId}
/>
);
case 'add-local-account':
return (
<CreateLocalAccountModal
key={name}
modalProps={modalProps}
actions={actions}
/>
);
return <CreateLocalAccountModal key={name} />;
case 'close-account':
return (
<CloseAccountModal
key={name}
modalProps={modalProps}
account={options.account}
balance={options.balance}
canDelete={options.canDelete}
@@ -142,10 +118,8 @@ export function Modals() {
return (
<SelectLinkedAccounts
key={name}
modalProps={modalProps}
externalAccounts={options.accounts}
requisitionId={options.requisitionId}
actions={actions}
syncSource={options.syncSource}
/>
);
@@ -154,7 +128,6 @@ export function Modals() {
return (
<ConfirmCategoryDelete
key={name}
modalProps={modalProps}
category={options.category}
group={options.group}
onDelete={options.onDelete}
@@ -165,7 +138,6 @@ export function Modals() {
return (
<ConfirmUnlinkAccount
key={name}
modalProps={modalProps}
accountName={options.accountName}
onUnlink={options.onUnlink}
/>
@@ -175,7 +147,6 @@ export function Modals() {
return (
<ConfirmTransactionEdit
key={name}
modalProps={modalProps}
onCancel={options.onCancel}
onConfirm={options.onConfirm}
confirmReason={options.confirmReason}
@@ -186,7 +157,7 @@ export function Modals() {
return (
<ConfirmTransactionDelete
key={name}
modalProps={modalProps}
message={options.message}
onConfirm={options.onConfirm}
/>
);
@@ -197,26 +168,17 @@ export function Modals() {
key={name}
watchUpdates
budgetId={options.budgetId}
modalProps={modalProps}
actions={actions}
backupDisabled={false}
/>
);
case 'manage-rules':
return (
<ManageRulesModal
key={name}
modalProps={modalProps}
payeeId={options?.payeeId}
/>
);
return <ManageRulesModal key={name} payeeId={options?.payeeId} />;
case 'edit-rule':
return (
<EditRule
key={name}
modalProps={modalProps}
defaultRule={options.rule}
onSave={options.onSave}
/>
@@ -226,7 +188,6 @@ export function Modals() {
return (
<MergeUnusedPayees
key={name}
modalProps={modalProps}
payeeIds={options.payeeIds}
targetPayeeId={options.targetPayeeId}
/>
@@ -234,27 +195,18 @@ export function Modals() {
case 'gocardless-init':
return (
<GoCardlessInitialise
key={name}
modalProps={modalProps}
onSuccess={options.onSuccess}
/>
<GoCardlessInitialise key={name} onSuccess={options.onSuccess} />
);
case 'simplefin-init':
return (
<SimpleFinInitialise
key={name}
modalProps={modalProps}
onSuccess={options.onSuccess}
/>
<SimpleFinInitialise key={name} onSuccess={options.onSuccess} />
);
case 'gocardless-external-msg':
return (
<GoCardlessExternalMsg
key={name}
modalProps={modalProps}
onMoveExternal={options.onMoveExternal}
onClose={() => {
options.onClose?.();
@@ -265,28 +217,15 @@ export function Modals() {
);
case 'create-encryption-key':
return (
<CreateEncryptionKeyModal
key={name}
modalProps={modalProps}
options={options}
/>
);
return <CreateEncryptionKeyModal key={name} options={options} />;
case 'fix-encryption-key':
return (
<FixEncryptionKeyModal
key={name}
modalProps={modalProps}
options={options}
/>
);
return <FixEncryptionKeyModal key={name} options={options} />;
case 'edit-field':
return (
<EditField
key={name}
modalProps={modalProps}
name={options.name}
onSubmit={options.onSubmit}
onClose={options.onClose}
@@ -297,7 +236,6 @@ export function Modals() {
return (
<CategoryAutocompleteModal
key={name}
modalProps={modalProps}
autocompleteProps={{
value: null,
onSelect: options.onSelect,
@@ -313,7 +251,6 @@ export function Modals() {
return (
<AccountAutocompleteModal
key={name}
modalProps={modalProps}
autocompleteProps={{
value: null,
onSelect: options.onSelect,
@@ -327,7 +264,6 @@ export function Modals() {
return (
<PayeeAutocompleteModal
key={name}
modalProps={modalProps}
autocompleteProps={{
value: null,
onSelect: options.onSelect,
@@ -340,8 +276,13 @@ export function Modals() {
return (
<SingleInputModal
key={name}
modalProps={modalProps}
title={<ModalTitle title="New Category" shrinkOnOverflow />}
name={name}
Header={props => (
<ModalHeader
{...props}
title={<ModalTitle title="New Category" shrinkOnOverflow />}
/>
)}
inputPlaceholder="Category name"
buttonText="Add"
onValidate={options.onValidate}
@@ -353,8 +294,15 @@ export function Modals() {
return (
<SingleInputModal
key={name}
modalProps={modalProps}
title={<ModalTitle title="New Category Group" shrinkOnOverflow />}
name={name}
Header={props => (
<ModalHeader
{...props}
title={
<ModalTitle title="New Category Group" shrinkOnOverflow />
}
/>
)}
inputPlaceholder="Category group name"
buttonText="Add"
onValidate={options.onValidate}
@@ -370,7 +318,6 @@ export function Modals() {
>
<RolloverBudgetSummaryModal
key={name}
modalProps={modalProps}
month={options.month}
onBudgetAction={options.onBudgetAction}
/>
@@ -378,21 +325,13 @@ export function Modals() {
);
case 'report-budget-summary':
return (
<ReportBudgetSummaryModal
key={name}
modalProps={modalProps}
month={options.month}
/>
);
return <ReportBudgetSummaryModal key={name} month={options.month} />;
case 'schedule-edit':
return (
<ScheduleDetails
key={name}
modalProps={modalProps}
id={options?.id || null}
actions={actions}
transaction={options?.transaction || null}
/>
);
@@ -401,36 +340,23 @@ export function Modals() {
return (
<ScheduleLink
key={name}
modalProps={modalProps}
actions={actions}
transactionIds={options?.transactionIds}
getTransaction={options?.getTransaction}
accountName={options?.accountName}
onScheduleLinked={options?.onScheduleLinked}
/>
);
case 'schedules-discover':
return (
<DiscoverSchedules
key={name}
modalProps={modalProps}
actions={actions}
/>
);
return <DiscoverSchedules key={name} />;
case 'schedule-posts-offline-notification':
return (
<PostsOfflineNotification
key={name}
modalProps={modalProps}
actions={actions}
/>
);
return <PostsOfflineNotification key={name} />;
case 'account-menu':
return (
<AccountMenuModal
key={name}
modalProps={modalProps}
accountId={options.accountId}
onSave={options.onSave}
onEditNotes={options.onEditNotes}
@@ -444,11 +370,11 @@ export function Modals() {
return (
<CategoryMenuModal
key={name}
modalProps={modalProps}
categoryId={options.categoryId}
onSave={options.onSave}
onEditNotes={options.onEditNotes}
onDelete={options.onDelete}
onToggleVisibility={options.onToggleVisibility}
onClose={options.onClose}
/>
);
@@ -460,7 +386,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<RolloverBudgetMenuModal
modalProps={modalProps}
categoryId={options.categoryId}
onUpdateBudget={options.onUpdateBudget}
onCopyLastMonthAverage={options.onCopyLastMonthAverage}
@@ -477,7 +402,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<ReportBudgetMenuModal
modalProps={modalProps}
categoryId={options.categoryId}
onUpdateBudget={options.onUpdateBudget}
onCopyLastMonthAverage={options.onCopyLastMonthAverage}
@@ -491,13 +415,13 @@ export function Modals() {
return (
<CategoryGroupMenuModal
key={name}
modalProps={modalProps}
groupId={options.groupId}
onSave={options.onSave}
onAddCategory={options.onAddCategory}
onEditNotes={options.onEditNotes}
onSaveNotes={options.onSaveNotes}
onDelete={options.onDelete}
onToggleVisibility={options.onToggleVisibility}
onClose={options.onClose}
/>
);
@@ -506,7 +430,6 @@ export function Modals() {
return (
<NotesModal
key={name}
modalProps={modalProps}
id={options.id}
name={options.name}
onSave={options.onSave}
@@ -520,7 +443,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<RolloverBalanceMenuModal
modalProps={modalProps}
categoryId={options.categoryId}
onCarryover={options.onCarryover}
onTransfer={options.onTransfer}
@@ -536,7 +458,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<RolloverToBudgetMenuModal
modalProps={modalProps}
onTransfer={options.onTransfer}
onCover={options.onCover}
onHoldBuffer={options.onHoldBuffer}
@@ -552,7 +473,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<HoldBufferModal
modalProps={modalProps}
month={options.month}
onSubmit={options.onSubmit}
/>
@@ -566,7 +486,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<ReportBalanceMenuModal
modalProps={modalProps}
categoryId={options.categoryId}
onCarryover={options.onCarryover}
/>
@@ -577,7 +496,6 @@ export function Modals() {
return (
<TransferModal
key={name}
modalProps={modalProps}
title={options.title}
month={options.month}
amount={options.amount}
@@ -590,10 +508,10 @@ export function Modals() {
return (
<CoverModal
key={name}
modalProps={modalProps}
title={options.title}
month={options.month}
showToBeBudgeted={options.showToBeBudgeted}
category={options.category}
onSubmit={options.onSubmit}
/>
);
@@ -602,7 +520,6 @@ export function Modals() {
return (
<ScheduledTransactionMenuModal
key={name}
modalProps={modalProps}
transactionId={options.transactionId}
onPost={options.onPost}
onSkip={options.onSkip}
@@ -613,7 +530,6 @@ export function Modals() {
return (
<BudgetPageMenuModal
key={name}
modalProps={modalProps}
onAddCategoryGroup={options.onAddCategoryGroup}
onToggleHiddenCategories={options.onToggleHiddenCategories}
onSwitchBudgetFile={options.onSwitchBudgetFile}
@@ -627,7 +543,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<RolloverBudgetMonthMenuModal
modalProps={modalProps}
month={options.month}
onBudgetAction={options.onBudgetAction}
onEditNotes={options.onEditNotes}
@@ -642,7 +557,6 @@ export function Modals() {
value={monthUtils.sheetForMonth(options.month)}
>
<ReportBudgetMonthMenuModal
modalProps={modalProps}
month={options.month}
onBudgetAction={options.onBudgetAction}
onEditNotes={options.onEditNotes}
@@ -651,7 +565,7 @@ export function Modals() {
);
case 'budget-list':
return <BudgetListModal key={name} modalProps={modalProps} />;
return <BudgetListModal key={name} />;
default:
console.error('Unknown modal:', name);

View File

@@ -6,7 +6,7 @@ import { useNotes } from '../hooks/useNotes';
import { SvgCustomNotesPaper } from '../icons/v2';
import { type CSSProperties, theme } from '../style';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Popover } from './common/Popover';
import { Tooltip } from './common/Tooltip';
import { View } from './common/View';
@@ -52,7 +52,7 @@ export function NotesButton({
<View style={{ flexShrink: 0 }}>
<Button
ref={triggerRef}
type="bare"
variant="bare"
aria-label="View notes"
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
style={{
@@ -61,8 +61,7 @@ export function NotesButton({
...(hasNotes && { display: 'flex !important' }),
...(isOpen && { color: theme.buttonNormalText }),
}}
onClick={event => {
event.stopPropagation();
onPress={() => {
setIsOpen(true);
}}
>

View File

@@ -5,18 +5,18 @@ import React, {
useMemo,
type SetStateAction,
} from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { removeNotification } from 'loot-core/client/actions';
import { type State } from 'loot-core/src/client/state-types';
import type { NotificationWithId } from 'loot-core/src/client/state-types/notifications';
import { useActions } from '../hooks/useActions';
import { AnimatedLoading } from '../icons/AnimatedLoading';
import { SvgDelete } from '../icons/v0';
import { useResponsive } from '../ResponsiveProvider';
import { styles, theme, type CSSProperties } from '../style';
import { Button, ButtonWithLoading } from './common/Button';
import { Button, ButtonWithLoading } from './common/Button2';
import { Link } from './common/Link';
import { Stack } from './common/Stack';
import { Text } from './common/Text';
@@ -120,6 +120,11 @@ function Notification({
[message, messageActions],
);
const { isNarrowWidth } = useResponsive();
const narrowStyle: CSSProperties = isNarrowWidth
? { minHeight: styles.mobileMinHeight }
: {};
return (
<View
style={{
@@ -133,10 +138,11 @@ function Notification({
>
<Stack
align="center"
justify="space-between"
direction="row"
style={{
padding: '14px 14px',
fontSize: 14,
...styles.mediumText,
backgroundColor: positive
? theme.noticeBackgroundLight
: error
@@ -156,7 +162,15 @@ function Notification({
>
<Stack align="flex-start">
{title && (
<View style={{ fontWeight: 700, marginBottom: 10 }}>{title}</View>
<View
style={{
...styles.mediumText,
fontWeight: 700,
marginBottom: 10,
}}
>
{title}
</View>
)}
<View>{processedMessage}</View>
{pre
@@ -178,15 +192,15 @@ function Notification({
: null}
{button && (
<ButtonWithLoading
type="bare"
loading={loading}
onClick={async () => {
variant="bare"
isLoading={loading}
onPress={async () => {
setLoading(true);
await button.action();
onRemove();
setLoading(false);
}}
style={{
style={({ isHovered, isPressed }) => ({
backgroundColor: 'transparent',
border: `1px solid ${
positive
@@ -196,31 +210,32 @@ function Notification({
: theme.warningBorder
}`,
color: 'currentColor',
fontSize: 14,
...styles.mediumText,
flexShrink: 0,
'&:hover, &:active': {
backgroundColor: positive
? theme.noticeBackground
: error
? theme.errorBackground
: theme.warningBackground,
},
}}
...(isHovered || isPressed
? {
backgroundColor: positive
? theme.noticeBackground
: error
? theme.errorBackground
: theme.warningBackground,
}
: {}),
...narrowStyle,
})}
>
{button.title}
</ButtonWithLoading>
)}
</Stack>
{sticky && (
<Button
type="bare"
aria-label="Close"
style={{ flexShrink: 0, color: 'currentColor' }}
onClick={onRemove}
>
<SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} />
</Button>
)}
<Button
variant="bare"
aria-label="Close"
style={{ flexShrink: 0, color: 'currentColor' }}
onPress={onRemove}
>
<SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} />
</Button>
</Stack>
{overlayLoading && (
<View
@@ -245,18 +260,22 @@ function Notification({
}
export function Notifications({ style }: { style?: CSSProperties }) {
const { removeNotification } = useActions();
const dispatch = useDispatch();
const { isNarrowWidth } = useResponsive();
const notifications = useSelector(
(state: State) => state.notifications.notifications,
);
const notificationInset = useSelector(
(state: State) => state.notifications.inset,
);
return (
<View
style={{
position: 'fixed',
bottom: 20,
right: 13,
left: isNarrowWidth ? 13 : undefined,
bottom: notificationInset?.bottom || 20,
top: notificationInset?.top,
right: notificationInset?.right || 13,
left: notificationInset?.left || (isNarrowWidth ? 13 : undefined),
zIndex: 10000,
...style,
}}
@@ -269,7 +288,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
if (note.onClose) {
note.onClose();
}
removeNotification(note.id);
dispatch(removeNotification(note.id));
}}
/>
))}

View File

@@ -6,7 +6,7 @@ import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
import { useResponsive } from '../ResponsiveProvider';
import { type CSSProperties, themeOptions, useTheme } from '../style';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Menu } from './common/Menu';
import { Popover } from './common/Popover';
@@ -44,9 +44,9 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
<>
<Button
ref={triggerRef}
type="bare"
variant="bare"
aria-label="Switch theme"
onClick={() => setMenuOpen(true)}
onPress={() => setMenuOpen(true)}
style={style}
>
<Icon style={{ width: 13, height: 13, color: 'inherit' }} />

View File

@@ -9,8 +9,9 @@ import { isDevelopmentEnvironment } from 'loot-core/src/shared/environment';
import { useActions } from '../hooks/useActions';
import { useGlobalPref } from '../hooks/useGlobalPref';
import { useLocalPref } from '../hooks/useLocalPref';
import { useMetadataPref } from '../hooks/useMetadataPref';
import { useNavigate } from '../hooks/useNavigate';
import { useSyncedPref } from '../hooks/useSyncedPref';
import { SvgArrowLeft } from '../icons/v1';
import {
SvgAlertTriangle,
@@ -24,7 +25,7 @@ import { theme, type CSSProperties, styles } from '../style';
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
import { AnimatedRefresh } from './AnimatedRefresh';
import { MonthCountSelector } from './budget/MonthCountSelector';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Link } from './common/Link';
import { Text } from './common/Text';
import { View } from './common/View';
@@ -60,15 +61,27 @@ type PrivacyButtonProps = {
function PrivacyButton({ style }: PrivacyButtonProps) {
const [isPrivacyEnabled, setPrivacyEnabledPref] =
useLocalPref('isPrivacyEnabled');
useSyncedPref('isPrivacyEnabled');
const privacyIconStyle = { width: 15, height: 15 };
useHotkeys(
'shift+ctrl+p, shift+cmd+p, shift+meta+p',
() => {
setPrivacyEnabledPref(!isPrivacyEnabled);
},
{
preventDefault: true,
scopes: ['app'],
},
[setPrivacyEnabledPref, isPrivacyEnabled],
);
return (
<Button
type="bare"
variant="bare"
aria-label={`${isPrivacyEnabled ? 'Disable' : 'Enable'} privacy mode`}
onClick={() => setPrivacyEnabledPref(!isPrivacyEnabled)}
onPress={() => setPrivacyEnabledPref(!isPrivacyEnabled)}
style={style}
>
{isPrivacyEnabled ? (
@@ -85,7 +98,7 @@ type SyncButtonProps = {
isMobile?: boolean;
};
function SyncButton({ style, isMobile = false }: SyncButtonProps) {
const [cloudFileId] = useLocalPref('cloudFileId');
const [cloudFileId] = useMetadataPref('cloudFileId');
const { sync } = useActions();
const [syncing, setSyncing] = useState(false);
@@ -184,10 +197,10 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
return (
<Button
type="bare"
variant="bare"
aria-label="Sync"
style={
isMobile
style={({ isHovered, isPressed }) => ({
...(isMobile
? {
...style,
WebkitAppRegion: 'none',
@@ -197,11 +210,11 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
...style,
WebkitAppRegion: 'none',
color: desktopColor,
}
}
hoveredStyle={hoveredStyle}
activeStyle={activeStyle}
onClick={sync}
}),
...(isHovered ? hoveredStyle : {}),
...(isPressed ? activeStyle : {}),
})}
onPress={sync}
>
{isMobile ? (
syncState === 'error' ? (
@@ -269,14 +282,15 @@ export function Titlebar({ style }: TitlebarProps) {
>
{(floatingSidebar || sidebar.alwaysFloats) && (
<Button
type="bare"
aria-label="Sidebar menu"
variant="bare"
style={{ marginRight: 8 }}
onPointerEnter={e => {
onHoverStart={e => {
if (e.pointerType === 'mouse') {
sidebar.setHidden(false);
}
}}
onPointerUp={e => {
onPress={e => {
if (e.pointerType !== 'mouse') {
sidebar.setHidden(!sidebar.hidden);
}
@@ -294,7 +308,7 @@ export function Titlebar({ style }: TitlebarProps) {
path="/accounts"
element={
location.state?.goBack ? (
<Button type="bare" onClick={() => navigate(-1)}>
<Button variant="bare" onPress={() => navigate(-1)}>
<SvgArrowLeft
width={10}
height={10}

View File

@@ -7,7 +7,7 @@ import { useActions } from '../hooks/useActions';
import { SvgClose } from '../icons/v1';
import { theme } from '../style';
import { Button } from './common/Button';
import { Button } from './common/Button2';
import { Link } from './common/Link';
import { Text } from './common/Text';
import { View } from './common/View';
@@ -72,10 +72,10 @@ export function UpdateNotification() {
</Link>
)
<Button
type="bare"
variant="bare"
aria-label="Close"
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
onClick={() => {
onPress={() => {
// Set a flag to never show an update notification again for this session
setAppState({
updateInfo: null,

View File

@@ -15,11 +15,9 @@ import * as queries from 'loot-core/src/client/queries';
import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
import { send, listen } from 'loot-core/src/platform/client/fetch';
import { currentDay } from 'loot-core/src/shared/months';
import * as monthUtils from 'loot-core/src/shared/months';
import { q } from 'loot-core/src/shared/query';
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
import {
deleteTransaction,
updateTransaction,
realizeTempTransactions,
ungroupTransaction,
@@ -34,7 +32,6 @@ import { useActions } from '../../hooks/useActions';
import { useCategories } from '../../hooks/useCategories';
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 } from '../../hooks/useSelected';
@@ -42,6 +39,8 @@ import {
SplitsExpandedProvider,
useSplitsExpanded,
} from '../../hooks/useSplitsExpanded';
import { useSyncedPref } from '../../hooks/useSyncedPref';
import { useTransactionBatchActions } from '../../hooks/useTransactionBatchActions';
import { styles, theme } from '../../style';
import { Button } from '../common/Button2';
import { Text } from '../common/Text';
@@ -76,7 +75,12 @@ function EmptyMessage({ onAdd }) {
manage it locally yourself.
</Text>
<Button variant="primary" style={{ marginTop: 20 }} onPress={onAdd}>
<Button
variant="primary"
style={{ marginTop: 20 }}
autoFocus
onPress={onAdd}
>
Add account
</Button>
@@ -97,12 +101,15 @@ function AllTransactions({
showBalances,
filtered,
children,
collapseTransactions,
}) {
const accountId = account.id;
const prependTransactions = usePreviewTransactions().map(trans => ({
...trans,
_inverse: accountId ? accountId !== trans.account : false,
}));
const prependTransactions = usePreviewTransactions(collapseTransactions).map(
trans => ({
...trans,
_inverse: accountId ? accountId !== trans.account : false,
}),
);
transactions ??= [];
@@ -112,7 +119,7 @@ function AllTransactions({
}
return balances && transactions?.length > 0
? balances[transactions[0].id]?.balance ?? 0
? (balances[transactions[0].id]?.balance ?? 0)
: 0;
}, [showBalances, balances, transactions]);
@@ -818,241 +825,26 @@ class AccountInternal extends PureComponent {
});
};
onBatchEdit = async (name, ids) => {
const { data } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
const transactions = ungroupTransactions(data);
onBatchEdit = (name, ids) => {
this.props.onBatchEdit({
name,
ids,
onSuccess: updatedIds => {
this.refetchTransactions();
const onChange = async (name, value, mode) => {
let transactionsToChange = transactions;
const newValue = value === null ? '' : value;
this.setState({ workingHard: true });
const changes = { deleted: [], updated: [] };
// Cleared is a special case right now
if (name === 'cleared') {
// Clear them if any are uncleared, otherwise unclear them
value = !!transactionsToChange.find(t => !t.cleared);
}
const idSet = new Set(ids);
transactionsToChange.forEach(trans => {
if (name === 'cleared' && trans.reconciled) {
// Skip transactions that are reconciled. Don't want to set them as
// uncleared.
return;
if (this.table.current) {
this.table.current.edit(updatedIds[0], 'select', false);
}
if (!idSet.has(trans.id)) {
// Skip transactions which aren't actually selected, since the query
// above also retrieves the siblings & parent of any selected splits.
return;
}
if (name === 'notes') {
if (mode === 'prepend') {
value =
trans.notes === null ? newValue : newValue + ' ' + trans.notes;
} else if (mode === 'append') {
value =
trans.notes === null ? newValue : trans.notes + ' ' + newValue;
} else if (mode === 'replace') {
value = newValue;
}
}
const transaction = {
...trans,
[name]: value,
};
if (name === 'account' && trans.account !== value) {
transaction.reconciled = false;
}
const { diff } = updateTransaction(transactionsToChange, transaction);
// TODO: We need to keep an updated list of transactions so
// the logic in `updateTransaction`, particularly about
// updating split transactions, works. This isn't ideal and we
// should figure something else out
transactionsToChange = applyChanges(diff, transactionsToChange);
changes.deleted = changes.deleted
? changes.deleted.concat(diff.deleted)
: diff.deleted;
changes.updated = changes.updated
? changes.updated.concat(diff.updated)
: diff.updated;
changes.added = changes.added
? changes.added.concat(diff.added)
: diff.added;
});
await send('transactions-batch-update', changes);
await this.refetchTransactions();
if (this.table.current) {
this.table.current.edit(transactionsToChange[0].id, 'select', false);
}
};
const pushPayeeAutocompleteModal = () => {
this.props.pushModal('payee-autocomplete', {
onSelect: payeeId => onChange(name, payeeId),
});
};
const pushAccountAutocompleteModal = () => {
this.props.pushModal('account-autocomplete', {
onSelect: accountId => onChange(name, accountId),
});
};
const pushCategoryAutocompleteModal = () => {
// Only show balances when all selected transaction are in the same month.
const transactionMonth = transactions[0]?.date
? monthUtils.monthFromDate(transactions[0]?.date)
: null;
const transactionsHaveSameMonth =
transactionMonth &&
transactions.every(
t => monthUtils.monthFromDate(t.date) === transactionMonth,
);
this.props.pushModal('category-autocomplete', {
month: transactionsHaveSameMonth ? transactionMonth : undefined,
onSelect: categoryId => onChange(name, categoryId),
});
};
if (
name === 'amount' ||
name === 'payee' ||
name === 'account' ||
name === 'date'
) {
const reconciledTransactions = transactions.filter(t => t.reconciled);
if (reconciledTransactions.length > 0) {
this.props.pushModal('confirm-transaction-edit', {
onConfirm: () => {
if (name === 'payee') {
pushPayeeAutocompleteModal();
} else if (name === 'account') {
pushAccountAutocompleteModal();
} else {
this.props.pushModal('edit-field', { name, onSubmit: onChange });
}
},
confirmReason: 'batchEditWithReconciled',
});
return;
}
}
if (name === 'cleared') {
// Cleared just toggles it on/off and it depends on the data
// loaded. Need to clean this up in the future.
onChange('cleared', null);
} else if (name === 'category') {
pushCategoryAutocompleteModal();
} else if (name === 'payee') {
pushPayeeAutocompleteModal();
} else if (name === 'account') {
pushAccountAutocompleteModal();
} else {
this.props.pushModal('edit-field', { name, onSubmit: onChange });
}
},
});
};
onBatchDuplicate = async ids => {
const onConfirmDuplicate = async ids => {
this.setState({ workingHard: true });
const { data } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
const changes = {
added: data
.reduce((newTransactions, trans) => {
return newTransactions.concat(
realizeTempTransactions(ungroupTransaction(trans)),
);
}, [])
.map(({ sort_order, ...trans }) => ({ ...trans })),
};
await send('transactions-batch-update', changes);
await this.refetchTransactions();
};
await this.checkForReconciledTransactions(
ids,
'batchDuplicateWithReconciled',
onConfirmDuplicate,
);
onBatchDuplicate = ids => {
this.props.onBatchDuplicate({ ids, onSuccess: this.refetchTransactions });
};
onBatchDelete = async ids => {
const onConfirmDelete = async ids => {
this.setState({ workingHard: true });
const { data } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
let transactions = ungroupTransactions(data);
const idSet = new Set(ids);
const changes = { deleted: [], updated: [] };
transactions.forEach(trans => {
const parentId = trans.parent_id;
// First, check if we're actually deleting this transaction by
// checking `idSet`. Then, we don't need to do anything if it's
// a child transaction and the parent is already being deleted
if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) {
return;
}
const { diff } = deleteTransaction(transactions, trans.id);
// TODO: We need to keep an updated list of transactions so
// the logic in `updateTransaction`, particularly about
// updating split transactions, works. This isn't ideal and we
// should figure something else out
transactions = applyChanges(diff, transactions);
changes.deleted = diff.deleted
? changes.deleted.concat(diff.deleted)
: diff.deleted;
changes.updated = diff.updated
? changes.updated.concat(diff.updated)
: diff.updated;
});
await send('transactions-batch-update', changes);
await this.refetchTransactions();
};
await this.checkForReconciledTransactions(
ids,
'batchDeleteWithReconciled',
onConfirmDelete,
);
onBatchDelete = ids => {
this.props.onBatchDelete({ ids, onSuccess: this.refetchTransactions });
};
onMakeAsSplitTransaction = async ids => {
@@ -1183,12 +975,19 @@ class AccountInternal extends PureComponent {
}
};
onBatchUnlink = async ids => {
await send('transactions-batch-update', {
updated: ids.map(id => ({ id, schedule: null })),
onBatchLinkSchedule = ids => {
this.props.onBatchLinkSchedule({
ids,
account: this.props.accounts.find(a => a.id === this.props.accountId),
onSuccess: this.refetchTransactions,
});
};
await this.refetchTransactions();
onBatchUnlinkSchedule = ids => {
this.props.onBatchUnlinkSchedule({
ids,
onSuccess: this.refetchTransactions,
});
};
onCreateRule = async ids => {
@@ -1314,10 +1113,10 @@ class AccountInternal extends PureComponent {
);
};
onConditionsOpChange = (value, conditions) => {
onConditionsOpChange = value => {
this.setState({ filterConditionsOp: value });
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
this.applyFilters([...conditions]);
this.applyFilters([...this.state.filterConditions]);
if (this.state.search !== '') {
this.onSearch(this.state.search);
}
@@ -1657,6 +1456,9 @@ class AccountInternal extends PureComponent {
balances={balances}
showBalances={showBalances}
filtered={transactionsFiltered}
collapseTransactions={ids =>
this.props.splitsExpandedDispatch({ type: 'close-splits', ids })
}
>
{(allTransactions, allBalances) => (
<SelectedProviderWithItems
@@ -1714,7 +1516,8 @@ class AccountInternal extends PureComponent {
onBatchDelete={this.onBatchDelete}
onBatchDuplicate={this.onBatchDuplicate}
onBatchEdit={this.onBatchEdit}
onBatchUnlink={this.onBatchUnlink}
onBatchLinkSchedule={this.onBatchLinkSchedule}
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
onCreateRule={this.onCreateRule}
onUpdateFilter={this.onUpdateFilter}
onClearFilters={this.onClearFilters}
@@ -1802,10 +1605,22 @@ class AccountInternal extends PureComponent {
function AccountHack(props) {
const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
const {
onBatchEdit,
onBatchDuplicate,
onBatchLinkSchedule,
onBatchUnlinkSchedule,
onBatchDelete,
} = useTransactionBatchActions();
return (
<AccountInternal
splitsExpandedDispatch={splitsExpandedDispatch}
onBatchEdit={onBatchEdit}
onBatchDuplicate={onBatchDuplicate}
onBatchLinkSchedule={onBatchLinkSchedule}
onBatchUnlinkSchedule={onBatchUnlinkSchedule}
onBatchDelete={onBatchDelete}
{...props}
/>
);
@@ -1824,12 +1639,12 @@ export function Account() {
const payees = usePayees();
const failedAccounts = useFailedAccounts();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [hideFraction = false] = useLocalPref('hideFraction');
const [expandSplits] = useLocalPref('expand-splits');
const [showBalances] = useLocalPref(`show-balances-${params.id}`);
const [hideCleared] = useLocalPref(`hide-cleared-${params.id}`);
const [hideReconciled] = useLocalPref(`hide-reconciled-${params.id}`);
const [showExtraBalances] = useLocalPref(
const [hideFraction = false] = useSyncedPref('hideFraction');
const [expandSplits] = useSyncedPref('expand-splits');
const [showBalances] = useSyncedPref(`show-balances-${params.id}`);
const [hideCleared] = useSyncedPref(`hide-cleared-${params.id}`);
const [hideReconciled] = useSyncedPref(`hide-reconciled-${params.id}`);
const [showExtraBalances] = useSyncedPref(
`show-extra-balances-${params.id || 'all-accounts'}`,
);
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);

View File

@@ -39,7 +39,15 @@ function getErrorMessage(type, code) {
return 'Your SimpleFIN Access Token is no longer valid. Please reset and generate a new token.';
case 'ACCOUNT_NEEDS_ATTENTION':
return 'The account needs your attention at [SimpleFIN](https://beta-bridge.simplefin.org/auth/login).';
return (
<>
The account needs your attention at{' '}
<Link variant="external" to="https://bridge.simplefin.org/auth/login">
SimpleFIN
</Link>
.
</>
);
default:
}
@@ -132,6 +140,7 @@ export function AccountSyncCheck() {
<Button onPress={unlink}>Unlink</Button>
<Button
variant="primary"
autoFocus
onPress={reauth}
style={{ marginLeft: 5 }}
>

View File

@@ -74,7 +74,8 @@ export function AccountHeader({
onBatchDelete,
onBatchDuplicate,
onBatchEdit,
onBatchUnlink,
onBatchLinkSchedule,
onBatchUnlinkSchedule,
onCreateRule,
onApplyFilter,
onUpdateFilter,
@@ -130,6 +131,33 @@ export function AccountHeader({
},
[searchInput],
);
useHotkeys(
't',
() => onAddTransaction(),
{
preventDefault: true,
scopes: ['app'],
},
[onAddTransaction],
);
useHotkeys(
'ctrl+i, cmd+i, meta+i',
() => onImport(),
{
scopes: ['app'],
},
[onImport],
);
useHotkeys(
'ctrl+b, cmd+b, meta+b',
() => onSync(),
{
enabled: canSync && !isServerOffline,
preventDefault: true,
scopes: ['app'],
},
[onSync],
);
return (
<>
@@ -310,12 +338,14 @@ export function AccountHeader({
</View>
) : (
<SelectedTransactionsButton
account={account}
getTransaction={id => transactions.find(t => t.id === id)}
onShow={onShowTransactions}
onDuplicate={onBatchDuplicate}
onDelete={onBatchDelete}
onEdit={onBatchEdit}
onUnlink={onBatchUnlink}
onLinkSchedule={onBatchLinkSchedule}
onUnlinkSchedule={onBatchUnlinkSchedule}
onCreateRule={onCreateRule}
onSetTransfer={onSetTransfer}
onScheduleAction={onScheduleAction}
@@ -352,7 +382,11 @@ export function AccountHeader({
</Button>
{account ? (
<View>
<MenuButton ref={triggerRef} onClick={() => setMenuOpen(true)} />
<MenuButton
aria-label="Account menu"
ref={triggerRef}
onPress={() => setMenuOpen(true)}
/>
<Popover
triggerRef={triggerRef}
@@ -379,7 +413,11 @@ export function AccountHeader({
</View>
) : (
<View>
<MenuButton ref={triggerRef} onClick={() => setMenuOpen(true)} />
<MenuButton
aria-label="Account menu"
ref={triggerRef}
onPress={() => setMenuOpen(true)}
/>
<Popover
triggerRef={triggerRef}

View File

@@ -5,6 +5,7 @@ import React, {
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'glamor';
@@ -44,6 +45,7 @@ function AccountList({
renderAccountItemGroupHeader = defaultRenderAccountItemGroupHeader,
renderAccountItem = defaultRenderAccountItem,
}: AccountListProps) {
const { t } = useTranslation();
let lastItem = null;
return (
@@ -63,10 +65,10 @@ function AccountList({
const group = `${
item.closed
? 'Closed Accounts'
? t('Closed Accounts')
: item.offbudget
? 'Off Budget'
: 'For Budget'
? t('Off Budget')
: t('For Budget')
}`;
lastItem = item;

View File

@@ -14,10 +14,12 @@ import React, {
import Downshift, { type StateChangeTypes } from 'downshift';
import { css } from 'glamor';
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/Button2';
import { Button } from '../common/Button';
import { Input } from '../common/Input';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
@@ -92,16 +94,8 @@ export function defaultFilterSuggestion<T extends Item>(
suggestion: T,
value: string,
) {
return getItemName(suggestion)
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.includes(
value
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, ''),
);
const name = getItemName(suggestion);
return getNormalisedString(name).includes(getNormalisedString(value));
}
function defaultFilterSuggestions<T extends Item>(
@@ -621,12 +615,7 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
}}
>
{name}
<Button
variant="bare"
aria-label="Remove autocomplete item"
style={{ marginLeft: 1 }}
onPress={onRemove}
>
<Button type="bare" style={{ marginLeft: 1 }} onClick={onRemove}>
<SvgRemove style={{ width: 8, height: 8 }} />
</Button>
</View>

View File

@@ -9,21 +9,24 @@ import React, {
type ReactElement,
useCallback,
} from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { css } from 'glamor';
import { reportBudget, rolloverBudget } from 'loot-core/client/queries';
import { integerToCurrency } from 'loot-core/shared/util';
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/src/types/models';
import { useCategories } from '../../hooks/useCategories';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useSyncedPref } from '../../hooks/useSyncedPref';
import { SvgSplit } from '../../icons/v0';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme, styles } from '../../style';
import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
import { makeAmountFullStyle } from '../budget/util';
import { Text } from '../common/Text';
import { TextOneLine } from '../common/TextOneLine';
@@ -69,6 +72,7 @@ function CategoryList({
showHiddenItems,
showBalances,
}: CategoryListProps) {
const { t } = useTranslation();
let lastGroup: string | undefined | null = null;
const filteredItems = useMemo(
@@ -99,7 +103,7 @@ function CategoryList({
}
const showGroup = item.cat_group !== lastGroup;
const groupName = `${item.group?.name}${item.group?.hidden ? ' (hidden)' : ''}`;
const groupName = `${item.group?.name}${item.group?.hidden ? ' ' + t('(hidden)') : ''}`;
lastGroup = item.cat_group;
return (
<Fragment key={item.id}>
@@ -137,8 +141,8 @@ function CategoryList({
}
function customSort(obj: CategoryAutocompleteItem, value: string): number {
const name = obj.name.toLowerCase();
const groupName = obj.group ? obj.group.name.toLowerCase() : '';
const name = getNormalisedString(obj.name);
const groupName = obj.group ? getNormalisedString(obj.group.name) : '';
if (obj.id === 'split') {
return -2;
}
@@ -206,21 +210,27 @@ export function CategoryAutocomplete({
): CategoryAutocompleteItem[] => {
return suggestions
.filter(suggestion => {
return (
suggestion.id === 'split' ||
suggestion.group?.name
.toLowerCase()
.includes(value.toLowerCase()) ||
(suggestion.group?.name + ' ' + suggestion.name)
.toLowerCase()
.includes(value.toLowerCase()) ||
defaultFilterSuggestion(suggestion, value)
);
if (suggestion.id === 'split') {
return true;
}
if (suggestion.group) {
return (
getNormalisedString(suggestion.group.name).includes(
getNormalisedString(value),
) ||
getNormalisedString(
suggestion.group.name + ' ' + suggestion.name,
).includes(getNormalisedString(value))
);
}
return defaultFilterSuggestion(suggestion, value);
})
.sort(
(a, b) =>
customSort(a, value.toLowerCase()) -
customSort(b, value.toLowerCase()),
customSort(a, getNormalisedString(value)) -
customSort(b, getNormalisedString(value)),
);
},
[],
@@ -331,7 +341,7 @@ function SplitTransactionButton({
<SvgSplit width={10} height={10} style={{ marginRight: 5 }} />
)}
</Text>
Split Transaction
<Trans>Split Transaction</Trans>
</View>
);
}
@@ -360,6 +370,7 @@ function CategoryItem({
showBalances,
...props
}: CategoryItemProps) {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
@@ -368,16 +379,19 @@ function CategoryItem({
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
const [budgetType] = useLocalPref('budgetType');
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const balance = useSheetValue(
const balanceBinding =
budgetType === 'rollover'
? rolloverBudget.catBalance(item.id)
: reportBudget.catBalance(item.id),
);
: reportBudget.catBalance(item.id);
const balance = useSheetValue<
'rollover-budget' | 'report-budget',
typeof balanceBinding
>(balanceBinding);
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
const toBudget = useSheetValue(rolloverBudget.toBudget);
const toBudget = useRolloverSheetValue(rolloverBudget.toBudget) ?? 0;
return (
<div
@@ -405,7 +419,7 @@ function CategoryItem({
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<TextOneLine>
{item.name}
{item.hidden ? ' (hidden)' : null}
{item.hidden ? ' ' + t('(hidden)') : null}
</TextOneLine>
<TextOneLine
style={{

View File

@@ -1,4 +1,5 @@
import React, { type ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from '../../style/theme';
import { View } from '../common/View';
@@ -16,6 +17,7 @@ export function FilterList<T extends { id: string; name: string }>({
highlightedIndex: number;
embedded?: boolean;
}) {
const { t } = useTranslation();
return (
<View>
<View
@@ -25,7 +27,7 @@ export function FilterList<T extends { id: string; name: string }>({
...(!embedded && { maxHeight: 175 }),
}}
>
<ItemHeader title="Saved Filters" type="filter" />
<ItemHeader title={t('Saved Filters')} type="filter" />
{items.map((item, idx) => {
return [
<div

View File

@@ -10,23 +10,25 @@ import React, {
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { css } from 'glamor';
import { createPayee } from 'loot-core/src/client/actions/queries';
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
import {
type AccountEntity,
type PayeeEntity,
} from 'loot-core/src/types/models';
import { useAccounts } from '../../hooks/useAccounts';
import { usePayees } from '../../hooks/usePayees';
import { SvgAdd } from '../../icons/v1';
import { useCommonPayees, usePayees } from '../../hooks/usePayees';
import { SvgAdd, SvgBookmark } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme, styles } from '../../style';
import { Button } from '../common/Button2';
import { Button } from '../common/Button';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
@@ -39,11 +41,48 @@ import { ItemHeader } from './ItemHeader';
type PayeeAutocompleteItem = PayeeEntity;
const MAX_AUTO_SUGGESTIONS = 5;
function getPayeeSuggestions(
commonPayees: PayeeAutocompleteItem[],
payees: PayeeAutocompleteItem[],
): (PayeeAutocompleteItem & PayeeItemType)[] {
if (commonPayees?.length > 0) {
const favoritePayees = payees.filter(p => p.favorite);
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
additionalCommonPayees = commonPayees
.filter(
p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)),
)
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length);
}
const frequentPayees: (PayeeAutocompleteItem & PayeeItemType)[] =
favoritePayees.concat(additionalCommonPayees).map(p => {
return { ...p, itemType: 'common_payee' };
});
const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
.filter(p => !frequentPayees.find(fp => fp.id === p.id))
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
return { ...p, itemType: determineItemType(p, false) };
});
return frequentPayees
.sort((a, b) => a.name.localeCompare(b.name))
.concat(filteredPayees);
}
return payees.map(p => {
return { ...p, itemType: determineItemType(p, false) };
});
}
function filterActivePayees(
payees: PayeeAutocompleteItem[],
focusTransferPayees: boolean,
accounts: AccountEntity[],
): PayeeAutocompleteItem[] {
) {
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
if (focusTransferPayees && activePayees) {
@@ -70,7 +109,8 @@ function stripNew(value) {
}
type PayeeListProps = {
items: PayeeAutocompleteItem[];
items: (PayeeAutocompleteItem & PayeeItemType)[];
commonPayees: PayeeEntity[];
getItemProps: (arg: {
item: PayeeAutocompleteItem;
}) => ComponentProps<typeof View>;
@@ -89,6 +129,25 @@ type PayeeListProps = {
footer: ReactNode;
};
type ItemTypes = 'account' | 'payee' | 'common_payee';
type PayeeItemType = {
itemType: ItemTypes;
};
function determineItemType(
item: PayeeAutocompleteItem,
isCommon: boolean,
): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
if (isCommon) {
return 'common_payee';
} else {
return 'payee';
}
}
function PayeeList({
items,
getItemProps,
@@ -100,6 +159,8 @@ function PayeeList({
renderPayeeItem = defaultRenderPayeeItem,
footer,
}: PayeeListProps) {
const { t } = useTranslation();
let createNew = null;
items = [...items];
@@ -133,16 +194,19 @@ function PayeeList({
})}
{items.map((item, idx) => {
const type = item.transfer_acct ? 'account' : 'payee';
const itemType = item.itemType;
let title;
if (type === 'payee' && lastType !== type) {
title = 'Payees';
} else if (type === 'account' && lastType !== type) {
title = 'Transfer To/From';
if (itemType === 'common_payee' && lastType !== itemType) {
title = t('Suggested Payees');
} else if (itemType === 'payee' && lastType !== itemType) {
title = t('Payees');
} else if (itemType === 'account' && lastType !== itemType) {
title = t('Transfer To/From');
}
const showMoreMessage =
idx === items.length - 1 && items.length > 100;
lastType = type;
lastType = itemType;
return (
<Fragment key={item.id}>
@@ -169,7 +233,7 @@ function PayeeList({
textAlign: 'center',
}}
>
More payees are available, search to find them
<Trans>More payees are available, search to find them</Trans>
</div>
)}
</Fragment>
@@ -219,6 +283,7 @@ export function PayeeAutocomplete({
payees,
...props
}: PayeeAutocompleteProps) {
const commonPayees = useCommonPayees();
const retrievedPayees = usePayees();
if (!payees) {
payees = retrievedPayees;
@@ -233,17 +298,21 @@ export function PayeeAutocomplete({
const [rawPayee, setRawPayee] = useState('');
const hasPayeeInput = !!rawPayee;
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(
payees,
const suggestions = getPayeeSuggestions(commonPayees, payees);
const filteredSuggestions = filterActivePayees(
suggestions,
focusTransferPayees,
accounts,
);
if (!hasPayeeInput) {
return suggestions;
return filteredSuggestions;
}
return [{ id: 'new', name: '' }, ...suggestions];
}, [payees, focusTransferPayees, accounts, hasPayeeInput]);
filteredSuggestions.forEach(s => {
console.log(s.name + ' ' + s.id);
});
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
}, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]);
const dispatch = useDispatch();
@@ -288,6 +357,7 @@ export function PayeeAutocomplete({
focused={payeeFieldFocused}
inputProps={{
...inputProps,
autoCapitalize: 'words',
onBlur: () => {
setRawPayee('');
setPayeeFieldFocused(false);
@@ -313,8 +383,12 @@ export function PayeeAutocomplete({
});
filtered.sort((p1, p2) => {
const r1 = p1.name.toLowerCase().startsWith(value.toLowerCase());
const r2 = p2.name.toLowerCase().startsWith(value.toLowerCase());
const r1 = getNormalisedString(p1.name).startsWith(
getNormalisedString(value),
);
const r2 = getNormalisedString(p2.name).startsWith(
getNormalisedString(value),
);
const r1exact = p1.name.toLowerCase() === value.toLowerCase();
const r2exact = p2.name.toLowerCase() === value.toLowerCase();
@@ -355,6 +429,7 @@ export function PayeeAutocomplete({
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList
items={items}
commonPayees={commonPayees}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
inputValue={inputValue}
@@ -366,24 +441,19 @@ export function PayeeAutocomplete({
<AutocompleteFooter embedded={embedded}>
{showMakeTransfer && (
<Button
variant={focusTransferPayees ? 'menuSelected' : 'menu'}
aria-label="Make transfer"
type={focusTransferPayees ? 'menuSelected' : 'menu'}
style={showManagePayees && { marginBottom: 5 }}
onPress={() => {
onClick={() => {
onUpdate?.(null, null);
setFocusTransferPayees(!focusTransferPayees);
}}
>
Make Transfer
<Trans>Make Transfer</Trans>
</Button>
)}
{showManagePayees && (
<Button
variant="menu"
aria-label="Manage payees"
onPress={() => onManagePayees()}
>
Manage Payees
<Button type="menu" onClick={() => onManagePayees()}>
<Trans>Manage Payees</Trans>
</Button>
)}
</AutocompleteFooter>
@@ -453,7 +523,7 @@ export function CreatePayeeButton({
style={{ marginRight: 5, display: 'inline-block' }}
/>
)}
Create Payee {payeeName}
<Trans>Create Payee {{ payeeName }}</Trans>
</View>
);
}
@@ -493,7 +563,19 @@ function PayeeItem({
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
const iconSize = isNarrowWidth ? 14 : 8;
let paddingLeftOverFromIcon = 20;
let itemIcon = undefined;
if (item.favorite) {
itemIcon = (
<SvgBookmark
width={iconSize}
height={iconSize}
style={{ marginRight: 5, display: 'inline-block' }}
/>
);
paddingLeftOverFromIcon -= iconSize + 5;
}
return (
<div
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
@@ -528,7 +610,7 @@ function PayeeItem({
: theme.menuAutoCompleteItemText,
borderRadius: embedded ? 4 : 0,
padding: 4,
paddingLeft: 20,
paddingLeft: paddingLeftOverFromIcon,
...narrowStyle,
},
])}`}
@@ -536,7 +618,10 @@ function PayeeItem({
data-highlighted={highlighted || undefined}
{...props}
>
<TextOneLine>{item.name}</TextOneLine>
<TextOneLine>
{itemIcon}
{item.name}
</TextOneLine>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export function ReportAutocomplete({
embedded,
...props
}: ReportAutocompleteProps) {
const reports = useReports() || [];
const { data: reports } = useReports();
return (
<Autocomplete

View File

@@ -1,4 +1,5 @@
import React, { Fragment, type ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from '../../style/theme';
import { View } from '../common/View';
@@ -16,6 +17,7 @@ export function ReportList<T extends { id: string; name: string }>({
highlightedIndex: number;
embedded?: boolean;
}) {
const { t } = useTranslation();
return (
<View>
<View
@@ -25,7 +27,7 @@ export function ReportList<T extends { id: string; name: string }>({
...(!embedded && { maxHeight: 175 }),
}}
>
<Fragment>{ItemHeader({ title: 'Saved Reports' })}</Fragment>
<Fragment>{ItemHeader({ title: t('Saved Reports') })}</Fragment>
{items.map((item, idx) => {
return [
<div

View File

@@ -3,10 +3,12 @@ import React, { type ComponentPropsWithoutRef } from 'react';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { SvgArrowThinRight } from '../../icons/v1';
import { type CSSProperties } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { Tooltip } from '../common/Tooltip';
import { View } from '../common/View';
import { type Binding } from '../spreadsheet';
import { CellValue } from '../spreadsheet/CellValue';
import { useFormat } from '../spreadsheet/useFormat';
import { useSheetValue } from '../spreadsheet/useSheetValue';
import { makeBalanceAmountStyle } from './util';
@@ -19,10 +21,11 @@ type BalanceWithCarryoverProps = Omit<
ComponentPropsWithoutRef<typeof CellValue>,
'binding'
> & {
carryover: Binding;
balance: Binding;
goal: Binding;
budgeted: Binding;
carryover: Binding<'rollover-budget', 'carryover'>;
balance: Binding<'rollover-budget', 'leftover'>;
goal: Binding<'rollover-budget', 'goal'>;
budgeted: Binding<'rollover-budget', 'budget'>;
longGoal: Binding<'rollover-budget', 'long-goal'>;
disabled?: boolean;
carryoverIndicator?: ({ style }: CarryoverIndicatorProps) => JSX.Element;
};
@@ -49,11 +52,27 @@ export function DefaultCarryoverIndicator({ style }: CarryoverIndicatorProps) {
);
}
function GoalTooltipRow({ children }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 10,
}}
>
{children}
</div>
);
}
export function BalanceWithCarryover({
carryover,
balance,
goal,
budgeted,
longGoal,
disabled,
carryoverIndicator = DefaultCarryoverIndicator,
...props
@@ -62,11 +81,40 @@ export function BalanceWithCarryover({
const balanceValue = useSheetValue(balance);
const goalValue = useSheetValue(goal);
const budgetedValue = useSheetValue(budgeted);
const longGoalValue = useSheetValue(longGoal);
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const valueStyle = makeBalanceAmountStyle(
balanceValue,
isGoalTemplatesEnabled ? goalValue : null,
budgetedValue,
longGoalValue === 1 ? balanceValue : budgetedValue,
);
const format = useFormat();
const differenceToGoal =
longGoalValue === 1 ? balanceValue - goalValue : budgetedValue - goalValue;
const balanceCellValue = (
<CellValue
{...props}
binding={balance}
type="financial"
getStyle={value =>
makeBalanceAmountStyle(
value,
isGoalTemplatesEnabled ? goalValue : null,
longGoalValue === 1 ? balanceValue : budgetedValue,
)
}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'right',
...(!disabled && {
cursor: 'pointer',
}),
...props.style,
}}
/>
);
return (
@@ -78,27 +126,55 @@ export function BalanceWithCarryover({
maxWidth: '100%',
}}
>
<CellValue
{...props}
binding={balance}
type="financial"
getStyle={value =>
makeBalanceAmountStyle(
value,
isGoalTemplatesEnabled ? goalValue : null,
budgetedValue,
)
}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'right',
...(!disabled && {
cursor: 'pointer',
}),
...props.style,
}}
/>
{isGoalTemplatesEnabled && goalValue !== null ? (
<Tooltip
content={
<View style={{ padding: 10 }}>
<span style={{ fontWeight: 'bold' }}>
{differenceToGoal === 0 ? (
<span style={{ color: theme.noticeText }}>Fully funded</span>
) : differenceToGoal > 0 ? (
<span style={{ color: theme.noticeText }}>
Overfunded ({format(differenceToGoal, 'financial')})
</span>
) : (
<span style={{ color: theme.errorText }}>
Underfunded ({format(differenceToGoal, 'financial')})
</span>
)}
</span>
<GoalTooltipRow>
<div>Goal Type:</div>
<div>{longGoalValue === 1 ? 'Long' : 'Template'}</div>
</GoalTooltipRow>
<GoalTooltipRow>
<div>Goal:</div>
<div>{format(goalValue, 'financial')}</div>
</GoalTooltipRow>
<GoalTooltipRow>
{longGoalValue !== 1 ? (
<>
<div>Budgeted:</div>
<div>{format(budgetedValue, 'financial')}</div>
</>
) : (
<>
<div>Balance:</div>
<div>{format(balanceValue, 'financial')}</div>
</>
)}
</GoalTooltipRow>
</View>
}
style={{ ...styles.tooltip, borderRadius: '0px 5px 5px 0px' }}
placement="bottom"
triggerProps={{ delay: 750 }}
>
{balanceCellValue}
</Tooltip>
) : (
balanceCellValue
)}
{carryoverValue && carryoverIndicator({ style: valueStyle })}
</span>
);

View File

@@ -1,8 +1,6 @@
// @ts-strict-ignore
import React, { type ComponentProps, memo } from 'react';
import * as monthUtils from 'loot-core/src/shared/months';
import { View } from '../common/View';
import { MonthPicker } from './MonthPicker';
@@ -17,18 +15,6 @@ type BudgetPageHeaderProps = {
export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
({ startMonth, onMonthSelect, numMonths, monthBounds }) => {
function getValidMonth(month) {
const start = monthBounds.start;
const end = monthUtils.subMonths(monthBounds.end, numMonths - 1);
if (month < start) {
return start;
} else if (month > end) {
return end;
}
return month;
}
return (
<View style={{ marginLeft: 200 + 5, flexShrink: 0 }}>
<View style={{ marginRight: 5 + getScrollbarWidth() }}>
@@ -37,7 +23,7 @@ export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
numDisplayed={numMonths}
monthBounds={monthBounds}
style={{ paddingTop: 5 }}
onSelect={month => onMonthSelect(getValidMonth(month))}
onSelect={month => onMonthSelect(month)}
/>
</View>
</View>

View File

@@ -9,7 +9,12 @@ import { BudgetCategories } from './BudgetCategories';
import { BudgetSummaries } from './BudgetSummaries';
import { BudgetTotals } from './BudgetTotals';
import { MonthsProvider } from './MonthsContext';
import { findSortDown, findSortUp, getScrollbarWidth } from './util';
import {
findSortDown,
findSortUp,
getScrollbarWidth,
separateGroups,
} from './util';
export function BudgetTable(props) {
const {
@@ -86,9 +91,10 @@ export function BudgetTable(props) {
};
const _onReorderGroup = (id, dropPos, targetId) => {
const [expenseGroups] = separateGroups(categoryGroups); // exclude Income group from sortable groups to fix off-by-one error
onReorderGroup({
id,
...findSortDown(categoryGroups, dropPos, targetId),
...findSortDown(expenseGroups, dropPos, targetId),
});
};

View File

@@ -2,7 +2,7 @@ import React, { type ComponentProps, memo, useRef, useState } from 'react';
import { SvgDotsHorizontalTriple } from '../../icons/v1';
import { theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
@@ -57,11 +57,9 @@ export const BudgetTotals = memo(function BudgetTotals({
<View style={{ flexGrow: '1' }}>Category</View>
<Button
ref={triggerRef}
type="bare"
variant="bare"
aria-label="Menu"
onClick={() => {
setMenuOpen(true);
}}
onPress={() => setMenuOpen(true)}
style={{ color: 'currentColor', padding: 3 }}
>
<SvgDotsHorizontalTriple

View File

@@ -1,7 +1,10 @@
// @ts-strict-ignore
import React, { useEffect, type ComponentProps } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import AutoSizer from 'react-virtualized-auto-sizer';
import * as monthUtils from 'loot-core/src/shared/months';
import { View } from '../common/View';
import { useBudgetMonthCount } from './BudgetMonthCountContext';
@@ -32,6 +35,7 @@ type DynamicBudgetTableInnerProps = {
} & DynamicBudgetTableProps;
const DynamicBudgetTableInner = ({
type,
width,
height,
prewarmStartMonth,
@@ -51,10 +55,65 @@ const DynamicBudgetTableInner = ({
setDisplayMax(numPossible);
}, [numPossible]);
function _onMonthSelect(month) {
onMonthSelect(month, numMonths);
function getValidMonth(month) {
const start = monthBounds.start;
const end = monthUtils.subMonths(monthBounds.end, numMonths - 1);
if (month < start) {
return start;
} else if (month > end) {
return end;
}
return month;
}
function _onMonthSelect(month) {
onMonthSelect(getValidMonth(month), numMonths);
}
useHotkeys(
'left',
() => {
_onMonthSelect(monthUtils.prevMonth(startMonth));
},
{
preventDefault: true,
scopes: ['app'],
},
[_onMonthSelect, startMonth],
);
useHotkeys(
'right',
() => {
_onMonthSelect(monthUtils.nextMonth(startMonth));
},
{
preventDefault: true,
scopes: ['app'],
},
[_onMonthSelect, startMonth],
);
useHotkeys(
'0',
() => {
_onMonthSelect(
monthUtils.subMonths(
monthUtils.currentMonth(),
type === 'rollover'
? Math.floor((numMonths - 1) / 2)
: numMonths === 2
? 1
: Math.max(numMonths - 2, 0),
),
);
},
{
preventDefault: true,
scopes: ['app'],
},
[_onMonthSelect, startMonth, numMonths],
);
return (
<View
style={{

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { View } from '../common/View';
import { RenderMonths } from './RenderMonths';
@@ -23,7 +23,7 @@ export function IncomeHeader({
justifyContent: 'flex-start',
}}
>
<Button onClick={onShowNewGroup} style={{ fontSize: 12, margin: 10 }}>
<Button onPress={onShowNewGroup} style={{ fontSize: 12, margin: 10 }}>
Add Group
</Button>
</View>

View File

@@ -133,6 +133,17 @@ export const MonthPicker = ({
!selected && {
backgroundColor: theme.buttonBareBackgroundHover,
}),
...(!hovered &&
!selected &&
current && {
backgroundColor: theme.buttonBareBackgroundHover,
filter: 'brightness(120%)',
}),
...(hovered &&
selected &&
current && {
filter: 'brightness(120%)',
}),
...(hovered &&
selected && {
backgroundColor: theme.tableBorderHover,

View File

@@ -8,7 +8,7 @@ import {
import { SvgCheveronDown } from '../../icons/v1';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
@@ -57,6 +57,7 @@ export function SidebarCategory({
userSelect: 'none',
WebkitUserSelect: 'none',
opacity: category.hidden || categoryGroup?.hidden ? 0.33 : undefined,
backgroundColor: 'transparent',
}}
>
<div
@@ -72,13 +73,10 @@ export function SidebarCategory({
</div>
<View style={{ flexShrink: 0, marginLeft: 5 }} ref={triggerRef}>
<Button
type="bare"
variant="bare"
className="hover-visible"
onClick={e => {
e.stopPropagation();
setMenuOpen(true);
}}
style={{ color: 'currentColor', padding: 3 }}
onPress={() => setMenuOpen(true)}
>
<SvgCheveronDown
width={14}

View File

@@ -5,7 +5,7 @@ import { type ConnectDragSource } from 'react-dnd';
import { SvgExpandArrow } from '../../icons/v0';
import { SvgCheveronDown } from '../../icons/v1';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
@@ -94,12 +94,9 @@ export function SidebarGroup({
<>
<View style={{ marginLeft: 5, flexShrink: 0 }} ref={triggerRef}>
<Button
type="bare"
variant="bare"
className="hover-visible"
onClick={e => {
e.stopPropagation();
setMenuOpen(true);
}}
onPress={() => setMenuOpen(true)}
style={{ padding: 3 }}
>
<SvgCheveronDown width={14} height={14} />

View File

@@ -24,6 +24,7 @@ import { useCategories } from '../../hooks/useCategories';
import { useGlobalPref } from '../../hooks/useGlobalPref';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useNavigate } from '../../hooks/useNavigate';
import { useSyncedPref } from '../../hooks/useSyncedPref';
import { styles } from '../../style';
import { View } from '../common/View';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
@@ -75,8 +76,7 @@ function BudgetInner(props: BudgetInnerProps) {
start: startMonth,
end: startMonth,
});
const [budgetTypePref] = useLocalPref('budgetType');
const budgetType = budgetTypePref || 'rollover';
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const [maxMonthsPref] = useGlobalPref('maxMonths');
const maxMonths = maxMonthsPref || 1;
const [initialized, setInitialized] = useState(false);

View File

@@ -1,9 +1,11 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
import { reportBudget } from 'loot-core/src/client/queries';
import { Menu } from '../../common/Menu';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { useReportSheetValue } from './ReportComponents';
type BalanceMenuProps = Omit<
ComponentPropsWithoutRef<typeof Menu>,
@@ -18,7 +20,8 @@ export function BalanceMenu({
onCarryover,
...props
}: BalanceMenuProps) {
const carryover = useSheetValue(reportBudget.catCarryover(categoryId));
const { t } = useTranslation();
const carryover = useReportSheetValue(reportBudget.catCarryover(categoryId));
return (
<Menu
{...props}
@@ -35,8 +38,8 @@ export function BalanceMenu({
{
name: 'carryover',
text: carryover
? 'Remove overspending rollover'
: 'Rollover overspending',
? t('Remove overspending rollover')
: t('Rollover overspending'),
},
]}
/>

View File

@@ -1,4 +1,5 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
import { Menu } from '../../common/Menu';
@@ -17,6 +18,7 @@ export function BudgetMenu({
onApplyBudgetTemplate,
...props
}: BudgetMenuProps) {
const { t } = useTranslation();
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const onMenuSelect = (name: string) => {
switch (name) {
@@ -47,25 +49,25 @@ export function BudgetMenu({
items={[
{
name: 'copy-single-last',
text: 'Copy last months budget',
text: t('Copy last months budget'),
},
{
name: 'set-single-3-avg',
text: 'Set to 3 month average',
text: t('Set to 3 month average'),
},
{
name: 'set-single-6-avg',
text: 'Set to 6 month average',
text: t('Set to 6 month average'),
},
{
name: 'set-single-12-avg',
text: 'Set to yearly average',
text: t('Set to yearly average'),
},
...(isGoalTemplatesEnabled
? [
{
name: 'apply-single-category-template',
text: 'Apply budget template',
text: t('Apply budget template'),
},
]
: []),

View File

@@ -1,25 +1,49 @@
// @ts-strict-ignore
import React, { memo, useRef, useState } from 'react';
import { Trans } from 'react-i18next';
import { reportBudget } from 'loot-core/src/client/queries';
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
import * as monthUtils from 'loot-core/src/shared/months';
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
import { SvgCheveronDown } from '../../../icons/v1';
import { styles, theme, type CSSProperties } from '../../../style';
import { Button } from '../../common/Button';
import { Button } from '../../common/Button2';
import { Popover } from '../../common/Popover';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
import { CellValue } from '../../spreadsheet/CellValue';
import { type Binding, type SheetFields } from '../../spreadsheet';
import { CellValue, type CellValueProps } from '../../spreadsheet/CellValue';
import { useFormat } from '../../spreadsheet/useFormat';
import { Field, SheetCell } from '../../table';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { Field, SheetCell, type SheetCellProps } from '../../table';
import { BalanceWithCarryover } from '../BalanceWithCarryover';
import { makeAmountGrey } from '../util';
import { BalanceMenu } from './BalanceMenu';
import { BudgetMenu } from './BudgetMenu';
export const useReportSheetValue = <
FieldName extends SheetFields<'report-budget'>,
>(
binding: Binding<'report-budget', FieldName>,
) => {
return useSheetValue(binding);
};
const ReportCellValue = <FieldName extends SheetFields<'report-budget'>>(
props: CellValueProps<'report-budget', FieldName>,
) => {
return <CellValue {...props} />;
};
const ReportSheetCell = <FieldName extends SheetFields<'report-budget'>>(
props: SheetCellProps<'report-budget', FieldName>,
) => {
return <SheetCell {...props} />;
};
const headerLabelStyle: CSSProperties = {
flex: 1,
padding: '0 5px',
@@ -38,8 +62,10 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
}}
>
<View style={headerLabelStyle}>
<Text style={{ color: theme.pageTextLight }}>Budgeted</Text>
<CellValue
<Text style={{ color: theme.pageTextLight }}>
<Trans>Budgeted</Trans>
</Text>
<ReportCellValue
binding={reportBudget.totalBudgetedExpense}
type="financial"
style={{ color: theme.pageTextLight, fontWeight: 600 }}
@@ -49,16 +75,20 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
/>
</View>
<View style={headerLabelStyle}>
<Text style={{ color: theme.pageTextLight }}>Spent</Text>
<CellValue
<Text style={{ color: theme.pageTextLight }}>
<Trans>Spent</Trans>
</Text>
<ReportCellValue
binding={reportBudget.totalSpent}
type="financial"
style={{ color: theme.pageTextLight, fontWeight: 600 }}
/>
</View>
<View style={headerLabelStyle}>
<Text style={{ color: theme.pageTextLight }}>Balance</Text>
<CellValue
<Text style={{ color: theme.pageTextLight }}>
<Trans>Balance</Trans>
</Text>
<ReportCellValue
binding={reportBudget.totalLeftover}
type="financial"
style={{ color: theme.pageTextLight, fontWeight: 600 }}
@@ -78,24 +108,40 @@ export function IncomeHeaderMonth() {
}}
>
<View style={headerLabelStyle}>
<Text style={{ color: theme.pageTextLight }}>Budgeted</Text>
<Text style={{ color: theme.pageTextLight }}>
<Trans>Budgeted</Trans>
</Text>
</View>
<View style={headerLabelStyle}>
<Text style={{ color: theme.pageTextLight }}>Received</Text>
<Text style={{ color: theme.pageTextLight }}>
<Trans>Received</Trans>
</Text>
</View>
</View>
);
}
type GroupMonthProps = {
month: string;
group: { id: string; is_income: boolean };
};
export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) {
export const GroupMonth = memo(function GroupMonth({
month,
group,
}: GroupMonthProps) {
const { id } = group;
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<SheetCell
<View
style={{
flex: 1,
flexDirection: 'row',
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetHeaderCurrentMonth
: theme.budgetHeaderOtherMonth,
}}
>
<ReportSheetCell
name="budgeted"
width="flex"
textAlign="right"
@@ -105,7 +151,7 @@ export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) {
type: 'financial',
}}
/>
<SheetCell
<ReportSheetCell
name="spent"
width="flex"
textAlign="right"
@@ -116,7 +162,7 @@ export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) {
}}
/>
{!group.is_income && (
<SheetCell
<ReportSheetCell
name="balance"
width="flex"
textAlign="right"
@@ -163,11 +209,20 @@ export const CategoryMonth = memo(function CategoryMonth({
const [balanceMenuOpen, setBalanceMenuOpen] = useState(false);
const triggerBalanceMenuRef = useRef(null);
const onMenuAction = (...args: Parameters<typeof onBudgetAction>) => {
onBudgetAction(...args);
setBalanceMenuOpen(false);
setMenuOpen(false);
};
return (
<View
style={{
flex: 1,
flexDirection: 'row',
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetCurrentMonth
: theme.budgetOtherMonth,
'& .hover-visible': {
opacity: 0,
transition: 'opacity .25s',
@@ -200,11 +255,8 @@ export const CategoryMonth = memo(function CategoryMonth({
>
<Button
ref={triggerRef}
type="bare"
onClick={e => {
e.stopPropagation();
setMenuOpen(true);
}}
variant="bare"
onPress={() => setMenuOpen(true)}
style={{
padding: 3,
}}
@@ -225,10 +277,9 @@ export const CategoryMonth = memo(function CategoryMonth({
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onBudgetAction?.(month, 'copy-single-last', {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
setMenuOpen(false);
}}
onSetMonthsAverage={numberOfMonths => {
if (
@@ -239,22 +290,20 @@ export const CategoryMonth = memo(function CategoryMonth({
return;
}
onBudgetAction?.(month, `set-single-${numberOfMonths}-avg`, {
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
setMenuOpen(false);
}}
onApplyBudgetTemplate={() => {
onBudgetAction?.(month, 'apply-single-category-template', {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
setMenuOpen(false);
}}
/>
</Popover>
</View>
)}
<SheetCell
<ReportSheetCell
name="budget"
exposed={editing}
focused={editing}
@@ -304,7 +353,7 @@ export const CategoryMonth = memo(function CategoryMonth({
data-testid="category-month-spent"
onClick={() => onShowActivity(category.id, month)}
>
<CellValue
<ReportCellValue
binding={reportBudget.catSumAmount(category.id)}
type="financial"
getStyle={makeAmountGrey}
@@ -336,6 +385,7 @@ export const CategoryMonth = memo(function CategoryMonth({
balance={reportBudget.catBalance(category.id)}
goal={reportBudget.catGoal(category.id)}
budgeted={reportBudget.catBudgeted(category.id)}
longGoal={reportBudget.catLongGoal(category.id)}
style={{
':hover': { textDecoration: 'underline' },
}}
@@ -351,11 +401,10 @@ export const CategoryMonth = memo(function CategoryMonth({
<BalanceMenu
categoryId={category.id}
onCarryover={carryover => {
onBudgetAction?.(month, 'carryover', {
onMenuAction(month, 'carryover', {
category: category.id,
flag: carryover,
});
setBalanceMenuOpen(false);
}}
/>
</Popover>

View File

@@ -1,4 +1,5 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFeatureFlag } from '../../../../hooks/useFeatureFlag';
import { Menu } from '../../../common/Menu';
@@ -24,6 +25,7 @@ export function BudgetMonthMenu({
onOverwriteWithBudgetTemplates,
...props
}: BudgetMonthMenuProps) {
const { t } = useTranslation();
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
return (
<Menu
@@ -51,25 +53,25 @@ export function BudgetMonthMenu({
}
}}
items={[
{ name: 'copy-last', text: 'Copy last months budget' },
{ name: 'set-zero', text: 'Set budgets to zero' },
{ name: 'copy-last', text: t('Copy last months budget') },
{ name: 'set-zero', text: t('Set budgets to zero') },
{
name: 'set-3-avg',
text: 'Set budgets to 3 month average',
text: t('Set budgets to 3 month average'),
},
...(isGoalTemplatesEnabled
? [
{
name: 'check-templates',
text: 'Check templates',
text: t('Check templates'),
},
{
name: 'apply-goal-template',
text: 'Apply budget template',
text: t('Apply budget template'),
},
{
name: 'overwrite-goal-template',
text: 'Overwrite with budget template',
text: t('Overwrite with budget template'),
},
]
: []),

View File

@@ -1,5 +1,6 @@
// @ts-strict-ignore
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'glamor';
@@ -8,7 +9,7 @@ import * as monthUtils from 'loot-core/src/shared/months';
import { SvgDotsHorizontalTriple } from '../../../../icons/v1';
import { SvgArrowButtonDown1, SvgArrowButtonUp1 } from '../../../../icons/v2';
import { theme, styles } from '../../../../style';
import { Button } from '../../../common/Button';
import { Button } from '../../../common/Button2';
import { Popover } from '../../../common/Popover';
import { Stack } from '../../../common/Stack';
import { View } from '../../../common/View';
@@ -25,6 +26,7 @@ type BudgetSummaryProps = {
month?: string;
};
export function BudgetSummary({ month }: BudgetSummaryProps) {
const { t } = useTranslation();
const {
currentMonth,
summaryCollapsed: collapsed,
@@ -50,7 +52,10 @@ export function BudgetSummary({ month }: BudgetSummaryProps) {
return (
<View
style={{
backgroundColor: theme.tableBackground,
backgroundColor:
month === currentMonth
? theme.budgetCurrentMonth
: theme.budgetOtherMonth,
boxShadow: styles.cardShadow,
borderRadius: 6,
marginLeft: 0,
@@ -84,10 +89,14 @@ export function BudgetSummary({ month }: BudgetSummaryProps) {
}}
>
<Button
type="bare"
aria-label={`${collapsed ? 'Expand' : 'Collapse'} month summary`}
variant="bare"
aria-label={
collapsed
? t('Expand month summary')
: t('Collapse month summary')
}
className="hover-visible"
onClick={onToggleSummaryCollapse}
onPress={onToggleSummaryCollapse}
>
<ExpandOrCollapseIcon
width={13}
@@ -133,9 +142,9 @@ export function BudgetSummary({ month }: BudgetSummaryProps) {
<View style={{ userSelect: 'none' }}>
<Button
ref={triggerRef}
type="bare"
aria-label="Menu"
onClick={onMenuOpen}
variant="bare"
aria-label={t('Menu')}
onPress={onMenuOpen}
>
<SvgDotsHorizontalTriple
width={15}

View File

@@ -1,30 +1,38 @@
// @ts-strict-ignore
import React, {
type CSSProperties,
type ComponentProps,
type ComponentType,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { theme, styles } from '../../../../style';
import { Text } from '../../../common/Text';
import { View } from '../../../common/View';
import { type SheetFields, type Binding } from '../../../spreadsheet';
import { CellValue } from '../../../spreadsheet/CellValue';
type BudgetTotalProps = {
type BudgetTotalProps<
CurrentField extends SheetFields<'report-budget'>,
TargetField extends SheetFields<'report-budget'>,
> = {
title: ReactNode;
current: ComponentProps<typeof CellValue>['binding'];
target: ComponentProps<typeof CellValue>['binding'];
current: Binding<'report-budget', CurrentField>;
target: Binding<'report-budget', TargetField>;
ProgressComponent: ComponentType<{ current; target }>;
style?: CSSProperties;
};
export function BudgetTotal({
export function BudgetTotal<
CurrentField extends SheetFields<'report-budget'>,
TargetField extends SheetFields<'report-budget'>,
>({
title,
current,
target,
ProgressComponent,
style,
}: BudgetTotalProps) {
}: BudgetTotalProps<CurrentField, TargetField>) {
const { t } = useTranslation();
return (
<View
style={{
@@ -45,7 +53,8 @@ export function BudgetTotal({
<Text>
<CellValue binding={current} type="financial" />
<Text style={{ color: theme.pageTextSubdued, fontStyle: 'italic' }}>
{' of '}
{' '}
{t('of')}{' '}
<CellValue
binding={target}
type="financial"

View File

@@ -1,19 +1,19 @@
import React, { type ComponentProps } from 'react';
import React from 'react';
import { theme } from '../../../../style';
import { type CellValue } from '../../../spreadsheet/CellValue';
import { useSheetValue } from '../../../spreadsheet/useSheetValue';
import { type Binding } from '../../../spreadsheet';
import { useReportSheetValue } from '../ReportComponents';
import { fraction } from './fraction';
import { PieProgress } from './PieProgress';
type ExpenseProgressProps = {
current: ComponentProps<typeof CellValue>['binding'];
target: ComponentProps<typeof CellValue>['binding'];
current: Binding<'report-budget', 'total-spent'>;
target: Binding<'report-budget', 'total-budgeted'>;
};
export function ExpenseProgress({ current, target }: ExpenseProgressProps) {
let totalSpent = useSheetValue(current) || 0;
const totalBudgeted = useSheetValue(target) || 0;
let totalSpent = useReportSheetValue(current) || 0;
const totalBudgeted = useReportSheetValue(target) || 0;
// Reverse total spent, and also set a bottom boundary of 0 (in case
// income goes into an expense category and it's "positive", don't

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { reportBudget } from 'loot-core/src/client/queries';
@@ -11,9 +12,10 @@ type ExpenseTotalProps = {
style?: CSSProperties;
};
export function ExpenseTotal({ style }: ExpenseTotalProps) {
const { t } = useTranslation();
return (
<BudgetTotal
title="Expenses"
title={t('Expenses')}
current={reportBudget.totalSpent}
target={reportBudget.totalBudgetedExpense}
ProgressComponent={ExpenseProgress}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { reportBudget } from 'loot-core/src/client/queries';
@@ -11,9 +12,10 @@ type IncomeTotalProps = {
style?: CSSProperties;
};
export function IncomeTotal({ style }: IncomeTotalProps) {
const { t } = useTranslation();
return (
<BudgetTotal
title="Income"
title={t('Income')}
current={reportBudget.totalIncome}
target={reportBudget.totalBudgetedIncome}
ProgressComponent={IncomeProgress}

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