Compare commits

...

117 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
a0dfb8afbd 🔖 (23.6.0) category hiding and filters for reports (#1087)
Web: https://github.com/actualbudget/actual/pull/1087
Server: https://github.com/actualbudget/actual-server/pull/207
Docs: https://github.com/actualbudget/docs/pull/179

---------

Co-authored-by: Jed Fox <git@jedfox.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-06-01 20:58:42 +01:00
shall0pass
1d301ac78d Bug: Cleanup script (#1084) 2023-06-01 10:25:00 -04:00
youngcw
8875f6d487 fix parser bug where by goals couldn't repeat on months/years >9 (#1083) 2023-06-01 08:26:28 -04:00
Tom French
66bfef28c0 chore: migrate some JS to TS (#1078) 2023-06-01 08:25:19 -04:00
Tom French
f03b8a3a14 chore: remove loot-design reference from readme.md (#1079) 2023-06-01 08:24:26 -04:00
Matiss Janis Aboltins
62ebd0627d 🐛 (budget) link from budget to transactions not working (#1067) 2023-06-01 07:11:28 +01:00
shall0pass
bb1a4747f5 Goals: Schedule include spent value in calculation (#1049)
This adds back the functionality, which was inadvertantly removed, that
includes the already spent column in the calculation when the template
is run.

Some transactions may be posted to the account prior to running the
templates and would result incorrect budgeted amounts.

---------

Co-authored-by: Jed Fox <git@jedfox.com>
2023-05-30 15:24:21 -04:00
shall0pass
d640859940 End of month cleanup script (#1016)
~This is really just a proof of concept. I have no delusions that this
might get included. I'm sure others might have a much cleaner
implementation.~
I'm now delusional.

Resolves https://github.com/actualbudget/actual/issues/508

Taking @youngcw 's advice, I changed the keyword to #cleanup for the end
of month script to keep it separated.

This screen video shows two categories that are sources of funds. At the
end of the month, any excess in these funds can be redistributed to your
highest priorities. Three categories are set as sinks, or recipients, of
excess funds.

#cleanup source   -> Move 'extra' funds to To Budget
#cleanup sink -> Fund category with To Budget funds, default weight = 1
#cleanup sink 2       -> Fund category with To Budget funds, weight = 2

Steps of the script:
1. Return funds from any category marked 'source'
2. Fund overspent categories fully if negative carryover is not allowed.
3. Fund each 'sink' category by the desired weight.

I run through the script twice. Once to show that if there is a debt
category that has a rolling negative balance, it will skip funding that
category first and once to show how if a rolling negative balance isn't
allowed, it will fund it before applying the weighted remainder. The
example shown uses weights of 60, 20, and 20; therefore, the Debt
category will receive 60% of the To Budget funds while General and Bills
receive 20% each. The weights could have been changed to 6, 2, and 2 or
3 for the Debt category with no additional value for General and Bills
to achieve the same result.


![cleanup_button](https://github.com/actualbudget/actual/assets/20625555/56ae2b29-9be6-4e85-b532-1b05cff7c4c7)
2023-05-30 15:24:03 -04:00
Jed Fox
e660e1e727 More import-related ESLint rules (#1070)
- Enforce that imports from the same package are merged into a single
import
- In `loot-core`, require that imports of other `loot-core` files use a
relative import (like the vast majority of such imports) rather than
specifiers starting with `loot-core/` (probably a result of moving files
out of other packages into `loot-core`)
2023-05-29 13:31:01 -04:00
Jed Fox
ad89aea45c Integrate useMemo into useLiveQuery (#1064) 2023-05-28 07:38:37 -04:00
Jack
c73416bdb8 [Feature] Hide category (#1060) 2023-05-27 15:15:09 +01:00
Jed Fox
6253aaa015 Use the useLiveQuery hook in a couple more places (#1061) 2023-05-25 16:50:55 -04:00
youngcw
0d2d861896 Goals: fix broken parser (#1059) 2023-05-25 07:06:35 -04:00
shall0pass
0baf4a094a Goals: Add timezone offset to date calculations (#1058) 2023-05-24 16:17:59 -04:00
Matiss Janis Aboltins
353474dacd 🔖 (6.0.1) api (#1057)
<!-- Thank you for submitting a pull request! Make sure to follow the
instructions to write release notes for your PR — it should only take a
minute or two:
https://github.com/actualbudget/docs#writing-good-release-notes -->
2023-05-24 18:44:14 +01:00
youngcw
5f38b579fe Template: Apply schedule template only on needed month (#1052)
Add option to schedule templates to budget the full amount only in the
needed month. Default behavior stays the same of spreading the expense
out over the available range.

To use the option, use a template like `#template schedule full
SCHEDULE_NAME`


Also some minor cleanup.
2023-05-24 10:57:25 -04:00
Eike Siewertsen
1e2bc29a60 change copy-migrations shebang to bash (#1056) 2023-05-24 10:26:03 -04:00
Jed Fox
fafd162db0 Export api/methods.js at the top level again (#1054)
This allows using the API as documented. In #877, I think this was
unintentionally converted to be a named export.
2023-05-22 18:20:09 -04:00
Jack
ec5e98b934 Fixed a bug where it was possible to make a transfer to the same account as the one making the transfer. (#1038)
The payee autocomplete was always using cached accounts. Added a check
to see if accounts was already passed in as a parameter - only using
cached if it wasnt.
2023-05-21 18:42:05 +01:00
Trevor Farlow
18e3a16299 Update to React Router v5.1 conventions (#1045) 2023-05-20 16:01:10 -06:00
Jed Fox
efe4194f9c A few fixes for Electron (#1048) 2023-05-20 09:46:10 -04:00
chylex
4249a0beb1 Make number parsing agnostic to decimal and thousands separators (#894) (#1029) 2023-05-20 08:27:17 -04:00
Shazib Hussain
461132b95e Fix electron app (#1003)
Updates to the latest version of electron and moves the backend-frontend
communication from node-ipc to websockets. This resolves the previous
roadblock regarding `nodeIntegration` .

Done

- Remove node-ipc in favour of websockets. 
- Move file copying out of `preload.js` to avoid importing module `fs`
there
- Bump all electron pacakge versions to the latest
- Added new package for finding open ports as node-ipc is gone
- Tweaked webpack config for above changes


Partially fixes #468

Questions/ Pending:
- Literally every single test fails for me, presumably some issue with
my setup/environment.
- The websocket communication is not using TLS. I'm not sure how to
enable this, or if we even need to as its all local.
- Still need to create the CI for building/deploying but I'm not sure
where start in this regard as i have no exp with it. Presumably we will
need to point the electron auto-updater to the github releases url's. If
people are happy with this PR I will look at adding the CI before its
merged.
- In dev mode only, I have disabled TLS security becuase my docker
container's cert is not signed. I _assume_ this will be true for other
people who spin up the server on thier own hardware. Perhaps I just need
to change my cert to one from letsencrypt or something...

Notes.
I have not touched javascript in eons so my apologies if the commit
trail is a bit fragmented. I tried to keep them fairly contained and
then there is a slightly gnarly final commit fixing all the linter
issues... Please let me know if you want me to squash some commits etc.

I initially tried to move this to web workers the same way the web app
does it but this was unsuccessful. I have found no way to spin up a
worker in one place (frontend/backend) and then pass this worker to the
other. The electron ipc channels don't allow you to directly pass
objects such as workers, everything is cloned/serialised. Passing a port
number so the other end can spin up its own socket works fine.

---------

Co-authored-by: Shazib Hussain <contact@shazib.com>
Co-authored-by: Jed Fox <git@jedfox.com>
2023-05-18 19:56:48 -04:00
Jed Fox
19af0b36a2 Upgrade react-spring, remove wobble (#1043)
Now we just have one spring animation library
2023-05-17 20:04:07 -04:00
Jed Fox
6fc4fc294a Update Yarn (#1042) 2023-05-17 18:54:06 -04:00
Alberto Gasparin
a6b6295426 Convert client to TS part 2 (#1037)
Another batch of components in `desktop-client` converted to TS
2023-05-18 07:42:53 +10:00
Trevor Farlow
029dfd4688 Responsive context (#964)
Introduces a **ResponsiveProvider** as the sole location that tracks
window size and makes that info available to the entire app. This can be
used for media queries and size-based component switching.
---------

Co-authored-by: Jed Fox <git@jedfox.com>
2023-05-16 21:02:07 -06:00
Jed Fox
2587350d1e Remove messaging around where to find schedules in the future (#1033) 2023-05-16 18:37:51 -04:00
Jed Fox
d400ebfda0 Fix the API (again) (#1002) 2023-05-16 14:56:24 -04:00
Jed Fox
34c8a73ee5 Remove @reactions/component dependency (#1036)
We can use hooks now!
2023-05-16 14:41:36 -04:00
Jack
4ecb58cd5c Updated account ordering in the account autocomplete popup (#1034) 2023-05-15 18:55:17 -04:00
SudoCerb
1305335f0a Add “Show unused payees” button (#1011)
# Add ability to filter the Manage Payees screen to show orphaned payees
only.

I aimed to modify as little code as possible - we now have a button on
the Manage Payees screen that will filter the table to show orphaned
payees only.
2023-05-15 16:18:14 -04:00
Jonas De Kegel
15b51921b2 Add .devcontainer (#1032)
Adds support for [devcontainers](https://containers.dev/), this should
make onboarding easier and should allow (especially simpler)
contributions entirely online via Github Codespaces 🚀
2023-05-15 11:04:28 -04:00
youngcw
54f9b712e4 Fix infinite loop in repeat goal (#1019)
I believe I found the infinite loop problem in the repeat goal.  

This doesn't mess things up if you are budgeting the same month that the
goal starts, that's probably why we didn't see it before.

Side note: The logic always starts at the start date in the template,
then increments until falling in the right month window. If this
template gets used for, say, a few years, it will start to bog down the
processing. If someone has a good quick fix I can add that.
2023-05-13 08:36:00 -04:00
shall0pass
5afd76fb45 Goals: Compounding changes for By and Schedules (#1028) 2023-05-13 08:23:59 -04:00
jonezy35
54c8d5b7b8 Added Dev Container (#1023) 2023-05-11 09:55:53 -04:00
Jed Fox
e4e9267c08 Pull shiftKey state directly from the initiating event (#1022)
Fixes #1021
2023-05-10 18:45:34 -04:00
TheTrueCaligari
655b677961 bugfix: wrong regex in the space-dot format (#1017)
The regex for the "space-dot" format was incorrect.

When entering the amount on a schedule, the number was incorrectly read
(12.34 became 1234.00)
Strangely, it was not an issue with transactions...
2023-05-10 10:54:33 -04:00
TheTrueCaligari
8faa7bd68d Fix for issue #319 (#1008) 2023-05-09 10:11:04 -04:00
Alberto Gasparin
f618055aab Migrate top common components to TS (#979) 2023-05-09 20:31:22 +10:00
Jed Fox
54fa4bccf6 Enable 'curly' rule (#1015)
Multi-line `if`/`for` statements in JS can be confusing since there
aren’t braces to indicate which code is enclosed in the statement. I set
the configuration to `multi-line` to enforce usage of braces for
multi-line statement bodies, but still allow things like `if (foo)
return;`. I additionally added the `consistent` option to require braces
for all elements of an if/else chain if one element has it. As you can
see, this set of options pretty closely matches the existing code style.

I was going to comment in #1008 about this stylistic change but realized
that it’s (IMO) a little impolite to ask for code style changes unless
they can be automatically enforced.

Note that `if (foo) { \n return; \n }` is still valid and won’t be
collapsed. I tried to automatically collapse all such cases but it was a
lot of files and I didn’t want to pick out the useful from the useless
differences.
2023-05-08 22:54:17 -04:00
Matiss Janis Aboltins
24d4070667 🐛 (TransactionTable) fix split-transaction focus field (#999)
Closes #996
2023-05-08 12:06:30 -06:00
youngcw
7b1c3665d5 Add feature request link to README (#1012) 2023-05-07 18:45:29 -04:00
youngcw
7d80c3eda6 Goal Templates: add option to prevent removing funds when using "up to" in goal (#1004) 2023-05-07 09:35:04 -04:00
Davis Silverman
f7aa313aea Detect more errors in JS OFX importer. (#1005) 2023-05-07 06:38:41 -04:00
Jed Fox
c9576d98e4 Remove “needs votes” label from feature requests that have been implemented (#1001) 2023-05-06 18:06:05 -04:00
Matiss Janis Aboltins
bcc2abf472 🐛 (reports) 1y date range should be 12 months not 13 months (#1000)
Closes #375
2023-05-05 21:51:30 +01:00
Matiss Janis Aboltins
933ca3ecca (reports) ability to fine-tune reports with filters (#994) 2023-05-05 19:54:23 +01:00
TheTrueCaligari
acaff825c1 Add a new number format (space-dot) (#995) 2023-05-05 10:26:35 -04:00
Matiss Janis Aboltins
f913d99c9f 🔖 (23.5.0) various improvements (#993) 2023-05-04 18:23:08 +01:00
Matiss Janis Aboltins
539cb0e5cf 🐛 (transactions) create transaction when clicking enter (#992)
Closes #943

Creates the transaction when clicking "enter". Irrelevant of which field
is currently active.
2023-05-04 18:05:44 +01:00
Jed Fox
d2185909c3 Add support for credit card OFX files (#987) 2023-05-02 17:48:01 -04:00
Jed Fox
646d0d90a4 Remove unused payee rules feature (#985)
Fixes #615. I would appreciate double-checking that I didn’t
accidentally delete anything that is important.

Since I’m removing the related API methods, this is technically a
breaking change (even if people would have no reason to remove this
stuff), so we should probably do a major release of the API package.
2023-05-02 14:08:05 -04:00
shall0pass
66f7336be8 Priorities for goals (#961)
This attempts to add priorities for goal templates and addresses most of
https://github.com/actualbudget/actual/issues/959.

I couldn't find a good way to preserve both "Apply" and "Overwrite"
operations, so this PR does away with the current "Apply" action
behavior. Every box with a budgeted value will be overwritten if a
template goal is present.

The added syntax to define priorities is as follows:
#template    -- priority 0, highest priority
#template-1  --priority 1, 2nd highest priority
#template-2 --priority 2, 3rd highest priority
#template-N --priority N, as many as you'd like.

~~Leaving as a draft as this may not be the preferred implementation but
I wanted others to be able to try it with netlify.~~

---------

Co-authored-by: Caleb Young <cwy@rincon.com>
2023-05-02 13:53:09 -04:00
Matiss Janis Aboltins
4cebdb537c (nordigen): ability to set secrets via the UI (#968) 2023-05-01 21:04:19 +01:00
Matiss Janis Aboltins
3d4b0c0e25 👷 run feature-request management action once (#982)
Run the feature-request github action only once for the "feature" label.

Demo: https://github.com/MatissJanis/actual/issues/8


Also fixed the link to issue list.
2023-05-01 20:37:54 +01:00
Matiss Janis Aboltins
99cf51c159 👷 run feature-request action on labeled action (#980)
Run the feature request management action when adding "feature" label.

This will allow us to..
1. remove "feature" label
2. add back "feature" label

Thus execute the workflow on the existing issues.
2023-05-01 20:26:04 +01:00
Matiss Janis Aboltins
e6b5782c64 👷 auto-close feature requests (#954) 2023-05-01 19:53:35 +01:00
Matiss Janis Aboltins
977296361c 🐛 (nordigen) bank-list error handling (#969)
Improving error handling for Nordigen.

Before: if loading banks failed - a loading indicator would be shown
forever.

After: is loading banks fails - an error message is shown.
2023-05-01 18:32:48 +01:00
Matiss Janis Aboltins
2d26cf3ad1 📝 add back rich, add alberto (#978)
Updating active contributor list
2023-05-01 10:47:46 +01:00
Matiss Janis Aboltins
759a018346 🔧 make start:browser the default cmd instead of start:desktop (#977) 2023-05-01 10:47:36 +01:00
Matiss Janis Aboltins
4aab3dec0c 🔥 remove unused prop-types dependency (#976)
Removing unused `prop-types` dependency.
2023-05-01 10:47:27 +01:00
Trevor Farlow
bd14b51e1c @typescript-eslint/no-unused-vars (#974)
Switch to TS version of `no-unused-vars` rule.
2023-04-30 17:20:55 -06:00
Alberto Gasparin
2d7e0c3f7a Migrate core to TS p4 (#957)
This is the last PR with lots of renaming for `loot-core`!
2023-04-30 09:25:45 +10:00
Matiss Janis Aboltins
c4d3a1ce76 🐛 position notification banners always at bottom (#972)
Closes #956
2023-04-29 18:58:39 +01:00
Matiss Janis Aboltins
db65e83722 🐛 (Text) single-line text (#967) 2023-04-29 17:26:34 +01:00
Matiss Janis Aboltins
71908b6fb9 🐛 (transaction-table) show checkbox on hover (#966)
Closes #965
2023-04-28 23:32:26 +01:00
Matiss Janis Aboltins
851fa8c7f5 refactor(typescript): move some common components to TS (#962) 2023-04-28 23:13:37 +01:00
Matiss Janis Aboltins
30684a47d7 🐛 (mobile) correct topbar color on settings page (#960)
Closes #935

Before: we render all the pages (even the inactive ones in some cases)

After: we render only the visible pages. Thus the topbar color is set
only 1x.. thus the settings page topbar has the correct color.
2023-04-26 22:23:48 +01:00
Matiss Janis Aboltins
4b712699a8 🐛 (schedules) make transfers appear in both accounts (#955)
Closes #551

Make scheduled transfers appear in both accounts.
2023-04-25 20:24:32 +01:00
Matiss Janis Aboltins
abc4552a78 🐛 (nordigen) fallback button to re-init bank-sync for popover blockers (#950) 2023-04-24 09:27:57 +01:00
Matiss Janis Aboltins
a69d858328 ♻️ re-arrange schedule operation options to be more logical (#953) 2023-04-24 09:27:17 +01:00
biohzrddd
9695043206 Add setting to change first day of the week: Issue #844 (#910)
<!-- Thank you for submitting a pull request! Make sure to follow the
instructions to write release notes for your PR — it should only take a
minute or two:
https://github.com/actualbudget/docs#writing-good-release-notes -->

Resolves issue #844 .

---------

Co-authored-by: biohzrddd <>
2023-04-23 15:43:34 -04:00
Aaron Eiche
e036397614 Fixes issue #846 by eliminating empty lines during import. (#951) 2023-04-23 20:38:32 +01:00
Henrik Maaland
43cd6b6347 Show pending transactions from Nordigen in transactions table (#919) 2023-04-23 18:15:39 +01:00
Matiss Janis Aboltins
9ee93f74fe 🐛 (autocomplete) touch event support (#949)
Closes #927
2023-04-23 17:41:57 +01:00
Matiss Janis Aboltins
89c065e401 🐛 (autocomplete) fix multi-select (#947) 2023-04-23 17:31:07 +01:00
Jed Fox
6325a36847 Don’t reset checked transactions when creating a schedule (#946) 2023-04-23 12:18:02 -04:00
Matiss Janis Aboltins
76c69a6e70 ⬆️ (autocomplete) upgrade Downshift dependency (#945)
Upgrading `Downshift` dependency
2023-04-23 16:57:15 +01:00
Jed Fox
c7f6ca4302 Fix j/k shortcuts to move between transactions on account pages (#939) 2023-04-23 08:15:36 -04:00
Matiss Janis Aboltins
36b2d7d090 🎨 (autocomplete) set min-width for the tooltip (#933) 2023-04-23 07:16:42 +01:00
Matiss Janis Aboltins
7c80a200d7 ♻️ (autocomplete) cleaning up state updates (#931) 2023-04-23 07:16:29 +01:00
Alberto Gasparin
e8a62f89a1 Migrate core to TS p3 (#896)
Another batch of `loot-core` migrated.
2023-04-22 20:43:47 -04:00
Davis Silverman
b0c5a9389c Use Peggy instead of deprecated Peg.js (#934)
Hi there, `Peg.js` is unmaintained, so I figure you all would appreciate
if I replaced it with the drop-in replacement of Peggy. This is work I
am breaking out of #918.

Peggy adds new features like source map support that we could use,
although I do not include that in this change-set. It may be useful for
debugging changes to the .pegjs file we have.e
2023-04-22 20:16:07 -04:00
Davis Silverman
a1d321d65e Add experimental new OFX importer (#921)
Hi there, 

I try to tackle #798 here. It was suggested to throw this behind a
feature flag, so here it is!

this does its best to import the problem file in #767. 

I am working on this because it would make my work on #918 easier :)

Feel free to set the feature flag to true and try the new importer. The
date parser is not as sophisticated as the one in `node-libofx`, but I
tried 3 different OFX files, one from my bank, one from the mocks, and
one from #767. They all seem to work well enough on that front, but this
is definitely the weak point of the new implementation.

Let me know what you think!
2023-04-22 12:36:12 -04:00
shall0pass
3ceb2d92ad [Feature] Initial concept for adding percentage based goals (#858)
This is an initial concept for adding percent based goal targets.

This version works by using a string in the form of: 
#template 10% of Income <- Income is an income category name. Only
income category names will work here currently.
or
#template 10% of all income<- 'all income' is a keyword in this context
and will base the calculation on the total income for the month.

Some of the nicer touches like Jed's polite notification that the syntax
isn't correct is not implemented here yet.
2023-04-22 11:44:07 -04:00
shall0pass
0bcf6ea6f9 Change method of calculating 'by' matches to use averaging (#879)
I've changed the method of calculating the budgeted amount. There may be
a more efficient way of writing the loop, so I'm looking forward to the
review.

This change implements the mathematics of
(Target1+Target2+TargetN-Last_months_category_balance) / (MonthsRemaing1
+ MonthsRemaining2 + MonthsRemaingN) * N. This is an averaged approach
for multiple templates in the same category. It will appear to
overbudget or underbudget some months compared to multiple single
targets in different categories, yet there should always be enough saved
in the category to satisfy the target due.

Setting a target, it's assumed that money will be spent in the
appropriate month, When a target reaches maturity, the money in the
category associated with that target should be spent or moved so the
remaining targets continue to be funded. I don't see an easy way of
fixing that, but I hope this change will be of some help.

Current method:
Notice how the Bills (flexible) category reduces each month, resulting
in larger budgeted amounts later in the goal cycle.


![Templates-now](https://user-images.githubusercontent.com/20625555/230964939-d20ca72b-1055-471a-9044-7cd640f19875.gif)

Proposed method:
**Note: The fact that the initial fill in this example equals the
expected fill is a coincidence based on the template values I chose. The
initial fills can be different from expected fill.


![Templates-proposed](https://user-images.githubusercontent.com/20625555/230965265-669f996c-3112-437b-ab83-9715ea5dfc7f.gif)
2023-04-22 11:43:34 -04:00
Matiss Janis Aboltins
f8b73355ab (e2e) improving stability - reducing flakiness (#932)
Small changes to the e2e tests to improve the stability.
2023-04-22 16:41:40 +01:00
Matiss Janis Aboltins
38d2e69858 🔖 (23.4.2) revert back to old autocomplete & keyboard shortcut fix (#930)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jed Fox <git@jedfox.com>
2023-04-22 15:27:58 +01:00
Jed Fox
944c7ff30f Improve sidebar auto-floating behavior (#868)
I got some feedback in the discord that this behavior was disruptive
when zooming in.

- I’ve reduced the breakpoint so hopefully the disruption of the
transition is matched by a significant improvement in available space
now.
- Also the 2 places in the app that use window width (including here)
now check for the width of the `<html>` tag, not the width of the
viewport (those 2 values can be different when doing a pinch-zoom,
causing undesirable layout shifts.)
- Most of the logic has been rewritten to improve the transitions

Mobile & desktop experience

https://user-images.githubusercontent.com/25517624/233653721-b13c5e22-ae11-4bdf-a494-a6916556d05e.mov

https://user-images.githubusercontent.com/25517624/233654784-b6cc1006-44ea-4066-be7a-8d0dd786fb5b.mov

(I’d like tapping on something to close the sidebar on mobile, but that
can be approached in a future PR)
2023-04-21 17:48:47 -04:00
Jed Fox
e9188813fd Fix handling for shortcuts (#926)
It turns out that `event.key` for ctrl/cmd+Z is `z`, and it’s `Z` for
ctrl/cmd+shift+Z.

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2023-04-20 18:32:51 -04:00
Jed Fox
131bb86711 Fix currencyToInteger returning null when amount is zero (#915)
Fixes #913. (And probably a few other similar bugs)
2023-04-20 17:21:56 -04:00
Matiss Janis Aboltins
eed097d41e 🔥 remove new autocomplete experiment (react-select) (#924)
Removing `react-select` and the new autocomplete. This experiment has
failed, so cleaning things up now.
2023-04-19 22:08:32 +01:00
Matiss Janis Aboltins
10559a68b3 ♻️ (autocomplete) refactor old autocomplete to remove lively dep (#916) 2023-04-19 16:24:58 +01:00
Pol Eyschen
6e7e98e139 Allow templates to follow named schedules (#885)
Co-authored-by: Jed Fox <git@jedfox.com>
2023-04-16 17:11:25 -04:00
Matiss Janis Aboltins
b89b74c3fa 🔖 (23.4.1) fix rule creation from transaction list (#911) 2023-04-16 18:00:13 +01:00
shall0pass
3fb69e1b1c Fill category field when selecting 'Create rule' (#886) 2023-04-16 11:49:01 -04:00
biohzrddd
19a8f14ad9 Fixes Issue 495 Transfer allowed from same account (#902) 2023-04-16 07:36:22 -04:00
biohzrddd
df63c7e141 Add daily option to schedules (#900) 2023-04-16 07:33:47 -04:00
shall0pass
adb20868cc Improve error reporting for goal templates (#895)
This improves the error reporting when issues are found with Goal
Templates. Before these changes, the only error that would be reported
is the "Bills" error in the image while the other issues would be
ignored and not funded.
2023-04-13 18:01:57 -04:00
Matiss Janis Aboltins
ec3efc7191 🎨 (mobile) settings page header (#887) 2023-04-13 20:00:02 +01:00
Jed Fox
ba59deae5f Add CodeQL action (#890) 2023-04-12 23:28:53 -04:00
Jed Fox
ad9cd5fc4d Remove usage of realpath (#893)
It turns out this command is not installed by default on macOS.
Fortunately the code doesn’t really require it to be used.
2023-04-12 16:38:26 -04:00
Alberto Gasparin
5dec98e754 Migrate core to TS p2 (#889)
Another batch of files from `loot-core` migrated to TS (loose mode)
2023-04-12 11:09:42 -04:00
shall0pass
8597b0a373 Use single decimal places to define targets (#891)
Resolves issue https://github.com/actualbudget/actual/issues/888
2023-04-12 11:07:49 -04:00
Jed Fox
15e9c0405d Upgrade @typescript-eslint packages (#884) 2023-04-11 13:48:20 -04:00
Alberto Gasparin
cd00da76ef Convert commonjs to esm (#877)
This PR converts everything (aside from electron) from CommonJS to ESM.
It is needed to reduce the changes that will happen during the migration
to Typescript (as TS does not play nice with CJS).

Basically:
- rewrite `require()` to `import`
- rewrite `module.exports` to `exports`
- introduce `ts-node` to run importers so we can convert them to TS too

Lastly, sorry for this larg-ish PR, not my preference but when I tried
to reduce its scope, I would end up with mixed commons/esm that was even
more tricky to handle.
2023-04-10 20:40:40 +01:00
Jed Fox
d7d5820c1c Recognize numpad enter key as enter key (#883) 2023-04-10 08:36:02 -04:00
Matiss Janis Aboltins
82482f4182 🐛 (autocomplete) show 'create payee' only if no payee matched (#881) 2023-04-09 22:00:43 +01:00
Matiss Janis Aboltins
88fb95b230 🐛 (rules) creation from account page (#882) 2023-04-09 20:33:41 +01:00
Matiss Janis Aboltins
7e33cda7b2 🔖 (api) 5.1.2 (#880)
<!-- Thank you for submitting a pull request! Make sure to follow the
instructions to write release notes for your PR — it should only take a
minute or two:
https://github.com/actualbudget/docs#writing-good-release-notes -->
2023-04-09 19:29:44 +01:00
Jed Fox
bf05b759aa Remove duplicate migration and default-db.sqlite files (#870)
These files will be automatically recreated by `yarn build:api`,
`yarn build:browser`, and `yarn start:browser`, so we don’t need
them in the repo.
2023-04-09 14:21:59 -04:00
Jakub Kuczys
835c1a54f7 Use Unicode-aware implementations of LOWER() and UPPER() in SQL queries (#865)
Fixes #840 by creating application-defined SQL functions
(https://www.sqlite.org/appfunc.html) for Unicode-aware implementations
of `LOWER()` and `UPPER()`. This uses
`String.prototype.toLower/UpperCase()` JS method.

I initially wanted to just redefine `LOWER()` and `UPPER()` but due to
[sql.js not supporting the definition of deterministic
functions](https://github.com/sql-js/sql.js/issues/551), I had to just
define them as separate functions and use that in the appropriate
places. It's probably better like that anyway...
2023-04-07 16:33:19 -04:00
Jed Fox
aa2e837e7e Don’t check for release notes on release/* branches (#864) 2023-04-07 16:15:12 -04:00
Jed Fox
98a32432ef Disable ESLint in CI again (#869)
This should speed up builds in CI, and must not have gotten caught when
upgrading `react-scripts`.
ref: arackaf/customize-cra#278
2023-04-07 16:15:00 -04:00
Matiss Janis Aboltins
7abbdcc5bb 🔖 (5.1.1) api (#873) 2023-04-07 21:11:12 +01:00
shall0pass
adf205db86 Allow goal template 'by' matches to compound (#860)
I believe this change allows for having multiple 'by' rules in the same
category. It seems to be working well for my purposes, but I would
appreciate further testing to assure there aren't regressions.

Example:

#template 300 by 2023-06
#template 3000 by 2023-08

Before this PR, having these two lines in the notes would only budget
funds for the earliest of the two strings and ignore the 3000 funding
target. With this PR, the sum of the two funding targets will be
respected.
2023-04-07 15:54:14 -04:00
530 changed files with 13353 additions and 12325 deletions

View File

@@ -0,0 +1,14 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
{
"name": "Actual development",
"dockerComposeFile": [
"../docker-compose.yml",
"docker-compose.yml"
],
// Alternatively:
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
"service": "actual-development",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"postCreateCommand": "yarn install"
}

View File

@@ -0,0 +1,6 @@
version: '3.8'
services:
actual-development:
volumes:
- ..:/workspaces:cached
command: /bin/sh -c "while sleep 1000; do :; done"

View File

@@ -1,3 +1,4 @@
/* eslint-disable rulesdir/typography */
const path = require('path');
const rulesDirPlugin = require('eslint-plugin-rulesdir');
@@ -9,13 +10,19 @@ rulesDirPlugin.RULES_DIR = path.join(
'rules',
);
const ruleFCMsg =
'Type the props argument and let TS infer or use ComponentType for a component prop';
module.exports = {
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
extends: ['react-app', 'plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: { project: [path.join(__dirname, './tsconfig.json')] },
reportUnusedDisableDirectives: true,
rules: {
'prettier/prettier': 'error',
'no-unused-vars': [
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'none',
@@ -24,10 +31,14 @@ module.exports = {
},
],
curly: ['error', 'multi-line', 'consistent'],
'no-restricted-globals': ['error'].concat(
require('confusing-browser-globals').filter(g => g !== 'self'),
),
'react/jsx-no-useless-fragment': 'error',
'rulesdir/typography': 'error',
// https://github.com/eslint/eslint/issues/16954
@@ -36,8 +47,15 @@ module.exports = {
// TODO: re-enable these rules
'react-hooks/exhaustive-deps': 'off',
// 'react-hooks/exhaustive-deps': [
// 'error',
// {
// additionalHooks: 'useLiveQuery',
// },
// ],
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { 'prefer-inline': true }],
'import/order': [
'error',
{
@@ -67,10 +85,70 @@ module.exports = {
},
],
'no-restricted-syntax': [
'error',
{
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
selector:
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
message:
'Using default React import is discouraged, please use named exports directly instead.',
},
],
// Rules disable during TS migration
'@typescript-eslint/no-var-requires': 'off',
'prefer-const': 'off',
'prefer-spread': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
overrides: [
{
files: ['./**/*.js'],
parserOptions: { project: null },
},
{
files: [
'./packages/desktop-client/**/*.{ts,tsx}',
'./packages/loot-core/src/client/**/*.{ts,tsx}',
],
rules: {
// enforce type over interface
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
// enforce import type
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/ban-types': [
'error',
{
types: {
// forbid FC as superflous
FunctionComponent: { message: ruleFCMsg },
FC: { message: ruleFCMsg },
},
extendDefaults: true,
},
],
},
},
{
files: ['./packages/loot-core/src/**/*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['loot-core/**'],
message:
'Please use relative imports in loot-core instead of importing from `loot-core/*`',
},
],
},
],
},
},
],
};

2
.gitattributes vendored
View File

@@ -8,6 +8,8 @@
# Declare files that will always have LF line endings on checkout.
*.js text eol=lf
*.ts text eol=lf
*.sh text eol=lf
yarn.lock text eol=lf
# Denote all files that are truly binary and should not be modified.

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
// overview:
// 1. Fetch the issues that are linked to the PR
// 2. Filter out the issues that are not feature requests
// 3. For each feature request:
// 1. Remove the 'help wanted' & 'needs votes' labels
// 3. Find the automated comment, hide the comment as 'outdated'
// 5. Post a new comment saying that the feature request has been implemented, and will be released in the next version. Link to the PR.
async function makeAPIRequest(query, variables) {
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ query, variables }),
});
return res.json();
}
function group(name, body) {
console.log(`::group::${name}`);
const result = body();
if (result instanceof Promise) {
return result.finally(() => console.log(`::endgroup::`));
}
console.log(`::endgroup::`);
return result;
}
async function main() {
const featureRequests = await group('Pull Request API Response', async () => {
const res = await makeAPIRequest(
/* GraphQL */ `
query FetchLinkedIssues($pr: Int!) {
repository(owner: "actualbudget", name: "actual") {
pullRequest(number: $pr) {
closingIssuesReferences(first: 10) {
nodes {
id
number
labels(first: 10) {
nodes {
id
name
}
}
}
}
}
}
}
`,
{ pr: parseInt(process.env.PR_NUMBER) },
);
console.log(JSON.stringify(res, null, 2));
return res.data.repository.pullRequest.closingIssuesReferences.nodes.filter(
issue => issue.labels.nodes.some(label => label.name === 'feature'),
);
});
if (featureRequests.length === 0) {
console.log('No linked feature requests found');
return;
}
for (const { id, number, labels } of featureRequests) {
await group(`Issue #${number}: Remove labels`, async () => {
const toRemove = labels.nodes
.filter(
label => label.name === 'help wanted' || label.name === 'needs votes',
)
.map(label => label.id);
const res = await makeAPIRequest(
/* GraphQL */ `
mutation RemoveLabels($issue: ID!, $labels: [ID!]!) {
removeLabelsFromLabelable(
input: {
clientMutationId: "1"
labelIds: $labels
labelableId: $issue
}
) {
clientMutationId
}
}
`,
{
issue: id,
labels: toRemove,
},
);
console.log(JSON.stringify(res, null, 2));
});
await group(`Issue #${number}: Collapse automatic comment`, async () => {
const commentRes = await makeAPIRequest(
/* GraphQL */ `
query FetchComments($issue: Int!) {
repository(owner: "actualbudget", name: "actual") {
issue(number: $issue) {
comments(first: 100) {
nodes {
id
body
author {
login
}
}
}
}
}
}
`,
{ issue: number },
);
console.log(JSON.stringify(commentRes, null, 2));
const comments = commentRes.data.repository.issue.comments.nodes.filter(
comment => comment.author.login === 'github-actions',
);
const commentToCollapse =
comments.find(comment =>
comment.body.includes('<!-- feature-auto-close-comment -->'),
) ||
comments.find(comment =>
comment.body.includes(
':sparkles: Thanks for sharing your idea! :sparkles:',
),
);
if (!commentToCollapse) {
console.log('No comment to collapse found');
process.exit(1);
}
const res = await makeAPIRequest(
/* GraphQL */ `
mutation CollapseComment($comment: ID!) {
minimizeComment(
input: { classifier: OUTDATED, subjectId: $comment }
) {
clientMutationId
}
}
`,
{ comment: commentToCollapse.id },
);
console.log(JSON.stringify(res, null, 2));
});
await group(`Issue #${number}: Post comment`, async () => {
const res = await makeAPIRequest(
/* GraphQL */ `
mutation PostComment($issue: ID!, $body: String!) {
addComment(
input: { subjectId: $issue, body: $body, clientMutationId: "1" }
) {
clientMutationId
}
}
`,
{
issue: id,
body: `:tada: This feature has been implemented in #${process.env.PR_NUMBER} and will be released in the next version. Thanks for sharing your idea! :tada:\n\n<!-- feature-implemented-comment -->`,
},
);
console.log(JSON.stringify(res, null, 2));
});
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -23,6 +23,13 @@ jobs:
uses: ./.github/actions/setup
- name: Build API
run: cd packages/api && yarn build
- name: Create package tgz
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
- name: Upload Build
uses: actions/upload-artifact@v3
with:
name: actual-api
path: packages/api/actual-api.tgz
web:
runs-on: ubuntu-latest
@@ -38,20 +45,26 @@ jobs:
name: actual-web
path: packages/desktop-client/build
# TODO: re-enable after solving https://github.com/actualbudget/actual/issues/468
# electron:
# # As electron builds take longer, we only run them in master.
# if: github.event_name != 'pull_request'
# strategy:
# matrix:
# os:
# - ubuntu-latest
# - windows-latest
# - macos-latest
# runs-on: ${{ matrix.os }}
# steps:
# - uses: actions/checkout@v3
# - name: Set up environment
# uses: ./.github/actions/setup
# - name: Build Electron
# run: ./bin/package
electron:
# As electron builds take longer, we only run them in master.
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@v3
with:
name: actual-electron-${{ matrix.os }}
path: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
packages/desktop-electron/dist/*.AppImage

View File

@@ -1,14 +0,0 @@
name: Check release notes
on:
pull_request:
branches: '*'
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Check release notes
uses: actualbudget/actions/release-notes/check@main

34
.github/workflows/check.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Test
on:
push:
branches:
- master
pull_request:
branches: '*'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Lint
run: yarn lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Typecheck
run: yarn typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Test
run: yarn test

32
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: CodeQL
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
- cron: '23 11 * * 6'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: '/language:javascript'

View File

@@ -1,17 +0,0 @@
name: Generate Release Notes
on:
push:
branches:
- release/*
jobs:
generate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Generate release notes
uses: actualbudget/actions/release-notes/generate@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -8,7 +8,7 @@ jobs:
needs-triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-ecosystem/action-add-labels@v1
if: github.event.issue.labels == null
with:
labels: needs triage

View File

@@ -0,0 +1,37 @@
name: Close feature requests with automated message
on:
issues:
types: [labeled]
jobs:
needs-votes:
if: ${{ github.event.label.name == 'feature' }}
runs-on: ubuntu-latest
steps:
- uses: actions-ecosystem/action-add-labels@v1
with:
labels: needs votes
- name: Add reactions
uses: aidan-mundy/react-to-issue@v1.1.1
with:
issue-number: ${{ github.event.issue.number }}
reactions: '+1'
- name: Create comment
uses: peter-evans/create-or-update-comment@v3
with:
issue-number: ${{ github.event.issue.number }}
body: |
:sparkles: Thanks for sharing your idea! :sparkles:
This repository is now using lodash style issue management for enhancements. This means enhancement issues will now be closed instead of leaving them open. This doesnt mean we dont accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
Dont forget to upvote the top comment with 👍!
<!-- feature-auto-close-comment -->
- name: Close Issue
run: gh issue close "https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,20 @@
name: Handle completed feature requests
on:
pull_request:
types: [closed]
jobs:
handle-feature-requests:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v2
with:
node-version: '19'
- name: Handle feature requests
run: node .github/actions/handle-feature-requests.js
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,14 @@
name: Remove 'help wanted' label from closed issues
on:
issues:
types: [closed]
jobs:
remove-help-wanted:
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
runs-on: ubuntu-latest
steps:
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: help wanted

View File

@@ -1,18 +0,0 @@
name: Linter
on:
push:
branches:
- master
pull_request:
branches: '*'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Lint
run: yarn lint

18
.github/workflows/release-notes.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: Release notes
on:
pull_request:
branches: '*'
jobs:
release-notes:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Check release notes
if: startsWith(github.head_ref, 'release/') == false
uses: actualbudget/actions/release-notes/check@main
- name: Generate release notes
if: startsWith(github.head_ref, 'release/') == true
uses: actualbudget/actions/release-notes/generate@main

View File

@@ -1,20 +0,0 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v7
with:
days-before-issue-stale: 90
days-before-issue-close: -1
stale-issue-label: "stale"
stale-issue-message: "🚧🚨 This issue is being marked as stale due to 90 days of inactivity. 🚧🚨"
only-labels: 'needs triage'
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,18 +0,0 @@
name: Test
on:
push:
branches:
- master
pull_request:
branches: '*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Test
run: yarn test

View File

@@ -1,18 +0,0 @@
name: Typecheck
on:
push:
branches:
- master
pull_request:
branches: '*'
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up environment
uses: ./.github/actions/setup
- name: Typecheck
run: yarn typecheck

5
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/data/*
!data/.gitkeep
/data2
packages/api/dist
packages/desktop-electron/client-build
packages/desktop-electron/.electron-symbols
packages/desktop-electron/dist
@@ -18,6 +19,7 @@ bundle.mobile.js
bundle.mobile.js.map
export-2020-01-10.csv
.idea
.vscode
**/*.log
@@ -29,3 +31,6 @@ export-2020-01-10.csv
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# VSCode
.vscode

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.4.1.cjs
yarnPath: .yarn/releases/yarn-3.5.1.cjs

View File

@@ -18,16 +18,12 @@ Here are some initial guidelines for how contributions will be treated:
(sorted alphabetically)
- @albertogasparin
- @j-f1
- @jlongster
- @MatissJanis
- @trevdor
## Alumni
(sorted alphabetically)
- @rich-howell
- @trevdor
## Project ideas
@@ -36,3 +32,18 @@ We welcome all contributions from the community. If you have an idea for a featu
If you do not have ideas what to build: the issue list is always a good starting point. Look for issues labeled with "[help wanted](https://github.com/actualbudget/actual/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)".
For first time contributions you can also filter the issues labeled with "[good first issue](https://github.com/actualbudget/actual/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)".
## Development Environment
If you would like to contribute you can fork this repository and create a branch specific to the project you are working on.
There are three options for developing:
1. Yarn
- This is the traditional way to get an environment stood up. Run `yarn` to install the dependencies followed by `yarn start:browser` to start the development server. You will then be able to access Actual at `localhost:3001`.
2. Docker Compose
- If you prefer to work with docker containers, a `docker-compose.yml` file is included. Run `docker compose up -d` to start Actual. It will be accessible at `localhost:3001`.
3. Dev container
- Directly integrated in some IDEs, dependencies will be installed automatically as you enter the container.
- Use your preferred method to `npm start` the project, your IDE should expose the project on your `localhost` for you.
Both options above will dynamically update as you make changes to files. If you are making changes to the front end UI, you may have to reload the page to see any changes you make.

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
###################################################
# This Dockerfile is used by the docker-compose.yml
# file to build the development container.
# Do not make any changes here unless you know what
# you are doing.
###################################################
FROM node:16-bullseye as dev
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
WORKDIR /app
CMD ["sh", "./docker-start.sh"]

View File

@@ -34,12 +34,17 @@ We have a wide range of documentation on how to use Actual, this is all availabl
The Actual app is split up into a few packages:
- loot-core - The core application that runs on any platform
- loot-design - The generic design components that make up the UI
- desktop-client - The desktop UI
- desktop-electron - The desktop app
More information on the project structure is available in our [community documentation](https://actualbudget.github.io/docs/Developers/project-layout).
## Feature Requests
Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc).
Vote for your favorite requests by reacting :+1: to the top comment of the request.
To add new feature requests, open a new Issue of the "Feature Request" type.
## Sponsors
Thanks to our wonderful sponsors who make Actual budget possible!

View File

@@ -42,7 +42,7 @@ git tag -a "$VERSION" -m "$NOTES"
git push origin "$VERSION"
# Make a macOS version
./bin/package --release --version "$VERSION"
./bin/package-electron --release --version "$VERSION"
# TODO: browser version

View File

@@ -74,19 +74,15 @@ if [ "$OSTYPE" == "msys" ]; then
fi
fi
# We only need to run linting once (and this doesn't seem to work on
# Windows for some reason)
if [[ $CI != true && "$OSTYPE" == "darwin"* ]]; then
yarn lint
fi
yarn patch-package
yarn rebuild-electron
yarn workspace loot-core build:node
yarn workspace @actual-app/web build
yarn workspace Actual update-client
yarn workspace desktop-electron update-client
(
cd packages/desktop-electron;
@@ -103,7 +99,7 @@ yarn workspace Actual update-client
echo "\nCreated release $VERSION with release notes \"$RELEASE_NOTES\""
elif [ "$RELEASE" == "beta" ]; then
yarn build --publish never --arm64 --x64
echo "\nCreated beta release $VERSION"
else
SKIP_NOTARIZATION=true yarn build --publish never --x64

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
###################################################
# This creates and stands up the development
# docker container. Depends on the Dockerfile and
# docker-start.sh files.
###################################################
services:
actual-development:
build: .
image: actual-development
ports:
- '3001:3001'
volumes:
- '.:/app'
restart: 'no'

13
docker-start.sh Normal file
View File

@@ -0,0 +1,13 @@
#####################################################
# This startup script is used by the docker container
# to check if the node_modules folder is empty and
# if so, run yarn to install the dependencies.
#####################################################
#!/bin/sh
if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
yarn
fi
yarn start:browser

View File

@@ -18,10 +18,12 @@
]
},
"scripts": {
"start": "npm-run-all --parallel 'start:desktop-*'",
"start": "yarn start:browser",
"start:desktop": "npm-run-all --parallel 'start:desktop-*'",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-electron": "yarn workspace Actual watch",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:electron": "yarn start:desktop",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
@@ -51,7 +53,7 @@
"resolutions": {
"react-error-overlay": "6.0.9"
},
"packageManager": "yarn@3.4.1",
"packageManager": "yarn@3.5.1",
"browserslist": [
"electron 12.0",
"defaults"

View File

@@ -1 +1,2 @@
app/bundle.api.js
dist

View File

@@ -1 +1,3 @@
app/bundle.api.js*
migrations
default-db.sqlite

View File

@@ -99,6 +99,6 @@ class Query {
}
}
module.exports = function q(table) {
export default function q(table) {
return new Query({ table });
};
}

Binary file not shown.

View File

@@ -1,34 +1,34 @@
let bundle = require('./app/bundle.api.js');
let injected = require('./injected');
let methods = require('./methods');
let utils = require('./utils');
let actualApp;
import fetch from 'node-fetch';
async function init(config = {}) {
import * as bundle from './app/bundle.api';
import * as injected from './injected';
let actualApp;
export const internal = bundle.lib;
// DEPRECATED: remove the next line in @actual-app/api v7
export * as methods from './methods';
export * from './methods';
export * as utils from './utils';
export async function init(config = {}) {
if (actualApp) {
return;
}
global.fetch = require('node-fetch');
global.fetch = fetch;
await bundle.init(config);
actualApp = bundle.lib;
injected.send = bundle.lib.send;
injected.override(bundle.lib.send);
return bundle.lib;
}
async function shutdown() {
export async function shutdown() {
if (actualApp) {
await actualApp.send('close-budget');
actualApp = null;
}
}
module.exports = {
init,
shutdown,
utils,
internal: bundle.lib,
...methods,
};

View File

@@ -1,5 +1,7 @@
// TODO: comment on why it works this way
let send;
export let send;
module.exports = { send };
export function override(sendImplementation) {
send = sendImplementation;
}

View File

@@ -1,11 +1,12 @@
const q = require('./app/query');
const injected = require('./injected');
import * as injected from './injected';
export { default as q } from './app/query';
function send(name, args) {
return injected.send(name, args);
}
async function runImport(name, func) {
export async function runImport(name, func) {
await send('api/start-import', { budgetName: name });
try {
await func();
@@ -16,15 +17,15 @@ async function runImport(name, func) {
await send('api/finish-import');
}
async function loadBudget(budgetId) {
export async function loadBudget(budgetId) {
return send('api/load-budget', { id: budgetId });
}
async function downloadBudget(syncId, { password } = {}) {
export async function downloadBudget(syncId, { password } = {}) {
return send('api/download-budget', { syncId, password });
}
async function batchBudgetUpdates(func) {
export async function batchBudgetUpdates(func) {
await send('api/batch-budget-start');
try {
await func();
@@ -33,63 +34,63 @@ async function batchBudgetUpdates(func) {
}
}
function runQuery(query) {
export function runQuery(query) {
return send('api/query', { query: query.serialize() });
}
function getBudgetMonths() {
export function getBudgetMonths() {
return send('api/budget-months');
}
function getBudgetMonth(month) {
export function getBudgetMonth(month) {
return send('api/budget-month', { month });
}
function setBudgetAmount(month, categoryId, value) {
export function setBudgetAmount(month, categoryId, value) {
return send('api/budget-set-amount', { month, categoryId, amount: value });
}
function setBudgetCarryover(month, categoryId, flag) {
export function setBudgetCarryover(month, categoryId, flag) {
return send('api/budget-set-carryover', { month, categoryId, flag });
}
function addTransactions(accountId, transactions) {
export function addTransactions(accountId, transactions) {
return send('api/transactions-add', { accountId, transactions });
}
function importTransactions(accountId, transactions) {
export function importTransactions(accountId, transactions) {
return send('api/transactions-import', { accountId, transactions });
}
function getTransactions(accountId, startDate, endDate) {
export function getTransactions(accountId, startDate, endDate) {
return send('api/transactions-get', { accountId, startDate, endDate });
}
function filterTransactions(accountId, text) {
export function filterTransactions(accountId, text) {
return send('api/transactions-filter', { accountId, text });
}
function updateTransaction(id, fields) {
export function updateTransaction(id, fields) {
return send('api/transaction-update', { id, fields });
}
function deleteTransaction(id) {
export function deleteTransaction(id) {
return send('api/transaction-delete', { id });
}
function getAccounts() {
export function getAccounts() {
return send('api/accounts-get');
}
function createAccount(account, initialBalance) {
export function createAccount(account, initialBalance) {
return send('api/account-create', { account, initialBalance });
}
function updateAccount(id, fields) {
export function updateAccount(id, fields) {
return send('api/account-update', { id, fields });
}
function closeAccount(id, transferAccountId, transferCategoryId) {
export function closeAccount(id, transferAccountId, transferCategoryId) {
return send('api/account-close', {
id,
transferAccountId,
@@ -97,116 +98,54 @@ function closeAccount(id, transferAccountId, transferCategoryId) {
});
}
function reopenAccount(id) {
export function reopenAccount(id) {
return send('api/account-reopen', { id });
}
function deleteAccount(id) {
export function deleteAccount(id) {
return send('api/account-delete', { id });
}
function createCategoryGroup(group) {
export function createCategoryGroup(group) {
return send('api/category-group-create', { group });
}
function updateCategoryGroup(id, fields) {
export function updateCategoryGroup(id, fields) {
return send('api/category-group-update', { id, fields });
}
function deleteCategoryGroup(id, transferCategoryId) {
export function deleteCategoryGroup(id, transferCategoryId) {
return send('api/category-group-delete', { id, transferCategoryId });
}
function getCategories() {
export function getCategories() {
return send('api/categories-get', { grouped: false });
}
function createCategory(category) {
export function createCategory(category) {
return send('api/category-create', { category });
}
function updateCategory(id, fields) {
export function updateCategory(id, fields) {
return send('api/category-update', { id, fields });
}
function deleteCategory(id, transferCategoryId) {
export function deleteCategory(id, transferCategoryId) {
return send('api/category-delete', { id, transferCategoryId });
}
function getPayees() {
export function getPayees() {
return send('api/payees-get');
}
function createPayee(payee) {
export function createPayee(payee) {
return send('api/payee-create', { payee });
}
function updatePayee(id, fields) {
export function updatePayee(id, fields) {
return send('api/payee-update', { id, fields });
}
function deletePayee(id) {
export function deletePayee(id) {
return send('api/payee-delete', { id });
}
function getPayeeRules(payeeId) {
return send('api/payee-rules-get', { payeeId });
}
function createPayeeRule(payeeId, rule) {
return send('api/payee-rule-create', { payee_id: payeeId, rule });
}
function updatePayeeRule(id, fields) {
return send('api/payee-rule-update', { id, fields });
}
function deletePayeeRule(id) {
return send('api/payee-rule-delete', { id });
}
module.exports = {
runImport,
runQuery,
q,
loadBudget,
downloadBudget,
batchBudgetUpdates,
getBudgetMonths,
getBudgetMonth,
setBudgetAmount,
setBudgetCarryover,
addTransactions,
importTransactions,
filterTransactions,
getTransactions,
updateTransaction,
deleteTransaction,
getAccounts,
createAccount,
updateAccount,
closeAccount,
reopenAccount,
deleteAccount,
getCategories,
createCategoryGroup,
updateCategoryGroup,
deleteCategoryGroup,
createCategory,
updateCategory,
deleteCategory,
getPayees,
createPayee,
updatePayee,
deletePayee,
getPayeeRules,
createPayeeRule,
deletePayeeRule,
updatePayeeRule,
};

View File

@@ -1,23 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE payees
(id TEXT PRIMARY KEY,
name TEXT,
category TEXT,
tombstone INTEGER DEFAULT 0,
transfer_acct TEXT);
CREATE TABLE payee_rules
(id TEXT PRIMARY KEY,
payee_id TEXT,
type TEXT,
value TEXT,
tombstone INTEGER DEFAULT 0);
CREATE INDEX payee_rules_lowercase_index ON payee_rules(LOWER(value));
CREATE TABLE payee_mapping
(id TEXT PRIMARY KEY,
targetId TEXT);
COMMIT;

View File

@@ -1,25 +0,0 @@
BEGIN TRANSACTION;
CREATE TEMPORARY TABLE category_groups_tmp
(id TEXT PRIMARY KEY,
name TEXT UNIQUE,
is_income INTEGER DEFAULT 0,
sort_order REAL,
tombstone INTEGER DEFAULT 0);
INSERT INTO category_groups_tmp SELECT * FROM category_groups;
DROP TABLE category_groups;
CREATE TABLE category_groups
(id TEXT PRIMARY KEY,
name TEXT,
is_income INTEGER DEFAULT 0,
sort_order REAL,
tombstone INTEGER DEFAULT 0);
INSERT INTO category_groups SELECT * FROM category_groups_tmp;
DROP TABLE category_groups_tmp;
COMMIT;

View File

@@ -1,7 +0,0 @@
BEGIN TRANSACTION;
CREATE INDEX trans_category_date ON transactions(category, date);
CREATE INDEX trans_category ON transactions(category);
CREATE INDEX trans_date ON transactions(date);
COMMIT;

View File

@@ -1,38 +0,0 @@
BEGIN TRANSACTION;
DELETE FROM spreadsheet_cells WHERE
name NOT LIKE '%!budget\_%' ESCAPE '\' AND
name NOT LIKE '%!carryover\_%' ESCAPE '\' AND
name NOT LIKE '%!buffered';
UPDATE OR REPLACE spreadsheet_cells SET name = REPLACE(name, '_', '-');
UPDATE OR REPLACE spreadsheet_cells SET
name =
SUBSTR(name, 1, 28) ||
'-' ||
SUBSTR(name, 29, 4) ||
'-' ||
SUBSTR(name, 33, 4) ||
'-' ||
SUBSTR(name, 37, 4) ||
'-' ||
SUBSTR(name, 41, 12)
WHERE name LIKE '%!budget-%' AND LENGTH(name) = 52;
UPDATE OR REPLACE spreadsheet_cells SET
name =
SUBSTR(name, 1, 31) ||
'-' ||
SUBSTR(name, 32, 4) ||
'-' ||
SUBSTR(name, 36, 4) ||
'-' ||
SUBSTR(name, 40, 4) ||
'-' ||
SUBSTR(name, 44, 12)
WHERE name LIKE '%!carryover-%' AND LENGTH(name) = 55;
UPDATE spreadsheet_cells SET expr = SUBSTR(expr, 2) WHERE name LIKE '%!carryover-%';
COMMIT;

View File

@@ -1,6 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE transactions ADD COLUMN cleared INTEGER DEFAULT 1;
ALTER TABLE transactions ADD COLUMN pending INTEGER DEFAULT 0;
COMMIT;

View File

@@ -1,10 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE rules
(id TEXT PRIMARY KEY,
stage TEXT,
conditions TEXT,
actions TEXT,
tombstone INTEGER DEFAULT 0);
COMMIT;

View File

@@ -1,13 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE transactions ADD COLUMN parent_id TEXT;
UPDATE transactions SET
parent_id = CASE
WHEN isChild THEN SUBSTR(id, 1, INSTR(id, '/') - 1)
ELSE NULL
END;
CREATE INDEX trans_parent_id ON transactions(parent_id);
COMMIT;

View File

@@ -1,56 +0,0 @@
BEGIN TRANSACTION;
DROP VIEW IF EXISTS v_transactions_layer2;
CREATE VIEW v_transactions_layer2 AS
SELECT
t.id AS id,
t.isParent AS is_parent,
t.isChild AS is_child,
t.acct AS account,
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
pm.targetId AS payee,
t.imported_description AS imported_payee,
IFNULL(t.amount, 0) AS amount,
t.notes AS notes,
t.date AS date,
t.financial_id AS imported_id,
t.error AS error,
t.starting_balance_flag AS starting_balance_flag,
t.transferred_id AS transfer_id,
t.sort_order AS sort_order,
t.cleared AS cleared,
t.tombstone AS tombstone
FROM transactions t
LEFT JOIN category_mapping cm ON cm.id = t.category
LEFT JOIN payee_mapping pm ON pm.id = t.description
WHERE
t.date IS NOT NULL AND
t.acct IS NOT NULL;
CREATE INDEX trans_sorted ON transactions(date desc, starting_balance_flag, sort_order desc, id);
DROP VIEW IF EXISTS v_transactions_layer1;
CREATE VIEW v_transactions_layer1 AS
SELECT t.* FROM v_transactions_layer2 t
LEFT JOIN transactions t2 ON (t.is_child = 1 AND t2.id = t.parent_id)
WHERE IFNULL(t.tombstone, 0) = 0 AND IFNULL(t2.tombstone, 0) = 0;
DROP VIEW IF EXISTS v_transactions;
CREATE VIEW v_transactions AS
SELECT t.* FROM v_transactions_layer1 t
ORDER BY t.date desc, t.starting_balance_flag, t.sort_order desc, t.id;
DROP VIEW IF EXISTS v_categories;
CREATE VIEW v_categories AS
SELECT
id,
name,
is_income,
cat_group AS "group",
sort_order,
tombstone
FROM categories;
COMMIT;

View File

@@ -1,7 +0,0 @@
BEGIN TRANSACTION;
CREATE INDEX messages_crdt_search ON messages_crdt(dataset, row, column, timestamp);
ANALYZE;
COMMIT;

View File

@@ -1,33 +0,0 @@
BEGIN TRANSACTION;
-- This adds the isChild/parent_id constraint in `where`
DROP VIEW IF EXISTS v_transactions_layer2;
CREATE VIEW v_transactions_layer2 AS
SELECT
t.id AS id,
t.isParent AS is_parent,
t.isChild AS is_child,
t.acct AS account,
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
pm.targetId AS payee,
t.imported_description AS imported_payee,
IFNULL(t.amount, 0) AS amount,
t.notes AS notes,
t.date AS date,
t.financial_id AS imported_id,
t.error AS error,
t.starting_balance_flag AS starting_balance_flag,
t.transferred_id AS transfer_id,
t.sort_order AS sort_order,
t.cleared AS cleared,
t.tombstone AS tombstone
FROM transactions t
LEFT JOIN category_mapping cm ON cm.id = t.category
LEFT JOIN payee_mapping pm ON pm.id = t.description
WHERE
t.date IS NOT NULL AND
t.acct IS NOT NULL AND
(t.isChild = 0 OR t.parent_id IS NOT NULL);
COMMIT;

View File

@@ -1,10 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE __meta__ (key TEXT PRIMARY KEY, value TEXT);
DROP VIEW IF EXISTS v_transactions_layer2;
DROP VIEW IF EXISTS v_transactions_layer1;
DROP VIEW IF EXISTS v_transactions;
DROP VIEW IF EXISTS v_categories;
COMMIT;

View File

@@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE accounts ADD COLUMN sort_order REAL;
COMMIT;

View File

@@ -1,28 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE schedules
(id TEXT PRIMARY KEY,
rule TEXT,
active INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0,
posts_transaction INTEGER DEFAULT 0,
tombstone INTEGER DEFAULT 0);
CREATE TABLE schedules_next_date
(id TEXT PRIMARY KEY,
schedule_id TEXT,
local_next_date INTEGER,
local_next_date_ts INTEGER,
base_next_date INTEGER,
base_next_date_ts INTEGER);
CREATE TABLE schedules_json_paths
(schedule_id TEXT PRIMARY KEY,
payee TEXT,
account TEXT,
amount TEXT,
date TEXT);
ALTER TABLE transactions ADD COLUMN schedule TEXT;
COMMIT;

View File

@@ -1,135 +0,0 @@
export default async function runMigration(db, uuid) {
function getValue(node) {
return node.expr != null ? node.expr : node.cachedValue;
}
db.execQuery(`
CREATE TABLE zero_budget_months
(id TEXT PRIMARY KEY,
buffered INTEGER DEFAULT 0);
CREATE TABLE zero_budgets
(id TEXT PRIMARY KEY,
month INTEGER,
category TEXT,
amount INTEGER DEFAULT 0,
carryover INTEGER DEFAULT 0);
CREATE TABLE reflect_budgets
(id TEXT PRIMARY KEY,
month INTEGER,
category TEXT,
amount INTEGER DEFAULT 0,
carryover INTEGER DEFAULT 0);
CREATE TABLE notes
(id TEXT PRIMARY KEY,
note TEXT);
CREATE TABLE kvcache (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
`);
// Migrate budget amounts and carryover
let budget = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
[],
true,
);
db.transaction(() => {
budget.forEach(monthBudget => {
let match = monthBudget.name.match(
/^(budget-report|budget)(\d+)!budget-(.+)$/,
);
if (match == null) {
console.log('Warning: invalid budget month name', monthBudget.name);
return;
}
let type = match[1];
let month = match[2].slice(0, 4) + '-' + match[2].slice(4);
let dbmonth = parseInt(match[2]);
let cat = match[3];
let amount = parseInt(getValue(monthBudget));
if (isNaN(amount)) {
amount = 0;
}
let sheetName = monthBudget.name.split('!')[0];
let carryover = db.runQuery(
'SELECT * FROM spreadsheet_cells WHERE name = ?',
[`${sheetName}!carryover-${cat}`],
true,
);
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
db.runQuery(
`INSERT INTO ${table} (id, month, category, amount, carryover) VALUES (?, ?, ?, ?, ?)`,
[
`${month}-${cat}`,
dbmonth,
cat,
amount,
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
],
);
});
});
// Migrate buffers
let buffers = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
[],
true,
);
db.transaction(() => {
buffers.forEach(buffer => {
let match = buffer.name.match(/^budget(\d+)!buffered$/);
if (match) {
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
let amount = parseInt(getValue(buffer));
if (isNaN(amount)) {
amount = 0;
}
db.runQuery(
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
[month, amount],
);
}
});
});
// Migrate notes
let notes = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
[],
true,
);
let parseNote = str => {
try {
let value = JSON.parse(str);
return value && value !== '' ? value : null;
} catch (e) {
return null;
}
};
db.transaction(() => {
notes.forEach(note => {
let parsed = parseNote(getValue(note));
if (parsed) {
let [, id] = note.name.split('!');
db.runQuery(`INSERT INTO notes (id, note) VALUES (?, ?)`, [id, parsed]);
}
});
});
db.execQuery(`
DROP TABLE spreadsheet_cells;
ANALYZE;
VACUUM;
`);
}

View File

@@ -1,25 +1,27 @@
{
"name": "@actual-app/api",
"version": "5.1.0",
"version": "6.1.0",
"license": "MIT",
"description": "An API for Actual",
"main": "index.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"app",
"default-db.sqlite",
"index.js",
"injected.js",
"methods.js",
"migrations",
"utils.js"
"dist"
],
"scripts": {
"lint": "eslint .",
"build": "yarn workspace loot-core build:api"
"build:app": "yarn workspace loot-core build:api",
"build:node": "tsc --p tsconfig.dist.json",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "rm -rf dist && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db"
},
"dependencies": {
"better-sqlite3": "^8.2.0",
"node-fetch": "^2.6.9",
"uuid": "3.3.2"
},
"devDependencies": {
"typescript": "^5.0.2"
}
}

View File

@@ -1,4 +1,4 @@
let api = require('./index');
import * as api from './index';
async function run() {
let app = await api.init({ config: { dataDir: '/tmp' } });

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Using ES2021 because thats the newest version where
// the latest Node 16.x release supports all of the features
"target": "es2021",
"module": "CommonJS",
"noEmit": false,
"declaration": true,
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist"]
}

View File

@@ -1,9 +1,7 @@
function amountToInteger(n) {
export function amountToInteger(n) {
return Math.round(n * 100);
}
function integerToAmount(n) {
export function integerToAmount(n) {
return parseFloat((n / 100).toFixed(2));
}
module.exports = { amountToInteger, integerToAmount };

View File

@@ -17,3 +17,5 @@ npm-debug.log
*kcab.*
public/kcab
public/data
public/data-file-index.txt

View File

@@ -2,16 +2,18 @@ const path = require('path');
const {
addWebpackResolve,
disableEsLint,
override,
overrideDevServer,
babelInclude,
} = require('customize-cra');
if (process.env.CI) {
process.env.DISABLE_ESLINT_PLUGIN = 'true';
}
module.exports = {
webpack: override(
babelInclude([path.resolve('src'), path.resolve('../loot-core')]),
process.env.CI && disableEsLint(),
addWebpackResolve({
extensions: [
...(process.env.IS_GENERIC_BROWSER

View File

@@ -1,17 +1,14 @@
import { test, expect } from '@playwright/test';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';
test.describe('Budget', () => {
let page;
let navigation; // eslint-disable-line no-unused-vars
let configurationPage;
let budgetPage;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
navigation = new Navigation(page);
configurationPage = new ConfigurationPage(page);
await page.goto('/');
@@ -25,7 +22,9 @@ test.describe('Budget', () => {
test('renders the summary information: available funds, overspent, budgeted and for next month', async () => {
const summary = budgetPage.budgetSummary.first();
await expect(summary.getByText('Available Funds')).toBeVisible();
await expect(summary.getByText('Available Funds')).toBeVisible({
timeout: 10000,
});
await expect(summary.getByText(/^Overspent in /)).toBeVisible();
await expect(summary.getByText('Budgeted')).toBeVisible();
await expect(summary.getByText('For Next Month')).toBeVisible();

View File

@@ -76,6 +76,6 @@ test.describe('Onboarding', () => {
await navigation.clickOnNoServer();
expect(await configurationPage.heading).toHaveText('Wheres the server?');
await expect(configurationPage.heading).toHaveText('Wheres the server?');
});
});

View File

@@ -91,6 +91,12 @@ export class AccountPage {
return new CloseAccountModal(this.page.locator('css=[aria-modal]'));
}
async _clearFocusedField() {
let isMac = process.platform === 'darwin';
await this.page.keyboard.press(isMac ? 'Meta+A' : 'Control+A');
await this.page.keyboard.press('Backspace');
}
async _fillTransactionFields(transactionRow, transaction) {
if (transaction.payee) {
await transactionRow.getByTestId('payee').click();
@@ -117,12 +123,14 @@ export class AccountPage {
if (transaction.debit) {
await transactionRow.getByTestId('debit').click();
await this._clearFocusedField();
await this.page.keyboard.type(transaction.debit);
await this.page.keyboard.press('Tab');
}
if (transaction.credit) {
await transactionRow.getByTestId('credit').click();
await this._clearFocusedField();
await this.page.keyboard.type(transaction.credit);
await this.page.keyboard.press('Tab');
}

View File

@@ -85,7 +85,7 @@ export class RulesPage {
}
if (value) {
await row.getByRole('combobox').fill(value);
await row.getByRole('textbox').fill(value);
await this.page.keyboard.press('Enter');
}
}

View File

@@ -71,12 +71,14 @@ export class SchedulesPage {
async _fillScheduleFields(data) {
if (data.payee) {
await this.page.getByLabel('Payee').fill(data.payee);
await this.page.getByRole('textbox', { name: 'Payee' }).fill(data.payee);
await this.page.keyboard.press('Enter');
}
if (data.account) {
await this.page.getByLabel('Account').fill(data.account);
await this.page
.getByRole('textbox', { name: 'Account' })
.fill(data.account);
await this.page.keyboard.press('Enter');
}

View File

@@ -1,12 +1,11 @@
{
"name": "@actual-app/web",
"version": "23.4.0",
"version": "23.6.0",
"license": "MIT",
"files": [
"build"
],
"devDependencies": {
"@jlongster/lively": "0.0.4",
"@juggle/resize-observer": "^3.1.2",
"@playwright/test": "^1.29.1",
"@reach/listbox": "^0.11.2",
@@ -15,17 +14,20 @@
"@react-aria/utils": "^3.13.3",
"@react-stately/collections": "^3.4.3",
"@react-stately/list": "^3.5.3",
"@reactions/component": "^2.0.2",
"@svgr/cli": "^6.5.1",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.1",
"@types/react-modal": "^3.16.0",
"@types/react-router-dom": "^5.3.3",
"chalk": "2.4.1",
"chroma-js": "^1.3.3",
"cross-env": "^7.0.3",
"customize-cra": "^1.0.0",
"date-fns": "^2.29.3",
"debounce": "^1.2.0",
"downshift": "1.31.16",
"downshift": "7.6.0",
"focus-visible": "^4.1.1",
"formik": "^0.11.10",
"glamor": "^2.20.40",
@@ -38,7 +40,6 @@
"mitt": "^3.0.0",
"node-noop": "1.0.0",
"pikaday": "1.8.0",
"prop-types": "15.6.0",
"react": "18.2.0",
"react-app-rewired": "^2.2.1",
"react-dnd": "^10.0.2",
@@ -51,13 +52,11 @@
"react-router-dom": "5.2.0",
"react-router-dom-v5-compat": "^6.4.1",
"react-scripts": "^5.0.1",
"react-select": "^5.7.0",
"react-spring": "^8.0.27",
"react-spring": "^9.7.1",
"react-virtualized-auto-sizer": "^1.0.2",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"victory": "^36.6.8",
"wobble": "^1.5.0"
"victory": "^36.6.8"
},
"scripts": {
"start": "cross-env PORT=3001 react-app-rewired start",
@@ -66,7 +65,7 @@
"build": "cross-env INLINE_RUNTIME_CHUNK=false react-app-rewired build",
"build:browser": "cross-env ./bin/build-browser",
"test": "react-app-rewired test",
"e2e": "npx playwright test e2e --browser=chromium",
"e2e": "npx playwright test --browser=chromium",
"lint": "eslint ."
},
"jest": {

View File

@@ -1,6 +1,7 @@
module.exports = {
timeout: 20000, // 20 seconds
retries: 1,
testDir: 'e2e/',
use: {
screenshot: 'on',
browserName: 'chromium',

View File

@@ -1,18 +0,0 @@
default-db.sqlite
migrations/.force-copy-windows
migrations/1548957970627_remove-db-version.sql
migrations/1550601598648_payees.sql
migrations/1555786194328_remove_category_group_unique.sql
migrations/1561751833510_indexes.sql
migrations/1567699552727_budget.sql
migrations/1582384163573_cleared.sql
migrations/1597756566448_rules.sql
migrations/1608652596043_parent_field.sql
migrations/1608652596044_trans_views.sql
migrations/1612625548236_optimize.sql
migrations/1614782639336_trans_views2.sql
migrations/1615745967948_meta.sql
migrations/1616167010796_accounts_order.sql
migrations/1618975177358_schedules.sql
migrations/1632571489012_remove_cache.js
migrations/1679728867040_rules_conditions.sql

View File

@@ -1,5 +0,0 @@
BEGIN TRANSACTION;
DROP TABLE db_version;
COMMIT;

View File

@@ -1,23 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE payees
(id TEXT PRIMARY KEY,
name TEXT,
category TEXT,
tombstone INTEGER DEFAULT 0,
transfer_acct TEXT);
CREATE TABLE payee_rules
(id TEXT PRIMARY KEY,
payee_id TEXT,
type TEXT,
value TEXT,
tombstone INTEGER DEFAULT 0);
CREATE INDEX payee_rules_lowercase_index ON payee_rules(LOWER(value));
CREATE TABLE payee_mapping
(id TEXT PRIMARY KEY,
targetId TEXT);
COMMIT;

View File

@@ -1,25 +0,0 @@
BEGIN TRANSACTION;
CREATE TEMPORARY TABLE category_groups_tmp
(id TEXT PRIMARY KEY,
name TEXT UNIQUE,
is_income INTEGER DEFAULT 0,
sort_order REAL,
tombstone INTEGER DEFAULT 0);
INSERT INTO category_groups_tmp SELECT * FROM category_groups;
DROP TABLE category_groups;
CREATE TABLE category_groups
(id TEXT PRIMARY KEY,
name TEXT,
is_income INTEGER DEFAULT 0,
sort_order REAL,
tombstone INTEGER DEFAULT 0);
INSERT INTO category_groups SELECT * FROM category_groups_tmp;
DROP TABLE category_groups_tmp;
COMMIT;

View File

@@ -1,7 +0,0 @@
BEGIN TRANSACTION;
CREATE INDEX trans_category_date ON transactions(category, date);
CREATE INDEX trans_category ON transactions(category);
CREATE INDEX trans_date ON transactions(date);
COMMIT;

View File

@@ -1,38 +0,0 @@
BEGIN TRANSACTION;
DELETE FROM spreadsheet_cells WHERE
name NOT LIKE '%!budget\_%' ESCAPE '\' AND
name NOT LIKE '%!carryover\_%' ESCAPE '\' AND
name NOT LIKE '%!buffered';
UPDATE OR REPLACE spreadsheet_cells SET name = REPLACE(name, '_', '-');
UPDATE OR REPLACE spreadsheet_cells SET
name =
SUBSTR(name, 1, 28) ||
'-' ||
SUBSTR(name, 29, 4) ||
'-' ||
SUBSTR(name, 33, 4) ||
'-' ||
SUBSTR(name, 37, 4) ||
'-' ||
SUBSTR(name, 41, 12)
WHERE name LIKE '%!budget-%' AND LENGTH(name) = 52;
UPDATE OR REPLACE spreadsheet_cells SET
name =
SUBSTR(name, 1, 31) ||
'-' ||
SUBSTR(name, 32, 4) ||
'-' ||
SUBSTR(name, 36, 4) ||
'-' ||
SUBSTR(name, 40, 4) ||
'-' ||
SUBSTR(name, 44, 12)
WHERE name LIKE '%!carryover-%' AND LENGTH(name) = 55;
UPDATE spreadsheet_cells SET expr = SUBSTR(expr, 2) WHERE name LIKE '%!carryover-%';
COMMIT;

View File

@@ -1,6 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE transactions ADD COLUMN cleared INTEGER DEFAULT 1;
ALTER TABLE transactions ADD COLUMN pending INTEGER DEFAULT 0;
COMMIT;

View File

@@ -1,10 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE rules
(id TEXT PRIMARY KEY,
stage TEXT,
conditions TEXT,
actions TEXT,
tombstone INTEGER DEFAULT 0);
COMMIT;

View File

@@ -1,13 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE transactions ADD COLUMN parent_id TEXT;
UPDATE transactions SET
parent_id = CASE
WHEN isChild THEN SUBSTR(id, 1, INSTR(id, '/') - 1)
ELSE NULL
END;
CREATE INDEX trans_parent_id ON transactions(parent_id);
COMMIT;

View File

@@ -1,56 +0,0 @@
BEGIN TRANSACTION;
DROP VIEW IF EXISTS v_transactions_layer2;
CREATE VIEW v_transactions_layer2 AS
SELECT
t.id AS id,
t.isParent AS is_parent,
t.isChild AS is_child,
t.acct AS account,
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
pm.targetId AS payee,
t.imported_description AS imported_payee,
IFNULL(t.amount, 0) AS amount,
t.notes AS notes,
t.date AS date,
t.financial_id AS imported_id,
t.error AS error,
t.starting_balance_flag AS starting_balance_flag,
t.transferred_id AS transfer_id,
t.sort_order AS sort_order,
t.cleared AS cleared,
t.tombstone AS tombstone
FROM transactions t
LEFT JOIN category_mapping cm ON cm.id = t.category
LEFT JOIN payee_mapping pm ON pm.id = t.description
WHERE
t.date IS NOT NULL AND
t.acct IS NOT NULL;
CREATE INDEX trans_sorted ON transactions(date desc, starting_balance_flag, sort_order desc, id);
DROP VIEW IF EXISTS v_transactions_layer1;
CREATE VIEW v_transactions_layer1 AS
SELECT t.* FROM v_transactions_layer2 t
LEFT JOIN transactions t2 ON (t.is_child = 1 AND t2.id = t.parent_id)
WHERE IFNULL(t.tombstone, 0) = 0 AND IFNULL(t2.tombstone, 0) = 0;
DROP VIEW IF EXISTS v_transactions;
CREATE VIEW v_transactions AS
SELECT t.* FROM v_transactions_layer1 t
ORDER BY t.date desc, t.starting_balance_flag, t.sort_order desc, t.id;
DROP VIEW IF EXISTS v_categories;
CREATE VIEW v_categories AS
SELECT
id,
name,
is_income,
cat_group AS "group",
sort_order,
tombstone
FROM categories;
COMMIT;

View File

@@ -1,7 +0,0 @@
BEGIN TRANSACTION;
CREATE INDEX messages_crdt_search ON messages_crdt(dataset, row, column, timestamp);
ANALYZE;
COMMIT;

View File

@@ -1,33 +0,0 @@
BEGIN TRANSACTION;
-- This adds the isChild/parent_id constraint in `where`
DROP VIEW IF EXISTS v_transactions_layer2;
CREATE VIEW v_transactions_layer2 AS
SELECT
t.id AS id,
t.isParent AS is_parent,
t.isChild AS is_child,
t.acct AS account,
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
pm.targetId AS payee,
t.imported_description AS imported_payee,
IFNULL(t.amount, 0) AS amount,
t.notes AS notes,
t.date AS date,
t.financial_id AS imported_id,
t.error AS error,
t.starting_balance_flag AS starting_balance_flag,
t.transferred_id AS transfer_id,
t.sort_order AS sort_order,
t.cleared AS cleared,
t.tombstone AS tombstone
FROM transactions t
LEFT JOIN category_mapping cm ON cm.id = t.category
LEFT JOIN payee_mapping pm ON pm.id = t.description
WHERE
t.date IS NOT NULL AND
t.acct IS NOT NULL AND
(t.isChild = 0 OR t.parent_id IS NOT NULL);
COMMIT;

View File

@@ -1,10 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE __meta__ (key TEXT PRIMARY KEY, value TEXT);
DROP VIEW IF EXISTS v_transactions_layer2;
DROP VIEW IF EXISTS v_transactions_layer1;
DROP VIEW IF EXISTS v_transactions;
DROP VIEW IF EXISTS v_categories;
COMMIT;

View File

@@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE accounts ADD COLUMN sort_order REAL;
COMMIT;

View File

@@ -1,28 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE schedules
(id TEXT PRIMARY KEY,
rule TEXT,
active INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0,
posts_transaction INTEGER DEFAULT 0,
tombstone INTEGER DEFAULT 0);
CREATE TABLE schedules_next_date
(id TEXT PRIMARY KEY,
schedule_id TEXT,
local_next_date INTEGER,
local_next_date_ts INTEGER,
base_next_date INTEGER,
base_next_date_ts INTEGER);
CREATE TABLE schedules_json_paths
(schedule_id TEXT PRIMARY KEY,
payee TEXT,
account TEXT,
amount TEXT,
date TEXT);
ALTER TABLE transactions ADD COLUMN schedule TEXT;
COMMIT;

View File

@@ -1,135 +0,0 @@
export default async function runMigration(db, uuid) {
function getValue(node) {
return node.expr != null ? node.expr : node.cachedValue;
}
db.execQuery(`
CREATE TABLE zero_budget_months
(id TEXT PRIMARY KEY,
buffered INTEGER DEFAULT 0);
CREATE TABLE zero_budgets
(id TEXT PRIMARY KEY,
month INTEGER,
category TEXT,
amount INTEGER DEFAULT 0,
carryover INTEGER DEFAULT 0);
CREATE TABLE reflect_budgets
(id TEXT PRIMARY KEY,
month INTEGER,
category TEXT,
amount INTEGER DEFAULT 0,
carryover INTEGER DEFAULT 0);
CREATE TABLE notes
(id TEXT PRIMARY KEY,
note TEXT);
CREATE TABLE kvcache (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
`);
// Migrate budget amounts and carryover
let budget = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
[],
true,
);
db.transaction(() => {
budget.forEach(monthBudget => {
let match = monthBudget.name.match(
/^(budget-report|budget)(\d+)!budget-(.+)$/,
);
if (match == null) {
console.log('Warning: invalid budget month name', monthBudget.name);
return;
}
let type = match[1];
let month = match[2].slice(0, 4) + '-' + match[2].slice(4);
let dbmonth = parseInt(match[2]);
let cat = match[3];
let amount = parseInt(getValue(monthBudget));
if (isNaN(amount)) {
amount = 0;
}
let sheetName = monthBudget.name.split('!')[0];
let carryover = db.runQuery(
'SELECT * FROM spreadsheet_cells WHERE name = ?',
[`${sheetName}!carryover-${cat}`],
true,
);
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
db.runQuery(
`INSERT INTO ${table} (id, month, category, amount, carryover) VALUES (?, ?, ?, ?, ?)`,
[
`${month}-${cat}`,
dbmonth,
cat,
amount,
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
],
);
});
});
// Migrate buffers
let buffers = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
[],
true,
);
db.transaction(() => {
buffers.forEach(buffer => {
let match = buffer.name.match(/^budget(\d+)!buffered$/);
if (match) {
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
let amount = parseInt(getValue(buffer));
if (isNaN(amount)) {
amount = 0;
}
db.runQuery(
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
[month, amount],
);
}
});
});
// Migrate notes
let notes = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
[],
true,
);
let parseNote = str => {
try {
let value = JSON.parse(str);
return value && value !== '' ? value : null;
} catch (e) {
return null;
}
};
db.transaction(() => {
notes.forEach(note => {
let parsed = parseNote(getValue(note));
if (parsed) {
let [, id] = note.name.split('!');
db.runQuery(`INSERT INTO notes (id, note) VALUES (?, ?)`, [id, parsed]);
}
});
});
db.execQuery(`
DROP TABLE spreadsheet_cells;
ANALYZE;
VACUUM;
`);
}

View File

@@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE rules ADD COLUMN conditions_op TEXT DEFAULT 'and';
COMMIT;

View File

@@ -0,0 +1,55 @@
import { type ReactNode, createContext, useContext } from 'react';
import { useViewportSize } from '@react-aria/utils';
import { breakpoints } from './tokens';
type TResponsiveContext = {
atLeastMediumWidth: boolean;
isNarrowWidth: boolean;
isSmallWidth: boolean;
isMediumWidth: boolean;
isWideWidth: boolean;
height: number;
width: number;
};
const ResponsiveContext = createContext<TResponsiveContext>(null);
export function ResponsiveProvider(props: { children: ReactNode }) {
/*
* Ensure we render on every viewport size change,
* even though we're interested in document.documentElement.client<Width|Height>
* clientWidth/Height are the document size, do not change on pinch-zoom,
* and are what our `min-width` media queries are reading
* Viewport size changes on pinch-zoom, which may be useful later when dealing with on-screen keyboards
*/
useViewportSize();
const height = document.documentElement.clientHeight;
const width = document.documentElement.clientWidth;
// Possible view modes: narrow, small, medium, wide
// To check if we're at least small width, check !isNarrowWidth
const viewportInfo = {
// atLeastMediumWidth is provided to avoid checking (isMediumWidth || isWideWidth)
atLeastMediumWidth: width >= breakpoints.medium,
isNarrowWidth: width < breakpoints.small,
isSmallWidth: width >= breakpoints.small && width < breakpoints.medium,
isMediumWidth: width >= breakpoints.medium && width < breakpoints.wide,
// No atLeastWideWidth because that's identical to isWideWidth
isWideWidth: width >= breakpoints.wide,
height,
width,
};
return (
<ResponsiveContext.Provider value={viewportInfo}>
{props.children}
</ResponsiveContext.Provider>
);
}
export function useResponsive() {
return useContext(ResponsiveContext);
}

View File

@@ -110,7 +110,6 @@ global.Actual = {
applyAppUpdate: () => {},
updateAppMenu: isBudgetOpen => {},
ipcConnect: () => {},
getServerSocket: async () => {
return worker;
},
@@ -119,12 +118,12 @@ global.Actual = {
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey) {
// Cmd/Ctrl+o
if (e.code === 'KeyO') {
if (e.key === 'o') {
e.preventDefault();
window.__actionsForMenu.closeBudget();
}
// Cmd/Ctrl+z
else if (e.code === 'KeyZ') {
else if (e.key.toLowerCase() === 'z') {
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import React, { createContext, useContext } from 'react';
let ActiveLocationContext = React.createContext(null);
let ActiveLocationContext = createContext(null);
export function ActiveLocationProvider({ location, children }) {
return (

View File

@@ -4,7 +4,7 @@ import { css } from 'glamor';
import Refresh from '../icons/v1/Refresh';
import View from './View';
import View from './common/View';
let spin = css.keyframes({
'0%': { transform: 'rotateZ(0deg)' },

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { css } from 'glamor';
@@ -10,6 +10,7 @@ import {
} from 'loot-core/src/platform/client/fetch';
import installPolyfills from '../polyfills';
import { ResponsiveProvider } from '../ResponsiveProvider';
import { styles, hasHiddenScrollbars } from '../style';
import AppBackground from './AppBackground';
@@ -19,7 +20,7 @@ import ManagementApp from './manager/ManagementApp';
import MobileWebMessage from './MobileWebMessage';
import UpdateNotification from './UpdateNotification';
class App extends React.Component {
class App extends Component {
state = {
fatalError: null,
initializing: true,
@@ -90,42 +91,44 @@ class App extends React.Component {
const { fatalError, initializing, hiddenScrollbars } = this.state;
return (
<div
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
{...css([
{
height: '100%',
backgroundColor: '#E8ECF0',
overflow: 'hidden',
},
styles.lightScrollbar,
])}
>
{fatalError ? (
<React.Fragment>
<AppBackground />
<FatalError error={fatalError} buttonText="Restart app" />
</React.Fragment>
) : initializing ? (
<AppBackground
initializing={initializing}
loadingText={loadingText}
/>
) : budgetId ? (
<FinancesApp />
) : (
<>
<ResponsiveProvider>
<div
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
{...css([
{
height: '100%',
backgroundColor: '#E8ECF0',
overflow: 'hidden',
},
styles.lightScrollbar,
])}
>
{fatalError ? (
<>
<AppBackground />
<FatalError error={fatalError} buttonText="Restart app" />
</>
) : initializing ? (
<AppBackground
initializing={initializing}
loadingText={loadingText}
/>
<ManagementApp isLoading={loadingText != null} />
</>
)}
) : budgetId ? (
<FinancesApp />
) : (
<>
<AppBackground
initializing={initializing}
loadingText={loadingText}
/>
<ManagementApp isLoading={loadingText != null} />
</>
)}
<UpdateNotification />
<MobileWebMessage />
</div>
<UpdateNotification />
<MobileWebMessage />
</div>
</ResponsiveProvider>
);
}
}

View File

@@ -10,7 +10,7 @@ import { View, Block } from './common';
function AppBackground({ initializing, loadingText }) {
return (
<React.Fragment>
<>
<Background />
{(loadingText != null || initializing) && (
@@ -32,7 +32,7 @@ function AppBackground({ initializing, loadingText }) {
<AnimatedLoading width={25} color={colors.n1} />
</View>
)}
</React.Fragment>
</>
);
}

View File

@@ -16,7 +16,7 @@ function BankSyncStatus({ accountsSyncing }) {
: accountsSyncing
: null;
const transitions = useTransition(name, null, {
const transitions = useTransition(name, {
from: { opacity: 0, transform: 'translateY(-100px)' },
enter: { opacity: 1, transform: 'translateY(0)' },
leave: { opacity: 0, transform: 'translateY(-100px)' },
@@ -35,10 +35,10 @@ function BankSyncStatus({ accountsSyncing }) {
zIndex: 501,
}}
>
{transitions.map(
({ item, key, props }) =>
{transitions(
(style, item) =>
item && (
<animated.div key={key} style={props}>
<animated.div key={item} style={style}>
<View
style={{
borderRadius: 4,

View File

@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import React, { Component, useState } from 'react';
import { colors } from '../style';
import { View, Stack, Text, Block, Modal, P, Link, Button } from './common';
import { Checkbox } from './forms';
class FatalError extends React.Component {
class FatalError extends Component {
state = { showError: false };
renderSimple(error) {

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { DndProvider } from 'react-dnd';
import Backend from 'react-dnd-html5-backend';
import { connect } from 'react-redux';
@@ -26,19 +26,19 @@ import * as undo from 'loot-core/src/platform/client/undo';
import Cog from '../icons/v1/Cog';
import PiggyBank from '../icons/v1/PiggyBank';
import Wallet from '../icons/v1/Wallet';
import { useResponsive } from '../ResponsiveProvider';
import { colors, styles } from '../style';
import { isMobile } from '../util';
import { getLocationState, makeLocationState } from '../util/location-state';
import { getIsOutdated, getLatestVersion } from '../util/versions';
import Account from './accounts/Account';
import { default as MobileAccount } from './accounts/MobileAccount';
import { default as MobileAccounts } from './accounts/MobileAccounts';
import MobileAccount from './accounts/MobileAccount';
import MobileAccounts from './accounts/MobileAccounts';
import { ActiveLocationProvider } from './ActiveLocation';
import BankSyncStatus from './BankSyncStatus';
import Budget from './budget';
import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
import { default as MobileBudget } from './budget/MobileBudget';
import MobileBudget from './budget/MobileBudget';
import { View } from './common';
import FloatableSidebar, { SidebarProvider } from './FloatableSidebar';
import GlobalKeys from './GlobalKeys';
@@ -57,74 +57,87 @@ import PostsOfflineNotification from './schedules/PostsOfflineNotification';
import Settings from './settings';
import Titlebar, { TitlebarProvider } from './Titlebar';
function PageRoute({ path, component: Component }) {
return (
<Route
path={path}
children={props => {
return (
<View
style={{
flex: 1,
display: props.match ? 'flex' : 'none',
}}
>
<Component {...props} />
</View>
);
}}
/>
);
function NarrowNotSupported({ children, redirectTo = '/budget' }) {
const { isNarrowWidth } = useResponsive();
return isNarrowWidth ? <Redirect to={redirectTo} /> : children;
}
function Routes({ isMobile, location }) {
function Routes({ location }) {
const { isNarrowWidth } = useResponsive();
return (
<Switch location={location}>
<Route path="/">
<Route path="/" exact render={() => <Redirect to="/budget" />} />
<Route path="/" exact render={() => <Redirect to="/budget" />} />
<PageRoute path="/reports" component={Reports} />
<PageRoute
path="/budget"
component={isMobile ? MobileBudget : Budget}
/>
<Route path="/reports">
<NarrowNotSupported>
<Reports />
</NarrowNotSupported>
</Route>
<Route path="/schedules" exact component={Schedules} />
<Route path="/schedule/edit" exact component={EditSchedule} />
<Route path="/schedule/edit/:id" component={EditSchedule} />
<Route path="/schedule/link" component={LinkSchedule} />
<Route path="/schedule/discover" component={DiscoverSchedules} />
<Route
path="/schedule/posts-offline-notification"
component={PostsOfflineNotification}
/>
<Route path="/budget">
{isNarrowWidth ? <MobileBudget /> : <Budget />}
</Route>
<Route path="/payees" exact component={ManagePayeesPage} />
<Route path="/rules" exact component={ManageRulesPage} />
<Route path="/settings" component={Settings} />
<Route path="/nordigen/link" exact component={NordigenLink} />
<Route path="/schedules" exact>
<NarrowNotSupported>
<Schedules />
</NarrowNotSupported>
</Route>
<Route path="/schedule/edit" exact>
<NarrowNotSupported>
<EditSchedule />
</NarrowNotSupported>
</Route>
<Route path="/schedule/edit/:id">
<NarrowNotSupported>
<EditSchedule />
</NarrowNotSupported>
</Route>
<Route path="/schedule/link">
<NarrowNotSupported>
<LinkSchedule />
</NarrowNotSupported>
</Route>
<Route path="/schedule/discover">
<NarrowNotSupported>
<DiscoverSchedules />
</NarrowNotSupported>
</Route>
<Route path="/schedule/posts-offline-notification">
<PostsOfflineNotification />
</Route>
<Route
path="/accounts/:id"
exact
children={props => {
const AcctCmp = isMobile ? MobileAccount : Account;
return (
props.match && <AcctCmp key={props.match.params.id} {...props} />
);
}}
/>
<Route
path="/accounts"
exact
component={isMobile ? MobileAccounts : Account}
/>
<Route path="/payees" exact>
<ManagePayeesPage />
</Route>
<Route path="/rules" exact>
<ManageRulesPage />
</Route>
<Route path="/settings">
<Settings />
</Route>
<Route path="/nordigen/link" exact>
<NarrowNotSupported>
<NordigenLink />
</NarrowNotSupported>
</Route>
<Route path="/accounts/:id" exact>
{props => {
const AcctCmp = isNarrowWidth ? MobileAccount : Account;
return (
props.match && <AcctCmp key={props.match.params.id} {...props} />
);
}}
</Route>
<Route path="/accounts" exact>
{isNarrowWidth ? <MobileAccounts /> : <Account />}
</Route>
</Switch>
);
}
function StackedRoutes({ isMobile }) {
function StackedRoutes() {
let location = useLocation();
let locationPtr = getLocationState(location, 'locationPtr');
@@ -139,14 +152,14 @@ function StackedRoutes({ isMobile }) {
return (
<ActiveLocationProvider location={locations[locations.length - 1]}>
<Routes location={base} isMobile={isMobile} />
<Routes location={base} />
{stack.map((location, idx) => (
<PageTypeProvider
key={location.key}
type="modal"
current={idx === stack.length - 1}
>
<Routes location={location} isMobile={isMobile} />
<Routes location={location} />
</PageTypeProvider>
))}
</ActiveLocationProvider>
@@ -177,6 +190,7 @@ function NavTab({ icon: TabIcon, name, path }) {
}
function MobileNavTabs() {
const { isNarrowWidth } = useResponsive();
return (
<div
style={{
@@ -184,74 +198,60 @@ function MobileNavTabs() {
borderTop: `1px solid ${colors.n10}`,
bottom: 0,
...styles.shadow,
display: 'flex',
display: isNarrowWidth ? 'flex' : 'none',
height: '80px',
justifyContent: 'space-around',
paddingTop: 10,
width: '100%',
}}
>
<NavTab name="Budget" path="/budget" icon={Wallet} isActive={false} />
<NavTab
name="Accounts"
path="/accounts"
icon={PiggyBank}
isActive={false}
/>
<NavTab name="Settings" path="/settings" icon={Cog} isActive={false} />
<NavTab name="Budget" path="/budget" icon={Wallet} />
<NavTab name="Accounts" path="/accounts" icon={PiggyBank} />
<NavTab name="Settings" path="/settings" icon={Cog} />
</div>
);
}
class FinancesApp extends React.Component {
constructor(props) {
super(props);
this.state = { isMobile: isMobile() };
this.history = createBrowserHistory();
function FinancesApp(props) {
const [patchedHistory] = useState(() => createBrowserHistory());
let oldPush = this.history.push;
this.history.push = (to, state) => {
useEffect(() => {
let oldPush = patchedHistory.push;
patchedHistory.push = (to, state) => {
let newState = makeLocationState(to.state || state);
if (typeof to === 'object') {
return oldPush.call(this.history, { ...to, state: newState });
return oldPush.call(patchedHistory, { ...to, state: newState });
} else {
return oldPush.call(this.history, to, newState);
return oldPush.call(patchedHistory, to, newState);
}
};
// I'm not sure if this is the best approach but we need this to
// globally. We could instead move various workflows inside global
// React components, but that's for another day.
window.__history = this.history;
window.__history = patchedHistory;
undo.setUndoState('url', window.location.href);
this.cleanup = this.history.listen(location => {
const cleanup = patchedHistory.listen(location => {
undo.setUndoState('url', window.location.href);
});
this.handleWindowResize = this.handleWindowResize.bind(this);
}
return cleanup;
}, []);
handleWindowResize() {
this.setState({
isMobile: isMobile(),
windowWidth: window.innerWidth,
});
}
componentDidMount() {
useEffect(() => {
// TODO: quick hack fix for showing the demo
if (this.history.location.pathname === '/subscribe') {
this.history.push('/');
if (patchedHistory.location.pathname === '/subscribe') {
patchedHistory.push('/');
}
// 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
this.props.getAccounts().then(accounts => {
props.getAccounts().then(accounts => {
if (accounts.length === 0) {
this.history.push('/accounts');
patchedHistory.push('/accounts');
}
});
@@ -261,96 +261,84 @@ class FinancesApp extends React.Component {
// Wait a little bit to make sure the sync button will get the
// sync start event. This can be improved later.
setTimeout(async () => {
await this.props.sync();
await props.sync();
// Check for upgrade notifications. We do this after syncing
// because these states are synced across devices, so they will
// only see it once for this file
checkForUpgradeNotifications(
this.props.addNotification,
this.props.resetSync,
this.history,
props.addNotification,
props.resetSync,
patchedHistory,
);
}, 100);
setTimeout(async () => {
await this.props.sync();
await checkForUpdateNotification(
this.props.addNotification,
props.addNotification,
getIsOutdated,
getLatestVersion,
this.props.loadPrefs,
this.props.savePrefs,
props.loadPrefs,
props.savePrefs,
);
}, 100);
}, []);
window.addEventListener('resize', this.handleWindowResize);
}
return (
<Router history={patchedHistory}>
<CompatRouter>
<View style={{ height: '100%', backgroundColor: colors.n10 }}>
<GlobalKeys />
componentWillUnmount() {
this.cleanup();
window.removeEventListener('resize', this.handleWindowResize);
}
render() {
return (
<Router history={this.history}>
<CompatRouter>
<View style={{ height: '100%', backgroundColor: colors.n10 }}>
<GlobalKeys />
<View style={{ flexDirection: 'row', flex: 1 }}>
{!this.state.isMobile && <FloatableSidebar />}
<View style={{ flexDirection: 'row', flex: 1 }}>
<FloatableSidebar />
<View
style={{
flex: 1,
overflow: 'hidden',
width: '100%',
}}
>
<Titlebar
style={{
WebkitAppRegion: 'drag',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
overflow: 'auto',
position: 'relative',
width: '100%',
}}
>
{!this.state.isMobile && (
<Titlebar
style={{
WebkitAppRegion: 'drag',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
)}
<div
style={{
flex: 1,
display: 'flex',
overflow: 'auto',
position: 'relative',
}}
>
<Notifications />
<BankSyncStatus />
<StackedRoutes isMobile={this.state.isMobile} />
<Modals history={this.history} />
</div>
{this.state.isMobile && (
<Switch>
<Route path="/budget" component={MobileNavTabs} />
<Route path="/accounts" component={MobileNavTabs} />
<Route path="/settings" component={MobileNavTabs} />
</Switch>
)}
<Notifications />
<BankSyncStatus />
<StackedRoutes />
<Modals history={patchedHistory} />
</div>
<Switch>
<Route path="/budget">
<MobileNavTabs />
</Route>
<Route path="/accounts">
<MobileNavTabs />
</Route>
<Route path="/settings">
<MobileNavTabs />
</Route>
</Switch>
</View>
</View>
</CompatRouter>
</Router>
);
}
</View>
</CompatRouter>
</Router>
);
}
function FinancesAppWithContext(props) {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { createRef, PureComponent } from 'react';
import memoizeOne from 'memoize-one';
@@ -15,7 +15,7 @@ function ResizeObserver({ onResize, children }) {
return children(ref);
}
export class FixedSizeList extends React.PureComponent {
export class FixedSizeList extends PureComponent {
_outerRef;
_resetIsScrollingTimeoutId = null;
@@ -31,9 +31,9 @@ export class FixedSizeList extends React.PureComponent {
constructor(props) {
super(props);
this.lastPositions = React.createRef();
this.lastPositions = createRef();
this.lastPositions.current = new Map();
this.needsAnimationRerender = React.createRef();
this.needsAnimationRerender = createRef();
this.needsAnimationRerender.current = false;
this.animationEnabled = false;

View File

@@ -1,34 +1,29 @@
import React, { useState, useEffect, useContext } from 'react';
import { connect } from 'react-redux';
import React, { createContext, useState, useContext, useMemo } from 'react';
import { connect, useSelector } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { useViewportSize } from '@react-aria/utils';
import mitt from 'mitt';
import * as actions from 'loot-core/src/client/actions';
import { colors } from '../style';
import { breakpoints } from '../tokens';
import { useResponsive } from '../ResponsiveProvider';
import { View } from './common';
import { SIDEBAR_WIDTH } from './sidebar';
import SidebarWithData from './SidebarWithData';
const SidebarContext = React.createContext(null);
const SidebarContext = createContext(null);
export function SidebarProvider({ children }) {
let emitter = mitt();
let floatingSidebar = useSelector(
state => state.prefs.global.floatingSidebar,
);
let [hidden, setHidden] = useState(true);
let { width } = useResponsive();
let alwaysFloats = width < 668;
let floating = floatingSidebar || alwaysFloats;
return (
<SidebarContext.Provider
value={{
show: () => emitter.emit('show'),
hide: () => emitter.emit('hide'),
toggle: () => emitter.emit('toggle'),
on: (name, listener) => {
emitter.on(name, listener);
return () => emitter.off(name, listener);
},
}}
value={{ hidden, setHidden, floating, alwaysFloats }}
>
{children}
</SidebarContext.Provider>
@@ -36,96 +31,56 @@ export function SidebarProvider({ children }) {
}
export function useSidebar() {
return useContext(SidebarContext);
let { hidden, setHidden, floating, alwaysFloats } =
useContext(SidebarContext);
return useMemo(
() => ({ hidden, setHidden, floating, alwaysFloats }),
[hidden, setHidden, floating, alwaysFloats],
);
}
function Sidebar({ floatingSidebar }) {
let [hidden, setHidden] = useState(true);
let sidebar = useSidebar();
let { isNarrowWidth } = useResponsive();
let windowWidth = useViewportSize().width;
let sidebarShouldFloat = floatingSidebar || windowWidth < breakpoints.medium;
let sidebarShouldFloat = floatingSidebar || sidebar.alwaysFloats;
if (!sidebarShouldFloat && hidden) {
setHidden(false);
}
useEffect(() => {
let cleanups = [
sidebar.on('show', () => setHidden(false)),
sidebar.on('hide', () => setHidden(true)),
sidebar.on('toggle', () => setHidden(hidden => !hidden)),
];
return () => {
cleanups.forEach(fn => fn());
};
}, [sidebar]);
return (
<>
{sidebarShouldFloat && (
<View
onMouseOver={() => setHidden(false)}
onMouseLeave={() => setHidden(true)}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: hidden ? 0 : 160,
zIndex: 999,
}}
></View>
)}
<View
onMouseOver={
sidebarShouldFloat
? e => {
e.stopPropagation();
setHidden(false);
}
: null
}
onMouseLeave={sidebarShouldFloat ? () => setHidden(true) : null}
style={{
position: 'absolute',
top: 50,
// If not floating, the -50 takes into account the transform below
bottom: sidebarShouldFloat ? 50 : -50,
zIndex: 1001,
borderRadius: '0 6px 6px 0',
overflow: 'hidden',
boxShadow:
!sidebarShouldFloat || hidden
? 'none'
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
transform: `translateY(${!sidebarShouldFloat ? -50 : 0}px)
translateX(${hidden ? -SIDEBAR_WIDTH : 0}px)`,
transition: 'transform .5s, box-shadow .5s',
}}
>
<SidebarWithData />
</View>
<View
style={[
{
backgroundColor: colors.n1,
opacity: sidebarShouldFloat ? 0 : 1,
transform: `translateX(${sidebarShouldFloat ? -50 : 0}px)`,
transition: 'transform .4s, opacity .2s',
width: SIDEBAR_WIDTH,
},
sidebarShouldFloat && {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
},
]}
></View>
</>
return isNarrowWidth ? null : (
<View
onMouseOver={
sidebarShouldFloat
? e => {
e.stopPropagation();
sidebar.setHidden(false);
}
: null
}
onMouseLeave={sidebarShouldFloat ? () => sidebar.setHidden(true) : null}
style={{
position: sidebarShouldFloat ? 'absolute' : null,
top: 12,
// If not floating, the -50 takes into account the transform below
bottom: sidebarShouldFloat ? 12 : -50,
zIndex: 1001,
borderRadius: sidebarShouldFloat ? '0 6px 6px 0' : 0,
overflow: 'hidden',
boxShadow:
!sidebarShouldFloat || sidebar.hidden
? 'none'
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
transform: `translateY(${!sidebarShouldFloat ? -12 : 0}px)
translateX(${
sidebarShouldFloat && sidebar.hidden
? -SIDEBAR_WIDTH
: 0
}px)`,
transition:
'transform .5s, box-shadow .5s, border-radius .5s, bottom .5s',
}}
>
<SidebarWithData />
</View>
);
}

View File

@@ -12,17 +12,17 @@ export default function GlobalKeys() {
}
if (e.metaKey) {
switch (e.code) {
case 'Digit1':
switch (e.key) {
case '1':
history.push('/budget');
break;
case 'Digit2':
case '2':
history.push('/reports');
break;
case 'Digit3':
case '3':
history.push('/accounts');
break;
case 'Comma':
case ',':
if (Platform.OS === 'mac') {
history.push('/settings');
}

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useContext } from 'react';
import React, { createContext, useEffect, useContext } from 'react';
import hotkeys from 'hotkeys-js';
import hotkeys, { type KeyHandler as HotKeyHandler } from 'hotkeys-js';
let KeyScopeContext = React.createContext('app');
let KeyScopeContext = createContext('app');
hotkeys.filter = event => {
var target = event.target || event.srcElement;
var tagName = target.tagName;
let target = (event.target || event.srcElement) as HTMLElement;
let tagName = target.tagName;
// This is the default behavior of hotkeys, except we only suppress
// key presses if the meta key is not pressed
@@ -16,7 +16,7 @@ hotkeys.filter = event => {
((tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT') &&
!target.readOnly))
!target['readOnly']))
) {
return false;
}
@@ -24,7 +24,16 @@ hotkeys.filter = event => {
return true;
};
export function KeyHandler({ keyName, eventType = 'keydown', handler }) {
type KeyHandlerProps = {
keyName: string;
eventType?: string;
handler: HotKeyHandler;
};
export function KeyHandler({
keyName,
eventType = 'keydown',
handler,
}: KeyHandlerProps) {
let scope = useContext(KeyScopeContext);
if (eventType !== 'keyup' && eventType !== 'keydown') {
@@ -44,6 +53,7 @@ export function KeyHandler({ keyName, eventType = 'keydown', handler }) {
hotkeys(keyName, { scope, keyup: true }, _handler);
return () => {
// @ts-expect-error unbind args typedef does not expect an object
hotkeys.unbind({
key: keyName,
scope,
@@ -55,7 +65,11 @@ export function KeyHandler({ keyName, eventType = 'keydown', handler }) {
return null;
}
export function KeyHandlers({ eventType, keys = {} }) {
type KeyHandlersProps = {
eventType?: string;
keys: Record<string, HotKeyHandler>;
};
export function KeyHandlers({ eventType, keys = {} }: KeyHandlersProps) {
let handlers = Object.keys(keys).map(key => {
return (
<KeyHandler
@@ -67,5 +81,6 @@ export function KeyHandlers({ eventType, keys = {} }) {
);
});
return handlers;
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{handlers}</>;
}

View File

@@ -1,4 +1,6 @@
import React, {
forwardRef,
memo,
useState,
useEffect,
useRef,
@@ -297,7 +299,7 @@ export function ActionExpression({ field, op, value, options, style }) {
);
}
let Rule = React.memo(
let Rule = memo(
({
rule,
hovered,
@@ -327,8 +329,8 @@ let Rule = React.memo(
<SelectCell
exposed={hovered || selected || editing}
focused={focusedField === 'select'}
onSelect={() => {
dispatchSelected({ type: 'select', id: rule.id });
onSelect={e => {
dispatchSelected({ type: 'select', id: rule.id, event: e });
}}
onEdit={() => onEdit(rule.id, 'select')}
selected={selected}
@@ -411,7 +413,7 @@ let Rule = React.memo(
},
);
let SimpleTable = React.forwardRef(
let SimpleTable = forwardRef(
(
{ data, navigator, loadMore, style, onHoverLeave, children, ...props },
ref,
@@ -476,7 +478,7 @@ function RulesHeader() {
exposed={true}
focused={false}
selected={selectedItems.size > 0}
onSelect={() => dispatchSelected({ type: 'select-all' })}
onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
/>
<Cell value="Stage" width={50} />
<Cell value="Rule" width="flex" />

View File

@@ -3,8 +3,8 @@ import { useDispatch, useSelector } from 'react-redux';
import { savePrefs } from 'loot-core/src/client/actions';
import { useResponsive } from '../ResponsiveProvider';
import { colors, styles } from '../style';
import { isMobile } from '../util';
import { View, Text, Button } from './common';
import { Checkbox } from './forms';
@@ -16,8 +16,10 @@ export default function MobileWebMessage() {
return (state.prefs.local && state.prefs.local.hideMobileMessage) || true;
});
const { isNarrowWidth } = useResponsive();
let [show, setShow] = useState(
isMobile() &&
isNarrowWidth &&
!hideMobileMessagePref &&
!document.cookie.match(/hideMobileMessage=true/),
);

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