Compare commits

..

110 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
8f634099e2 🔖 (24.4.0) custom reports improvements, stability enhancements (#2537) 2024-04-02 07:07:16 +01:00
Joel Jeremy Marquez
7ee48e4c1d Fix mobile autocomplete colors (#2530)
* Fix mobile autocomplete colors

* Release notes

* Remove narrow specific autocomplete colors

* Update upcoming-release-notes/2530.md

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

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-04-01 05:43:30 -07:00
Matiss Janis Aboltins
270705b3cd 📈 adding extra console.log lines for bank-sync to help with troubleshooting (#2529)
* 📈 adding extra console.log lines for bank-sync to help with troubleshooting

* Release notes
2024-03-31 13:28:45 +01:00
Neil
9588a76109 Add Area Icon (#2526)
* Add Area Icon

* notes
2024-03-30 20:14:49 +00:00
Ryan Bianchi
ada9e7da31 [WIP] Fix custom reports cold-reload crash (#2528)
* when custom reports were cold-reloaded, the selections would be null crashing the page

* add release notes for PR

* fix typo in release note file name
2024-03-30 20:03:20 +00:00
Neil
38ffccb903 Custom Reports live data (#2505)
* Live card data

* Live Data

* notes

* Patch strict TS issues

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-03-26 09:04:26 +00:00
Robert Dyer
f5023a7c07 feature: Show account sync indicators when viewing accounts on mobile (#2476)
* cleanup: Move account sync indicators to the left on mobile

* cleanup: account sync indicators on mobile should show pending/failed syncs

* feature: show account sync indicator when viewing account on mobile

* fix lint issues

* add release note
2024-03-25 22:11:50 -07:00
Joel Jeremy Marquez
bc6a0f8e60 Fix mobile transactions load more (#2504)
* Fix mobile transaction load more

* Release notes
2024-03-25 08:24:07 -07:00
Joel Jeremy Marquez
5ee7d336ef Autocomplete changes related to mobile modals PR (#2500)
* Autocomplete changes related to mobile modals PR

* Release notes

* Fix lint error

* AccountDetails

* Code review updates
2024-03-25 08:23:29 -07:00
Matiss Janis Aboltins
24e42daa51 🐛 fix hotkeys sometimes not working (#2489) 2024-03-24 07:08:07 +00:00
Matiss Janis Aboltins
4bafd13c55 🐛 (import) disallow importing transactions with too large or small amounts (#2494) 2024-03-24 07:06:34 +00:00
Matiss Janis Aboltins
afaee6bc16 ♻️ (typescript) port some common components to strict TS (#2481) 2024-03-23 09:35:20 +00:00
Neil
e44fbb3847 Create wip (#2443)
* Create wip.yml

Adding WIP to new PRs

* Create 2443.md

notes

* Update .github/workflows/wip.yml

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

* Update .github/workflows/wip.yml

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

* Update upcoming-release-notes/2443.md

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

* Update wip.yml

* Update 2443.md

* Update wip.yml

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-03-22 20:53:12 +00:00
Neil
ff70c654a2 Custom Reports Error Catch (#2492)
* Error Catch

* notes

* fixes
2024-03-22 16:03:47 +00:00
Joel Jeremy Marquez
586a26968c Force async onRefresh on PullToRefresh component (#2487)
* Force async onRefresh

* Release notes
2024-03-21 13:36:59 -07:00
Matiss Janis Aboltins
107acdb36b 🐛 (report) do not include offbudget transfers in cash-flow card expenses (#2485) 2024-03-21 19:13:59 +00:00
Matiss Janis Aboltins
5343030800 🐛 (import) patch phantom transactions getting created (#2464) 2024-03-21 18:16:37 +00:00
Robert Dyer
d7635755f2 Fix error when viewing uncategorized transactions when there are upcoming scheduled transactions. (#2475)
* fix: Error when opening uncategorized transactions page

* add release note

* fix linter warning
2024-03-21 07:45:58 -07:00
Wizmaster
501c6a02cc Import locked transactions from nYNAB (#2474)
* Import locked transactions from nYNAB

* Import locked transactions from nYNAB
- Adding PR release note
2024-03-20 08:13:01 -07:00
Neil
6281cc751e Custom Reports: Intervals Updates (#2479)
* IntervalsUpdates

* notes

* updates

* add Ranges

* calc Leg strict

* ts updates

* review updates
2024-03-20 09:01:44 +00:00
Ikko Eltociear Ashimine
dc7cab501e Update sync.ts (#2478) 2024-03-19 19:35:52 +00:00
Daniel Gale-Rosen
f51376a4a5 Increase minimum cleared column width (Fix #2341) (#2462)
* increase minimum cleared column width

* add release notes

* update snapshots
2024-03-17 21:25:14 -07:00
Joel Jeremy Marquez
6f600a4fee Convert BudgetTable to a functional component (#2459)
* Convert BudgetTable to a functional component

* Release notes

* Fix lint errors

* Remove undo-event listener
2024-03-15 07:25:07 -07:00
Neil
c82a6dc5ef Custom Reports disabled list (#2410)
* Add interval split and menu items

* notes

* disabledList work

* notes

* fix

* fix balanceType

* rework constants

* review fixes
2024-03-14 19:35:59 +00:00
youngcw
ab97a3fbcd import locked transactions ynab4 (#2455)
* import locked transactions

* note, lint
2024-03-12 17:06:52 -07:00
shall0pass
8b15c8cc17 [Theme] Bugfix: Bulk edit background color for Midnight theme (#2460)
* bulk edit background color

* change

* NOTE
2024-03-12 19:01:28 -05:00
Matiss Janis Aboltins
02ec279a64 ♻️ (typescript) make rollover budget components strict TS compliant (#2453) 2024-03-12 22:04:25 +00:00
Joel Jeremy Marquez
ab8c3c018a [Maintenance] Reorganize mobile components (#2425)
* Reorganize mobile components

* Fix lint error

* Cleanup

* Fix lint error

* Release notes

* Cleanup

* Cleanup useActions

* useDebounceCallback
2024-03-12 15:01:38 -07:00
Joel Jeremy Marquez
8356640e45 Fix midnight theme autocomplete hover (#2461)
* Fix midnight theme autocomplete hover and make ItemHeader not depend on category groups

* Update

* Release notes

* Fix typecheck error
2024-03-12 12:54:16 -07:00
Joel Jeremy Marquez
1767a32b3d [Maintenance] Input onChangeValue and onUpdate prop naming (#2381)
* Differentiate Input onUpdate to onChangeValue

* Release notes

* Fix lint error

* Remove onChange
2024-03-12 08:54:47 -07:00
Neil
5bcfc71be6 Hide hidden categories in auto-complete (#2429)
* the work

* notes

* add to elements
2024-03-11 16:23:37 +00:00
DJ Mountney
998efb9447 Fix budget key tab navigation (#2452)
* Fix budget key tab navigation

- restore the collapsed property

* Add release note
2024-03-09 14:19:30 -07:00
Robert Dyer
dc159d71a2 Show a modal to confirm unlinking accounts (#2441) 2024-03-09 18:40:16 +00:00
Robert Dyer
7db7b5c400 Provide "api/category-groups-get" API endpoint (#2446) 2024-03-09 17:29:20 +00:00
Robert Dyer
823b426952 Show scheduled transactions when viewing "All accounts" (#2447) 2024-03-09 17:17:15 +00:00
DJ Mountney
8827169bfa Fix flaky transfer e2e test (#2434)
* Fix flaky transfer e2e test

- If tests ran too fast, the transactions wouldn't finish rendering
  before the select of the transactions was attempted.

* Add release note
2024-03-07 13:02:42 -08:00
CampaniaGuy
90eaf2ba17 [WIP] Implement "pill gesture" icon in navigation bar on Mobile view for better UX (#2419)
* Fix mobile UX, issue #2079

* upcoming-release-notes

* Fix navigation bar buttons not fully displayed

* pill size changed, exported nav bar height

* Edit navigation bar const
Edit nav bar pill color

* Removed export

* Fixes release notes

* Fixed formatting

* Fix mobile UX, issue #2079

* upcoming-release-notes

* Fix navigation bar buttons not fully displayed

* pill size changed, exported nav bar height

* Edit navigation bar const
Edit nav bar pill color

* Removed export

* Fixes release notes

* Fixed formatting

* Test schedules and mobile images created

* Reverted schedules checks test images
2024-03-06 15:41:05 -07:00
Neil
a5fa0f3bb6 Show activity updates (#2408)
* Show activity changes

* notes

* adjust filter loading

* switch back

* adjust test

* lint fix

* budget test

* lint fix

* Filtered Balance

* flicker fix

* VRT updates

* remove test variables

* default category

* categoryId separated
2024-03-06 22:37:28 +00:00
Matiss Janis Aboltins
4570459d85 🔥 delete abandoned sankey feature (#2417)
Closes #1919
2024-03-06 17:28:08 +00:00
Attila Kerekes
76cbd44c75 fix ofx amount parsing for longer decimal places (#2421) 2024-03-06 09:42:41 -07:00
shall0pass
0e0d960cd4 [Bugfix] Midnight theme - Budget Name color, Mobile category color in account view (#2422)
* budget name / mobile account category color

* note

* color of plus on account page
2024-03-05 13:50:41 -06:00
shall0pass
98c17bd5e0 [Goals] Schedule top off amount (#2404)
* add log messages to schedule templates

* log the included schedules

* more log output

* use a negate filter for sinking funds

* carve out a top out exception if no sinking funds

* note
2024-03-04 14:46:08 -06:00
shall0pass
601c9aa7df [End of Month Cleanup] Remove automatic funding of rollover categories (#2409)
* remove automatic funding of rollover categories

* note
2024-03-04 14:45:15 -06:00
Matiss Janis Aboltins
3b77609159 (bank-sync) quality of life improvements for sync (#2416) 2024-03-04 18:42:34 +00:00
DJ Mountney
66261641a0 Add option to make a transfer from two selected transactions (#2398)
* Adds option to make a transfer from two selected transactions

- Transactions amount must match
- Transactions must be from different accounts
- Split transactions not eligible
2024-03-03 13:34:08 -08:00
DJ Mountney
4b034468e3 Update shared transaction module to strict typescript (#2388)
* Update shared transaction module to strict typescript
2024-03-03 13:18:03 -08:00
Matiss Janis Aboltins
b700aee87d ♻️ (typescript) move some files to strict mode (#2403) 2024-03-03 17:50:13 +00:00
Matiss Janis Aboltins
9fca85209f 🔖 (24.3.0) (#2413)
* 🔖 (24.3.0)

* Remove used release notes

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-03 17:05:39 +00:00
Neil
a9362cc6f9 BarGraph crash (#2411)
* Fix crash

* notes
2024-03-02 07:35:05 +00:00
Neil
40296dc876 Custom reports updates (#2386)
* Button changes and time filters

* rename on dashboard

* notes

* fix time filters

* Sort Categories

* Page title

* category sort order

* move button

* featureflag

* Highlight report name

* sankey fix

* VRT

* remove doubled element

* update

* fixes

* fixes

* create button fix and rename card fix

* Title change
2024-03-01 18:41:32 +00:00
Neil
ed1e0ceb30 Custom Reports AutoComplete (#2350)
* updated saved work

* merge fixes

* Disable CREATE TABLE

* notes

* turn on db table

* Fix TableGraph recall crash

* table format changes

* type fixes

* fixing some card displays

* merge fixes

* revert table change

* cardMenu width

* Add Saved Reports Autocomplete

* notes

* fix invalid values crash

* Title and auto-focus and esc

* notes

* fix filtering logic

* reload saved filters

* lint fix

* visual graph changes

* merge fixes

* fix

* review updates
2024-02-28 21:50:23 +00:00
Neil
55817b0e70 Add interval split and menu items to custom reports (#2389)
* Add interval split and menu items

* notes
2024-02-28 19:47:42 +00:00
youngcw
6d7d12138c Update csv amount parser. Fix #2374 (#2399)
* specify 2 decimal places in the csv amount parser

* note

* update tests

* cleanup
2024-02-28 11:39:23 -07:00
youngcw
52f1f79c01 fix #2363 broken create schedule from transaction (#2401)
* fix missing name

* note

* update how payees is created

* lint
2024-02-27 10:44:20 -07:00
shall0pass
991fc4f450 [Theme] Midnight updates (#2394)
* color updates

* release note
2024-02-26 14:21:54 -06:00
shall0pass
4a8c692d06 [Theme] Midnight updates (#2385)
* update colors

* remove comments
2024-02-21 13:50:00 -06:00
Neil
0609f47cc3 AutoComplete clean-up (#2349)
* AutoComplete clean-up

* notes

* lint fix

* fix tests

* review fix

* type
2024-02-21 18:59:05 +00:00
Julian Dominguez-Schatz
0d1e6f2ee7 Display rules with splits on rules page (#2368)
* Show splits on rules page

* Add `ActionExpression` for 'allocate'-type actions

* Add release notes

* Fix type errors
2024-02-21 11:47:23 -07:00
Neil
8568aebdbb Custom Reports: save reports (*new SQL table creation*) (#2335)
* updated saved work

* merge fixes

* Disable CREATE TABLE

* notes

* turn on db table

* Fix TableGraph recall crash

* table format changes

* type fixes

* fixing some card displays

* merge fixes

* revert table change

* cardMenu width

* notes

* fix filtering logic

* reload saved filters

* lint fix

* visual graph changes

* slice fixes

* typescript update
2024-02-21 18:12:26 +00:00
Julian Dominguez-Schatz
bfb7c1d213 Add plugin for offline PWA support (#2369)
* Add plugin for offline PWA support

* Add release notes

* Attempt to fix kcab fetch issue

* Fix type errors

* Cache more file types

* Empty commit to try to bump action

* Attempt to fix fonts
2024-02-20 18:23:27 -07:00
Joel Jeremy Marquez
e526555748 [Maintenance] Add tsconfig excludes (#2380)
* Add tsconfig excludes

* Release notes
2024-02-20 13:11:14 -08:00
DJ Mountney
45c4b262a2 Fix ability to rename budget in the UI (#2383) 2024-02-20 11:04:54 -08:00
youngcw
e1f805b9c9 schedule default amount (#2360)
* schedule default amount

* note

* vrt
2024-02-20 10:07:18 -07:00
DJ Mountney
d0c11cd3af Converts html special characters in ofx values to plaintext (#2364)
* Converts html special characters in ofx values to plaintext

- Apply during ofx/qfx import
2024-02-17 10:49:02 -08:00
shall0pass
0c5bce8baf [Enhancement] Theme: Midnight (#2312)
* midnight updates

* updates

* updates

* background one shade darker

* change highlights to match other accents

* release note

* link color

* variable spelling

* Upcoming pill color

* theme switch bug

* remove development, type error

* toggle background, disabled background+text

* account pillboxes and icons

* typecheck error
2024-02-16 09:43:24 -06:00
Matiss Janis Aboltins
e650e00cb8 ♻️ (eslint) enable some rules to enforce better code quality (#2357) 2024-02-16 07:34:55 +00:00
DHRUV RAMDEV
422996f8a7 fix: Margin when editing account on desktop (#2286)
* fix: Margin when editing account on desktop

* add release notes

* increase margin

* Update regression tests

* fix: Reduce padding in other areas

* update snapshots
2024-02-15 10:34:23 -08:00
Tristan Radisson
9cad57c607 #1383 Allow early post transaction of schedules (#2358)
* Allow early schedule post transaction

* Added release notes file
2024-02-15 07:33:00 -07:00
Neil
2a0f8335ed Convert SavedFilters to Typescript (#2320)
* move saved filters

* MenuButton

* fixes

* update

* FiltersStack

* notes

* merge fixes

* review fixes

* error catch
2024-02-13 11:16:54 -08:00
Joel Jeremy Marquez
08cbdab2a1 Hooks for frequently made operations (#2293)
* Hooks for frequently made operations

* Release notes

* Fix typecheck errors

* Remove useGlobalPrefs

* Add null checks

* Fix showCleared pref

* Add loaded flag for categories, accounts and payees state

* Refactor to reduce unnecessary states

* Fix eslint errors

* Fix hooks deps

* Add useEffect

* Fix typecheck error

* Set local and global pref hooks

* Fix lint error

* VRT

* Fix typecheck error

* Remove eager loading

* Fix typecheck error

* Fix typo

* Fix typecheck error

* Update useTheme

* Typecheck errors

* Typecheck error

* defaultValue

* Explicitly check undefined

* Remove useGlobalPref and useLocalPref defaults

* Fix default prefs

* Default value

* Fix lint error

* Set default theme

* Default date format in Account

* Update packages/desktop-client/src/style/theme.tsx

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

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2024-02-12 07:07:44 -08:00
Matiss Janis Aboltins
ec2de3b387 🔥 (remove victory dependency) (#2356) 2024-02-11 21:50:18 +00:00
Johannes Löthberg
65372e86a5 Remove category spending report (#2344)
It has been superseded by the new custom reports feature.

Signed-off-by: Johannes Löthberg <johannes@kyriasis.com>
2024-02-11 17:06:41 +00:00
Hinerangi Courtenay
53a61000a4 fix: Mobile menu blocks items #2040 (#2352)
* Fix Mobile menu blocks items (#2040)

* Only set bottom padding on pages the mobile nav bar is used (#2040)

* Add release note for PR
2024-02-10 18:13:13 -08:00
Neil
485902af6b Update Custom Report styles (#2345)
* updated saved work

* merge fixes

* Disable CREATE TABLE

* notes

* turn on db table

* Fix TableGraph recall crash

* table format changes

* type fixes

* fixing some card displays

* merge fixes

* revert table change

* Revert Changes

* notes

* merge fixes

* notes

* notes

* revert notes

* Area changes
2024-02-10 21:54:47 +00:00
Neil
5a67b7e822 Dynamic graph margins (#2346)
* dynamic graph margins

* notes
2024-02-08 16:31:54 -08:00
DJ Mountney
b994a6a74a Fix parse errors with OFX data with no transactions (#2342)
* Fix parse errors with OFX data with no transactions

- Return an empty array instead of an array of undefined
2024-02-08 13:15:21 -08:00
Matiss Janis Aboltins
b1b266e83c 🐛 (typescript) patching sendCatch type (#2343)
* 🐛 (typescript) patching sendCatch type

* Release notes
2024-02-08 20:53:05 +00:00
youngcw
58626c0026 update manifest (#2285)
* update manifest

* note

* note

* typo

* theme color

* add maskable icons

* fix icon color
2024-02-08 10:41:55 -07:00
DJ Mountney
45094daf2f Typescript: pass 2 at updating api-handlers to match server handlers (#2334)
* Typescript: pass 2 at updating api-handlers to match server handlers
2024-02-08 09:06:43 -08:00
Julian Dominguez-Schatz
2bb7b3c2ee Add rules with splits (#2059)
* Add split creation UI to rule creation modal

* Support applying splits when rules execute

* fix: deserialize transaction before running rules

According to how rules are run in other places in the app, we should be
supplying a "deserialized" (i.e., integer-for-amount and ISO date)
transaction rather than a "serialized" (amount-plus-formatted-date) one.
This fixes a crash in how split transactions are applied, as well as
date-based rules not applying correctly previously (any rule with a date
condition would never match on mobile).

* Add release notes

* Fix missing types pulled in from master

* PR feedback: use `getActions`

* PR feedback: use `flatMap`

* Fix action deletion

* Don't flicker upon split deletion

* Let users specify parent transaction actions (e.g. linking schedules)

* Support empty splits

* Revert adding `no-op` action type

* Support splits by percent

* Fix types

* Fix crash on transactions page when posting a transaction

The crash would probably have occurred in other places too with
auto-posting schedules :/

* Fix a bug where schedules wouldn't be marked as completed

This was because the query that we previously used didn't select parent
transactions, so no transaction was marked as being scheduled (since
only parent transactions have schedule IDs).

* Add feature flag

* Limit set actions within splits to fewer fields

* Fix merge conflict

* Don't run split rules if feature is disabled

* Fix percent-based splits not applying

* Fix crash when editing parent transaction amount

* Auto-format

* Attempt to fix failing tests

* More test/bug fixes

* Add an extra split at the end if there is a remaining amount

* Make sure split has correct values for dynamic remainder

* Remove extraneous console.log
2024-02-08 07:56:30 -07:00
Matiss Janis Aboltins
d6f610a326 added 'show/hide balance' button to cash flow report (#2322) 2024-02-08 08:12:36 +00:00
Joel Jeremy Marquez
8a8113a648 Update loot-core deps (#2280)
* Upgrade desktop-client depenencies

* yarn dedupe

* Update useSelectors

* Update loot-core deps

* yarn dedupe

* Move deps to devDependencies

* yarn dedupe
2024-02-07 18:40:59 -08:00
Joel Jeremy Marquez
029e2f09bf Update desktop client package versions (#2270)
* Upgrade desktop-client depenencies

* Release notes

* yarn dedupe

* yarn dedupe

* Fix typecheck error

* Update sass

* Update useSelectors
2024-02-06 17:43:09 -08:00
jaarasys-henria
86007e392f [Enhancement] Make transaction list sortable by cleared status (#1994) 2024-02-06 15:07:36 -07:00
Neil
4c4f2fd426 Custom Reports: add save reports menu (#2257)
* Add schema work

* notes

* merge fixes

* Add Reports Save Menu

* merge fixes

* updates

* notes

* updates

* updates

* save updates fix

* typecheck fixes

* merge fixes

* saveReport strict Typescript

* fix sidebar

* lint fix

* fixing functionality plus clean up

* clean up
2024-02-05 22:40:46 +00:00
Matiss Janis Aboltins
7a18827b1d allow running AQL against local database (#2326) 2024-02-05 19:16:02 +00:00
shall0pass
77fd65b2e7 [Bugfix] Cleanup tool: Add balance check (#2295)
* add balance check

* lint

* fill rollover categories after non-rollover

* allow partial fills of non-rollover

* update

* release note and youngcw suggestion

* warnings

* remove commented coded
2024-02-05 06:21:42 -06:00
Julian Dominguez-Schatz
30f03e8079 Save name fields on unfocus (#2327) 2024-02-04 16:50:36 +00:00
Matiss Janis Aboltins
6e16262b63 🔥 removing unused/dead code (#2328) 2024-02-04 16:05:11 +00:00
xentara1
29a515f3fe [Feature] Add ability to create schedules from existing transactions (#2222) 2024-02-03 19:00:27 +00:00
Julian Dominguez-Schatz
a5ab1a8fae Re-open autocomplete dropdown on change (#2325)
* Re-open autocomplete dropdown on change

* Add release notes with new PR ID
2024-02-03 11:16:15 -07:00
yoyotogblo
d37622162a Change lookback and look forward time range when fuzzy matching (#2300)
Change to 7 days prior and after for imported transactions when fuzzy matching
2024-02-03 11:08:45 -07:00
Neil
e3a8366dd7 Update and organize reports (#2274)
* Add schema work

* notes

* merge fixes

* Add Reports Save Menu

* merge fixes

* updates

* notes

* updates

* updates

* save updates fix

* typecheck fixes

* revert changes

* notes

* error fixes

* update

* fix

* merge fixes

* review changes

* reportChange and savedStatus

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

Co-authored-by: DJ Mountney <david.mountney@twkie.net>

* merge fixes

---------

Co-authored-by: DJ Mountney <david.mountney@twkie.net>
2024-02-02 23:52:35 -08:00
Joel Jeremy Marquez
d5e49dde59 Update yarn to 4.0.2 (#2283)
* Update yarn

* Release notes
2024-02-02 18:11:07 -08:00
Joel Jeremy Marquez
f5258e6ebe Consider child transactions when fuzzy matching imported transactions (#2309)
* Consider child transaction when fuzzy matching

* Release notes
2024-02-02 18:03:57 -08:00
Joel Jeremy Marquez
f4d80fad92 [Maintenance] Remove modals.d.ts (#2298)
* Remove modals.d.ts

* Release notes

* Fix typecheck

* Fix lint error
2024-02-02 17:56:23 -08:00
DJ Mountney
3324dd5fa0 Fix docker start browser error (#2304)
* Fix docker start browser error

Don't launch browser when in docker
2024-02-02 17:18:27 -08:00
shall0pass
14509d15df [Bugfix] Dark Theme variable name misspelled (#2317)
* misspelling of variable name

* release note
2024-02-02 15:51:06 -06:00
Ifeoluwa Odubela
9b461c48c9 Bugfix: Add Primary Button hover background colors for light and dark theme #1971 (#2123)
* Bugfix: Add Primary Button hover background colors

* Add release notes

* Rename 1971.md to 2123.md

* Update release note 2123.md typo

* Update packages/desktop-client/src/style/themes/dark.ts

* Update packages/desktop-client/src/style/themes/light.ts

---------

Co-authored-by: Neil <55785687+carkom@users.noreply.github.com>
2024-02-02 21:26:13 +00:00
jaarasys-henria
55f2d126b3 [Maintenance] Pass HTTPS flag to dev container to enable HTTPS (#2316) 2024-02-02 21:14:45 +00:00
Matiss Janis Aboltins
6ae2047ab8 🔧 upgrade deprecated github actions (#2319) 2024-02-02 20:38:57 +00:00
Pedro Primor
9fdffcc8e9 Change month picker hover background color (#2121)
* Change month picker hover background color

* Add release notes

* Add updated screenshots from visual regression tests

* Revert "Add updated screenshots from visual regression tests"

This reverts commit d9d83b4789.

* Update failing visual regression tests screenshots

* Revert "Update failing visual regression tests screenshots"

This reverts commit aaef019191.
2024-02-02 13:11:16 -07:00
DHRUV RAMDEV
3daff4381f feat: Don't allow duplicate cat-groups in budget (#2262)
* feat: Don't allow duplicate cat-groups in budget

* Add release notes

* fix: error message

* pass group instead of name for accurate error message

* improve error message
2024-02-02 13:10:36 -07:00
Neil
5914469b11 Convert FiltersMenu to Typescript (part 1) (#2231)
* migration work

* notes

* typecheck

* typecheck fixes

* fixes

* merge fixes

* typecheck updates

* review fixes
2024-02-02 20:10:24 +00:00
youngcw
39e7f2598b fix budget header collapsed colors (#2313)
* fix budget header collapsed colors

* note
2024-02-02 13:09:53 -07:00
Neil
c8d326d24b Custom Reports - split out hidden categories from offbudget (#2302)
* Add Toggles

* budget table

* testing

* updates

* updates

* fixes

* updates

* fix Menu

* lint fixes

* fix keybindings

* revert budget menu changes

* notes

* remove default exports

* fixes

* disabled fix

* add style option

* lint fix

* remove css

* lint fixes

* color updates

* merge menu with togglemenu

* host

* menu fixes

* fix regression

* remove host

* adjustments

* work

* fix hidden filters

* merge fixes

* adjustments

* updates

* fix uncat table values

* fixes

* notes

* title change

* Adjust showHide selector
2024-02-02 20:09:27 +00:00
Matiss Janis Aboltins
d8639a2a71 🔖 (24.2.0) cleared transaction improvements; experimental simplefin bank-sync (#2311) 2024-02-02 19:20:53 +00:00
Matiss Janis Aboltins
734191424b 🐛 (goCardless) patch incomplete migration (#2308) 2024-02-01 16:40:12 +00:00
shall0pass
5d4fcfde00 [Enhancement] Goal Target with cleanup template (#2282)
* update goal target after montly cleanup

* release note
2024-01-31 13:42:05 -06:00
Joel Jeremy Marquez
54d7e5460a [Cleanup] useSingleActiveEditForm hook on mobile budget table (#2263)
* useSingleActiveEditForm on mobile budget table

* Release notes

* Remove unused variables
2024-01-30 14:37:06 -08:00
467 changed files with 11735 additions and 8726 deletions

View File

@@ -38,6 +38,7 @@ module.exports = {
extends: [
'react-app',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
@@ -57,7 +58,7 @@ module.exports = {
'@typescript-eslint/no-unused-vars': [
'warn',
{
varsIgnorePattern: '^_',
varsIgnorePattern: '^(_|React)',
ignoreRestSiblings: true,
},
],
@@ -90,15 +91,7 @@ module.exports = {
'react/prop-types': 'off',
// TODO: re-enable these rules
'react-hooks/exhaustive-deps': 'off',
'react/display-name': 'off',
'react/react-in-jsx-scope': 'off',
// 'react-hooks/exhaustive-deps': [
// 'warn',
// {
// additionalHooks: 'useLiveQuery',
// },
// ],
'no-var': 'warn',
'react/jsx-curly-brace-presence': 'warn',
@@ -277,6 +270,69 @@ module.exports = {
'import/no-default-export': 'off',
},
},
{
// TODO: fix the issues in these files
files: [
'./packages/desktop-client/src/components/accounts/Account.jsx',
'./packages/desktop-client/src/components/accounts/MobileAccount.jsx',
'./packages/desktop-client/src/components/accounts/MobileAccounts.jsx',
'./packages/desktop-client/src/components/App.tsx',
'./packages/desktop-client/src/components/budget/BudgetCategories.jsx',
'./packages/desktop-client/src/components/budget/BudgetSummaries.tsx',
'./packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx',
'./packages/desktop-client/src/components/budget/index.tsx',
'./packages/desktop-client/src/components/budget/MobileBudget.tsx',
'./packages/desktop-client/src/components/budget/rollover/HoldTooltip.tsx',
'./packages/desktop-client/src/components/budget/rollover/TransferTooltip.tsx',
'./packages/desktop-client/src/components/common/Menu.tsx',
'./packages/desktop-client/src/components/FinancesApp.tsx',
'./packages/desktop-client/src/components/GlobalKeys.ts',
'./packages/desktop-client/src/components/LoggedInUser.tsx',
'./packages/desktop-client/src/components/manager/ManagementApp.jsx',
'./packages/desktop-client/src/components/manager/subscribe/common.tsx',
'./packages/desktop-client/src/components/ManageRules.tsx',
'./packages/desktop-client/src/components/mobile/MobileAmountInput.jsx',
'./packages/desktop-client/src/components/mobile/MobileNavTabs.tsx',
'./packages/desktop-client/src/components/Modals.tsx',
'./packages/desktop-client/src/components/modals/EditRule.jsx',
'./packages/desktop-client/src/components/modals/ImportTransactions.jsx',
'./packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx',
'./packages/desktop-client/src/components/Notifications.tsx',
'./packages/desktop-client/src/components/payees/ManagePayees.jsx',
'./packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx',
'./packages/desktop-client/src/components/payees/PayeeTable.tsx',
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx',
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx',
'./packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx',
'./packages/desktop-client/src/components/reports/reports/CustomReport.jsx',
'./packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx',
'./packages/desktop-client/src/components/reports/SaveReportName.tsx',
'./packages/desktop-client/src/components/reports/useReport.ts',
'./packages/desktop-client/src/components/schedules/ScheduleDetails.jsx',
'./packages/desktop-client/src/components/schedules/SchedulesTable.tsx',
'./packages/desktop-client/src/components/select/DateSelect.tsx',
'./packages/desktop-client/src/components/sidebar/Tools.tsx',
'./packages/desktop-client/src/components/sort.tsx',
'./packages/desktop-client/src/components/spreadsheet/useSheetValue.ts',
'./packages/desktop-client/src/components/table.tsx',
'./packages/desktop-client/src/components/Titlebar.tsx',
'./packages/desktop-client/src/components/transactions/MobileTransaction.jsx',
'./packages/desktop-client/src/components/transactions/SelectedTransactions.jsx',
'./packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx',
'./packages/desktop-client/src/components/transactions/TransactionList.jsx',
'./packages/desktop-client/src/components/transactions/TransactionsTable.jsx',
'./packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx',
'./packages/desktop-client/src/hooks/useAccounts.ts',
'./packages/desktop-client/src/hooks/useCategories.ts',
'./packages/desktop-client/src/hooks/usePayees.ts',
'./packages/desktop-client/src/hooks/useProperFocus.tsx',
'./packages/desktop-client/src/hooks/useSelected.tsx',
'./packages/loot-core/src/client/query-hooks.tsx',
],
rules: {
'react-hooks/exhaustive-deps': 'off',
},
},
],
settings: {
'import/resolver': {

View File

@@ -4,11 +4,11 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18.16.0
- name: Cache
uses: actions/cache@v3
uses: actions/cache@v4
id: cache
with:
path: '**/node_modules'

View File

@@ -21,7 +21,7 @@ jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build API
@@ -29,7 +29,7 @@ jobs:
- name: Create package tgz
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
- name: Upload Build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: actual-api
path: packages/api/actual-api.tgz
@@ -37,7 +37,7 @@ jobs:
crdt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build CRDT
@@ -45,7 +45,7 @@ jobs:
- name: Create package tgz
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
- name: Upload Build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
@@ -53,18 +53,18 @@ jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: ./bin/package-browser
- name: Upload Build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: actual-web
path: packages/desktop-client/build
- name: Upload Build Stats
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: build-stats
path: packages/desktop-client/build-stats

View File

@@ -14,7 +14,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Lint
@@ -22,7 +22,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Typecheck
@@ -30,7 +30,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Test
@@ -40,8 +40,8 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '19'
- name: Check migrations

View File

@@ -22,14 +22,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
with:
category: '/language:javascript'

View File

@@ -16,7 +16,7 @@ jobs:
outputs:
netlify_url: ${{ steps.netlify.outputs.url }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Wait for Netlify build to finish
@@ -33,19 +33,20 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Run E2E Tests on Netlify URL
run: yarn e2e
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-client-test-results
path: packages/desktop-client/test-results/
retention-days: 30
overwrite: true
vrt:
name: Visual regression
needs: netlify
@@ -53,16 +54,17 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Netlify URL
run: yarn vrt
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-client-test-results
path: packages/desktop-client/test-results/
retention-days: 30
overwrite: true

View File

@@ -26,7 +26,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -41,7 +41,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
- name: Upload Build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}
path: |

View File

@@ -24,7 +24,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -34,7 +34,7 @@ jobs:
- name: Build Electron
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}
path: |

View File

@@ -24,8 +24,8 @@ jobs:
runs-on: ubuntu-latest
steps:
# This is not a security concern because we have approved & merged the PR
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '19'
- name: Handle feature requests

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Check release notes
if: startsWith(github.head_ref, 'release/') == false
uses: actualbudget/actions/release-notes/check@main

View File

@@ -46,7 +46,7 @@ jobs:
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@v2
uses: dawidd6/action-download-artifact@v3
id: pr-build
with:
branch: ${{github.base_ref}}
@@ -55,7 +55,7 @@ jobs:
path: base
- name: Download build artifact from PR
uses: dawidd6/action-download-artifact@v2
uses: dawidd6/action-download-artifact@v3
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml

27
.github/workflows/wip.yml vendored Normal file
View File

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

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.1.cjs
yarnPath: .yarn/releases/yarn-4.0.2.cjs

View File

@@ -10,4 +10,4 @@ if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
yarn
fi
yarn start:browser
BROWSER=0 yarn start:browser

View File

@@ -8,6 +8,8 @@ services:
actual-development:
build: .
image: actual-development
environment:
- HTTPS
ports:
- '3001:3001'
volumes:

View File

@@ -52,17 +52,19 @@
"eslint-plugin-react": "7.32.2",
"eslint-plugin-rulesdir": "^0.2.2",
"node-jq": "^4.0.1",
"npm-run-all": "^4.1.3",
"npm-run-all": "^4.1.5",
"prettier": "3.2.4",
"react-refresh": "^0.14.0",
"source-map-support": "^0.5.21",
"typescript": "^5.0.2",
"typescript-strict-plugin": "^2.2.2-beta.2"
},
"resolutions": {
"rollup": "4.9.4"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "yarn@4.0.1",
"packageManager": "yarn@4.0.2",
"browserslist": [
"electron 24.0",
"defaults"

View File

@@ -58,11 +58,42 @@ describe('API CRUD operations', () => {
await api.loadBudget(budgetName);
});
// apis: createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
test('CategoryGroups: successfully update category groups', async () => {
const month = '2023-10';
global.currentMonth = month;
// get existing category groups
const groups = await api.getCategoryGroups();
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({
hidden: 0,
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
is_income: 0,
name: 'Usual Expenses',
sort_order: 16384,
tombstone: 0,
}),
expect.objectContaining({
hidden: 0,
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
is_income: 0,
name: 'Investments and Savings',
sort_order: 32768,
tombstone: 0,
}),
expect.objectContaining({
hidden: 0,
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
is_income: 1,
name: 'Income',
sort_order: 32768,
tombstone: 0,
}),
]),
);
// create our test category group
const mainGroupId = await api.createCategoryGroup({
name: 'test-group',

View File

@@ -121,6 +121,10 @@ export function deleteAccount(id) {
return send('api/account-delete', { id });
}
export function getCategoryGroups() {
return send('api/category-groups-get');
}
export function createCategoryGroup(group) {
return send('api/category-group-create', { group });
}

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "6.4.0",
"version": "6.7.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -21,17 +21,17 @@
"clean": "rm -rf dist @types"
},
"dependencies": {
"better-sqlite3": "^9.2.2",
"better-sqlite3": "^9.3.0",
"compare-versions": "^6.1.0",
"node-fetch": "^3.3.2",
"uuid": "^9.0.0"
"uuid": "^9.0.1"
},
"devDependencies": {
"@swc/core": "^1.3.105",
"@swc/jest": "^0.2.31",
"@types/jest": "^27.5.0",
"@types/jest": "^27.5.2",
"@types/uuid": "^9.0.2",
"jest": "^27.0.0",
"jest": "^27.5.1",
"tsc-alias": "^1.8.8",
"typescript": "^5.0.2"
}

View File

@@ -17,14 +17,14 @@
"dependencies": {
"google-protobuf": "^3.12.0-rc.1",
"murmurhash": "^2.0.1",
"uuid": "^9.0.0"
"uuid": "^9.0.1"
},
"devDependencies": {
"@swc/core": "^1.3.105",
"@swc/jest": "^0.2.31",
"@types/jest": "^27.5.0",
"@types/jest": "^27.5.2",
"@types/uuid": "^9.0.2",
"jest": "^27.0.0",
"jest": "^27.5.1",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.0.2"
}

View File

@@ -37,6 +37,12 @@ First start a dev instance:
```sh
HTTPS=true yarn start
```
or using the dev container:
```
HTTPS=true docker compose up --build
```
Note the network IP address and port the dev instance is listening on.
Next, navigate to the root of your project folder, run the standartised docker container, and launch the visual regression tests from within it.

View File

@@ -7,6 +7,7 @@ test.describe('Accounts', () => {
let page;
let navigation;
let configurationPage;
let accountPage;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
@@ -22,7 +23,7 @@ test.describe('Accounts', () => {
});
test('creates a new account and views the initial balance transaction', async () => {
const accountPage = await navigation.createAccount({
accountPage = await navigation.createAccount({
name: 'New Account',
offBudget: false,
balance: 100,
@@ -38,7 +39,7 @@ test.describe('Accounts', () => {
});
test('closes an account', async () => {
const accountPage = await navigation.goToAccountPage('Roth IRA');
accountPage = await navigation.goToAccountPage('Roth IRA');
await expect(accountPage.accountName).toHaveText('Roth IRA');
@@ -50,4 +51,52 @@ test.describe('Accounts', () => {
await expect(accountPage.accountName).toHaveText('Closed: Roth IRA');
await expect(page).toMatchThemeScreenshots();
});
test.describe('Budgeted Accounts', () => {
// Reset filters
test.afterEach(async () => {
await accountPage.removeFilter(0);
});
test('creates a transfer from two existing transactions', async () => {
accountPage = await navigation.goToAccountPage('For budget');
await expect(accountPage.accountName).toHaveText('Budgeted Accounts');
await accountPage.filterByNote('Test Acc Transfer');
await accountPage.createSingleTransaction({
account: 'Ally Savings',
payee: '',
notes: 'Test Acc Transfer',
category: 'Food',
debit: '34.56',
});
await accountPage.createSingleTransaction({
account: 'HSBC',
payee: '',
notes: 'Test Acc Transfer',
category: 'Food',
credit: '34.56',
});
await page.waitForTimeout(100); // Give time for the previous transaction to be rendered
await accountPage.selectNthTransaction(0);
await accountPage.selectNthTransaction(1);
await accountPage.clickSelectAction('Make transfer');
let transaction = accountPage.getNthTransaction(0);
await expect(transaction.payee).toHaveText('Ally Savings');
await expect(transaction.category).toHaveText('Transfer');
await expect(transaction.credit).toHaveText('34.56');
await expect(transaction.account).toHaveText('HSBC');
transaction = accountPage.getNthTransaction(1);
await expect(transaction.payee).toHaveText('HSBC');
await expect(transaction.category).toHaveText('Transfer');
await expect(transaction.debit).toHaveText('34.56');
await expect(transaction.account).toHaveText('Ally Savings');
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -59,12 +59,9 @@ test.describe('Budget', () => {
});
test('clicking on spent amounts opens a transaction page', async () => {
const categoryName = await budgetPage.getCategoryNameForRow(1);
const accountPage = await budgetPage.clickOnSpentAmountForRow(1);
expect(page.url()).toContain('/accounts');
expect(await accountPage.accountName.textContent()).toMatch(
new RegExp(String.raw`${categoryName} \(\w+ \d+\)`),
);
expect(await accountPage.accountName.textContent()).toMatch('All Accounts');
await page.getByRole('button', { name: 'Back' }).click();
});
});

View File

@@ -1597,7 +1597,7 @@
"date": "2023-08-04",
"amount": 0,
"memo": "getting paid",
"cleared": "cleared",
"cleared": "reconciled",
"approved": true,
"flag_color": null,
"account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32",
@@ -1657,7 +1657,7 @@
"date": "2023-08-04",
"amount": 1000000,
"memo": "",
"cleared": "cleared",
"cleared": "reconciled",
"approved": true,
"flag_color": null,
"account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -25,6 +25,9 @@ export class AccountPage {
this.filterButton = this.page.getByRole('button', { name: 'Filter' });
this.filterSelectTooltip = this.page.getByTestId('filters-select-tooltip');
this.selectButton = this.page.getByTestId('transactions-select-button');
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
}
/**
@@ -68,14 +71,21 @@ export class AccountPage {
await this.cancelTransactionButton.click();
}
async selectNthTransaction(index) {
const row = this.transactionTableRow.nth(index);
await row.getByTestId('select').click();
}
/**
* Retrieve the data for the nth-transaction.
* 0-based index
*/
getNthTransaction(index) {
const row = this.transactionTableRow.nth(index);
const account = row.getByTestId('account');
return {
...(account ? { account } : {}),
payee: row.getByTestId('payee'),
notes: row.getByTestId('notes'),
category: row.getByTestId('category'),
@@ -84,6 +94,11 @@ export class AccountPage {
};
}
async clickSelectAction(action) {
await this.selectButton.click();
await this.selectTooltip.getByRole('button', { name: action }).click();
}
/**
* Open the modal for closing the account.
*/
@@ -106,6 +121,15 @@ export class AccountPage {
return new FilterTooltip(this.page.getByTestId('filters-menu-tooltip'));
}
/**
* Filter to a specific note
*/
async filterByNote(note) {
const filterTooltip = await this.filterBy('Note');
await this.page.keyboard.type(note);
await filterTooltip.applyButton.click();
}
/**
* Remove the nth filter
*/
@@ -117,6 +141,12 @@ export class AccountPage {
}
async _fillTransactionFields(transactionRow, transaction) {
if (transaction.account) {
await transactionRow.getByTestId('account').click();
await this.page.keyboard.type(transaction.account);
await this.page.keyboard.press('Tab');
}
if (transaction.payee) {
await transactionRow.getByTestId('payee').click();
await this.page.keyboard.type(transaction.payee);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -1,26 +1,26 @@
{
"name": "@actual-app/web",
"version": "24.1.0",
"version": "24.4.0",
"license": "MIT",
"files": [
"build"
],
"devDependencies": {
"@juggle/resize-observer": "^3.1.2",
"@juggle/resize-observer": "^3.4.0",
"@playwright/test": "^1.41.1",
"@reach/listbox": "^0.18.0",
"@react-aria/focus": "^3.14.0",
"@react-aria/listbox": "^3.10.1",
"@react-aria/utils": "^3.19.0",
"@react-stately/collections": "^3.10.0",
"@react-stately/list": "^3.9.1",
"@react-aria/focus": "^3.16.0",
"@react-aria/listbox": "^3.11.3",
"@react-aria/utils": "^3.23.0",
"@react-stately/collections": "^3.10.4",
"@react-stately/list": "^3.10.2",
"@rollup/plugin-inject": "^5.0.5",
"@svgr/cli": "^8.0.1",
"@svgr/cli": "^8.1.0",
"@swc/core": "^1.3.105",
"@swc/helpers": "^0.5.3",
"@swc/plugin-react-remove-properties": "^1.5.108",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@testing-library/react": "14.1.2",
"@testing-library/user-event": "14.5.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.1",
"@types/react-modal": "^3.16.0",
@@ -32,14 +32,13 @@
"@vitejs/plugin-react-swc": "^3.5.0",
"chokidar": "^3.5.3",
"cross-env": "^7.0.3",
"date-fns": "^2.29.3",
"debounce": "^1.2.0",
"downshift": "7.6.0",
"focus-visible": "^4.1.1",
"date-fns": "^2.30.0",
"debounce": "^1.2.1",
"downshift": "7.6.2",
"focus-visible": "^4.1.5",
"glamor": "^2.20.40",
"hotkeys-js": "3.10.3",
"inter-ui": "^3.19.3",
"jest": "^27.0.0",
"jest": "^27.5.1",
"jest-watch-typeahead": "^2.2.2",
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
@@ -48,27 +47,28 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-error-boundary": "^4.0.11",
"react-error-boundary": "^4.0.12",
"react-hotkeys-hook": "^4.5.0",
"react-markdown": "^8.0.7",
"react-merge-refs": "^1.1.0",
"react-modal": "3.16.1",
"react-redux": "7.2.1",
"react-router-dom": "6.11.2",
"react-redux": "7.2.9",
"react-router-dom": "6.21.3",
"react-simple-pull-to-refresh": "^1.3.3",
"react-spring": "^9.7.1",
"react-virtualized-auto-sizer": "^1.0.2",
"recharts": "^2.8.0",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"react-spring": "^9.7.3",
"react-virtualized-auto-sizer": "^1.0.21",
"recharts": "^2.10.4",
"redux": "^4.2.1",
"redux-thunk": "^2.4.2",
"remark-gfm": "^3.0.1",
"rollup-plugin-visualizer": "^5.11.0",
"sass": "^1.63.6",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.70.0",
"swc-loader": "^0.2.3",
"terser-webpack-plugin": "^5.3.9",
"typescript": "^5.0.2",
"uuid": "^9.0.0",
"victory": "^36.6.8",
"terser-webpack-plugin": "^5.3.10",
"usehooks-ts": "^3.0.1",
"uuid": "^9.0.1",
"vite": "^5.0.12",
"vite-plugin-pwa": "^0.19.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.2.1",
"webpack-bundle-analyzer": "^4.10.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -1,20 +1,51 @@
{
"name": "Actual",
"short_name": "Actual",
"description": "A local-first personal finance tool",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"screenshots": [
{
"src": "/screenshot_wide.png",
"form_factor": "wide",
"label": "Actual Budget Homepage",
"type": "image/png",
"sizes": "1280x720"
},
{
"src": "/screenshot_narrow.png",
"form_factor": "narrow",
"label": "Actual Budget Mobile Homepage",
"type": "image/png",
"sizes": "350x600"
}
],
"theme_color": "#8812E1",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "."
"start_url": "./"
}

View File

@@ -5,16 +5,18 @@ import {
useErrorBoundary,
type FallbackProps,
} from 'react-error-boundary';
import { HotkeysProvider } from 'react-hotkeys-hook';
import { useSelector } from 'react-redux';
import * as Platform from 'loot-core/src/client/platform';
import { type State } from 'loot-core/src/client/state-types';
import {
init as initConnection,
send,
} from 'loot-core/src/platform/client/fetch';
import { type GlobalPrefs } from 'loot-core/src/types/prefs';
import { useActions } from '../hooks/useActions';
import { useLocalPref } from '../hooks/useLocalPref';
import { installPolyfills } from '../polyfills';
import { ResponsiveProvider } from '../ResponsiveProvider';
import { styles, hasHiddenScrollbars, ThemeStyle } from '../style';
@@ -31,26 +33,13 @@ import { UpdateNotification } from './UpdateNotification';
type AppInnerProps = {
budgetId: string;
cloudFileId: string;
loadingText: string;
loadBudget: (
id: string,
loadingText?: string,
options?: object,
) => Promise<void>;
closeBudget: () => Promise<void>;
loadGlobalPrefs: () => Promise<GlobalPrefs>;
};
function AppInner({
budgetId,
cloudFileId,
loadingText,
loadBudget,
closeBudget,
loadGlobalPrefs,
}: AppInnerProps) {
function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
const [initializing, setInitializing] = useState(true);
const { showBoundary: showErrorBoundary } = useErrorBoundary();
const loadingText = useSelector((state: State) => state.app.loadingText);
const { loadBudget, closeBudget, loadGlobalPrefs } = useActions();
async function init() {
const socketName = await global.Actual.getServerSocket();
@@ -123,14 +112,9 @@ function ErrorFallback({ error }: FallbackProps) {
}
export function App() {
const budgetId = useSelector(
state => state.prefs.local && state.prefs.local.id,
);
const cloudFileId = useSelector(
state => state.prefs.local && state.prefs.local.cloudFileId,
);
const loadingText = useSelector(state => state.app.loadingText);
const { loadBudget, closeBudget, loadGlobalPrefs, sync } = useActions();
const [budgetId] = useLocalPref('id');
const [cloudFileId] = useLocalPref('cloudFileId');
const { sync } = useActions();
const [hiddenScrollbars, setHiddenScrollbars] = useState(
hasHiddenScrollbars(),
);
@@ -163,34 +147,29 @@ export function App() {
}, [sync]);
return (
<ResponsiveProvider>
<View
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<HotkeysProvider initiallyActiveScopes={['*']}>
<ResponsiveProvider>
<View
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
style={{
flexGrow: 1,
overflow: 'hidden',
...styles.lightScrollbar,
}}
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
<DevelopmentTopBar />
)}
<AppInner
budgetId={budgetId}
cloudFileId={cloudFileId}
loadingText={loadingText}
loadBudget={loadBudget}
closeBudget={closeBudget}
loadGlobalPrefs={loadGlobalPrefs}
/>
</ErrorBoundary>
<ThemeStyle />
<View
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
style={{
flexGrow: 1,
overflow: 'hidden',
...styles.lightScrollbar,
}}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
<DevelopmentTopBar />
)}
<AppInner budgetId={budgetId} cloudFileId={cloudFileId} />
</ErrorBoundary>
<ThemeStyle />
</View>
</View>
</View>
</ResponsiveProvider>
</ResponsiveProvider>
</HotkeysProvider>
);
}

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { useTransition, animated } from 'react-spring';
import { type State } from 'loot-core/src/client/state-types';
import { theme, styles } from '../style';
import { AnimatedRefresh } from './AnimatedRefresh';
@@ -9,20 +11,20 @@ import { Text } from './common/Text';
import { View } from './common/View';
export function BankSyncStatus() {
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
const accountsSyncing = useSelector(
(state: State) => state.account.accountsSyncing,
);
const accountsSyncingCount = accountsSyncing.length;
const name = accountsSyncing
? accountsSyncing === '__all'
? 'accounts'
: accountsSyncing
: null;
const transitions = useTransition(name, {
from: { opacity: 0, transform: 'translateY(-100px)' },
enter: { opacity: 1, transform: 'translateY(0)' },
leave: { opacity: 0, transform: 'translateY(-100px)' },
unique: true,
});
const transitions = useTransition(
accountsSyncingCount > 0 ? 'syncing' : null,
{
from: { opacity: 0, transform: 'translateY(-100px)' },
enter: { opacity: 1, transform: 'translateY(0)' },
leave: { opacity: 0, transform: 'translateY(-100px)' },
unique: true,
},
);
return (
<View
@@ -52,10 +54,13 @@ export function BankSyncStatus() {
}}
>
<AnimatedRefresh
animating={true}
animating
iconStyle={{ color: theme.pillTextSelected }}
/>
<Text>Syncing {item}</Text>
<Text style={{ marginLeft: 5 }}>
Syncing... {accountsSyncingCount} account
{accountsSyncingCount > 1 && 's'} remaining
</Text>
</View>
</animated.div>
),

View File

@@ -166,7 +166,9 @@ export function FatalError({ buttonText, error }: FatalErrorProps) {
>
{showSimpleRender ? <RenderSimple error={error} /> : <RenderUIError />}
<Paragraph>
<Button onClick={() => window.Actual.relaunch()}>{buttonText}</Button>
<Button onClick={() => window.Actual?.relaunch()}>
{buttonText}
</Button>
</Paragraph>
<Paragraph isLast={true} style={{ fontSize: 11 }}>
<LinkButton onClick={() => setShowError(true)}>Show Error</LinkButton>

View File

@@ -2,6 +2,7 @@
import React, { type ReactElement, useEffect, useMemo } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend as Backend } from 'react-dnd-html5-backend';
import { useSelector } from 'react-redux';
import {
Route,
Routes,
@@ -11,14 +12,12 @@ import {
useHref,
} from 'react-router-dom';
import hotkeys from 'hotkeys-js';
import { AccountsProvider } from 'loot-core/src/client/data-hooks/accounts';
import { PayeesProvider } from 'loot-core/src/client/data-hooks/payees';
import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
import { type State } from 'loot-core/src/client/state-types';
import { checkForUpdateNotification } from 'loot-core/src/client/update-notification';
import * as undo from 'loot-core/src/platform/client/undo';
import { useAccounts } from '../hooks/useAccounts';
import { useActions } from '../hooks/useActions';
import { useNavigate } from '../hooks/useNavigate';
import { useResponsive } from '../ResponsiveProvider';
@@ -32,6 +31,7 @@ import { View } from './common/View';
import { GlobalKeys } from './GlobalKeys';
import { ManageRulesPage } from './ManageRulesPage';
import { MobileNavTabs } from './mobile/MobileNavTabs';
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
import { Modals } from './Modals';
import { Notifications } from './Notifications';
import { ManagePayeesPage } from './payees/ManagePayeesPage';
@@ -39,9 +39,9 @@ import { Reports } from './reports';
import { NarrowAlternate, WideComponent } from './responsive';
import { ScrollProvider } from './ScrollProvider';
import { Settings } from './settings';
import { FloatableSidebar, SidebarProvider } from './sidebar';
import { FloatableSidebar } from './sidebar';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { Titlebar, TitlebarProvider } from './Titlebar';
import { TransactionEdit } from './transactions/MobileTransaction';
function NarrowNotSupported({
redirectTo = '/budget',
@@ -71,18 +71,19 @@ function WideNotSupported({ children, redirectTo = '/budget' }) {
return isNarrowWidth ? children : null;
}
function RouterBehaviors({ getAccounts }) {
function RouterBehaviors() {
const navigate = useNavigate();
const accounts = useAccounts();
const accountsLoaded = useSelector(
(state: State) => state.queries.accountsLoaded,
);
useEffect(() => {
// Get the accounts and check if any exist. If there are no
// accounts, we want to redirect the user to the All Accounts
// screen which will prompt them to add an account
getAccounts().then(accounts => {
if (accounts.length === 0) {
navigate('/accounts');
}
});
}, []);
// If there are no accounts, we want to redirect the user to
// the All Accounts screen which will prompt them to add an account
if (accountsLoaded && accounts.length === 0) {
navigate('/accounts');
}
}, [accountsLoaded, accounts]);
const location = useLocation();
const href = useHref(location);
@@ -96,9 +97,6 @@ function RouterBehaviors({ getAccounts }) {
function FinancesAppWithoutContext() {
const actions = useActions();
useEffect(() => {
// The default key handler scope
hotkeys.setScope('app');
// Wait a little bit to make sure the sync button will get the
// sync start event. This can be improved later.
setTimeout(async () => {
@@ -116,7 +114,7 @@ function FinancesAppWithoutContext() {
return (
<BrowserRouter>
<RouterBehaviors getAccounts={actions.getAccounts} />
<RouterBehaviors />
<ExposeNavigate />
<View style={{ height: '100%' }}>
@@ -265,13 +263,9 @@ export function FinancesApp() {
<TitlebarProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<PayeesProvider>
<AccountsProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</AccountsProvider>
</PayeesProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</TitlebarProvider>

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { useEffect } from 'react';
import * as Platform from 'loot-core/src/client/platform';
@@ -8,7 +7,7 @@ import { useNavigate } from '../hooks/useNavigate';
export function GlobalKeys() {
const navigate = useNavigate();
useEffect(() => {
const handleKeys = e => {
const handleKeys = (e: KeyboardEvent) => {
if (Platform.isBrowser) {
return;
}

View File

@@ -1,87 +0,0 @@
// @ts-strict-ignore
import React, { createContext, useEffect, useContext } from 'react';
import hotkeys, { type KeyHandler as HotKeyHandler } from 'hotkeys-js';
const KeyScopeContext = createContext('app');
hotkeys.filter = event => {
const target = (event.target || event.srcElement) as HTMLElement;
const tagName = target.tagName;
// This is the default behavior of hotkeys, except we only suppress
// key presses if the meta key is not pressed
if (
!event.metaKey &&
(target.isContentEditable ||
((tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT') &&
!target['readOnly']))
) {
return false;
}
return true;
};
type KeyHandlerProps = {
keyName: string;
eventType?: string;
handler: HotKeyHandler;
};
function KeyHandler({
keyName,
eventType = 'keydown',
handler,
}: KeyHandlerProps) {
const scope = useContext(KeyScopeContext);
if (eventType !== 'keyup' && eventType !== 'keydown') {
throw new Error('KeyHandler: unknown event type: ' + eventType);
}
useEffect(() => {
function _handler(event, hk) {
// Right now it always overrides the default behavior, but in
// the future we can make this customizable
event.preventDefault();
if (event.type === eventType && handler) {
return handler(event, hk);
}
}
hotkeys(keyName, { scope, keyup: true }, _handler);
return () => {
// @ts-expect-error unbind args typedef does not expect an object
hotkeys.unbind({
key: keyName,
scope,
method: _handler,
});
};
}, [keyName, handler, scope]);
return null;
}
type KeyHandlersProps = {
eventType?: string;
keys: Record<string, HotKeyHandler>;
};
export function KeyHandlers({ eventType, keys = {} }: KeyHandlersProps) {
const handlers = Object.keys(keys).map(key => {
return (
<KeyHandler
key={key}
keyName={key}
eventType={eventType}
handler={keys[key]}
/>
);
});
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{handlers}</>;
}

View File

@@ -2,6 +2,8 @@
import React, { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { type State } from 'loot-core/src/client/state-types';
import { useActions } from '../hooks/useActions';
import { theme, styles, type CSSProperties } from '../style';
@@ -22,7 +24,7 @@ export function LoggedInUser({
style,
color,
}: LoggedInUserProps) {
const userData = useSelector(state => state.user.data);
const userData = useSelector((state: State) => state.user.data);
const { getUserData, signOut, closeBudget } = useActions();
const [loading, setLoading] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);

View File

@@ -7,7 +7,7 @@ import React, {
type SetStateAction,
type Dispatch,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { pushModal } from 'loot-core/src/client/actions/modals';
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
@@ -17,7 +17,9 @@ import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
import { describeSchedule } from 'loot-core/src/shared/schedules';
import { type RuleEntity } from 'loot-core/src/types/models';
import { useAccounts } from '../hooks/useAccounts';
import { useCategories } from '../hooks/useCategories';
import { usePayees } from '../hooks/usePayees';
import { useSelected, SelectedProvider } from '../hooks/useSelected';
import { theme } from '../style';
@@ -103,11 +105,13 @@ function ManageRulesContent({
const { data: schedules } = SchedulesQuery.useQuery();
const { list: categories } = useCategories();
const state = useSelector(state => ({
payees: state.queries.payees,
accounts: state.queries.accounts,
const payees = usePayees();
const accounts = useAccounts();
const state = {
payees,
accounts,
schedules,
}));
};
const filterData = useMemo(
() => ({
...state,

View File

@@ -1,8 +1,6 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { savePrefs } from 'loot-core/src/client/actions';
import { useLocalPref } from '../hooks/useLocalPref';
import { useResponsive } from '../ResponsiveProvider';
import { theme, styles } from '../style';
@@ -14,27 +12,24 @@ import { Checkbox } from './forms';
const buttonStyle = { border: 0, fontSize: 15, padding: '10px 13px' };
export function MobileWebMessage() {
const hideMobileMessagePref = useSelector(state => {
return (state.prefs.local && state.prefs.local.hideMobileMessage) || true;
});
const [hideMobileMessage = true, setHideMobileMessagePref] =
useLocalPref('hideMobileMessage');
const { isNarrowWidth } = useResponsive();
const [show, setShow] = useState(
isNarrowWidth &&
!hideMobileMessagePref &&
!hideMobileMessage &&
!document.cookie.match(/hideMobileMessage=true/),
);
const [requestDontRemindMe, setRequestDontRemindMe] = useState(false);
const dispatch = useDispatch();
function onTry() {
setShow(false);
if (requestDontRemindMe) {
// remember the pref indefinitely
dispatch(savePrefs({ hideMobileMessage: true }));
setHideMobileMessagePref(true);
} else {
// Set a cookie for 5 minutes
const d = new Date();

View File

@@ -3,18 +3,19 @@ import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { type State } from 'loot-core/src/client/state-types';
import { type PopModalAction } from 'loot-core/src/client/state-types/modals';
import { send } from 'loot-core/src/platform/client/fetch';
import { useActions } from '../hooks/useActions';
import { useCategories } from '../hooks/useCategories';
import { useSyncServerStatus } from '../hooks/useSyncServerStatus';
import { type CommonModalProps } from '../types/modals';
import { CategoryGroupMenu } from './modals/CategoryGroupMenu';
import { CategoryMenu } from './modals/CategoryMenu';
import { CloseAccount } from './modals/CloseAccount';
import { ConfirmCategoryDelete } from './modals/ConfirmCategoryDelete';
import { ConfirmTransactionEdit } from './modals/ConfirmTransactionEdit';
import { ConfirmUnlinkAccount } from './modals/ConfirmUnlinkAccount';
import { CreateAccount } from './modals/CreateAccount';
import { CreateEncryptionKey } from './modals/CreateEncryptionKey';
import { CreateLocalAccount } from './modals/CreateLocalAccount';
@@ -40,14 +41,18 @@ import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
import { ScheduleDetails } from './schedules/ScheduleDetails';
import { ScheduleLink } from './schedules/ScheduleLink';
export type CommonModalProps = {
onClose: () => PopModalAction;
onBack: () => PopModalAction;
showBack: boolean;
isCurrent: boolean;
isHidden: boolean;
stackIndex: number;
};
export function Modals() {
const modalStack = useSelector(state => state.modals.modalStack);
const isHidden = useSelector(state => state.modals.isHidden);
const accounts = useSelector(state => state.queries.accounts);
const { grouped: categoryGroups, list: categories } = useCategories();
const budgetId = useSelector(
state => state.prefs.local && state.prefs.local.id,
);
const modalStack = useSelector((state: State) => state.modals.modalStack);
const isHidden = useSelector((state: State) => state.modals.isHidden);
const actions = useActions();
const location = useLocation();
@@ -97,8 +102,6 @@ export function Modals() {
account={options.account}
balance={options.balance}
canDelete={options.canDelete}
accounts={accounts.filter(acct => acct.closed === 0)}
categoryGroups={categoryGroups}
actions={actions}
/>
);
@@ -109,7 +112,6 @@ export function Modals() {
modalProps={modalProps}
externalAccounts={options.accounts}
requisitionId={options.requisitionId}
localAccounts={accounts.filter(acct => acct.closed === 0)}
actions={actions}
syncSource={options.syncSource}
/>
@@ -119,19 +121,21 @@ export function Modals() {
return (
<ConfirmCategoryDelete
modalProps={modalProps}
category={
'category' in options &&
categories.find(c => c.id === options.category)
}
group={
'group' in options &&
categoryGroups.find(g => g.id === options.group)
}
categoryGroups={categoryGroups}
category={options.category}
group={options.group}
onDelete={options.onDelete}
/>
);
case 'confirm-unlink-account':
return (
<ConfirmUnlinkAccount
modalProps={modalProps}
accountName={options.accountName}
onUnlink={options.onUnlink}
/>
);
case 'confirm-transaction-edit':
return (
<ConfirmTransactionEdit
@@ -145,7 +149,7 @@ export function Modals() {
return (
<LoadBackup
watchUpdates
budgetId={budgetId}
budgetId={options.budgetId}
modalProps={modalProps}
actions={actions}
backupDisabled={false}
@@ -301,6 +305,7 @@ export function Modals() {
modalProps={modalProps}
id={options?.id || null}
actions={actions}
transaction={options?.transaction || null}
/>
);
@@ -311,6 +316,8 @@ export function Modals() {
modalProps={modalProps}
actions={actions}
transactionIds={options?.transactionIds}
getTransaction={options?.getTransaction}
pushModal={options?.pushModal}
/>
);

View File

@@ -7,6 +7,7 @@ import React, {
} from 'react';
import { useSelector } from 'react-redux';
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';
@@ -238,7 +239,9 @@ function Notification({
export function Notifications({ style }: { style?: CSSProperties }) {
const { removeNotification } = useActions();
const notifications = useSelector(state => state.notifications.notifications);
const notifications = useSelector(
(state: State) => state.notifications.notifications,
);
return (
<View
style={{

View File

@@ -7,8 +7,7 @@ import React, {
type ReactNode,
} from 'react';
import { usePrivacyMode } from 'loot-core/src/client/privacy';
import { usePrivacyMode } from '../hooks/usePrivacyMode';
import { useResponsive } from '../ResponsiveProvider';
import { View } from './common/View';

View File

@@ -5,13 +5,14 @@ import React, {
useState,
useContext,
useEffect,
useCallback,
} from 'react';
import debounce from 'debounce';
type IScrollContext = {
scrollY: number | undefined;
isBottomReached: boolean;
hasScrolledToBottom: (tolerance?: number) => boolean;
};
const ScrollContext = createContext<IScrollContext | undefined>(undefined);
@@ -22,14 +23,20 @@ type ScrollProviderProps = {
export function ScrollProvider({ children }: ScrollProviderProps) {
const [scrollY, setScrollY] = useState(undefined);
const [isBottomReached, setIsBottomReached] = useState(false);
const [scrollHeight, setScrollHeight] = useState(undefined);
const [clientHeight, setClientHeight] = useState(undefined);
const hasScrolledToBottom = useCallback(
(tolerance = 1) => scrollHeight - scrollY <= clientHeight + tolerance,
[clientHeight, scrollHeight, scrollY],
);
useEffect(() => {
const listenToScroll = debounce(e => {
setScrollY(e.target?.scrollTop || 0);
setIsBottomReached(
e.target?.scrollHeight - e.target?.scrollTop <= e.target?.clientHeight,
);
const target = e.target;
setScrollY(target?.scrollTop || 0);
setScrollHeight(target?.scrollHeight || 0);
setClientHeight(target?.clientHeight || 0);
}, 10);
window.addEventListener('scroll', listenToScroll, {
@@ -43,7 +50,7 @@ export function ScrollProvider({ children }: ScrollProviderProps) {
}, []);
return (
<ScrollContext.Provider value={{ scrollY, isBottomReached }}>
<ScrollContext.Provider value={{ scrollY, hasScrolledToBottom }}>
{children}
</ScrollContext.Provider>
);

View File

@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import type { Theme } from 'loot-core/src/types/prefs';
import { useActions } from '../hooks/useActions';
import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
import { useResponsive } from '../ResponsiveProvider';
import { type CSSProperties, themeOptions, useTheme } from '../style';
@@ -16,8 +15,7 @@ type ThemeSelectorProps = {
};
export function ThemeSelector({ style }: ThemeSelectorProps) {
const theme = useTheme();
const { saveGlobalPrefs } = useActions();
const [theme, switchTheme] = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
const { isNarrowWidth } = useResponsive();
@@ -26,17 +24,15 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
light: SvgSun,
dark: SvgMoonStars,
auto: SvgSystem,
midnight: SvgMoonStars,
} as const;
async function onMenuSelect(newTheme: string) {
function onMenuSelect(newTheme: Theme) {
setMenuOpen(false);
saveGlobalPrefs({
theme: newTheme as Theme,
});
switchTheme(newTheme);
}
const Icon = themeIcons?.[theme] || SvgSun;
const Icon = themeIcons[theme] || SvgSun;
return isNarrowWidth ? null : (
<Button

View File

@@ -6,7 +6,7 @@ import React, {
useContext,
type ReactNode,
} from 'react';
import { useSelector } from 'react-redux';
import { useHotkeys } from 'react-hotkeys-hook';
import { Routes, Route, useLocation } from 'react-router-dom';
import * as Platform from 'loot-core/src/client/platform';
@@ -16,6 +16,8 @@ import { type LocalPrefs } from 'loot-core/src/types/prefs';
import { useActions } from '../hooks/useActions';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
import { useGlobalPref } from '../hooks/useGlobalPref';
import { useLocalPref } from '../hooks/useLocalPref';
import { useNavigate } from '../hooks/useNavigate';
import { SvgArrowLeft } from '../icons/v1';
import {
@@ -36,10 +38,9 @@ import { Link } from './common/Link';
import { Paragraph } from './common/Paragraph';
import { Text } from './common/Text';
import { View } from './common/View';
import { KeyHandlers } from './KeyHandlers';
import { LoggedInUser } from './LoggedInUser';
import { useServerURL } from './ServerContext';
import { useSidebar } from './sidebar';
import { useSidebar } from './sidebar/SidebarProvider';
import { useSheetValue } from './spreadsheet/useSheetValue';
import { ThemeSelector } from './ThemeSelector';
import { Tooltip } from './tooltips';
@@ -118,10 +119,8 @@ type PrivacyButtonProps = {
};
function PrivacyButton({ style }: PrivacyButtonProps) {
const isPrivacyEnabled = useSelector(
state => state.prefs.local?.isPrivacyEnabled,
);
const { savePrefs } = useActions();
const [isPrivacyEnabled, setPrivacyEnabledPref] =
useLocalPref('isPrivacyEnabled');
const privacyIconStyle = { width: 15, height: 15 };
@@ -129,7 +128,7 @@ function PrivacyButton({ style }: PrivacyButtonProps) {
<Button
type="bare"
aria-label={`${isPrivacyEnabled ? 'Disable' : 'Enable'} privacy mode`}
onClick={() => savePrefs({ isPrivacyEnabled: !isPrivacyEnabled })}
onClick={() => setPrivacyEnabledPref(!isPrivacyEnabled)}
style={style}
>
{isPrivacyEnabled ? (
@@ -146,7 +145,7 @@ type SyncButtonProps = {
isMobile?: boolean;
};
function SyncButton({ style, isMobile = false }: SyncButtonProps) {
const cloudFileId = useSelector(state => state.prefs.local?.cloudFileId);
const [cloudFileId] = useLocalPref('cloudFileId');
const { sync } = useActions();
const [syncing, setSyncing] = useState(false);
@@ -232,63 +231,63 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
marginRight: 5,
};
return (
<>
<KeyHandlers
keys={{
'ctrl+s, cmd+s': () => {
sync();
},
}}
/>
useHotkeys(
'ctrl+s, cmd+s, meta+s',
sync,
{
enableOnFormTags: true,
preventDefault: true,
scopes: ['app'],
},
[sync],
);
<Button
type="bare"
aria-label="Sync"
style={
isMobile
? {
...style,
WebkitAppRegion: 'none',
...mobileIconStyle,
}
: {
...style,
WebkitAppRegion: 'none',
color: desktopColor,
}
}
hoveredStyle={hoveredStyle}
activeStyle={activeStyle}
onClick={sync}
>
{isMobile ? (
syncState === 'error' ? (
<SvgAlertTriangle width={14} height={14} />
) : (
<AnimatedRefresh width={18} height={18} animating={syncing} />
)
) : syncState === 'error' ? (
<SvgAlertTriangle width={13} />
return (
<Button
type="bare"
aria-label="Sync"
style={
isMobile
? {
...style,
WebkitAppRegion: 'none',
...mobileIconStyle,
}
: {
...style,
WebkitAppRegion: 'none',
color: desktopColor,
}
}
hoveredStyle={hoveredStyle}
activeStyle={activeStyle}
onClick={sync}
>
{isMobile ? (
syncState === 'error' ? (
<SvgAlertTriangle width={14} height={14} />
) : (
<AnimatedRefresh animating={syncing} />
)}
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
{syncState === 'disabled'
? 'Disabled'
: syncState === 'offline'
? 'Offline'
: 'Sync'}
</Text>
</Button>
</>
<AnimatedRefresh width={18} height={18} animating={syncing} />
)
) : syncState === 'error' ? (
<SvgAlertTriangle width={13} />
) : (
<AnimatedRefresh animating={syncing} />
)}
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
{syncState === 'disabled'
? 'Disabled'
: syncState === 'offline'
? 'Offline'
: 'Sync'}
</Text>
</Button>
);
}
function BudgetTitlebar() {
const maxMonths = useSelector(state => state.prefs.global?.maxMonths);
const budgetType = useSelector(state => state.prefs.local?.budgetType);
const { saveGlobalPrefs } = useActions();
const [maxMonths, setMaxMonthsPref] = useGlobalPref('maxMonths');
const [budgetType] = useLocalPref('budgetType');
const { sendEvent } = useContext(TitlebarContext);
const [loading, setLoading] = useState(false);
@@ -317,7 +316,7 @@ function BudgetTitlebar() {
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MonthCountSelector
maxMonths={maxMonths || 1}
onChange={value => saveGlobalPrefs({ maxMonths: value })}
onChange={value => setMaxMonthsPref(value)}
/>
{reportBudgetEnabled && (
<View style={{ marginLeft: -5 }}>
@@ -390,9 +389,7 @@ export function Titlebar({ style }: TitlebarProps) {
const sidebar = useSidebar();
const { isNarrowWidth } = useResponsive();
const serverURL = useServerURL();
const floatingSidebar = useSelector(
state => state.prefs.global?.floatingSidebar,
);
const [floatingSidebar] = useGlobalPref('floatingSidebar');
return isNarrowWidth ? null : (
<View

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { type State } from 'loot-core/src/client/state-types';
import { useActions } from '../hooks/useActions';
import { SvgClose } from '../icons/v1';
import { theme } from '../style';
@@ -11,9 +13,9 @@ import { Text } from './common/Text';
import { View } from './common/View';
export function UpdateNotification() {
const updateInfo = useSelector(state => state.app.updateInfo);
const updateInfo = useSelector((state: State) => state.app.updateInfo);
const showUpdateNotification = useSelector(
state => state.app.showUpdateNotification,
(state: State) => state.app.showUpdateNotification,
);
const { updateApp, setAppState } = useActions();

View File

@@ -5,6 +5,7 @@ import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom';
import { debounce } from 'debounce';
import { bindActionCreators } from 'redux';
import { validForTransfer } from 'loot-core/client/transfer';
import * as actions from 'loot-core/src/client/actions';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import {
@@ -26,17 +27,22 @@ import {
} from 'loot-core/src/shared/transactions';
import { applyChanges, groupById } from 'loot-core/src/shared/util';
import { useAccounts } from '../../hooks/useAccounts';
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 { SelectedProviderWithItems } from '../../hooks/useSelected';
import {
SplitsExpandedProvider,
useSplitsExpanded,
} from '../../hooks/useSplitsExpanded';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { TransactionList } from '../transactions/TransactionList';
import {
SplitsExpandedProvider,
useSplitsExpanded,
} from '../transactions/TransactionsTable';
import { AccountHeader } from './Header';
@@ -198,7 +204,7 @@ class AccountInternal extends PureComponent {
this.state = {
search: '',
filters: [],
filters: props.conditions || [],
loading: true,
workingHard: false,
reconcileAmount: null,
@@ -274,7 +280,7 @@ class AccountInternal extends PureComponent {
// Important that any async work happens last so that the
// listeners are set up synchronously
await this.props.initiallyLoadPayees();
await this.fetchTransactions();
await this.fetchTransactions(this.state.filters);
// If there is a pending undo, apply it immediately (this happens
// when an undo changes the location to this page)
@@ -331,10 +337,11 @@ class AccountInternal extends PureComponent {
this.paged?.run();
};
fetchTransactions = () => {
fetchTransactions = filters => {
const query = this.makeRootQuery();
this.rootQuery = this.currentQuery = query;
this.updateQuery(query);
if (filters) this.applyFilters(filters);
else this.updateQuery(query);
if (this.props.accountId) {
this.props.markAccountRead(this.props.accountId);
@@ -342,18 +349,8 @@ class AccountInternal extends PureComponent {
};
makeRootQuery = () => {
const locationState = this.props.location.state;
const accountId = this.props.accountId;
if (locationState && locationState.filter) {
return q('transactions')
.options({ splits: 'grouped' })
.filter({
'account.offbudget': false,
...locationState.filter,
});
}
return queries.makeTransactionsQuery(accountId);
};
@@ -456,7 +453,7 @@ class AccountInternal extends PureComponent {
const accountId = this.props.accountId;
const account = this.props.accounts.find(acct => acct.id === accountId);
await this.props.syncAndDownload(account ? account.id : null);
await this.props.syncAndDownload(account ? account.id : undefined);
};
onImport = async () => {
@@ -465,7 +462,7 @@ class AccountInternal extends PureComponent {
const categories = await this.props.getCategories();
if (account) {
const res = await window.Actual.openFileDialog({
const res = await window.Actual?.openFileDialog({
filters: [
{
name: 'Financial Files',
@@ -497,7 +494,7 @@ class AccountInternal extends PureComponent {
accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-');
const filename = `${normalizedName || 'transactions'}.csv`;
window.Actual.saveFile(
window.Actual?.saveFile(
exportedTransactions,
filename,
'Export Transactions',
@@ -593,7 +590,12 @@ class AccountInternal extends PureComponent {
});
break;
case 'unlink':
this.props.unlinkAccount(accountId);
this.props.pushModal('confirm-unlink-account', {
accountName: account.name,
onUnlink: () => {
this.props.unlinkAccount(accountId);
},
});
break;
case 'close':
this.props.openAccountCloseModal(accountId);
@@ -1054,6 +1056,52 @@ class AccountInternal extends PureComponent {
this.props.pushModal('edit-rule', { rule });
};
onSetTransfer = async ids => {
const onConfirmTransfer = async ids => {
this.setState({ workingHard: true });
const payees = await this.props.getPayees();
const { data: transactions } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*'),
);
const [fromTrans, toTrans] = transactions;
if (transactions.length === 2 && validForTransfer(fromTrans, toTrans)) {
const fromPayee = payees.find(
p => p.transfer_acct === fromTrans.account,
);
const toPayee = payees.find(p => p.transfer_acct === toTrans.account);
const changes = {
updated: [
{
...fromTrans,
payee: toPayee.id,
transfer_id: toTrans.id,
},
{
...toTrans,
payee: fromPayee.id,
transfer_id: fromTrans.id,
},
],
};
await send('transactions-batch-update', changes);
}
await this.refetchTransactions();
};
await this.checkForReconciledTransactions(
ids,
'batchEditWithReconciled',
onConfirmTransfer,
);
};
onCondOpChange = (value, filters) => {
this.setState({ conditionsOp: value });
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
@@ -1199,6 +1247,7 @@ class AccountInternal extends PureComponent {
applySort = (field, ascDesc, prevField, prevAscDesc) => {
const filters = this.state.filters;
const isFiltered = filters.length > 0;
const sortField = getField(!field ? this.state.sort.field : field);
const sortAscDesc = !ascDesc ? this.state.sort.ascDesc : ascDesc;
const sortPrevField = getField(
@@ -1208,34 +1257,78 @@ class AccountInternal extends PureComponent {
? this.state.sort.prevAscDesc
: prevAscDesc;
if (!field) {
//no sort was made (called by applyFilters)
this.currentQuery = this.currentQuery.orderBy({
const sortCurrentQuery = function (that, sortField, sortAscDesc) {
if (sortField === 'cleared') {
that.currentQuery = that.currentQuery.orderBy({
reconciled: sortAscDesc,
});
}
that.currentQuery = that.currentQuery.orderBy({
[sortField]: sortAscDesc,
});
} else {
//sort called directly
if (filters.length > 0) {
//if filters already exist then apply them
this.applyFilters([...filters]);
this.currentQuery = this.currentQuery.orderBy({
[sortField]: sortAscDesc,
};
const sortRootQuery = function (that, sortField, sortAscDesc) {
if (sortField === 'cleared') {
that.currentQuery = that.rootQuery.orderBy({
reconciled: sortAscDesc,
});
that.currentQuery = that.currentQuery.orderBy({
cleared: sortAscDesc,
});
} else {
//no filters exist make new rootquery
this.currentQuery = this.rootQuery.orderBy({
that.currentQuery = that.rootQuery.orderBy({
[sortField]: sortAscDesc,
});
}
}
if (sortPrevField) {
//apply previos sort if it exists
this.currentQuery = this.currentQuery.orderBy({
};
// sort by previously used sort field, if any
const maybeSortByPreviousField = function (
that,
sortPrevField,
sortPrevAscDesc,
) {
if (!sortPrevField) {
return;
}
if (sortPrevField === 'cleared') {
that.currentQuery = that.currentQuery.orderBy({
reconciled: sortPrevAscDesc,
});
}
that.currentQuery = that.currentQuery.orderBy({
[sortPrevField]: sortPrevAscDesc,
});
};
switch (true) {
// called by applyFilters to sort an already filtered result
case !field:
sortCurrentQuery(this, sortField, sortAscDesc);
break;
// called directly from UI by sorting a column.
// active filters need to be applied before sorting
case isFiltered:
this.applyFilters([...filters]);
sortCurrentQuery(this, sortField, sortAscDesc);
break;
// called directly from UI by sorting a column.
// no active filters, start a new root query.
case !isFiltered:
sortRootQuery(this, sortField, sortAscDesc);
break;
default:
}
this.updateQuery(this.currentQuery, this.state.filters.length > 0);
maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc);
this.updateQuery(this.currentQuery, isFiltered);
};
onSort = (headerClicked, ascDesc) => {
@@ -1393,6 +1486,7 @@ class AccountInternal extends PureComponent {
onDeleteFilter={this.onDeleteFilter}
onApplyFilter={this.onApplyFilter}
onScheduleAction={this.onScheduleAction}
onSetTransfer={this.onSetTransfer}
/>
<View style={{ flex: 1 }}>
@@ -1487,23 +1581,46 @@ export function Account() {
const location = useLocation();
const { grouped: categoryGroups } = useCategories();
const state = useSelector(state => ({
newTransactions: state.queries.newTransactions,
matchedTransactions: state.queries.matchedTransactions,
accounts: state.queries.accounts,
failedAccounts: state.account.failedAccounts,
dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
hideFraction: state.prefs.local.hideFraction || false,
expandSplits: state.prefs.local['expand-splits'],
showBalances: params.id && state.prefs.local['show-balances-' + params.id],
showCleared: params.id && !state.prefs.local['hide-cleared-' + params.id],
showExtraBalances:
state.prefs.local['show-extra-balances-' + params.id || 'all-accounts'],
payees: state.queries.payees,
modalShowing: state.modals.modalStack.length > 0,
accountsSyncing: state.account.accountsSyncing,
lastUndoState: state.app.lastUndoState,
}));
const newTransactions = useSelector(state => state.queries.newTransactions);
const matchedTransactions = useSelector(
state => state.queries.matchedTransactions,
);
const accounts = useAccounts();
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 [showExtraBalances] = useLocalPref(
`show-extra-balances-${params.id || 'all-accounts'}`,
);
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
const lastUndoState = useSelector(state => state.app.lastUndoState);
const conditions =
location.state && location.state.conditions
? location.state.conditions
: [];
const state = {
newTransactions,
matchedTransactions,
accounts,
failedAccounts,
dateFormat,
hideFraction,
expandSplits,
showBalances,
showCleared: !hideCleared,
showExtraBalances,
payees,
modalShowing,
accountsSyncing,
lastUndoState,
conditions,
};
const dispatch = useDispatch();
const filtersList = useFilters();
@@ -1513,26 +1630,25 @@ export function Account() {
);
const transform = useMemo(() => {
let filterByAccount = queries.getAccountFilter(params.id, '_account');
let filterByPayee = queries.getAccountFilter(
const filterByAccount = queries.getAccountFilter(params.id, '_account');
const filterByPayee = queries.getAccountFilter(
params.id,
'_payee.transfer_acct',
);
// Never show schedules on these pages
if (
(location.state && location.state.filter) ||
params.id === 'uncategorized'
) {
filterByAccount = { id: null };
filterByPayee = { id: null };
}
return q => {
q = q.filter({
$and: [{ '_account.closed': false }],
$or: [filterByAccount, filterByPayee],
});
if (params.id) {
if (params.id === 'uncategorized') {
q = q.filter({ next_date: null });
} else {
q = q.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return q.orderBy({ next_date: 'desc' });
};
}, [params.id]);
@@ -1548,7 +1664,7 @@ export function Account() {
{...actionCreators}
modalShowing={state.modalShowing}
accountId={params.id}
categoryId={location?.state?.filter?.category}
categoryId={location?.state?.categoryId}
location={location}
filtersList={filtersList}
/>

View File

@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { authorizeBank } from '../../gocardless';
import { useAccounts } from '../../hooks/useAccounts';
import { useActions } from '../../hooks/useActions';
import { SvgExclamationOutline } from '../../icons/v1';
import { theme } from '../../style';
@@ -49,7 +50,7 @@ function getErrorMessage(type, code) {
}
export function AccountSyncCheck() {
const accounts = useSelector(state => state.queries.accounts);
const accounts = useAccounts();
const failedAccounts = useSelector(state => state.account.failedAccounts);
const { unlinkAccount, pushModal } = useActions();

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { isPreviewId } from 'loot-core/shared/transactions';
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
import { q } from 'loot-core/src/shared/query';
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
@@ -14,7 +15,6 @@ import { PrivacyFilter } from '../PrivacyFilter';
import { CellValue } from '../spreadsheet/CellValue';
import { useFormat } from '../spreadsheet/useFormat';
import { useSheetValue } from '../spreadsheet/useSheetValue';
import { isPreviewId } from '../transactions/TransactionsTable';
function DetailedBalance({ name, balance, isExactBalance = true }) {
const format = useFormat();
@@ -104,6 +104,20 @@ function SelectedBalance({ selectedItems, account }) {
);
}
function FilteredBalance({ selectedItems }) {
const balance = selectedItems
.filter(item => !item._unmatched && !item.is_parent)
.reduce((sum, product) => sum + product.amount, 0);
return (
<DetailedBalance
name="Filtered balance:"
balance={balance}
isExactBalance={true}
/>
);
}
function MoreBalances({ balanceQuery }) {
const cleared = useSheetValue({
name: balanceQuery.name + '-cleared',
@@ -127,6 +141,8 @@ export function Balances({
showExtraBalances,
onToggleExtraBalances,
account,
filteredItems,
transactions,
}) {
const selectedItems = useSelectedItems();
@@ -148,6 +164,8 @@ export function Balances({
opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0,
},
'&:hover svg': { opacity: 1 },
paddingTop: 1,
paddingBottom: 1,
}}
>
<CellValue
@@ -182,6 +200,9 @@ export function Balances({
{selectedItems.size > 0 && (
<SelectedBalance selectedItems={selectedItems} account={account} />
)}
{filteredItems.length > 0 && (
<FilteredBalance selectedItems={transactions} />
)}
</View>
);
}

View File

@@ -1,5 +1,8 @@
import React, { useState, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
import { AnimatedLoading } from '../../icons/AnimatedLoading';
import { SvgAdd } from '../../icons/v1';
@@ -21,11 +24,9 @@ import { Search } from '../common/Search';
import { Stack } from '../common/Stack';
import { View } from '../common/View';
import { FilterButton } from '../filters/FiltersMenu';
import { FiltersStack } from '../filters/SavedFilters';
import { KeyHandlers } from '../KeyHandlers';
import { FiltersStack } from '../filters/FiltersStack';
import { NotesButton } from '../NotesButton';
import { SelectedTransactionsButton } from '../transactions/SelectedTransactions';
import { useSplitsExpanded } from '../transactions/TransactionsTable';
import { Balances } from './Balance';
import { ReconcilingMessage, ReconcileTooltip } from './Reconcile';
@@ -53,7 +54,6 @@ export function AccountHeader({
search,
filters,
conditionsOp,
savePrefs,
pushModal,
onSearch,
onAddTransaction,
@@ -79,17 +79,25 @@ export function AccountHeader({
onCondOpChange,
onDeleteFilter,
onScheduleAction,
onSetTransfer,
}) {
const [menuOpen, setMenuOpen] = useState(false);
const searchInput = useRef(null);
const splitsExpanded = useSplitsExpanded();
const syncServerStatus = useSyncServerStatus();
const isUsingServer = syncServerStatus !== 'no-server';
const isServerOffline = syncServerStatus === 'offline';
const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
let canSync = account && account.account_id;
let canSync = account && account.account_id && isUsingServer;
if (!account) {
// All accounts - check for any syncable account
canSync = !!accounts.find(account => !!account.account_id);
canSync = !!accounts.find(account => !!account.account_id) && isUsingServer;
}
// Only show the ability to make linked transfers on multi-account views.
const showMakeTransfer = !account;
function onToggleSplits() {
if (tableRef.current) {
splitsExpanded.dispatch({
@@ -97,39 +105,47 @@ export function AccountHeader({
id: tableRef.current.getScrolledItem(),
});
savePrefs({
'expand-splits': !(splitsExpanded.state.mode === 'expand'),
});
setExpandSplitsPref(!(splitsExpanded.state.mode === 'expand'));
}
}
useHotkeys(
'ctrl+f, cmd+f, meta+f',
() => {
if (searchInput.current) {
searchInput.current.focus();
}
},
{
enableOnFormTags: true,
preventDefault: true,
scopes: ['app'],
},
[searchInput],
);
return (
<>
<KeyHandlers
keys={{
'ctrl+f, cmd+f': () => {
if (searchInput.current) {
searchInput.current.focus();
}
},
}}
/>
<View style={{ ...styles.pageContent, paddingBottom: 10, flexShrink: 0 }}>
<View style={{ marginTop: 2, alignItems: 'flex-start' }}>
<View
style={{ marginTop: 2, marginBottom: 10, alignItems: 'flex-start' }}
>
<View>
{editingName ? (
<InitialFocus>
<Input
defaultValue={accountName}
onEnter={e => onSaveName(e.target.value)}
onBlur={() => onExposeName(false)}
onBlur={e => onSaveName(e.target.value)}
onEscape={() => onExposeName(false)}
style={{
fontSize: 25,
fontWeight: 500,
marginTop: -5,
marginBottom: -2,
marginLeft: -5,
paddingTop: 2,
paddingBottom: 2,
}}
/>
</InitialFocus>
@@ -153,7 +169,7 @@ export function AccountHeader({
fontSize: 25,
fontWeight: 500,
marginRight: 5,
marginBottom: 5,
marginBottom: -1,
}}
data-testid="account-name"
>
@@ -185,7 +201,7 @@ export function AccountHeader({
</View>
) : (
<View
style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }}
style={{ fontSize: 25, fontWeight: 500, marginBottom: -1 }}
data-testid="account-name"
>
{account && account.closed
@@ -201,6 +217,8 @@ export function AccountHeader({
showExtraBalances={showExtraBalances}
onToggleExtraBalances={onToggleExtraBalances}
account={account}
filteredItems={filters}
transactions={transactions}
/>
<Stack
@@ -210,19 +228,24 @@ export function AccountHeader({
style={{ marginTop: 12 }}
>
{((account && !account.closed) || canSync) && (
<Button type="bare" onClick={canSync ? onSync : onImport}>
<Button
type="bare"
onClick={canSync ? onSync : onImport}
disabled={canSync && isServerOffline}
>
{canSync ? (
<>
<AnimatedRefresh
width={13}
height={13}
animating={
(account && accountsSyncing === account.name) ||
accountsSyncing === '__all'
account
? accountsSyncing.includes(account.id)
: accountsSyncing.length > 0
}
style={{ marginRight: 4 }}
/>{' '}
Sync
{isServerOffline ? 'Sync offline' : 'Sync'}
</>
) : (
<>
@@ -265,8 +288,10 @@ export function AccountHeader({
onEdit={onBatchEdit}
onUnlink={onBatchUnlink}
onCreateRule={onCreateRule}
onSetTransfer={onSetTransfer}
onScheduleAction={onScheduleAction}
pushModal={pushModal}
showMakeTransfer={showMakeTransfer}
/>
)}
<Button

View File

@@ -1,16 +1,40 @@
// @ts-strict-ignore
import React, { Fragment, type ComponentProps, type ReactNode } from 'react';
import React, {
Fragment,
type ComponentProps,
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { css } from 'glamor';
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import { type AccountEntity } from 'loot-core/src/types/models';
import { useAccounts } from '../../hooks/useAccounts';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { Autocomplete } from './Autocomplete';
import { ItemHeader } from './ItemHeader';
type AccountAutocompleteItem = AccountEntity;
type AccountListProps = {
items: AccountAutocompleteItem[];
getItemProps: (arg: {
item: AccountAutocompleteItem;
}) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded: boolean;
renderAccountItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderAccountItem?: (
props: ComponentPropsWithoutRef<typeof AccountItem>,
) => ReactElement<typeof AccountItem>;
};
function AccountList({
items,
@@ -19,7 +43,7 @@ function AccountList({
embedded,
renderAccountItemGroupHeader = defaultRenderAccountItemGroupHeader,
renderAccountItem = defaultRenderAccountItem,
}) {
}: AccountListProps) {
let lastItem = null;
return (
@@ -68,15 +92,19 @@ function AccountList({
);
}
type AccountAutoCompleteProps = {
type AccountAutocompleteProps = ComponentProps<
typeof Autocomplete<AccountAutocompleteItem>
> & {
embedded?: boolean;
includeClosedAccounts: boolean;
includeClosedAccounts?: boolean;
renderAccountItemGroupHeader?: (
props: AccountItemGroupHeaderProps,
) => ReactNode;
renderAccountItem?: (props: AccountItemProps) => ReactNode;
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderAccountItem?: (
props: ComponentPropsWithoutRef<typeof AccountItem>,
) => ReactElement<typeof AccountItem>;
closeOnBlur?: boolean;
} & ComponentProps<typeof Autocomplete>;
};
export function AccountAutocomplete({
embedded,
@@ -85,12 +113,12 @@ export function AccountAutocomplete({
renderAccountItem,
closeOnBlur,
...props
}: AccountAutoCompleteProps) {
let accounts = useCachedAccounts() || [];
}: AccountAutocompleteProps) {
const accounts = useAccounts() || [];
//remove closed accounts if needed
//then sort by closed, then offbudget
accounts = accounts
const accountSuggestions: AccountAutocompleteItem[] = accounts
.filter(item => {
return includeClosedAccounts ? item : !item.closed;
})
@@ -108,7 +136,7 @@ export function AccountAutocomplete({
highlightFirst={true}
embedded={embedded}
closeOnBlur={closeOnBlur}
suggestions={accounts}
suggestions={accountSuggestions}
renderItems={(items, getItemProps, highlightedIndex) => (
<AccountList
items={items}
@@ -124,39 +152,14 @@ export function AccountAutocomplete({
);
}
type AccountItemGroupHeaderProps = {
title: string;
style?: CSSProperties;
};
export function AccountItemGroupHeader({
title,
style,
...props
}: AccountItemGroupHeaderProps) {
return (
<div
style={{
color: theme.menuAutoCompleteTextHeader,
padding: '4px 9px',
...style,
}}
data-testid={`${title}-account-item-group`}
{...props}
>
{title}
</div>
);
}
function defaultRenderAccountItemGroupHeader(
props: AccountItemGroupHeaderProps,
): ReactNode {
return <AccountItemGroupHeader {...props} />;
props: ComponentPropsWithoutRef<typeof ItemHeader>,
): ReactElement<typeof ItemHeader> {
return <ItemHeader {...props} type="account" />;
}
type AccountItemProps = {
item: AccountEntity;
item: AccountAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
@@ -171,6 +174,14 @@ export function AccountItem({
...props
}: AccountItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<div
// List each account up to a max
@@ -199,24 +210,25 @@ export function AccountItem({
className={`${className} ${css([
{
backgroundColor: highlighted
? embedded && isNarrowWidth
? theme.menuItemBackgroundHover
: theme.menuAutoCompleteBackgroundHover
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
padding: 4,
paddingLeft: 20,
borderRadius: embedded ? 4 : 0,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-account-item`}
data-highlighted={highlighted || undefined}
{...props}
>
{item.name}
<TextOneLine>{item.name}</TextOneLine>
</div>
);
}
function defaultRenderAccountItem(props: AccountItemProps): ReactNode {
function defaultRenderAccountItem(
props: ComponentPropsWithoutRef<typeof AccountItem>,
): ReactElement<typeof AccountItem> {
return <AccountItem {...props} />;
}

View File

@@ -15,12 +15,45 @@ import Downshift, { type StateChangeTypes } from 'downshift';
import { css } from 'glamor';
import { SvgRemove } from '../../icons/v2';
import { theme, type CSSProperties } from '../../style';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, type CSSProperties, styles } from '../../style';
import { Button } from '../common/Button';
import { Input } from '../common/Input';
import { View } from '../common/View';
import { Tooltip } from '../tooltips';
type CommonAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps<typeof Tooltip>;
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
idx: number,
value?: string,
) => ReactNode;
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
clearOnBlur?: boolean;
clearOnSelect?: boolean;
closeOnBlur?: boolean;
onClose?: () => void;
};
type Item = {
id?: string;
name: string;
@@ -41,7 +74,7 @@ function findItem<T extends Item>(
return value;
}
function getItemName(item: null | string | Item): string {
function getItemName<T extends Item>(item: T | T['name'] | null): string {
if (item == null) {
return '';
} else if (typeof item === 'string') {
@@ -50,7 +83,7 @@ function getItemName(item: null | string | Item): string {
return item.name || '';
}
function getItemId(item: Item | Item['id']) {
function getItemId<T extends Item>(item: T | T['id']) {
if (typeof item === 'string') {
return item;
}
@@ -168,38 +201,12 @@ function defaultItemToString<T extends Item>(item?: T) {
return item ? getItemName(item) : '';
}
type SingleAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps<typeof Tooltip>;
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
idx: number,
value?: string,
) => ReactNode;
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type?: 'single' | never;
onSelect: (id: T['id'], value: string) => void;
tableBehavior?: boolean;
closeOnBlur?: boolean;
value: T | T['id'];
isMulti?: boolean;
value: null | T | T['id'];
};
function SingleAutocomplete<T extends Item>({
focused,
embedded = false,
@@ -220,10 +227,11 @@ function SingleAutocomplete<T extends Item>({
onUpdate,
strict,
onSelect,
tableBehavior,
clearOnBlur = true,
clearOnSelect = false,
closeOnBlur = true,
onClose,
value: initialValue,
isMulti = false,
}: SingleAutocompleteProps<T>) {
const [selectedItem, setSelectedItem] = useState(() =>
findItem(strict, suggestions, initialValue),
@@ -239,6 +247,26 @@ function SingleAutocomplete<T extends Item>({
);
const [highlightedIndex, setHighlightedIndex] = useState(null);
const [isOpen, setIsOpen] = useState(embedded);
const open = () => setIsOpen(true);
const close = () => {
setIsOpen(false);
onClose?.();
};
const { isNarrowWidth } = useResponsive();
const narrowInputStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
}
: {};
inputProps = {
...inputProps,
style: {
...narrowInputStyle,
...inputProps.style,
},
};
// Update the selected item if the suggestion list or initial
// input value has changed
@@ -273,10 +301,10 @@ function SingleAutocomplete<T extends Item>({
setSelectedItem(item);
setHighlightedIndex(null);
if (isMulti) {
if (clearOnSelect) {
setValue('');
} else {
setIsOpen(false);
close();
}
if (onSelect) {
@@ -359,10 +387,11 @@ function SingleAutocomplete<T extends Item>({
setValue(value);
setIsChanged(true);
open();
}}
onStateChange={changes => {
if (
tableBehavior &&
!clearOnBlur &&
changes.type === Downshift.stateChangeTypes.mouseUp
) {
return;
@@ -421,7 +450,7 @@ function SingleAutocomplete<T extends Item>({
inputProps.onFocus?.(e);
if (openOnFocus) {
setIsOpen(true);
open();
}
},
onBlur: e => {
@@ -431,11 +460,11 @@ function SingleAutocomplete<T extends Item>({
if (!closeOnBlur) return;
if (!tableBehavior) {
if (clearOnBlur) {
if (e.target.value === '') {
onSelect?.(null, e.target.value);
setSelectedItem(null);
setIsOpen(false);
close();
return;
}
@@ -445,7 +474,7 @@ function SingleAutocomplete<T extends Item>({
resetState(value);
} else {
setIsOpen(false);
close();
}
},
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
@@ -505,7 +534,11 @@ function SingleAutocomplete<T extends Item>({
setValue(getItemName(originalItem));
setSelectedItem(findItem(strict, suggestions, originalItem));
setHighlightedIndex(null);
setIsOpen(embedded ? true : false);
if (embedded) {
open();
} else {
close();
}
}
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
@@ -578,36 +611,37 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
);
}
type MultiAutocompleteProps<
T extends Item,
Value = SingleAutocompleteProps<T>['value'],
> = Omit<SingleAutocompleteProps<T>, 'value' | 'onSelect'> & {
value: Value[];
onSelect: (ids: Value[], id?: string) => void;
type MultiAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type: 'multi';
onSelect: (ids: T['id'][], id?: T['id']) => void;
value: null | T[] | T['id'][];
};
function MultiAutocomplete<T extends Item>({
value: selectedItems,
value: selectedItems = [],
onSelect,
suggestions,
strict,
clearOnBlur = true,
...props
}: MultiAutocompleteProps<T>) {
const [focused, setFocused] = useState(false);
const lastSelectedItems = useRef<typeof selectedItems>();
const selectedItemIds = selectedItems.map(getItemId);
useEffect(() => {
lastSelectedItems.current = selectedItems;
});
function onRemoveItem(id: (typeof selectedItems)[0]) {
const items = selectedItems.filter(i => i !== id);
function onRemoveItem(id: T['id']) {
const items = selectedItemIds.filter(i => i !== id);
onSelect(items);
}
function onAddItem(id: string) {
function onAddItem(id: T['id']) {
if (id) {
id = id.trim();
onSelect([...selectedItems, id], id);
onSelect([...selectedItemIds, id], id);
}
}
@@ -616,7 +650,7 @@ function MultiAutocomplete<T extends Item>({
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
) {
if (e.key === 'Backspace' && e.currentTarget.value === '') {
onRemoveItem(selectedItems[selectedItems.length - 1]);
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
}
prevOnKeyDown?.(e);
@@ -625,10 +659,12 @@ function MultiAutocomplete<T extends Item>({
return (
<Autocomplete
{...props}
isMulti
type="single"
value={null}
clearOnBlur={clearOnBlur}
clearOnSelect={true}
suggestions={suggestions.filter(
item => !selectedItems.includes(getItemId(item)),
item => !selectedItemIds.includes(getItemId(item)),
)}
onSelect={onAddItem}
highlightFirst
@@ -720,18 +756,10 @@ type AutocompleteProps<T extends Item> =
| ComponentProps<typeof SingleAutocomplete<T>>
| ComponentProps<typeof MultiAutocomplete<T>>;
function isMultiAutocomplete<T extends Item>(
_props: AutocompleteProps<T>,
multi?: boolean,
): _props is ComponentProps<typeof MultiAutocomplete<T>> {
return multi;
}
export function Autocomplete<T extends Item>({
multi,
...props
}: AutocompleteProps<T> & { multi?: boolean }) {
if (isMultiAutocomplete(props, multi)) {
}: AutocompleteProps<T>) {
if (props.type === 'multi') {
return <MultiAutocomplete {...props} />;
}

View File

@@ -5,6 +5,8 @@ import React, {
type ReactNode,
type SVGProps,
type ComponentType,
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { css } from 'glamor';
@@ -16,27 +18,36 @@ import {
import { SvgSplit } from '../../icons/v0';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { Text } from '../common/Text';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
import { ItemHeader } from './ItemHeader';
type CategoryAutocompleteItem = CategoryEntity & {
group?: CategoryGroupEntity;
};
export type CategoryListProps = {
items: Array<CategoryEntity & { group?: CategoryGroupEntity }>;
items: CategoryAutocompleteItem[];
getItemProps?: (arg: {
item: CategoryEntity;
item: CategoryAutocompleteItem;
}) => Partial<ComponentProps<typeof View>>;
highlightedIndex: number;
embedded?: boolean;
footer?: ReactNode;
renderSplitTransactionButton?: (
props: SplitTransactionButtonProps,
) => ReactNode;
props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
) => ReactElement<typeof SplitTransactionButton>;
renderCategoryItemGroupHeader?: (
props: CategoryItemGroupHeaderProps,
) => ReactNode;
renderCategoryItem?: (props: CategoryItemProps) => ReactNode;
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderCategoryItem?: (
props: ComponentPropsWithoutRef<typeof CategoryItem>,
) => ReactElement<typeof CategoryItem>;
showHiddenItems?: boolean;
};
function CategoryList({
items,
@@ -47,6 +58,7 @@ function CategoryList({
renderSplitTransactionButton = defaultRenderSplitTransactionButton,
renderCategoryItemGroupHeader = defaultRenderCategoryItemGroupHeader,
renderCategoryItem = defaultRenderCategoryItem,
showHiddenItems,
}: CategoryListProps) {
let lastGroup: string | undefined | null = null;
@@ -69,14 +81,23 @@ function CategoryList({
});
}
if ((item.hidden || item.group?.hidden) && !showHiddenItems) {
return <Fragment key={item.id} />;
}
const showGroup = item.cat_group !== lastGroup;
const groupName = `${item.group?.name}${item.group?.hidden ? ' (hidden)' : ''}`;
lastGroup = item.cat_group;
return (
<Fragment key={item.id}>
{showGroup && item.group?.name && (
<Fragment key={item.group.name}>
{renderCategoryItemGroupHeader({
title: item.group.name,
title: groupName,
style: {
...(showHiddenItems &&
item.group?.hidden && { color: theme.pageTextSubdued }),
},
})}
</Fragment>
)}
@@ -86,6 +107,10 @@ function CategoryList({
item,
highlighted: highlightedIndex === idx,
embedded,
style: {
...(showHiddenItems &&
item.hidden && { color: theme.pageTextSubdued }),
},
})}
</Fragment>
</Fragment>
@@ -97,16 +122,21 @@ function CategoryList({
);
}
type CategoryAutocompleteProps = ComponentProps<typeof Autocomplete> & {
type CategoryAutocompleteProps = ComponentProps<
typeof Autocomplete<CategoryAutocompleteItem>
> & {
categoryGroups: Array<CategoryGroupEntity>;
showSplitOption?: boolean;
renderSplitTransactionButton?: (
props: SplitTransactionButtonProps,
) => ReactNode;
props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
) => ReactElement<typeof SplitTransactionButton>;
renderCategoryItemGroupHeader?: (
props: CategoryItemGroupHeaderProps,
) => ReactNode;
renderCategoryItem?: (props: CategoryItemProps) => ReactNode;
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderCategoryItem?: (
props: ComponentPropsWithoutRef<typeof CategoryItem>,
) => ReactElement<typeof CategoryItem>;
showHiddenCategories?: boolean;
};
export function CategoryAutocomplete({
@@ -117,11 +147,10 @@ export function CategoryAutocomplete({
renderSplitTransactionButton,
renderCategoryItemGroupHeader,
renderCategoryItem,
showHiddenCategories,
...props
}: CategoryAutocompleteProps) {
const categorySuggestions: Array<
CategoryEntity & { group?: CategoryGroupEntity }
> = useMemo(
const categorySuggestions: CategoryAutocompleteItem[] = useMemo(
() =>
categoryGroups.reduce(
(list, group) =>
@@ -170,6 +199,7 @@ export function CategoryAutocomplete({
renderSplitTransactionButton={renderSplitTransactionButton}
renderCategoryItemGroupHeader={renderCategoryItemGroupHeader}
renderCategoryItem={renderCategoryItem}
showHiddenItems={showHiddenCategories}
/>
)}
{...props}
@@ -177,35 +207,10 @@ export function CategoryAutocomplete({
);
}
type CategoryItemGroupHeaderProps = {
title: string;
style?: CSSProperties;
};
export function CategoryItemGroupHeader({
title,
style,
...props
}: CategoryItemGroupHeaderProps) {
return (
<div
style={{
color: theme.menuAutoCompleteTextHeader,
padding: '4px 9px',
...style,
}}
data-testid={`${title}-category-item-group`}
{...props}
>
{title}
</div>
);
}
function defaultRenderCategoryItemGroupHeader(
props: CategoryItemGroupHeaderProps,
) {
return <CategoryItemGroupHeader {...props} />;
props: ComponentPropsWithoutRef<typeof ItemHeader>,
): ReactElement<typeof ItemHeader> {
return <ItemHeader {...props} type="category" />;
}
type SplitTransactionButtonProps = {
@@ -222,7 +227,6 @@ function SplitTransactionButton({
style,
...props
}: SplitTransactionButtonProps) {
const { isNarrowWidth } = useResponsive();
return (
<View
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
@@ -249,9 +253,7 @@ function SplitTransactionButton({
role="button"
style={{
backgroundColor: highlighted
? embedded && isNarrowWidth
? theme.menuItemBackgroundHover
: theme.menuAutoCompleteBackgroundHover
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
borderRadius: embedded ? 4 : 0,
flexShrink: 0,
@@ -283,12 +285,12 @@ function SplitTransactionButton({
function defaultRenderSplitTransactionButton(
props: SplitTransactionButtonProps,
) {
): ReactElement<typeof SplitTransactionButton> {
return <SplitTransactionButton {...props} />;
}
type CategoryItemProps = {
item: CategoryEntity & { group?: CategoryGroupEntity };
item: CategoryAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
@@ -298,36 +300,50 @@ type CategoryItemProps = {
export function CategoryItem({
item,
className,
style,
highlighted,
embedded,
...props
}: CategoryItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<div
style={style}
// See comment above.
role="button"
className={`${className} ${css([
{
backgroundColor: highlighted
? embedded && isNarrowWidth
? theme.menuItemBackgroundHover
: theme.menuAutoCompleteBackgroundHover
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
padding: 4,
paddingLeft: 20,
borderRadius: embedded ? 4 : 0,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-category-item`}
data-highlighted={highlighted || undefined}
{...props}
>
{item.name}
<TextOneLine>
{item.name}
{item.hidden ? ' (hidden)' : null}
</TextOneLine>
</div>
);
}
function defaultRenderCategoryItem(props: CategoryItemProps) {
function defaultRenderCategoryItem(
props: ComponentPropsWithoutRef<typeof CategoryItem>,
): ReactElement<typeof CategoryItem> {
return <CategoryItem {...props} />;
}

View File

@@ -0,0 +1,34 @@
import React, { type ComponentProps } from 'react';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import { type TransactionFilterEntity } from 'loot-core/types/models/transaction-filter';
import { Autocomplete } from './Autocomplete';
import { FilterList } from './FilterList';
export function FilterAutocomplete({
embedded,
...props
}: {
embedded?: boolean;
} & ComponentProps<typeof Autocomplete<TransactionFilterEntity>>) {
const filters = useFilters() || [];
return (
<Autocomplete
strict={true}
highlightFirst={true}
embedded={embedded}
suggestions={filters}
renderItems={(items, getItemProps, highlightedIndex) => (
<FilterList
items={items}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
embedded={embedded}
/>
)}
{...props}
/>
);
}

View File

@@ -1,26 +1,21 @@
import React, { type ComponentProps } from 'react';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import { type TransactionFilterEntity } from 'loot-core/src/types/models';
import { theme } from '../../style';
import { theme } from '../../style/theme';
import { View } from '../common/View';
import { Autocomplete } from './Autocomplete';
import { ItemHeader } from './ItemHeader';
type FilterListProps<T> = {
items: T[];
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded?: boolean;
};
function FilterList<T extends { id: string; name: string }>({
export function FilterList<T extends { id: string; name: string }>({
items,
getItemProps,
highlightedIndex,
embedded,
}: FilterListProps<T>) {
}: {
items: T[];
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded?: boolean;
}) {
return (
<View>
<View
@@ -30,6 +25,7 @@ function FilterList<T extends { id: string; name: string }>({
...(!embedded && { maxHeight: 175 }),
}}
>
<ItemHeader title="Saved Filters" type="filter" />
{items.map((item, idx) => {
return [
<div
@@ -55,32 +51,3 @@ function FilterList<T extends { id: string; name: string }>({
</View>
);
}
type SavedFilterAutocompleteProps = {
embedded?: boolean;
} & ComponentProps<typeof Autocomplete<TransactionFilterEntity>>;
export function SavedFilterAutocomplete({
embedded,
...props
}: SavedFilterAutocompleteProps) {
const filters = useFilters() || [];
return (
<Autocomplete
strict={true}
highlightFirst={true}
embedded={embedded}
suggestions={filters}
renderItems={(items, getItemProps, highlightedIndex) => (
<FilterList
items={items}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
embedded={embedded}
/>
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useResponsive } from '../../ResponsiveProvider';
import { styles, theme } from '../../style';
import { type CSSProperties } from '../../style/types';
type ItemHeaderProps = {
title: string;
style?: CSSProperties;
type?: string;
};
export function ItemHeader({ title, style, type, ...props }: ItemHeaderProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.largeText,
color: theme.menuItemTextHeader,
paddingTop: 10,
paddingBottom: 10,
}
: {};
return (
<div
style={{
color: theme.menuAutoCompleteTextHeader,
padding: '4px 9px',
...narrowStyle,
...style,
}}
data-testid={`${title}-${type}-item-group`}
{...props}
>
{title}
</div>
);
}

View File

@@ -7,24 +7,27 @@ import React, {
type ReactNode,
type ComponentType,
type SVGProps,
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { useDispatch } from 'react-redux';
import { css } from 'glamor';
import { createPayee } from 'loot-core/src/client/actions/queries';
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
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 { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { Button } from '../common/Button';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import {
@@ -32,8 +35,15 @@ import {
defaultFilterSuggestion,
AutocompleteFooter,
} from './Autocomplete';
import { ItemHeader } from './ItemHeader';
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
type PayeeAutocompleteItem = PayeeEntity;
function getPayeeSuggestions(
payees: PayeeAutocompleteItem[],
focusTransferPayees: boolean,
accounts: AccountEntity[],
): PayeeAutocompleteItem[] {
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
if (focusTransferPayees && activePayees) {
@@ -43,11 +53,11 @@ function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
return activePayees || [];
}
function makeNew(value, rawPayee) {
if (value === 'new' && !rawPayee.startsWith('new:')) {
function makeNew(id, rawPayee) {
if (id === 'new' && !rawPayee.startsWith('new:')) {
return 'new:' + rawPayee;
}
return value;
return id;
}
// Convert the fully resolved new value into the 'new' id that can be
@@ -59,6 +69,26 @@ function stripNew(value) {
return value;
}
type PayeeListProps = {
items: PayeeAutocompleteItem[];
getItemProps: (arg: {
item: PayeeAutocompleteItem;
}) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded: boolean;
inputValue: string;
renderCreatePayeeButton?: (
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
) => ReactNode;
renderPayeeItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactNode;
renderPayeeItem?: (
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactNode;
footer: ReactNode;
};
function PayeeList({
items,
getItemProps,
@@ -69,8 +99,7 @@ function PayeeList({
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
renderPayeeItem = defaultRenderPayeeItem,
footer,
}) {
const isFiltered = items.filtered;
}: PayeeListProps) {
let createNew = null;
items = [...items];
@@ -111,7 +140,8 @@ function PayeeList({
} else if (type === 'account' && lastType !== type) {
title = 'Transfer To/From';
}
const showMoreMessage = idx === items.length - 1 && isFiltered;
const showMoreMessage =
idx === items.length - 1 && items.length > 100;
lastType = type;
return (
@@ -151,22 +181,24 @@ function PayeeList({
);
}
type PayeeAutocompleteProps = {
value: ComponentProps<typeof Autocomplete>['value'];
inputProps: ComponentProps<typeof Autocomplete>['inputProps'];
type PayeeAutocompleteProps = ComponentProps<
typeof Autocomplete<PayeeAutocompleteItem>
> & {
showMakeTransfer?: boolean;
showManagePayees?: boolean;
tableBehavior: ComponentProps<typeof Autocomplete>['tableBehavior'];
embedded?: boolean;
closeOnBlur: ComponentProps<typeof Autocomplete>['closeOnBlur'];
onUpdate?: (value: string) => void;
onSelect?: (value: string) => void;
onManagePayees: () => void;
renderCreatePayeeButton?: (props: CreatePayeeButtonProps) => ReactNode;
renderPayeeItemGroupHeader?: (props: PayeeItemGroupHeaderProps) => ReactNode;
renderPayeeItem?: (props: PayeeItemProps) => ReactNode;
onManagePayees?: () => void;
renderCreatePayeeButton?: (
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
) => ReactElement<typeof CreatePayeeButton>;
renderPayeeItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderPayeeItem?: (
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[];
payees?: PayeeEntity[];
payees?: PayeeAutocompleteItem[];
};
export function PayeeAutocomplete({
@@ -174,9 +206,9 @@ export function PayeeAutocomplete({
inputProps,
showMakeTransfer = true,
showManagePayees = false,
tableBehavior,
embedded,
clearOnBlur = true,
closeOnBlur,
embedded,
onUpdate,
onSelect,
onManagePayees,
@@ -187,12 +219,12 @@ export function PayeeAutocomplete({
payees,
...props
}: PayeeAutocompleteProps) {
const cachedPayees = useCachedPayees();
const retrievedPayees = usePayees();
if (!payees) {
payees = cachedPayees;
payees = retrievedPayees;
}
const cachedAccounts = useCachedAccounts();
const cachedAccounts = useAccounts();
if (!accounts) {
accounts = cachedAccounts;
}
@@ -200,7 +232,7 @@ export function PayeeAutocomplete({
const [focusTransferPayees, setFocusTransferPayees] = useState(false);
const [rawPayee, setRawPayee] = useState('');
const hasPayeeInput = !!rawPayee;
const payeeSuggestions = useMemo(() => {
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(
payees,
focusTransferPayees,
@@ -215,20 +247,22 @@ export function PayeeAutocomplete({
const dispatch = useDispatch();
async function handleSelect(value, rawInputValue) {
if (tableBehavior) {
onSelect?.(makeNew(value, rawInputValue));
async function handleSelect(idOrIds, rawInputValue) {
if (!clearOnBlur) {
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
} else {
const create = () => dispatch(createPayee(rawInputValue));
const create = payeeName => dispatch(createPayee(payeeName));
if (Array.isArray(value)) {
value = await Promise.all(value.map(v => (v === 'new' ? create() : v)));
if (Array.isArray(idOrIds)) {
idOrIds = await Promise.all(
idOrIds.map(v => (v === 'new' ? create(rawInputValue) : v)),
);
} else {
if (value === 'new') {
value = await create();
if (idOrIds === 'new') {
idOrIds = await create(rawInputValue);
}
}
onSelect?.(value);
onSelect?.(idOrIds, rawInputValue);
}
}
@@ -241,7 +275,7 @@ export function PayeeAutocomplete({
embedded={embedded}
value={stripNew(value)}
suggestions={payeeSuggestions}
tableBehavior={tableBehavior}
clearOnBlur={clearOnBlur}
closeOnBlur={closeOnBlur}
itemToString={item => {
if (!item) {
@@ -261,9 +295,7 @@ export function PayeeAutocomplete({
onFocus: () => setPayeeFieldFocused(true),
onChange: setRawPayee,
}}
onUpdate={(value, inputValue) =>
onUpdate && onUpdate(makeNew(value, inputValue))
}
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect}
getHighlightedIndex={suggestions => {
if (suggestions.length > 1 && suggestions[0].id === 'new') {
@@ -308,10 +340,7 @@ export function PayeeAutocomplete({
}
});
const isf = filtered.length > 100;
filtered = filtered.slice(0, 100);
// @ts-expect-error TODO: solve this somehow
filtered.filtered = isf;
if (filtered.length >= 2 && filtered[0].id === 'new') {
if (
@@ -340,7 +369,7 @@ export function PayeeAutocomplete({
type={focusTransferPayees ? 'menuSelected' : 'menu'}
style={showManagePayees && { marginBottom: 5 }}
onClick={() => {
onUpdate?.(null);
onUpdate?.(null, null);
setFocusTransferPayees(!focusTransferPayees);
}}
>
@@ -378,6 +407,13 @@ export function CreatePayeeButton({
...props
}: CreatePayeeButtonProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
}
: {};
const iconSize = isNarrowWidth ? 14 : 8;
return (
<View
data-testid="create-payee-button"
@@ -391,13 +427,12 @@ export function CreatePayeeButton({
fontWeight: 500,
padding: '6px 9px',
backgroundColor: highlighted
? embedded && isNarrowWidth
? theme.menuItemBackgroundHover
: theme.menuAutoCompleteBackgroundHover
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
':active': {
backgroundColor: 'rgba(100, 100, 100, .25)',
},
...narrowStyle,
...style,
}}
{...props}
@@ -406,8 +441,8 @@ export function CreatePayeeButton({
<Icon style={{ marginRight: 5, display: 'inline-block' }} />
) : (
<SvgAdd
width={8}
height={8}
width={iconSize}
height={iconSize}
style={{ marginRight: 5, display: 'inline-block' }}
/>
)}
@@ -417,44 +452,19 @@ export function CreatePayeeButton({
}
function defaultRenderCreatePayeeButton(
props: CreatePayeeButtonProps,
): ReactNode {
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
): ReactElement<typeof CreatePayeeButton> {
return <CreatePayeeButton {...props} />;
}
type PayeeItemGroupHeaderProps = {
title: string;
style?: CSSProperties;
};
export function PayeeItemGroupHeader({
title,
style,
...props
}: PayeeItemGroupHeaderProps) {
return (
<div
style={{
color: theme.menuAutoCompleteTextHeader,
padding: '4px 9px',
...style,
}}
data-testid={`${title}-payee-item-group`}
{...props}
>
{title}
</div>
);
}
function defaultRenderPayeeItemGroupHeader(
props: PayeeItemGroupHeaderProps,
): ReactNode {
return <PayeeItemGroupHeader {...props} />;
props: ComponentPropsWithoutRef<typeof ItemHeader>,
): ReactElement<typeof ItemHeader> {
return <ItemHeader {...props} type="payee" />;
}
type PayeeItemProps = {
item: PayeeEntity;
item: PayeeAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
@@ -469,6 +479,14 @@ export function PayeeItem({
...props
}: PayeeItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<div
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
@@ -496,24 +514,25 @@ export function PayeeItem({
className={`${className} ${css([
{
backgroundColor: highlighted
? embedded && isNarrowWidth
? theme.menuItemBackgroundHover
: theme.menuAutoCompleteBackgroundHover
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
borderRadius: embedded ? 4 : 0,
padding: 4,
paddingLeft: 20,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-payee-item`}
data-highlighted={highlighted || undefined}
{...props}
>
{item.name}
<TextOneLine>{item.name}</TextOneLine>
</div>
);
}
function defaultRenderPayeeItem(props: PayeeItemProps): ReactNode {
function defaultRenderPayeeItem(
props: ComponentPropsWithoutRef<typeof PayeeItem>,
): ReactElement<typeof PayeeItem> {
return <PayeeItem {...props} />;
}

View File

@@ -0,0 +1,36 @@
import React, { type ComponentProps } from 'react';
import { useReports } from 'loot-core/client/data-hooks/reports';
import { type CustomReportEntity } from 'loot-core/src/types/models/reports';
import { Autocomplete } from './Autocomplete';
import { ReportList } from './ReportList';
type ReportAutocompleteProps = {
embedded?: boolean;
} & ComponentProps<typeof Autocomplete<CustomReportEntity>>;
export function ReportAutocomplete({
embedded,
...props
}: ReportAutocompleteProps) {
const reports = useReports() || [];
return (
<Autocomplete
strict={true}
highlightFirst={true}
embedded={embedded}
suggestions={reports}
renderItems={(items, getItemProps, highlightedIndex) => (
<ReportList
items={items}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
embedded={embedded}
/>
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,52 @@
import React, { Fragment, type ComponentProps } from 'react';
import { theme } from '../../style/theme';
import { View } from '../common/View';
import { ItemHeader } from './ItemHeader';
export function ReportList<T extends { id: string; name: string }>({
items,
getItemProps,
highlightedIndex,
embedded,
}: {
items: T[];
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded?: boolean;
}) {
return (
<View>
<View
style={{
overflow: 'auto',
padding: '5px 0',
...(!embedded && { maxHeight: 175 }),
}}
>
<Fragment>{ItemHeader({ title: 'Saved Reports' })}</Fragment>
{items.map((item, idx) => {
return [
<div
{...(getItemProps ? getItemProps({ item }) : null)}
key={item.id}
style={{
backgroundColor:
highlightedIndex === idx
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
padding: 4,
paddingLeft: 20,
borderRadius: embedded ? 4 : 0,
}}
data-highlighted={highlightedIndex === idx || undefined}
>
{item.name}
</div>,
];
})}
</View>
</View>
);
}

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