Compare commits

...

69 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
f9a1b094cc 🔖 23.4.0 (#863) 2023-04-06 22:04:12 +01:00
Jed Fox
5d559afe30 Enable linting for all packages (#861)
<!-- 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-06 15:49:43 -04:00
Matiss Janis Aboltins
4d0e9cadd3 🐛 (autocomplete) height of the input box (#862) 2023-04-06 20:44:15 +01:00
Matiss Janis Aboltins
bfe896a30e (autocomplete) turn on feature flag by default (#850) 2023-04-06 18:28:42 +01:00
chris heazlewood
44573c0fe5 Store payee in imported_payee during ynab4/ynab5 import (#736) 2023-04-06 17:49:24 +01:00
Matiss Janis Aboltins
7fbb26f2c9 🐛 (autocomplete) allow editing selected payee name (#856) 2023-04-05 22:44:48 +01:00
Jed Fox
143ddeaa96 Force the sidebar to always float when the window is narrow (#835)
This improves usability of narrow screen widths, and also prepares for
further optimizations that would allow us to use the sidebar largely
as-is on mobile, instead of having to have a tab bar.

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2023-04-05 17:25:57 -04:00
Matiss Janis Aboltins
61f1802840 🐛 dismiss update notification only after clicking close (#854)
Closes #764

Dismiss the update notification only after clicking "close" button
2023-04-04 21:18:12 +01:00
Matiss Janis Aboltins
e23f9d822b 🐛 normalize value when switching between single/multi select (#855)
Closes #779

Normalize the input value when switching between single/multi select
fields. Most visible when using the "notes" field filter.
2023-04-04 21:17:47 +01:00
Jed Fox
6ef1f3d15d Fix display of loading indicator in management app (#853)
Fixes #852.
2023-04-04 13:48:30 -04:00
Matiss Janis Aboltins
0dd5536914 🐛 navigation back to config-server page after clicking on "no server" (#851)
Closes #842

---------

Co-authored-by: Jed Fox <git@jedfox.com>
2023-04-04 18:34:59 +01:00
Matiss Janis Aboltins
072c3504fe Ported NewAutocomplete to TypeScript (#831)
Small migration of `NewAutocomplete`.

---------

Co-authored-by: Jed Fox <git@jedfox.com>
2023-04-04 18:06:42 +01:00
Jed Fox
5d921f7ab1 Update to latest stable date-fns version (#849)
Previously, we were using an alpha version of date-fns v2. Now we’re
using the latest stable v2.
2023-04-03 14:28:24 -04:00
Jed Fox
267bd8cc07 Remove Safari pinned tab icon (#848)
Safari now supports real favicons, and the existing icon was just a
solid circle which isn’t very clear.

Before/After:
<img width="139" alt="Screenshot_2023-04-03 14 05 06"
src="https://user-images.githubusercontent.com/25517624/229590886-b814d622-6780-4222-804c-edc84c43a2f6.png">
<img width="140" alt="Screenshot_2023-04-03 14 06 56"
src="https://user-images.githubusercontent.com/25517624/229591285-d808fa92-6141-4a23-8e50-e1f46e5b3d03.png">
2023-04-03 14:24:59 -04:00
Alberto Gasparin
79ad04dd88 Convert loot-core to TS p1 (#841)
Part 1 of the conversion. Mostly renaming js to ts and making sure
things make still sense. Added also handy TS ESLint rules.

In order to support the various .web/.electron/... I ended up adopting
`index.d.ts` as pattern to share type definition. Let me know if that
makes sense for you too. Right now the function type definition is
duplicated, but the solution will be importing from `index.d.ts` and
using `const fn: FnDef = () => ...` that way we can keep all variants in
sync from a single type file.

Such rewrite however is better done in another PR otherwise we risk
confusing git and loosing history (rename + too many changes). Another
thing that might do in the next PR is convert all files to ESModules, as
things get confusing between CJS exports, ESM default/named and TS adds
extra complains.
2023-04-03 10:29:59 -04:00
Matiss Janis Aboltins
405a92e926 (e2e) improve stability of budget e2e tests (#845)
Sometimes the test failed because..


`parseFloat("1,234.55") === 1)`
2023-04-02 20:21:22 +01:00
Matiss Janis Aboltins
69140d6290 🐛 (autocomplete) remove portal from table view for smoother experience (#839) 2023-04-01 20:13:09 +01:00
Matiss Janis Aboltins
e8da21fc80 🐛 (autocomplete) set min-height used to calculate the flip (top-bottom) (#837) 2023-04-01 08:10:40 +01:00
Matiss Janis Aboltins
ad9a4067a8 🐛 (autocomplete) delay when switching from Select to CreatableSelect (#836) 2023-03-31 22:17:54 +01:00
Matiss Janis Aboltins
7e6b760796 ♻️ (bank-sync) improved UX for linking nordigen accounts (#792)
Improving the UX for Nordigen bank-sync account import modal.
2023-03-31 20:33:49 +01:00
Matiss Janis Aboltins
45c06c2303 🐛 (autocomplete) set min-width of the autocomplete (#834)
Implement min-width for the autocomplete to make it look better on small
screens.


https://github.com/actualbudget/actual/issues/773#issuecomment-1482254636

<img width="304" alt="Screenshot 2023-03-31 at 19 02 06"
src="https://user-images.githubusercontent.com/886567/229195868-7d858f18-0c1a-4a9d-95be-5dd0e4aef92c.png">
2023-03-31 19:15:42 +01:00
Jed Fox
440093b30a Allow data: URLs for images in Netlify deploys (#832) 2023-03-31 12:28:34 -05:00
Alberto Gasparin
f76a07c3cf Add "all" or "any" conditions in rules (#811) 2023-03-29 22:29:07 +01:00
Alberto Gasparin
c009a0c7fb Start Typescript inception (#816) 2023-03-28 16:49:51 -05:00
Jed Fox
c025e516fb Fix error when running importTransactions from the API (#819) 2023-03-27 11:26:01 -05:00
Matiss Janis Aboltins
649b4c90e0 (e2e) adding onboarding and budget tests (#813)
Added onboarding and budget e2e tests. Also fixed an issue for
first-time flows using imports: currently people end up with a blank
white screen after importing. Instead they should see the budget table.

Related: https://github.com/actualbudget/actual/issues/583
2023-03-26 18:25:22 +01:00
Aidan Harbison
28c6894021 transaction-import: treat (amount) as -amount (#808)
- When parsing an amount string, consider surrounding parentheses to
mean the amount is negative.
- Ensures all input to `parseFloat()` is sanitized.

Closes: #807
2023-03-24 17:57:50 +00:00
Jed Fox
e71d4dc680 Move all loot-design code into desktop-client (#800)
Seems to build fine, will test later.
2023-03-24 13:56:09 -04:00
Matiss Janis Aboltins
ba778c9e9f (bank-sync) add explicit nordigen bank-sync warning (#801)
Adding an explicit bank-sync warning disclaimer.
#724 

<img width="542" alt="Screenshot 2023-03-21 at 20 09 54"
src="https://user-images.githubusercontent.com/886567/226729803-29606532-3d9f-4114-8987-9612bd92181b.png">
2023-03-23 12:23:56 +00:00
Alberto Gasparin
605a0d82ed Allow rules creation from account page (#802)
Adding a "create rule" menu item in the transactions dropdown to open
the create new rule modal, pre-filled with the payee information.
Fixes #749
2023-03-23 08:14:25 -04:00
Matiss Janis Aboltins
edf2122059 🐛 (dev-server) retry loading backend script in web-worker (#806)
Co-authored-by: Jed Fox <git@jedfox.com>
2023-03-22 19:59:52 +00:00
Matiss Janis Aboltins
889ca322f1 📝 remove rich from core contributors (#803)
😢
2023-03-21 23:27:48 +00:00
Tomek Modrzyński
070bd212c5 Re-enable goal templates by passing flag values to the budget summary component (#797) 2023-03-21 18:23:40 -04:00
Jed Fox
8e94d1777b Improve visual consistency on the settings page (#799)
This PR improves the consistency of the settings UI by moving everything
(except the budget name field on mobile) into `<Setting>` boxes.
Additionally, it adds a “Settings” option to the file dropdown menu (I
keep expecting it to be there, and I think it’s reasonable to expose it
in both locations so it’s easier to access)
2023-03-21 16:11:18 -04:00
Jed Fox
3cb18683c6 nit: use curly apostrophes throughout the UI (#791) 2023-03-21 13:48:31 -04:00
Jakub Kuczys
6c01e6eaaf Accept .blob files when importing Actual export (#785)
I'm not sure if this is something you want but it was a simple change so
I figured I might as well contribute it. This PR allows the user to
upload `.blob` files that they may have gotten from server's
`user-files/` folder. This can be useful if the user didn't export the
file but has server backups.

---------

Co-authored-by: Jed Fox <git@jedfox.com>
2023-03-21 09:39:54 -04:00
Matiss Janis Aboltins
84cbe6e54c 💚 disabling flaky unit test step (#795)
Disabling the flaky unit test step. We should re-enable it eventually,
but right now it just creates unnecessary noise..
2023-03-20 19:44:05 +00:00
Jed Fox
181d088e76 Match spacing in autocomplete (#793) 2023-03-20 14:51:42 -04:00
Matiss Janis Aboltins
5a75befc05 (bank-sync) expose demo-bank in the UI for DEV & preview builds (#790)
Depends on server change:
https://github.com/actualbudget/actual-server/pull/168
2023-03-20 18:37:21 +00:00
Matiss Janis Aboltins
c099e1ff10 🐛 (autocomplete) consistent input height between multi/single input (#787)
Making consistent height between multi/single input autocomplete.
2023-03-19 17:20:04 +00:00
Piero Mamberti
15e6843acf 753 - Clarify Account type cannot be changed (#774) 2023-03-19 08:33:13 -04:00
Matiss Janis Aboltins
e7bfd35b9a (autocomplete) enable new version for dev/preview deploys (#789)
Enabling the new autocomplete for dev/preview deployments.

This will allow us to spot any more issues there might be before we
release the new autocomplete.

https://github.com/actualbudget/actual/issues/773
2023-03-18 20:48:37 +00:00
Matiss Janis Aboltins
1df7acdca7 ♻️ refactor Nordigen and Category autocomplete usage (#784)
The final `Autocomplete` refactors. After this is merged what's
remaining is to do extensive testing and address the bugs in
https://github.com/actualbudget/actual/issues/773

This PR moves `Nordigen` autocomplete to the new one without using a
feature flag. IMO this is a safe change given the simple nature of the
Nordigen autocomplete component.
2023-03-18 20:30:01 +00:00
Matiss Janis Aboltins
67c3be97a1 ♻️ move all feature flags to use useFeatureFlag hook (#786)
Refactored all feature flags to use the new `useFeatureFlag` hook.

Also added a new functionality to this feature flag: ability to define
custom "default" value for a feature flag. This will allow us to enable
the new autocomplete component for everyone using Netlify builds
eventually (need to address some issues before doing so).
2023-03-18 18:41:45 +00:00
Jed Fox
8def8393da Remove a few unused class components, convert a few components to functions (#783) 2023-03-18 10:59:24 -04:00
Matiss Janis Aboltins
c3c2861dbd ♻️ further autocomplete refactors (#778)
Further iterations on the new autocomplete.

1. Created `AccountAutocomplete`
2. Started using new autocomplete in `GenericInput` (used for notes
field)
3. Extracted common functionality between the three new autocompletes to
a generic component: `Autocomplete`
2023-03-18 14:25:24 +00:00
Jed Fox
97b1b6f815 Improve handling of large currency amounts (#725)
- Add a “hide decimal places” setting to visually hide the `.xx` from
currency values globally
- When hiding the fractional digits, slightly decrease character spacing
to allow more digits to show up

Ref: #327

New settings:
<img width="566" alt="Screenshot_2023-03-17 14 19 46"
src="https://user-images.githubusercontent.com/25517624/225986815-b884b93d-02f9-48b3-a73d-d27f90b678cf.png">


Before/after:
<img width="149" alt="Screenshot_2023-02-27 21 47 07"
src="https://user-images.githubusercontent.com/25517624/222916856-21ab4f03-56c6-4b24-8fc1-ac4b883138b7.png"><img
width="131" alt="Screenshot_2023-02-27 22 02 01"
src="https://user-images.githubusercontent.com/25517624/222916859-cf882ca3-6087-4994-818e-239c3374e412.png">
2023-03-18 09:41:38 -04:00
Matiss Janis Aboltins
7063af9e58 revert change to useTableNavigator (#775) 2023-03-18 13:28:31 +00:00
Jed Fox
beef97d7b8 Move the welcome modal to an interstitial, add import button (#762)
I noticed that the first run flow is suboptimal for people who want to
import an existing file from Actual/YNAB. I’ve moved the welcome modal
into the management app and set it up to appear when there are no
budgets available (which also has the benefit of allowing people to see
the modal again!)

I think there’s some weirdness around getting the modal to reappear when
deleting a budget file which I want to work out before merging this.

This PR also reorganizes the management app a bit to reduce usage of
modals (currently, hitting escape while the budget list is open leaves
you with a blank page).

<img width="539" alt="Screenshot_2023-03-18 08 53 54"
src="https://user-images.githubusercontent.com/25517624/226107462-b2b88791-1015-4397-b290-c64e7fcc0f41.png">

- [x] Ensure modal consistently appears when needed (no longer a modal!)
- [x] Fix e2e tests
2023-03-18 09:21:53 -04:00
Matiss Janis Aboltins
98948744ca change unit test usage of notes field (#780)
Added an extra `waitFor` after a flaky unit test step.

I'm not really super happy with this workaround.. but it does make the
test much more stable (re-ran 5x and no failures:
https://github.com/actualbudget/actual/actions/runs/4455134799).

I think there is some internal timeout happening somewhere which is
causing this issue.. But not really sure where. And this will hopefully
get auto-fixed once we migrate to a new table. 🤞
2023-03-18 12:44:55 +00:00
Matiss Janis Aboltins
2903fd0037 🔥 remove unused tableNavigatorOpts code-path (#781)
Just cleaning up things: removing an unused code-path.
2023-03-18 12:16:24 +00:00
Matiss Janis Aboltins
ce40e61ab7 🐛 (TransactionsTable) bring back missing onHover (#777)
Brining back `onHover`. This is a small regression.
2023-03-18 11:09:03 +00:00
Matiss Janis Aboltins
fc9ca18f1c ⬆️ finish react v18 upgrade: react-dom change (#776)
Finishing off the React v18 upgrade by doing a change in `react-dom`.
Effectively this upgrades from v17 to v18.

https://react.dev/blog/2022/03/08/react-18-upgrade-guide
2023-03-17 23:19:35 +00:00
Matiss Janis Aboltins
141035cdf0 ♻️ (autocomplete) refactor PayeeAutocomplete to react-select (#741) 2023-03-17 22:36:53 +00:00
Matiss Janis Aboltins
610a044f5f ♻️ (TransactionsTable) port to react hooks (#769) 2023-03-17 21:20:20 +00:00
Matiss Janis Aboltins
26363ed82d ⬆️ upgrade fast-check to improve unit test perf (#772)
Upgraded `fast-check` to improve unit test performance.
2023-03-17 21:20:01 +00:00
Matiss Janis Aboltins
815413e48c reducing flakiness of tests by removing randomization (#771)
This is not a full fix for the flakiness. One of the test cases will
still be flaky. But at least this fixes the other test cases thus
improving stability.
2023-03-17 18:37:02 +00:00
Jed Fox
4a3fe1d9fb node-libofx: add transaction_acct_name function (#670)
I am currently not working on adding support for importing to multiple
accounts, but I wanted to give anyone who takes that on a starting point
by updating the underlying C library to provide access to the account
name field.
2023-03-17 14:00:35 -04:00
Jed Fox
c5c4cbbeb2 Update wording across the UI to clarify that we don’t own any servers (#768) 2023-03-17 13:59:10 -04:00
Matiss Janis Aboltins
5d7ead44aa ⬆️ upgrade React from v16 to v18 (#696) 2023-03-17 12:10:40 +00:00
Matiss Janis Aboltins
d9bc64e792 🐛 making desktop-client tests independent (#765)
Tests cases should be independent. You should be able to run them in
whatever order you want. And they should still pass.

Currently this is not the case. The order of the tests is very important
due to the "pseudo" randomization algorithm.

This PR makes the mock data IDs truly unique thus better exposing the
issue in our tests. Also this PR fixes the dependency issues thus making
each test case truly independent.

---------

Co-authored-by: Jed Fox <git@jedfox.com>
2023-03-16 23:49:43 +00:00
Jed Fox
d166d8f8e8 Disable ESLint when building in CI (#763)
This way formatting issues won’t prevent the preview from building (and
it should build a bit faster due to not having to run ESLint).
2023-03-16 14:41:57 -04:00
Matiss Janis Aboltins
ad08494899 🔥 remove Debugger, perf-deets and codemirror (#755)
Removing
- Debugger
- `perf-deeets`
- `codemirror`
2023-03-16 18:36:15 +00:00
Jed Fox
2762495a68 Fix end-to-end testing workflow (#758) 2023-03-14 19:33:15 -04:00
Jed Fox
d25c31089c Make goal template keywords case insensitive (#756) 2023-03-14 16:02:06 -04:00
Jed Fox
96c7af0c8d Fix #template 0 causing an error (#751)
Thanks @kidglove57 for spotting this issue and helping me track down the
cause!

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2023-03-14 14:52:12 -04:00
Jed Fox
319679fd65 Add support for automatically generating release notes (#746)
See https://github.com/actualbudget/docs/pull/129 for more details. If
this is accepted, I’ll fill in release notes for the PRs that have been
submitted since the last release and submit a corresponding PR to
`actual-server`.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-14 14:18:14 -04:00
Jed Fox
c7e531a26c Reduce client build size by 1MB (#750)
before:

```
kcab.worker.4bdc73a8d45eb2115156.js (2.1 MiB)
xfo.kcab.worker.4bdc73a8d45eb2115156.js (1010 KiB)
```

after:

```
kcab.worker.39f5fba82d7bc7477962.js (1.41 MiB)
xfo.kcab.worker.39f5fba82d7bc7477962.js (1000 KiB)
```

What’s changed:

- `loot-core` did not have a `browserslist` config, so
`@babel/preset-env` assumes we want to [transpile all the way back to
ES5](https://babeljs.io/docs/options#no-targets). I’ve removed the
`browserslist` config from each of the `package.json` files and moved it
to the root so this doesn’t happen again.
- I updated the target from `electron 3.0` to `electron 12.0` to match
our Electron dependency
- I’ve added `defaults` (currently equivalent to `> 0.5%, last 2
versions, Firefox ESR, not dead`) which is [recommended by
browserslist](https://browsersl.ist/#q=defaults). We could consider
tightening this, but it doesn’t offer a ton of space savings at this
point to just target Electron 12.
- Since much less transpilation will be happening, stack traces (dev and
prod) will be much easier to read!
2023-03-14 13:55:39 -04:00
Waseem Hassan Shahid
21f0644987 fix(nordigen/sync): Use bookingDate as fallback during sync (#754)
Fixes
https://github.com/actualbudget/actual/issues/724#issuecomment-1468453526

And add the missing fallback condition that wasn't catered in
https://github.com/actualbudget/actual/pull/745
2023-03-14 17:50:25 +00:00
1094 changed files with 5499 additions and 5196 deletions

View File

@@ -1,6 +1,17 @@
const path = require('path');
const rulesDirPlugin = require('eslint-plugin-rulesdir');
rulesDirPlugin.RULES_DIR = path.join(
__dirname,
'packages',
'eslint-plugin-actual',
'lib',
'rules',
);
module.exports = {
plugins: ['prettier', 'import'],
extends: ['react-app'],
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
extends: ['react-app', 'plugin:@typescript-eslint/recommended'],
reportUnusedDisableDirectives: true,
rules: {
'prettier/prettier': 'error',
@@ -17,6 +28,8 @@ module.exports = {
require('confusing-browser-globals').filter(g => g !== 'self'),
),
'rulesdir/typography': 'error',
// https://github.com/eslint/eslint/issues/16954
// https://github.com/eslint/eslint/issues/16953
'no-loop-func': 'off',
@@ -53,5 +66,11 @@ module.exports = {
pathGroupsExcludedImportTypes: ['react'],
},
],
// Rules disable during TS migration
'@typescript-eslint/no-var-requires': 'off',
'prefer-const': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
};

1
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1 @@
<!-- 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 -->

View File

@@ -0,0 +1,14 @@
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

View File

@@ -15,24 +15,16 @@ jobs:
uses: ./.github/actions/setup
- name: Setup Playwright
run: npx playwright install chromium --with-deps
- name: Wait for Pages changed to neutral
uses: fountainhead/action-wait-for-check@v1.1.0
id: wait-for-Netlify
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
checkName: 'Pages changed - actualbudget'
- name: Waiting for Netlify Preview
if: steps.wait-for-Netlify.outputs.conclusion == 'neutral'
uses: jakepartusch/wait-for-netlify-action@v1.4
id: waitFor200
with:
site_name: 'actualbudget'
max_timeout: 240
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./bin/netlify-wait-for-build
- name: Run E2E Tests on Netlify URL
run: yarn e2e
env:
E2E_START_URL: https://deploy-preview-${{env.GITHUB_PR_NUMBER}}--actualbudget.netlify.app
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- uses: actions/upload-artifact@v3
if: always()
with:

View File

@@ -0,0 +1,17 @@
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 }}

18
.github/workflows/typecheck.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
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

View File

@@ -21,9 +21,14 @@ Here are some initial guidelines for how contributions will be treated:
- @j-f1
- @jlongster
- @MatissJanis
- @rich-howell
- @trevdor
## Alumni
(sorted alphabetically)
- @rich-howell
## Project ideas
We welcome all contributions from the community. If you have an idea for a feature you want to build - please go ahead and submit a PR with the implementation or if it's a larger feature - open a new issue so we can discuss it.

40
bin/netlify-wait-for-build Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
current_commit=$(git rev-parse HEAD)
echo "Running on commit $COMMIT_SHA"
function get_status() {
echo "::group::API Response"
curl --header "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/actualbudget/actual/commits/$COMMIT_SHA/statuses" > /tmp/status.json
cat /tmp/status.json
echo "::endgroup::"
netlify=$(jq '[.[] | select(.context == "netlify/actualbudget/deploy-preview")][0]' /tmp/status.json)
state=$(jq -r '.state' <<< "$netlify")
echo "::group::Netlify Status"
echo "$netlify"
echo "::endgroup::"
}
get_status
while [ "$netlify" == "null" ]; do
echo "Waiting for Netlify to start building..."
sleep 10
get_status
done
while [ "$state" == "pending" ]; do
echo "Waiting for Netlify to finish building..."
sleep 10
get_status
done
if [ "$state" == "success" ]; then
echo -e "\033[0;32mNetlify build succeeded!\033[0m"
jq -r '"url=" + .target_url' <<< "$netlify" > $GITHUB_OUTPUT
exit 0
else
echo -e "\033[0;31mNetlify build failed. Cancelling end-to-end tests.\033[0m"
exit 1
fi

View File

@@ -25,27 +25,35 @@
"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",
"build:browser": "./bin/package-browser",
"test": "yarn workspaces foreach --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --verbose run test",
"e2e": "yarn workspaces foreach --parallel --verbose run e2e",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "cross-env NODE_ENV=development yarn workspaces foreach --verbose run lint --max-warnings 0",
"typecheck": "yarn tsc",
"postinstall": "patch-package"
},
"devDependencies": {
"cross-env": "^5.1.5",
"eslint": "8.35.0",
"eslint": "^8.37.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-rulesdir": "^0.2.2",
"npm-run-all": "^4.1.3",
"patch-package": "^6.1.2",
"prettier": "2.8.2",
"react-refresh": "^0.14.0",
"source-map-support": "^0.5.21"
"source-map-support": "^0.5.21",
"typescript": "^5.0.2"
},
"resolutions": {
"react-error-overlay": "6.0.9"
},
"packageManager": "yarn@3.4.1"
"packageManager": "yarn@3.4.1",
"browserslist": [
"electron 12.0",
"defaults"
]
}

View File

@@ -0,0 +1 @@
app/bundle.api.js

View File

@@ -11,14 +11,14 @@ class Query {
validateRefs: true,
limit: null,
offset: null,
...state
...state,
};
}
filter(expr) {
return new Query({
...this.state,
filterExpressions: [...this.state.filterExpressions, expr]
filterExpressions: [...this.state.filterExpressions, expr],
});
}
@@ -27,8 +27,8 @@ class Query {
return new Query({
...this.state,
filterExpressions: this.state.filterExpressions.filter(
expr => !exprSet.has(Object.keys(expr)[0])
)
expr => !exprSet.has(Object.keys(expr)[0]),
),
});
}
@@ -55,7 +55,7 @@ class Query {
return new Query({
...this.state,
groupExpressions: [...this.state.groupExpressions, ...exprs]
groupExpressions: [...this.state.groupExpressions, ...exprs],
});
}
@@ -66,7 +66,7 @@ class Query {
return new Query({
...this.state,
orderExpressions: [...this.state.orderExpressions, ...exprs]
orderExpressions: [...this.state.orderExpressions, ...exprs],
});
}
@@ -99,24 +99,6 @@ class Query {
}
}
function getPrimaryOrderBy(query, defaultOrderBy) {
let orderExprs = query.serialize().orderExpressions;
if (orderExprs.length === 0) {
if (defaultOrderBy) {
return { order: 'asc', ...defaultOrderBy };
}
return null;
}
let firstOrder = orderExprs[0];
if (typeof firstOrder === 'string') {
return { field: firstOrder, order: 'asc' };
}
// Handle this form: { field: 'desc' }
let [field] = Object.keys(firstOrder);
return { field, order: firstOrder[field] };
}
module.exports = function q(table) {
return new Query({ table });
};

View File

@@ -105,10 +105,6 @@ function deleteAccount(id) {
return send('api/account-delete', { id });
}
function getCategoryGroups() {
return send('api/categories-get', { grouped: true });
}
function createCategoryGroup(group) {
return send('api/category-group-create', { group });
}

View File

@@ -6,7 +6,7 @@ export default async function runMigration(db, uuid) {
db.execQuery(`
CREATE TABLE zero_budget_months
(id TEXT PRIMARY KEY,
buffered INTEGER DEFAULT 0);
buffered INTEGER DEFAULT 0);
CREATE TABLE zero_budgets
(id TEXT PRIMARY KEY,
@@ -34,12 +34,12 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let budget = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
[],
true
true,
);
db.transaction(() => {
budget.map(monthBudget => {
budget.forEach(monthBudget => {
let match = monthBudget.name.match(
/^(budget-report|budget)(\d+)!budget-(.+)$/
/^(budget-report|budget)(\d+)!budget-(.+)$/,
);
if (match == null) {
console.log('Warning: invalid budget month name', monthBudget.name);
@@ -60,7 +60,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let carryover = db.runQuery(
'SELECT * FROM spreadsheet_cells WHERE name = ?',
[`${sheetName}!carryover-${cat}`],
true
true,
);
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
@@ -71,8 +71,8 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
dbmonth,
cat,
amount,
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0
]
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
],
);
});
});
@@ -81,10 +81,10 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let buffers = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
[],
true
true,
);
db.transaction(() => {
buffers.map(buffer => {
buffers.forEach(buffer => {
let match = buffer.name.match(/^budget(\d+)!buffered$/);
if (match) {
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
@@ -95,7 +95,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
db.runQuery(
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
[month, amount]
[month, amount],
);
}
});
@@ -105,7 +105,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let notes = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
[],
true
true,
);
let parseNote = str => {

View File

@@ -14,6 +14,7 @@
"utils.js"
],
"scripts": {
"lint": "eslint .",
"build": "yarn workspace loot-core build:api"
},
"dependencies": {

View File

@@ -1 +1,3 @@
bundle.browser.js
bundle.browser.js
build/
public/kcab/

View File

@@ -1,23 +1,28 @@
const path = require('path');
const {
addWebpackResolve,
disableEsLint,
override,
overrideDevServer,
babelInclude,
} = require('customize-cra');
const path = require('path');
module.exports = {
webpack: override(
babelInclude([
path.resolve('src'),
path.resolve('../loot-core'),
path.resolve('../loot-design'),
]),
babelInclude([path.resolve('src'), path.resolve('../loot-core')]),
process.env.CI && disableEsLint(),
addWebpackResolve({
extensions: [
...(process.env.IS_GENERIC_BROWSER ? ['.browser.js'] : []),
...(process.env.IS_GENERIC_BROWSER
? ['.browser.js', '.browser.ts', '.browser.tsx']
: []),
'.web.js',
'.web.ts',
'.web.tsx',
'.js',
'.ts',
'.tsx',
],
}),
config => {

View File

@@ -0,0 +1,54 @@
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('/');
budgetPage = await configurationPage.createTestFile();
});
test.afterAll(async () => {
await page.close();
});
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(/^Overspent in /)).toBeVisible();
await expect(summary.getByText('Budgeted')).toBeVisible();
await expect(summary.getByText('For Next Month')).toBeVisible();
});
test('transfer funds to another category', async () => {
const currentFundsA = await budgetPage.getBalanceForRow(1);
const currentFundsB = await budgetPage.getBalanceForRow(2);
await budgetPage.transferAllBalance(1, 2);
await page.waitForTimeout(1000);
expect(await budgetPage.getBalanceForRow(2)).toEqual(
currentFundsA + currentFundsB,
);
});
test('budget table is rendered', async () => {
await expect(budgetPage.budgetTable).toBeVisible();
expect(await budgetPage.getTableTotals()).toEqual({
budgeted: expect.any(Number),
spent: expect.any(Number),
balance: expect.any(Number),
});
});
});

Binary file not shown.

View File

@@ -0,0 +1,81 @@
import path from 'path';
import { test, expect } from '@playwright/test';
import { AccountPage } from './page-models/account-page';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';
test.describe('Onboarding', () => {
let page;
let navigation;
let configurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new Navigation(page);
configurationPage = new ConfigurationPage(page);
await page.goto('/');
});
test.afterEach(async () => {
await page.close();
});
test('creates a new budget file by importing YNAB4 budget', async () => {
await configurationPage.clickOnNoServer();
const budgetPage = await configurationPage.importBudget(
'YNAB4',
path.resolve(__dirname, 'data/ynab4-demo-budget.zip'),
);
await expect(budgetPage.budgetTable).toBeVisible({ timeout: 30000 });
const accountPage = await navigation.goToAccountPage(
'Account1 with Starting Balance',
);
await expect(accountPage.accountBalance).toHaveText('-400.00');
await navigation.goToAccountPage('Account2 no Starting Balance');
await expect(accountPage.accountBalance).toHaveText('2,607.00');
});
// TODO: implement this test once we have an example nYNAB file
// test('creates a new budget file by importing nYNAB budget');
test('creates a new budget file by importing Actual budget', async () => {
await configurationPage.clickOnNoServer();
const budgetPage = await configurationPage.importBudget(
'Actual',
path.resolve(__dirname, 'data/actual-demo-budget.zip'),
);
await expect(budgetPage.budgetTable).toBeVisible();
const accountPage = await navigation.goToAccountPage('Ally Savings');
await expect(accountPage.accountBalance).toHaveText('1,772.80');
await navigation.goToAccountPage('Roth IRA');
await expect(accountPage.accountBalance).toHaveText('2,745.81');
});
test('creates a new empty budget file', async () => {
await configurationPage.clickOnNoServer();
await configurationPage.startFresh();
const accountPage = new AccountPage(page);
await expect(accountPage.accountName).toBeVisible();
await expect(accountPage.accountName).toHaveText('All Accounts');
await expect(accountPage.accountBalance).toHaveText('0.00');
});
test('navigates back to start page by clicking on “no server” in an empty budget file', async () => {
await configurationPage.clickOnNoServer();
await configurationPage.startFresh();
await navigation.clickOnNoServer();
expect(await configurationPage.heading).toHaveText('Wheres the server?');
});
});

View File

@@ -5,6 +5,7 @@ export class AccountPage {
this.page = page;
this.accountName = this.page.getByTestId('account-name');
this.accountBalance = this.page.getByTestId('account-balance');
this.addNewTransactionButton = this.page.getByRole('button', {
name: 'Add New',
});

View File

@@ -0,0 +1,74 @@
export class BudgetPage {
constructor(page) {
this.page = page;
this.budgetSummary = page.getByTestId('budget-summary');
this.budgetTable = page.getByTestId('budget-table');
this.budgetTableTotals = this.budgetTable.getByTestId('budget-totals');
}
async getTableTotals() {
return {
budgeted: parseInt(
await this.budgetTableTotals
.getByTestId(/total-budgeted$/)
.textContent(),
10,
),
spent: parseInt(
await this.budgetTableTotals.getByTestId(/total-spent$/).textContent(),
10,
),
balance: parseInt(
await this.budgetTableTotals
.getByTestId(/total-leftover$/)
.textContent(),
10,
),
};
}
async showMoreMonths() {
await this.page.getByTestId('calendar-icon').first().click();
}
async getBalanceForRow(idx) {
return Math.round(
parseFloat(
(
await this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('balance')
.textContent()
).replace(/,/g, ''),
) * 100,
);
}
async transferAllBalance(fromIdx, toIdx) {
const toName = await this.budgetTable
.getByTestId('row')
.nth(toIdx)
.getByTestId('category-name')
.textContent();
await this.budgetTable
.getByTestId('row')
.nth(fromIdx)
.getByTestId('balance')
.getByTestId(/^budget/)
.click();
await this.page
.getByRole('button', { name: 'Transfer to another category' })
.click();
await this.page.getByPlaceholder('(none)').click();
await this.page.keyboard.type(toName);
await this.page.keyboard.press('Enter');
await this.page.getByRole('button', { name: 'Transfer' }).click();
}
}

View File

@@ -1,10 +1,64 @@
import { BudgetPage } from './budget-page';
export class ConfigurationPage {
constructor(page) {
this.page = page;
this.heading = page.getByRole('heading');
}
async createTestFile() {
await this.page.getByRole('button', { name: 'Create test file' }).click();
await this.page.getByRole('button', { name: 'Close' }).click();
return new BudgetPage(this.page);
}
async clickOnNoServer() {
await this.page.getByRole('button', { name: 'Dont use a server' }).click();
}
async startFresh() {
await this.page.getByRole('button', { name: 'Start fresh' }).click();
}
async importBudget(type, file) {
const fileChooserPromise = this.page.waitForEvent('filechooser');
await this.page.getByRole('button', { name: 'Import my budget' }).click();
switch (type) {
case 'YNAB4':
await this.page
.getByRole('button', {
name: 'YNAB4 The old unsupported desktop app',
})
.click();
await this.page
.getByRole('button', { name: 'Select zip file...' })
.click();
break;
case 'nYNAB':
await this.page
.getByRole('button', { name: 'nYNAB The newer web app' })
.click();
await this.page.getByRole('button', { name: 'Select file...' }).click();
break;
case 'Actual':
await this.page
.getByRole('button', {
name: 'Actual Import a file exported from Actual',
})
.click();
await this.page.getByRole('button', { name: 'Select file...' }).click();
break;
default:
throw new Error(`Unrecognized import type: ${type}`);
}
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(file);
return new BudgetPage(this.page);
}
}

View File

@@ -70,4 +70,8 @@ export class Navigation {
await this.page.getByRole('button', { name: 'Create' }).click();
return new AccountPage(this.page);
}
async clickOnNoServer() {
await this.page.getByRole('button', { name: 'No server' }).click();
}
}

View File

@@ -36,6 +36,17 @@ export class RulesPage {
}
async _fillRuleFields(data) {
if (data.conditionsOp) {
await this.page
.getByTestId('conditions-op')
.getByRole('button')
.first()
.click();
await this.page
.getByRole('option', { exact: true, name: data.conditionsOp })
.click();
}
if (data.conditions) {
await this._fillEditorFields(
data.conditions,

View File

@@ -65,9 +65,9 @@ test.describe('Schedules', () => {
],
conditions: [
'payee is Home Depot',
'account is HSBC',
expect.stringMatching(/^date is approx Every month on the/),
'amount is approx -25.00',
'and account is HSBC',
expect.stringMatching(/^and date is approx Every month on the/),
'and amount is approx -25.00',
],
});

View File

@@ -31,7 +31,7 @@ test.describe('Transactions', () => {
payee: 'Home Depot',
notes: 'Notes field',
category: 'Food',
debit: '12.34'
debit: '12.34',
});
expect(await accountPage.getNthTransaction(0)).toMatchObject({
@@ -39,7 +39,7 @@ test.describe('Transactions', () => {
notes: 'Notes field',
category: 'Food',
debit: '12.34',
credit: ''
credit: '',
});
});
@@ -48,15 +48,15 @@ test.describe('Transactions', () => {
{
payee: 'Krogger',
notes: 'Notes',
debit: '333.33'
debit: '333.33',
},
{
category: 'General',
debit: '222.22'
debit: '222.22',
},
{
debit: '111.11'
}
debit: '111.11',
},
]);
expect(await accountPage.getNthTransaction(0)).toMatchObject({
@@ -64,21 +64,21 @@ test.describe('Transactions', () => {
notes: 'Notes',
category: 'Split',
debit: '333.33',
credit: ''
credit: '',
});
expect(await accountPage.getNthTransaction(1)).toMatchObject({
payee: 'Krogger',
notes: '',
category: 'General',
debit: '222.22',
credit: ''
credit: '',
});
expect(await accountPage.getNthTransaction(2)).toMatchObject({
payee: 'Krogger',
notes: '',
category: 'Categorize',
debit: '111.11',
credit: ''
credit: '',
});
});
});

View File

@@ -1,12 +1,13 @@
{
"name": "@actual-app/web",
"version": "23.3.2",
"version": "23.4.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",
"@react-aria/focus": "^3.8.0",
@@ -15,35 +16,48 @@
"@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",
"chalk": "2.4.1",
"codemirror": "^5.37.0",
"chroma-js": "^1.3.3",
"cross-env": "^7.0.3",
"customize-cra": "^1.0.0",
"date-fns": "2.0.0-alpha.27",
"date-fns": "^2.29.3",
"debounce": "^1.2.0",
"eslint": "^8.35.0",
"downshift": "1.31.16",
"focus-visible": "^4.1.1",
"formik": "^0.11.10",
"glamor": "^2.20.40",
"hotkeys-js": "3.8.2",
"identity-obj-proxy": "3.0.0",
"inter-ui": "^3.19.3",
"jest": "^27.0.0",
"jest-watch-typeahead": "^2.2.2",
"memoize-one": "^4.0.0",
"mitt": "^3.0.0",
"perf-deets": "^1.0.15",
"node-noop": "1.0.0",
"pikaday": "1.8.0",
"prop-types": "15.6.0",
"react": "16.13.1",
"react": "18.2.0",
"react-app-rewired": "^2.2.1",
"react-dnd": "^10.0.2",
"react-dom": "16.13.1",
"react-dnd-html5-backend": "^10.0.2",
"react-dom": "18.2.0",
"react-merge-refs": "^1.1.0",
"react-modal": "3.16.1",
"react-redux": "7.2.1",
"react-router": "5.2.0",
"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-virtualized-auto-sizer": "^1.0.2",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"victory": "^36.6.8"
"victory": "^36.6.8",
"wobble": "^1.5.0"
},
"scripts": {
"start": "cross-env PORT=3001 react-app-rewired start",
@@ -53,15 +67,11 @@
"build:browser": "cross-env ./bin/build-browser",
"test": "react-app-rewired test",
"e2e": "npx playwright test e2e --browser=chromium",
"lint": "eslint src"
"lint": "eslint ."
},
"jest": {
"setupFilesAfterEnv": [
"<rootDir>/src/setupTests.js",
"<rootDir>/../loot-design/src/setupTests.js"
"<rootDir>/src/setupTests.js"
]
},
"browserslist": [
"electron 3.0"
]
}
}

View File

@@ -1,10 +1,10 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Content-Security-Policy: default-src 'self' blob:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
/kcab/*
Content-Security-Policy: default-src 'self' blob:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
/*.wasm
Content-Type: application/wasm

View File

@@ -15,3 +15,4 @@ 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

@@ -6,7 +6,7 @@ export default async function runMigration(db, uuid) {
db.execQuery(`
CREATE TABLE zero_budget_months
(id TEXT PRIMARY KEY,
buffered INTEGER DEFAULT 0);
buffered INTEGER DEFAULT 0);
CREATE TABLE zero_budgets
(id TEXT PRIMARY KEY,
@@ -34,12 +34,12 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let budget = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
[],
true
true,
);
db.transaction(() => {
budget.map(monthBudget => {
budget.forEach(monthBudget => {
let match = monthBudget.name.match(
/^(budget-report|budget)(\d+)!budget-(.+)$/
/^(budget-report|budget)(\d+)!budget-(.+)$/,
);
if (match == null) {
console.log('Warning: invalid budget month name', monthBudget.name);
@@ -60,7 +60,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let carryover = db.runQuery(
'SELECT * FROM spreadsheet_cells WHERE name = ?',
[`${sheetName}!carryover-${cat}`],
true
true,
);
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
@@ -71,8 +71,8 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
dbmonth,
cat,
amount,
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0
]
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
],
);
});
});
@@ -81,10 +81,10 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let buffers = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
[],
true
true,
);
db.transaction(() => {
buffers.map(buffer => {
buffers.forEach(buffer => {
let match = buffer.name.match(/^budget(\d+)!buffered$/);
if (match) {
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
@@ -95,7 +95,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
db.runQuery(
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
[month, amount]
[month, amount],
);
}
});
@@ -105,7 +105,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
let notes = db.runQuery(
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
[],
true
true,
);
let parseNote = str => {

View File

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

View File

@@ -27,11 +27,6 @@
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
<link
rel="mask-icon"
href="%PUBLIC_URL%/safari-pinned-tab.svg"
color="#5bbad5"
/>
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2547 4964 c-1 -1 -47 -4 -102 -7 -582 -32 -1150 -289 -1571 -711
-101 -102 -228 -250 -261 -306 -9 -16 -21 -30 -25 -30 -4 0 -8 -4 -8 -9 0 -6
-13 -29 -29 -53 -88 -130 -186 -327 -247 -496 -36 -99 -98 -324 -108 -397 -4
-23 -8 -50 -10 -62 -3 -12 -8 -53 -11 -90 -4 -37 -9 -81 -11 -98 -6 -46 -5
-312 1 -385 16 -176 46 -332 100 -515 75 -253 226 -548 390 -761 91 -118 100
-128 220 -249 117 -116 123 -122 225 -200 36 -28 67 -52 70 -55 17 -19 196
-129 292 -179 352 -187 740 -282 1143 -281 165 1 255 8 410 35 90 16 247 51
275 62 8 4 17 7 20 8 3 1 25 8 50 15 25 7 47 14 50 15 3 1 19 7 35 14 17 6 55
22 85 33 92 36 292 142 386 203 365 238 655 555 849 930 118 226 211 501 240
710 3 17 7 40 9 52 3 12 7 50 11 85 3 35 8 81 10 103 6 48 5 280 0 350 -7 99
-22 207 -41 305 -25 128 -79 314 -114 395 -5 11 -25 58 -45 105 -38 90 -123
253 -154 298 -10 15 -36 54 -57 87 -119 184 -335 415 -524 560 -36 28 -67 52
-70 55 -16 17 -190 125 -275 171 -240 130 -561 237 -798 265 -29 4 -55 8 -58
10 -9 5 -347 23 -352 18z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -8,7 +8,6 @@ const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
// everything else.
let IS_DEV = process.env.NODE_ENV === 'development';
let IS_PERF_BUILD = process.env.PERF_BUILD != null;
let ACTUAL_VERSION = process.env.REACT_APP_ACTUAL_VERSION;
// *** Start the backend ***
@@ -32,46 +31,10 @@ function createBackendWorker() {
'SharedArrayBufferOverride',
),
});
if (IS_DEV || IS_PERF_BUILD) {
worker.onmessage = e => {
if (e.data.type === '__actual:backend-running') {
let activity = document.querySelector('.debugger .activity');
if (activity) {
let original = window.getComputedStyle(activity)['background-color'];
activity.style.transition = 'none';
activity.style.backgroundColor = '#3EBD93';
setTimeout(() => {
activity.style.transition = 'background-color 1s';
activity.style.backgroundColor = original;
}, 100);
}
}
};
import('perf-deets/frontend').then(({ listenForPerfData }) => {
listenForPerfData(worker);
});
}
}
createBackendWorker();
if (IS_DEV || IS_PERF_BUILD) {
import('perf-deets/frontend').then(({ listenForPerfData }) => {
listenForPerfData(window);
global.__startProfile = () => {
window.postMessage({ type: '__perf-deets:start-profile' });
worker.postMessage({ type: '__perf-deets:start-profile' });
};
global.__stopProfile = () => {
window.postMessage({ type: '__perf-deets:stop-profile' });
worker.postMessage({ type: '__perf-deets:stop-profile' });
};
});
}
global.Actual = {
IS_DEV,
ACTUAL_VERSION,
@@ -153,22 +116,15 @@ global.Actual = {
},
};
if (IS_DEV) {
global.Actual.reloadBackend = () => {
worker.postMessage({ type: '__actual:shutdown' });
createBackendWorker();
};
}
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey) {
// Cmd/Ctrl+o
if (e.keyCode === 79) {
if (e.code === 'KeyO') {
e.preventDefault();
window.__actionsForMenu.closeBudget();
}
// Cmd/Ctrl+z
else if (e.keyCode === 90) {
else if (e.code === 'KeyZ') {
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||

View File

@@ -1,14 +1,49 @@
/* globals importScripts, backend */
let hasInitialized = false;
self.addEventListener('message', e => {
/**
* Sometimes the frontend build is way faster than backend.
* This results in the frontend starting up before backend is
* finished and thus the backend script is not available.
*
* The goal of this function is to retry X amount of times
* to retrieve the backend script with a small delay.
*/
const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => {
try {
importScripts(script);
} catch (e) {
// Break if maxRetries has exceeded
if (maxRetries <= 0) {
throw e;
} else {
console.groupCollapsed(
`Failed to load backend, will retry ${maxRetries} more time(s)`,
);
console.log(e);
console.groupEnd();
}
// Attempt to retry after a small delay
await new Promise(resolve =>
setTimeout(async () => {
await importScriptsWithRetry(script, {
maxRetries: maxRetries - 1,
});
resolve();
}, 5000),
);
}
};
self.addEventListener('message', async e => {
if (!hasInitialized) {
let msg = e.data;
if (msg.type === 'init') {
hasInitialized = true;
let isDev = !!msg.isDev;
let version = msg.version;
// let version = msg.version;
let hash = msg.hash;
if (!self.SharedArrayBuffer && !msg.isSharedArrayBufferOverrideEnabled) {
@@ -19,26 +54,21 @@ self.addEventListener('message', e => {
return;
}
importScripts(`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`);
backend.initApp(version, isDev, self).then(
() => {
if (isDev) {
console.log('Backend running!');
self.postMessage({ type: '__actual:backend-running' });
}
},
err => {
console.log(err);
let msg = {
type: 'app-init-failure',
IDBFailure: err.message.includes('indexeddb-failure'),
};
self.postMessage(msg);
throw err;
},
await importScriptsWithRetry(
`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`,
{ maxRetries: isDev ? 5 : 0 },
);
backend.initApp(isDev, self).catch(err => {
console.log(err);
let msg = {
type: 'app-init-failure',
IDBFailure: err.message.includes('indexeddb-failure'),
};
self.postMessage(msg);
throw err;
});
}
}
});

View File

@@ -2,8 +2,9 @@ import React from 'react';
import { css } from 'glamor';
import { View } from 'loot-design/src/components/common';
import Refresh from 'loot-design/src/svg/v1/Refresh';
import Refresh from '../icons/v1/Refresh';
import View from './View';
let spin = css.keyframes({
'0%': { transform: 'rotateZ(0deg)' },

View File

@@ -8,9 +8,9 @@ import {
init as initConnection,
send,
} from 'loot-core/src/platform/client/fetch';
import { styles, hasHiddenScrollbars } from 'loot-design/src/style';
import installPolyfills from '../polyfills';
import { styles, hasHiddenScrollbars } from '../style';
import AppBackground from './AppBackground';
import FatalError from './FatalError';
@@ -114,13 +114,13 @@ class App extends React.Component {
) : budgetId ? (
<FinancesApp />
) : (
<React.Fragment>
<>
<AppBackground
initializing={initializing}
loadingText={loadingText}
/>
<ManagementApp />
</React.Fragment>
<ManagementApp isLoading={loadingText != null} />
</>
)}
<UpdateNotification />

View File

@@ -2,11 +2,11 @@ import React from 'react';
import { css } from 'glamor';
import { View, Block } from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import AnimatedLoading from 'loot-design/src/svg/AnimatedLoading';
import AnimatedLoading from '../icons/AnimatedLoading';
import { colors } from '../style';
import Background from './Background';
import { View, Block } from './common';
function AppBackground({ initializing, loadingText }) {
return (

View File

@@ -3,10 +3,11 @@ import { connect } from 'react-redux';
import { useTransition, animated } from 'react-spring';
import * as actions from 'loot-core/src/client/actions';
import { View, Text } from 'loot-design/src/components/common';
import { colors, styles } from 'loot-design/src/style';
import { colors, styles } from '../style';
import AnimatedRefresh from './AnimatedRefresh';
import { View, Text } from './common';
function BankSyncStatus({ accountsSyncing }) {
let name = accountsSyncing

View File

@@ -1,314 +0,0 @@
import React from 'react';
import CodeMirror from 'codemirror';
import * as spreadsheet from 'loot-core/src/client/sheetql/spreadsheet';
import {
send,
init as initConnection,
} from 'loot-core/src/platform/client/fetch';
import {
View,
Button,
Input,
InlineField,
} from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
require('codemirror/lib/codemirror.css');
require('codemirror/theme/monokai.css');
class Debugger extends React.Component {
state = {
recording: false,
selecting: false,
name: '__global!tmp',
collapsed: true,
node: null,
};
toggleRecord = () => {
if (this.state.recording) {
window.__stopProfile();
this.setState({ recording: false });
} else {
window.__startProfile();
this.setState({ recording: true });
}
};
reloadBackend = async () => {
window.Actual.reloadBackend();
initConnection(await global.Actual.getServerSocket());
};
init() {
this.mirror = CodeMirror(this.node, {
theme: 'monokai',
});
this.mirror.setSize('100%', '100%');
// this.mirror.on('change', () => {
// const val = this.mirror.getValue();
// const [sheetName, name] = this.state.name.split('!');
// spreadsheet.set(sheetName, name, this.mirror.getValue());
// });
const mouseoverHandler = e => {
let node = e.target;
let cellname = null;
while (!cellname && node) {
cellname = node.dataset && node.dataset.cellname;
node = node.parentNode;
}
if (this.state.selecting && cellname) {
this.bind(cellname);
}
};
document.body.addEventListener('mouseover', mouseoverHandler, false);
const clickHandler = e => {
if (this.state.selecting) {
this.setState({ selecting: false });
}
};
document.body.addEventListener('click', clickHandler, false);
this.removeListeners = () => {
document.body.removeEventListener('mouseover', mouseoverHandler);
document.body.removeEventListener('click', clickHandler);
};
this.bind(this.state.name);
}
deinit() {
if (this.unbind) {
this.unbind();
}
this.removeListeners();
this.mirror = null;
}
bind(resolvedName) {
if (this.unbind) {
this.unbind();
}
const [sheetName, name] = resolvedName.split('!');
let currentReq = Math.random();
this.currentReq = currentReq;
send('debugCell', { sheetName, name }).then(node => {
if (currentReq === this.currentReq) {
if (node._run) {
this.mirror.setValue(node._run);
}
this.setState({ name: node.name, node });
this.unbind = spreadsheet.bind(sheetName, { name }, null, node => {
if (currentReq !== this.currentReq) {
return;
}
this.setState({ node: { ...this.state.node, value: node.value } });
this.valueNode.style.transition = 'none';
this.valueNode.style.backgroundColor = colors.y9;
setTimeout(() => {
this.valueNode.style.transition = 'background-color .8s';
this.valueNode.style.backgroundColor = 'rgba(0, 0, 0, 0)';
}, 50);
});
}
});
}
componentWillUnmount() {
if (this.unbind) {
this.unbind();
this.unbind = null;
}
}
onShow = () => {
this.setState({ collapsed: false }, () => {
this.init();
});
};
onClose = () => {
this.setState({ collapsed: true }, () => {
this.deinit();
});
};
onSelect = () => {
this.setState({ selecting: true });
};
onNameChange = e => {
const name = e.target.value;
this.bind(name);
this.setState({ name });
};
unselect() {
if (this.unbind) {
this.unbind();
this.unbind = null;
this.setState({ sheetName: null, name: null, node: null });
}
}
render() {
const { children } = this.props;
const { name, node, selecting, collapsed, recording } = this.state;
return (
<View
style={{
height: '100%',
'& .CodeMirror': { border: '1px solid ' + colors.b4 },
}}
>
<div style={{ flex: 1, overflow: 'hidden' }}>{children}</div>
<View
className="debugger"
style={[
{
position: 'fixed',
right: 0,
bottom: 0,
margin: 15,
padding: 10,
backgroundColor: 'rgba(50, 50, 50, .85)',
color: 'white',
zIndex: 1000,
flexDirection: 'row',
alignItems: 'center',
},
!collapsed && {
width: 700,
height: 200,
},
]}
>
{collapsed ? (
<React.Fragment>
<div
className="activity"
style={{
width: 10,
height: 10,
backgroundColor: '#303030',
marginRight: 10,
borderRadius: 10,
}}
/>
<Button onClick={this.toggleRecord} style={{ marginRight: 10 }}>
{recording ? 'Stop' : 'Start'} Profile
</Button>
<Button onClick={this.reloadBackend} style={{ marginRight: 10 }}>
Reload backend
</Button>
<Button onClick={this.onShow}>^</Button>
</React.Fragment>
) : (
<View style={{ flex: 1 }}>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-start',
marginBottom: 5,
flexShrink: 0,
}}
>
<Button
style={{
backgroundColor: '#303030',
color: 'white',
padding: '2px 5px',
marginRight: 5,
}}
onClick={this.onClose}
>
v
</Button>
<Button
style={[
{
backgroundColor: '#303030',
color: 'white',
padding: '2px 5px',
},
selecting && {
backgroundColor: colors.p7,
},
]}
onClick={this.onSelect}
>
Inspect Cell
</Button>
</View>
<InlineField label="Name" style={{ flex: '0 0 auto' }}>
<Input
value={name}
onChange={this.onNameChange}
style={{
backgroundColor: '#303030',
color: 'white',
flex: 1,
}}
/>
</InlineField>
<InlineField
label="Expr"
style={{ flex: 1, alignItems: 'stretch', overflow: 'hidden' }}
>
<div
style={{ flex: 1, overflow: 'hidden' }}
ref={n => (this.node = n)}
/>
</InlineField>
<InlineField
label="Dependencies"
labelWidth={100}
style={{ flex: '0 0 auto' }}
>
<pre
style={{
backgroundColor: 'rgba(0, 0, 0, 0)',
height: 30,
overflow: 'scroll',
}}
>
{node && JSON.stringify(node._dependencies, null, 2)}
</pre>
</InlineField>
<InlineField label="Value" style={{ flex: '0 0 auto' }}>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0)',
transition: 'background-color .5s',
height: 30,
overflow: 'scroll',
}}
ref={n => (this.valueNode = n)}
>
{node && JSON.stringify(node.value)}
</div>
</InlineField>
</View>
)}
</View>
</View>
);
}
}
export default Debugger;

View File

@@ -1,17 +1,9 @@
import React, { useState } from 'react';
import {
View,
Stack,
Text,
Block,
Modal,
P,
Link,
Button,
} from 'loot-design/src/components/common';
import { Checkbox } from 'loot-design/src/components/forms';
import { colors } from 'loot-design/src/style';
import { colors } from '../style';
import { View, Stack, Text, Block, Modal, P, Link, Button } from './common';
import { Checkbox } from './forms';
class FatalError extends React.Component {
state = { showError: false };
@@ -22,10 +14,10 @@ class FatalError extends React.Component {
// IndexedDB wasn't able to open the database
msg = (
<Text>
Your browser doesn{"'"}t support IndexedDB in this environment, a
feature that Actual requires to run. This might happen if you are in
private browsing mode. Please try a different browser or turn off
private browsing.
Your browser doesnt support IndexedDB in this environment, a feature
that Actual requires to run. This might happen if you are in private
browsing mode. Please try a different browser or turn off private
browsing.
</Text>
);
} else if (error.SharedArrayBufferMissing) {

View File

@@ -22,13 +22,11 @@ import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
import checkForUpdateNotification from 'loot-core/src/client/update-notification';
import checkForUpgradeNotifications from 'loot-core/src/client/upgrade-notifications';
import * as undo from 'loot-core/src/platform/client/undo';
import { BudgetMonthCountProvider } from 'loot-design/src/components/budget/BudgetMonthCountContext';
import { View } from 'loot-design/src/components/common';
import { colors, styles } from 'loot-design/src/style';
import Cog from 'loot-design/src/svg/v1/Cog';
import PiggyBank from 'loot-design/src/svg/v1/PiggyBank';
import Wallet from 'loot-design/src/svg/v1/Wallet';
import Cog from '../icons/v1/Cog';
import PiggyBank from '../icons/v1/PiggyBank';
import Wallet from '../icons/v1/Wallet';
import { colors, styles } from '../style';
import { isMobile } from '../util';
import { getLocationState, makeLocationState } from '../util/location-state';
import { getIsOutdated, getLatestVersion } from '../util/versions';
@@ -39,7 +37,9 @@ import { default as 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 { View } from './common';
import FloatableSidebar, { SidebarProvider } from './FloatableSidebar';
import GlobalKeys from './GlobalKeys';
import { ManageRulesPage } from './ManageRulesPage';
@@ -56,7 +56,6 @@ import LinkSchedule from './schedules/LinkSchedule';
import PostsOfflineNotification from './schedules/PostsOfflineNotification';
import Settings from './settings';
import Titlebar, { TitlebarProvider } from './Titlebar';
// import Debugger from './Debugger';
function PageRoute({ path, component: Component }) {
return (
@@ -336,7 +335,6 @@ class FinancesApp extends React.Component {
<Notifications />
<BankSyncStatus />
<StackedRoutes isMobile={this.state.isMobile} />
{/*window.Actual.IS_DEV && <Debugger />*/}
<Modals history={this.history} />
</div>
{this.state.isMobile && (

View File

@@ -2,8 +2,9 @@ import React from 'react';
import memoizeOne from 'memoize-one';
import useResizeObserver from '../hooks/useResizeObserver';
import { View } from './common';
import useResizeObserver from './useResizeObserver';
const IS_SCROLLING_DEBOUNCE_INTERVAL = 150;

View File

@@ -2,13 +2,16 @@ import React, { useState, useEffect, useContext } from 'react';
import { connect } 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 { View } from 'loot-design/src/components/common';
import { SIDEBAR_WIDTH } from 'loot-design/src/components/sidebar';
import { colors } from 'loot-design/src/style';
import { colors } from '../style';
import { breakpoints } from '../tokens';
import { View } from './common';
import { SIDEBAR_WIDTH } from './sidebar';
import SidebarWithData from './SidebarWithData';
const SidebarContext = React.createContext(null);
@@ -20,6 +23,7 @@ export function SidebarProvider({ children }) {
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);
@@ -39,7 +43,10 @@ function Sidebar({ floatingSidebar }) {
let [hidden, setHidden] = useState(true);
let sidebar = useSidebar();
if (!floatingSidebar && hidden) {
let windowWidth = useViewportSize().width;
let sidebarShouldFloat = floatingSidebar || windowWidth < breakpoints.medium;
if (!sidebarShouldFloat && hidden) {
setHidden(false);
}
@@ -47,6 +54,7 @@ function Sidebar({ floatingSidebar }) {
let cleanups = [
sidebar.on('show', () => setHidden(false)),
sidebar.on('hide', () => setHidden(true)),
sidebar.on('toggle', () => setHidden(hidden => !hidden)),
];
return () => {
cleanups.forEach(fn => fn());
@@ -55,7 +63,7 @@ function Sidebar({ floatingSidebar }) {
return (
<>
{floatingSidebar && (
{sidebarShouldFloat && (
<View
onMouseOver={() => setHidden(false)}
onMouseLeave={() => setHidden(true)}
@@ -72,27 +80,27 @@ function Sidebar({ floatingSidebar }) {
<View
onMouseOver={
floatingSidebar
sidebarShouldFloat
? e => {
e.stopPropagation();
setHidden(false);
}
: null
}
onMouseLeave={floatingSidebar ? () => setHidden(true) : null}
onMouseLeave={sidebarShouldFloat ? () => setHidden(true) : null}
style={{
position: 'absolute',
top: 50,
// If not floating, the -50 takes into account the transform below
bottom: floatingSidebar ? 50 : -50,
bottom: sidebarShouldFloat ? 50 : -50,
zIndex: 1001,
borderRadius: '0 6px 6px 0',
overflow: 'hidden',
boxShadow:
!floatingSidebar || hidden
!sidebarShouldFloat || hidden
? 'none'
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
transform: `translateY(${!floatingSidebar ? -50 : 0}px)
transform: `translateY(${!sidebarShouldFloat ? -50 : 0}px)
translateX(${hidden ? -SIDEBAR_WIDTH : 0}px)`,
transition: 'transform .5s, box-shadow .5s',
}}
@@ -104,12 +112,12 @@ function Sidebar({ floatingSidebar }) {
style={[
{
backgroundColor: colors.n1,
opacity: floatingSidebar ? 0 : 1,
transform: `translateX(${floatingSidebar ? -50 : 0}px)`,
opacity: sidebarShouldFloat ? 0 : 1,
transform: `translateX(${sidebarShouldFloat ? -50 : 0}px)`,
transition: 'transform .4s, opacity .2s',
width: SIDEBAR_WIDTH,
},
floatingSidebar && {
sidebarShouldFloat && {
position: 'absolute',
top: 0,
bottom: 0,

View File

@@ -1,28 +1,28 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import * as Platform from 'loot-core/src/client/platform';
class GlobalKeys extends React.Component {
componentDidMount() {
export default function GlobalKeys() {
let history = useHistory();
useEffect(() => {
const handleKeys = e => {
if (Platform.isBrowser) {
return;
}
if (e.metaKey) {
const { history } = this.props;
switch (e.keyCode) {
case 49:
switch (e.code) {
case 'Digit1':
history.push('/budget');
break;
case 50:
case 'Digit2':
history.push('/reports');
break;
case 51:
case 'Digit3':
history.push('/accounts');
break;
case 188: // ,
case 'Comma':
if (Platform.OS === 'mac') {
history.push('/settings');
}
@@ -34,18 +34,8 @@ class GlobalKeys extends React.Component {
document.addEventListener('keydown', handleKeys);
this.cleanupListeners = () => {
document.removeEventListener('keydown', handleKeys);
};
}
return () => document.removeEventListener('keydown', handleKeys);
}, []);
componentWillUnmount() {
this.cleanupListeners();
}
render() {
return null;
}
return null;
}
export default withRouter(GlobalKeys);

View File

@@ -3,15 +3,10 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import * as actions from 'loot-core/src/client/actions';
import {
View,
Text,
Button,
Tooltip,
Menu,
} from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import { colors } from '../style';
import { View, Text, Button, Tooltip, Menu } from './common';
import { useServerURL } from './ServerContext';
function LoggedInUser({

View File

@@ -21,14 +21,16 @@ import { getMonthYearFormat } from 'loot-core/src/shared/months';
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
import { getRecurringDescription } from 'loot-core/src/shared/schedules';
import { integerToCurrency } from 'loot-core/src/shared/util';
import {
View,
Text,
Button,
Stack,
ExternalLink,
Input,
} from 'loot-design/src/components/common';
import useSelected, {
useSelectedDispatch,
useSelectedItems,
SelectedProvider,
} from '../hooks/useSelected';
import ArrowRight from '../icons/v0/RightArrow2';
import { colors } from '../style';
import { View, Text, Button, Stack, ExternalLink, Input } from './common';
import {
SelectCell,
Row,
@@ -37,14 +39,7 @@ import {
CellButton,
TableHeader,
useTableNavigator,
} from 'loot-design/src/components/table';
import useSelected, {
useSelectedDispatch,
useSelectedItems,
SelectedProvider,
} from 'loot-design/src/components/useSelected';
import { colors } from 'loot-design/src/style';
import ArrowRight from 'loot-design/src/svg/v0/RightArrow2';
} from './table';
let SchedulesQuery = liveQueryContext(q('schedules').select('*'));
@@ -218,7 +213,7 @@ export function ConditionExpression({
op,
value,
options,
stage,
prefix,
style,
}) {
return (
@@ -237,6 +232,7 @@ export function ConditionExpression({
style,
]}
>
{prefix && <Text style={{ color: colors.n3 }}>{prefix} </Text>}
<Text style={{ color: colors.p4 }}>{mapField(field, options)}</Text>{' '}
<Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
<Value value={value} field={field} />
@@ -368,7 +364,7 @@ let Rule = React.memo(
op={cond.op}
value={cond.value}
options={cond.options}
stage={rule.stage}
prefix={i > 0 ? friendlyOp(rule.conditionsOp) : null}
style={i !== 0 && { marginTop: 3 }}
/>
))}
@@ -692,6 +688,7 @@ function ManageRulesContent({ isModal, payeeId, setLoading }) {
function onCreateRule() {
let rule = {
stage: null,
conditionsOp: 'and',
conditions: [
{
field: 'payee',

View File

@@ -2,12 +2,13 @@ import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { savePrefs } from 'loot-core/src/client/actions';
import { View, Text, Button } from 'loot-design/src/components/common';
import { Checkbox } from 'loot-design/src/components/forms';
import { colors, styles } from 'loot-design/src/style';
import { colors, styles } from '../style';
import { isMobile } from '../util';
import { View, Text, Button } from './common';
import { Checkbox } from './forms';
let buttonStyle = { border: 0, fontSize: 15, padding: '10px 13px' };
export default function MobileWebMessage() {
@@ -99,7 +100,7 @@ export default function MobileWebMessage() {
userSelect: 'none',
}}
>
Don't remind me again
Dont remind me again
</label>
</View>
</View>

View File

@@ -8,27 +8,27 @@ import { bindActionCreators } from 'redux';
import * as actions from 'loot-core/src/client/actions';
import { send, listen, unlisten } from 'loot-core/src/platform/client/fetch';
import BudgetSummary from 'loot-design/src/components/modals/BudgetSummary';
import CloseAccount from 'loot-design/src/components/modals/CloseAccount';
import ConfigureLinkedAccounts from 'loot-design/src/components/modals/ConfigureLinkedAccounts';
import CreateLocalAccount from 'loot-design/src/components/modals/CreateLocalAccount';
import EditField from 'loot-design/src/components/modals/EditField';
import ImportTransactions from 'loot-design/src/components/modals/ImportTransactions';
import LoadBackup from 'loot-design/src/components/modals/LoadBackup';
import NordigenExternalMsg from 'loot-design/src/components/modals/NordigenExternalMsg';
import PlaidExternalMsg from 'loot-design/src/components/modals/PlaidExternalMsg';
import SelectLinkedAccounts from 'loot-design/src/components/modals/SelectLinkedAccounts';
import useFeatureFlag from '../hooks/useFeatureFlag';
import useSyncServerStatus from '../hooks/useSyncServerStatus';
import BudgetSummary from './modals/BudgetSummary';
import CloseAccount from './modals/CloseAccount';
import ConfigureLinkedAccounts from './modals/ConfigureLinkedAccounts';
import ConfirmCategoryDelete from './modals/ConfirmCategoryDelete';
import CreateAccount from './modals/CreateAccount';
import CreateEncryptionKey from './modals/CreateEncryptionKey';
import CreateLocalAccount from './modals/CreateLocalAccount';
import EditField from './modals/EditField';
import EditRule from './modals/EditRule';
import FixEncryptionKey from './modals/FixEncryptionKey';
import ImportTransactions from './modals/ImportTransactions';
import LoadBackup from './modals/LoadBackup';
import ManageRulesModal from './modals/ManageRulesModal';
import MergeUnusedPayees from './modals/MergeUnusedPayees';
import WelcomeScreen from './modals/WelcomeScreen';
import NordigenExternalMsg from './modals/NordigenExternalMsg';
import PlaidExternalMsg from './modals/PlaidExternalMsg';
import SelectLinkedAccounts from './modals/SelectLinkedAccounts';
function Modals({
history,
@@ -41,6 +41,9 @@ function Modals({
budgetId,
actions,
}) {
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const syncServerStatus = useSyncServerStatus();
return modalStack.map(({ name, options = {} }, idx) => {
@@ -91,9 +94,9 @@ function Modals({
<Route path="/select-linked-accounts">
<SelectLinkedAccounts
modalProps={modalProps}
accounts={options.accounts}
externalAccounts={options.accounts}
requisitionId={options.requisitionId}
actualAccounts={accounts.filter(acct => acct.closed === 0)}
localAccounts={accounts.filter(acct => acct.closed === 0)}
upgradingAccountId={options.upgradingAccountId}
actions={actions}
/>
@@ -273,21 +276,20 @@ function Modals({
actions={actions}
name={options.name}
onSubmit={options.onSubmit}
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
/>
);
}}
/>
<Route path="/welcome-screen">
<WelcomeScreen modalProps={modalProps} actions={actions} />
</Route>
<Route path="/budget-summary">
<BudgetSummary
key={name}
modalProps={modalProps}
month={options.month}
actions={actions}
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
isGoalTemplatesEnabled={isGoalTemplatesEnabled}
/>
</Route>
</Switch>

View File

@@ -6,8 +6,8 @@ import q from 'loot-core/src/client/query-helpers';
import { useLiveQuery } from 'loot-core/src/client/query-hooks';
import { send } from 'loot-core/src/platform/client/fetch';
import CustomNotesPaper from '../icons/v2/CustomNotesPaper';
import { colors } from '../style';
import CustomNotesPaper from '../svg/v2/CustomNotesPaper';
import { View, Button, Tooltip, useTooltip, Text } from './common';

View File

@@ -4,6 +4,11 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from 'loot-core/src/client/actions';
import Loading from '../icons/AnimatedLoading';
import Delete from '../icons/v0/Delete';
import { styles, colors } from '../style';
import {
View,
Text,
@@ -11,10 +16,7 @@ import {
ButtonWithLoading,
Stack,
ExternalLink,
} from 'loot-design/src/components/common';
import { styles, colors } from 'loot-design/src/style';
import Loading from 'loot-design/src/svg/AnimatedLoading';
import Delete from 'loot-design/src/svg/v0/Delete';
} from './common';
function compileMessage(message, actions, setLoading, onRemove) {
return (

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { Modal, View, Text } from 'loot-design/src/components/common';
import { styles } from 'loot-design/src/style';
import { styles } from '../style';
import { Modal, View, Text } from './common';
let PageTypeContext = React.createContext({ type: 'page' });

View File

@@ -10,17 +10,13 @@ import { closeBudget } from 'loot-core/src/client/actions/budgets';
import * as Platform from 'loot-core/src/client/platform';
import * as queries from 'loot-core/src/client/queries';
import { send } from 'loot-core/src/platform/client/fetch';
import {
Button,
Input,
InitialFocus,
Text,
Tooltip,
Menu,
} from 'loot-design/src/components/common';
import { Sidebar } from 'loot-design/src/components/sidebar';
import { styles, colors } from 'loot-design/src/style';
import ExpandArrow from 'loot-design/src/svg/v0/ExpandArrow';
import useFeatureFlag from '../hooks/useFeatureFlag';
import ExpandArrow from '../icons/v0/ExpandArrow';
import { styles, colors } from '../style';
import { Button, Input, InitialFocus, Text, Tooltip, Menu } from './common';
import { Sidebar } from './sidebar';
function EditableBudgetName({ prefs, savePrefs }) {
let dispatch = useDispatch();
@@ -49,9 +45,10 @@ function EditableBudgetName({ prefs, savePrefs }) {
}
let items = [
{ name: 'rename', text: 'Rename Budget' },
{ name: 'rename', text: 'Rename budget' },
{ name: 'settings', text: 'Settings' },
...(Platform.isBrowser ? [{ name: 'help', text: 'Help' }] : []),
{ name: 'close', text: 'Close File' },
{ name: 'close', text: 'Close file' },
];
if (editing) {
@@ -123,6 +120,8 @@ function SidebarWithData({
saveGlobalPrefs,
getAccounts,
}) {
const syncAccount = useFeatureFlag('syncAccount');
useEffect(() => void getAccounts(), [getAccounts]);
async function onReorder(id, dropPos, targetId) {
@@ -149,9 +148,7 @@ function SidebarWithData({
onFloat={() => saveGlobalPrefs({ floatingSidebar: !floatingSidebar })}
onReorder={onReorder}
onAddAccount={() =>
replaceModal(
prefs['flags.syncAccount'] ? 'add-account' : 'add-local-account',
)
replaceModal(syncAccount ? 'add-account' : 'add-local-account')
}
showClosedAccounts={prefs['ui.showClosedAccounts']}
onToggleClosedAccounts={() =>

View File

@@ -2,12 +2,25 @@ import React, { useState, useEffect, useRef, useContext } from 'react';
import { connect } from 'react-redux';
import { Switch, Route, withRouter } from 'react-router-dom';
import { useViewportSize } from '@react-aria/utils';
import { css, media } from 'glamor';
import * as actions from 'loot-core/src/client/actions';
import * as Platform from 'loot-core/src/client/platform';
import * as queries from 'loot-core/src/client/queries';
import { listen } from 'loot-core/src/platform/client/fetch';
import useFeatureFlag from '../hooks/useFeatureFlag';
import ArrowLeft from '../icons/v1/ArrowLeft';
import AlertTriangle from '../icons/v2/AlertTriangle';
import ArrowButtonRight1 from '../icons/v2/ArrowButtonRight1';
import NavigationMenu from '../icons/v2/NavigationMenu';
import { colors } from '../style';
import tokens, { breakpoints } from '../tokens';
import AccountSyncCheck from './accounts/AccountSyncCheck';
import AnimatedRefresh from './AnimatedRefresh';
import { MonthCountSelector } from './budget/MonthCountSelector';
import {
View,
Text,
@@ -16,21 +29,11 @@ import {
ButtonWithLoading,
Tooltip,
P,
} from 'loot-design/src/components/common';
import SheetValue from 'loot-design/src/components/spreadsheet/SheetValue';
import { colors } from 'loot-design/src/style';
import ArrowLeft from 'loot-design/src/svg/v1/ArrowLeft';
import AlertTriangle from 'loot-design/src/svg/v2/AlertTriangle';
import ArrowButtonRight1 from 'loot-design/src/svg/v2/ArrowButtonRight1';
import NavigationMenu from 'loot-design/src/svg/v2/NavigationMenu';
import tokens from 'loot-design/src/tokens';
import AccountSyncCheck from './accounts/AccountSyncCheck';
import AnimatedRefresh from './AnimatedRefresh';
import { MonthCountSelector } from './budget/MonthCountSelector';
} from './common';
import { useSidebar } from './FloatableSidebar';
import LoggedInUser from './LoggedInUser';
import { useServerURL } from './ServerContext';
import SheetValue from './spreadsheet/SheetValue';
export let TitlebarContext = React.createContext();
@@ -163,7 +166,7 @@ function BudgetTitlebar({ globalPrefs, saveGlobalPrefs, localPrefs }) {
let [loading, setLoading] = useState(false);
let [showTooltip, setShowTooltip] = useState(false);
let reportBudgetEnabled = localPrefs['flags.reportBudget'];
const reportBudgetEnabled = useFeatureFlag('reportBudget');
function onSwitchType() {
setLoading(true);
@@ -264,6 +267,9 @@ function Titlebar({
let sidebar = useSidebar();
const serverURL = useServerURL();
let windowWidth = useViewportSize().width;
let sidebarAlwaysFloats = windowWidth < breakpoints.medium;
return (
<View
style={[
@@ -283,20 +289,24 @@ function Titlebar({
style,
]}
>
{floatingSidebar && (
{(floatingSidebar || sidebarAlwaysFloats) && (
<Button
bare
style={{
marginRight: 8,
'& .arrow-right': { opacity: 0, transition: 'opacity .3s' },
'& .menu': { opacity: 1, transition: 'opacity .3s' },
'&:hover .arrow-right': { opacity: 1 },
'&:hover .menu': { opacity: 0 },
'&:hover .arrow-right': !sidebarAlwaysFloats && { opacity: 1 },
'&:hover .menu': !sidebarAlwaysFloats && { opacity: 0 },
}}
onMouseEnter={() => sidebar.show()}
onMouseLeave={() => sidebar.hide()}
onClick={() => {
saveGlobalPrefs({ floatingSidebar: !floatingSidebar });
if (windowWidth >= breakpoints.medium) {
saveGlobalPrefs({ floatingSidebar: !floatingSidebar });
} else {
sidebar.toggle();
}
}}
>
<View style={{ width: 15, height: 15 }}>

View File

@@ -1,274 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import * as actions from 'loot-core/src/client/actions';
import BudgetCategories from './tutorial/BudgetCategories';
import BudgetInitial from './tutorial/BudgetInitial';
import BudgetNewIncome from './tutorial/BudgetNewIncome';
import BudgetNextMonth from './tutorial/BudgetNextMonth';
import BudgetSummary from './tutorial/BudgetSummary';
import CategoryBalance from './tutorial/CategoryBalance';
import Final from './tutorial/Final';
import Intro from './tutorial/Intro';
import Overspending from './tutorial/Overspending';
import TransactionAdd from './tutorial/TransactionAdd';
import TransactionEnter from './tutorial/TransactionEnter';
function generatePath(innerRect, outerRect) {
const i = innerRect;
const o = outerRect;
// prettier-ignore
return `
M0,0 ${o.width},0 ${o.width},${o.height} L0,${o.height} L0,0 Z
M${i.left},${i.top} L${i.left+i.width},${i.top} L${i.left+i.width},${i.top+i.height} L${i.left},${i.top+i.height} L${i.left},${i.top} Z
`;
}
function expandRect({ top, left, width, height }, padding) {
if (typeof padding === 'number') {
return {
top: top - padding,
left: left - padding,
width: width + padding * 2,
height: height + padding * 2,
};
} else if (padding) {
return {
top: top - (padding.top || 0),
left: left - (padding.left || 0),
width: width + (padding.right || 0) + (padding.left || 0),
height: height + (padding.bottom || 0) + (padding.top || 0),
};
}
return { top, left, width, height };
}
function withinWindow(rect) {
return {
top: rect.top,
left: rect.left,
width: Math.min(rect.left + rect.width, window.innerWidth) - rect.left,
height: Math.min(rect.top + rect.height, window.innerHeight) - rect.top,
};
}
class MeasureNodes extends React.Component {
state = { measurements: null };
componentDidMount() {
window.addEventListener('resize', () => {
setTimeout(() => this.updateMeasurements(true), 0);
});
this.updateMeasurements();
}
componentDidUpdate(prevProps) {
if (prevProps.nodes !== this.props.nodes) {
this.updateMeasurements();
}
}
updateMeasurements() {
this.setState({
measurements: this.props.nodes.map(node => node.getBoundingClientRect()),
});
}
render() {
const { children } = this.props;
const { measurements } = this.state;
return measurements ? children(...measurements) : null;
}
}
class Tutorial extends React.Component {
state = { highlightRect: null, windowRect: null };
static contextTypes = {
getTutorialNode: PropTypes.func,
endTutorial: PropTypes.func,
};
onClose = didQuitEarly => {
// The difference between these is `endTutorial` permanently
// disable the tutorial. If the user walked all the way through
// it, never show it to them again. Otherwise they will see if
// again if they create a new budget.
if (didQuitEarly) {
this.props.closeTutorial();
} else {
this.props.endTutorial();
}
};
getContent(stage, targetRect, navigationProps) {
switch (stage) {
case 'budget-summary':
return (
<BudgetSummary
fromYNAB={this.props.fromYNAB}
targetRect={targetRect}
navigationProps={navigationProps}
/>
);
case 'budget-categories':
return (
<BudgetCategories
targetRect={targetRect}
navigationProps={navigationProps}
/>
);
case 'transaction-add':
return (
<TransactionAdd
targetRect={targetRect}
navigationProps={navigationProps}
/>
);
case 'budget-new-income':
return (
<BudgetNewIncome
targetRect={targetRect}
navigationProps={navigationProps}
/>
);
case 'budget-next-month':
return <div>hi</div>;
default:
throw new Error(
`Encountered an unexpected error rendering the tutorial content for ${stage}`,
);
}
}
render() {
const { stage, fromYNAB, nextTutorialStage, closeTutorial } = this.props;
if (stage === null) {
return null;
}
const navigationProps = {
nextTutorialStage: this.props.nextTutorialStage,
previousTutorialStage: this.props.previousTutorialStage,
closeTutorial: () => this.onClose(true),
endTutorial: () => this.onClose(false),
};
switch (stage) {
case 'intro':
return (
<Intro
nextTutorialStage={nextTutorialStage}
closeTutorial={closeTutorial}
fromYNAB={fromYNAB}
/>
);
case 'budget-initial':
return (
<BudgetInitial
nextTutorialStage={nextTutorialStage}
closeTutorial={closeTutorial}
navigationProps={navigationProps}
/>
);
case 'budget-next-month':
return (
<BudgetNextMonth
nextTutorialStage={nextTutorialStage}
closeTutorial={closeTutorial}
navigationProps={navigationProps}
/>
);
case 'budget-next-month2':
return (
<BudgetNextMonth
nextTutorialStage={nextTutorialStage}
closeTutorial={closeTutorial}
navigationProps={navigationProps}
stepTwo={true}
/>
);
case 'transaction-enter':
return (
<TransactionEnter
fromYNAB={fromYNAB}
navigationProps={navigationProps}
/>
);
case 'budget-category-balance':
return <CategoryBalance navigationProps={navigationProps} />;
case 'budget-overspending':
return <Overspending navigationProps={navigationProps} />;
case 'budget-overspending2':
return (
<Overspending navigationProps={navigationProps} stepTwo={true} />
);
case 'final':
return (
<Final
nextTutorialStage={nextTutorialStage}
closeTutorial={closeTutorial}
navigationProps={navigationProps}
/>
);
default:
// Default case defined below (outside the switch statement)
}
const { node: targetNode, expand } = this.context.getTutorialNode(stage);
return (
<MeasureNodes nodes={[targetNode.parentNode, document.body]}>
{(targetRect, windowRect) => {
targetRect = withinWindow(
expandRect(expandRect(targetRect, 5), expand),
);
return (
<div>
{ReactDOM.createPortal(
<svg
width={windowRect.width}
height={windowRect.height}
viewBox={'0 0 ' + windowRect.width + ' ' + windowRect.height}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style={{
position: 'absolute',
top: 0,
left: 0,
zIndex: 1000,
pointerEvents: 'none',
}}
>
<path
fill="rgba(0, 0, 0, .2)"
fill-rule="evenodd"
d={generatePath(targetRect, windowRect)}
style={{ pointerEvents: 'fill' }}
/>
</svg>,
document.body,
)}
{this.getContent(stage, targetRect, navigationProps)}
</div>
);
}}
</MeasureNodes>
);
}
}
export default connect(
state => ({
stage: state.tutorial.stage,
fromYNAB: state.tutorial.fromYNAB,
}),
dispatch => bindActionCreators(actions, dispatch),
)(Tutorial);

View File

@@ -1,41 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
class Tutorial extends React.Component {
static childContextTypes = {
setTutorialNode: PropTypes.func,
getTutorialNode: PropTypes.func,
endTutorial: PropTypes.func,
};
constructor() {
super();
this.nodes = {};
}
getChildContext() {
return {
setTutorialNode: this.setTutorialNode,
getTutorialNode: this.getTutorialNode,
};
}
setTutorialNode = (name, node, expand) => {
this.nodes[name] = { node, expand };
};
getTutorialNode = (name, node) => {
return this.nodes[name];
};
render() {
const { children } = this.props;
return React.Children.only(children);
}
}
export default connect(state => ({ deactivated: state.tutorial.deactivated }))(
Tutorial,
);

View File

@@ -4,9 +4,11 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from 'loot-core/src/client/actions';
import { View, Text, Link, Button } from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import Close from 'loot-design/src/svg/v1/Close';
import Close from '../icons/v1/Close';
import { colors } from '../style';
import { View, Text, Link, Button } from './common';
function closeNotification(setAppState) {
// Set a flag to never show an update notification again for this session

View File

@@ -26,6 +26,28 @@ import {
applyChanges,
groupById,
} from 'loot-core/src/shared/util';
import useFeatureFlag from '../../hooks/useFeatureFlag';
import {
SelectedProviderWithItems,
useSelectedItems,
} from '../../hooks/useSelected';
import useSyncServerStatus from '../../hooks/useSyncServerStatus';
import Loading from '../../icons/AnimatedLoading';
import Add from '../../icons/v1/Add';
import DotsHorizontalTriple from '../../icons/v1/DotsHorizontalTriple';
import ArrowButtonRight1 from '../../icons/v2/ArrowButtonRight1';
import ArrowsExpand3 from '../../icons/v2/ArrowsExpand3';
import ArrowsShrink3 from '../../icons/v2/ArrowsShrink3';
import CheckCircle1 from '../../icons/v2/CheckCircle1';
import DownloadThickBottom from '../../icons/v2/DownloadThickBottom';
import Pencil1 from '../../icons/v2/Pencil1';
import SvgRemove from '../../icons/v2/Remove';
import SearchAlternate from '../../icons/v2/SearchAlternate';
import { authorizeBank } from '../../nordigen';
import { styles, colors } from '../../style';
import { useActiveLocation } from '../ActiveLocation';
import AnimatedRefresh from '../AnimatedRefresh';
import {
View,
Text,
@@ -36,34 +58,13 @@ import {
Tooltip,
Menu,
Stack,
} from 'loot-design/src/components/common';
import { KeyHandlers } from 'loot-design/src/components/KeyHandlers';
import NotesButton from 'loot-design/src/components/NotesButton';
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
import format from 'loot-design/src/components/spreadsheet/format';
import useSheetValue from 'loot-design/src/components/spreadsheet/useSheetValue';
import { SelectedItemsButton } from 'loot-design/src/components/table';
import {
SelectedProviderWithItems,
useSelectedItems,
} from 'loot-design/src/components/useSelected';
import { styles, colors } from 'loot-design/src/style';
import Loading from 'loot-design/src/svg/AnimatedLoading';
import Add from 'loot-design/src/svg/v1/Add';
import DotsHorizontalTriple from 'loot-design/src/svg/v1/DotsHorizontalTriple';
import ArrowButtonRight1 from 'loot-design/src/svg/v2/ArrowButtonRight1';
import ArrowsExpand3 from 'loot-design/src/svg/v2/ArrowsExpand3';
import ArrowsShrink3 from 'loot-design/src/svg/v2/ArrowsShrink3';
import CheckCircle1 from 'loot-design/src/svg/v2/CheckCircle1';
import DownloadThickBottom from 'loot-design/src/svg/v2/DownloadThickBottom';
import Pencil1 from 'loot-design/src/svg/v2/Pencil1';
import SvgRemove from 'loot-design/src/svg/v2/Remove';
import SearchAlternate from 'loot-design/src/svg/v2/SearchAlternate';
import useSyncServerStatus from '../../hooks/useSyncServerStatus';
import { authorizeBank } from '../../nordigen';
import { useActiveLocation } from '../ActiveLocation';
import AnimatedRefresh from '../AnimatedRefresh';
} from '../common';
import { KeyHandlers } from '../KeyHandlers';
import NotesButton from '../NotesButton';
import CellValue from '../spreadsheet/CellValue';
import format from '../spreadsheet/format';
import useSheetValue from '../spreadsheet/useSheetValue';
import { SelectedItemsButton } from '../table';
import { FilterButton, AppliedFilters } from './Filters';
import TransactionList from './TransactionList';
@@ -166,7 +167,7 @@ function ReconcilingMessage({
{(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')}
</strong>{' '}
to match
<br /> your bank{"'"}s balance of{' '}
<br /> your banks balance of{' '}
<Text style={{ fontWeight: 700 }}>
{format(targetBalance, 'financial')}
</Text>
@@ -285,7 +286,7 @@ function AccountMenu({
},
{
name: 'toggle-cleared',
text: (showCleared ? 'Hide' : 'Show') + ' "Cleared" Checkboxes',
text: (showCleared ? 'Hide' : 'Show') + ' Cleared Checkboxes',
},
{ name: 'export', text: 'Export' },
{ name: 'reconcile', text: 'Reconcile' },
@@ -400,6 +401,7 @@ function Balances({ balanceQuery, showExtraBalances, onToggleExtraBalances }) {
}}
>
<Button
data-testid="account-balance"
bare
onClick={onToggleExtraBalances}
style={{
@@ -479,6 +481,7 @@ function SelectedTransactionsButton({
onDelete,
onEdit,
onUnlink,
onCreateRule,
onScheduleAction,
}) {
let selectedItems = useSelectedItems();
@@ -551,6 +554,10 @@ function SelectedTransactionsButton({
name: 'link-schedule',
text: 'Link schedule',
},
{
name: 'create-rule',
text: 'Create rule',
},
]),
Menu.line,
{ type: Menu.label, name: 'Edit field' },
@@ -604,6 +611,9 @@ function SelectedTransactionsButton({
case 'unlink-schedule':
onUnlink([...selectedItems]);
break;
case 'create-rule':
onCreateRule([...selectedItems]);
break;
default:
onEdit(name, [...selectedItems]);
}
@@ -650,6 +660,7 @@ const AccountHeader = React.memo(
onBatchDuplicate,
onBatchEdit,
onBatchUnlink,
onCreateRule,
onApplyFilter,
onUpdateFilter,
onDeleteFilter,
@@ -757,6 +768,7 @@ const AccountHeader = React.memo(
) : (
<View
style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }}
data-testid="account-name"
>
{account && account.closed
? 'Closed: ' + accountName
@@ -883,6 +895,7 @@ const AccountHeader = React.memo(
onDelete={onBatchDelete}
onEdit={onBatchEdit}
onUnlink={onBatchUnlink}
onCreateRule={onCreateRule}
onScheduleAction={onScheduleAction}
/>
)}
@@ -1663,6 +1676,44 @@ class AccountInternal extends React.PureComponent {
await this.refetchTransactions();
};
onCreateRule = async ids => {
let { data } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
let transactions = ungroupTransactions(data);
let payeeCondition = transactions[0].imported_payee
? {
field: 'imported_payee',
op: 'is',
value: transactions[0].imported_payee,
type: 'string',
}
: {
field: 'payee',
op: 'is',
value: transactions[0].payee,
type: 'id',
};
let rule = {
stage: null,
conditions: [payeeCondition],
actions: [
{
op: 'set',
field: 'category',
value: null,
type: 'id',
},
],
};
this.props.pushModal('edit-rule', { rule });
};
onUpdateFilter = (oldFilter, updatedFilter) => {
this.applyFilters(
this.state.filters.map(f => (f === oldFilter ? updatedFilter : f)),
@@ -1728,6 +1779,7 @@ class AccountInternal extends React.PureComponent {
payees,
syncEnabled,
dateFormat,
hideFraction,
addNotification,
accountsSyncing,
replaceModal,
@@ -1818,6 +1870,7 @@ class AccountInternal extends React.PureComponent {
onBatchDuplicate={this.onBatchDuplicate}
onBatchEdit={this.onBatchEdit}
onBatchUnlink={this.onBatchUnlink}
onCreateRule={this.onCreateRule}
onUpdateFilter={this.onUpdateFilter}
onDeleteFilter={this.onDeleteFilter}
onApplyFilter={this.onApplyFilter}
@@ -1856,6 +1909,7 @@ class AccountInternal extends React.PureComponent {
this.state.search !== '' || this.state.filters.length > 0
}
dateFormat={dateFormat}
hideFraction={hideFraction}
addNotification={addNotification}
renderEmpty={() =>
showEmptyMessage ? (
@@ -1912,14 +1966,15 @@ function AccountHack(props) {
}
export default function Account(props) {
const syncEnabled = useFeatureFlag('syncAccount');
let state = useSelector(state => ({
newTransactions: state.queries.newTransactions,
matchedTransactions: state.queries.matchedTransactions,
accounts: state.queries.accounts,
failedAccounts: state.account.failedAccounts,
categoryGroups: state.queries.categories.grouped,
syncEnabled: state.prefs.local['flags.syncAccount'],
dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
hideFraction: state.prefs.local.hideFraction || false,
expandSplits: props.match && state.prefs.local['expand-splits'],
showBalances:
props.match &&
@@ -1972,6 +2027,7 @@ export default function Account(props) {
<AccountHack
{...state}
{...actionCreators}
syncEnabled={syncEnabled}
modalShowing={
state.modalShowing ||
!!(activeLocation.state && activeLocation.state.locationPtr)

View File

@@ -2,11 +2,11 @@ import React, { useState } from 'react';
import { connect } from 'react-redux';
import * as actions from 'loot-core/src/client/actions';
import { View, Button, Tooltip } from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import ExclamationOutline from 'loot-design/src/svg/v1/ExclamationOutline';
import ExclamationOutline from '../../icons/v1/ExclamationOutline';
import { authorizeBank } from '../../nordigen';
import { colors } from '../../style';
import { View, Button, Tooltip } from '../common';
function getErrorMessage(type, code) {
switch (type.toUpperCase()) {
@@ -28,14 +28,6 @@ function getErrorMessage(type, code) {
}
break;
case 'API_ERROR':
switch (code.toUpperCase()) {
case 'PLANNED_MAINTENANCE':
return 'Our servers are currently undergoing maintenance and will be available again soon.';
default:
}
break;
case 'RATE_LIMIT_EXCEEDED':
return 'Rate limit exceeded for this item. Please try again later.';
@@ -118,7 +110,7 @@ function AccountSyncCheck({
color: 'currentColor',
}}
/>{' '}
This account is experiencing connection problems. Let{"'"}s fix it.
This account is experiencing connection problems. Lets fix it.
</Button>
{open && (

View File

@@ -21,6 +21,10 @@ import {
TYPE_INFO,
} from 'loot-core/src/shared/rules';
import { titleFirst } from 'loot-core/src/shared/util';
import DeleteIcon from '../../icons/v0/Delete';
import SettingsSliderAlternate from '../../icons/v2/SettingsSliderAlternate';
import { colors } from '../../style';
import {
View,
Text,
@@ -29,11 +33,7 @@ import {
Button,
Menu,
CustomSelect,
} from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import DeleteIcon from 'loot-design/src/svg/v0/Delete';
import SettingsSliderAlternate from 'loot-design/src/svg/v2/SettingsSliderAlternate';
} from '../common';
import { Value } from '../ManageRules';
import GenericInput from '../util/GenericInput';
@@ -166,7 +166,7 @@ function ConfigureField({
width={300}
onClose={() => dispatch({ type: 'close' })}
>
<FocusScope contain>
<FocusScope>
<View style={{ marginBottom: 10 }}>
{field === 'amount' || field === 'date' ? (
<CustomSelect

View File

@@ -19,9 +19,9 @@ import {
isPreviewId,
ungroupTransactions,
} from 'loot-core/src/shared/transactions';
import { colors } from 'loot-design/src/style';
import { withThemeColor } from 'loot-design/src/util/withThemeColor';
import { colors } from '../../style';
import { withThemeColor } from '../../util/withThemeColor';
import SyncRefresh from '../SyncRefresh';
import { default as AccountDetails } from './MobileAccountDetails';
@@ -231,6 +231,7 @@ function Account(props) {
let balance = queries.accountBalance(account);
let numberFormat = state.prefs.numberFormat || 'comma-dot';
let hideFraction = state.prefs.hideFraction || false;
return (
<SyncRefresh onSync={onRefresh}>
@@ -246,7 +247,7 @@ function Account(props) {
// format changes
{...state}
{...actionCreators}
key={numberFormat}
key={numberFormat + hideFraction}
account={account}
accounts={props.accounts}
categories={state.categories}

View File

@@ -1,77 +1,62 @@
import React, { useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
Button,
InputWithContent,
Label,
View,
} from 'loot-design/src/components/common';
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
import Text from 'loot-design/src/components/Text';
import { colors, styles } from 'loot-design/src/style';
import Add from 'loot-design/src/svg/v1/Add';
import CheveronLeft from 'loot-design/src/svg/v1/CheveronLeft';
import SearchAlternate from 'loot-design/src/svg/v2/SearchAlternate';
import Add from '../../icons/v1/Add';
import CheveronLeft from '../../icons/v1/CheveronLeft';
import SearchAlternate from '../../icons/v2/SearchAlternate';
import { colors, styles } from '../../style';
import { Button, InputWithContent, Label, View } from '../common';
import CellValue from '../spreadsheet/CellValue';
import Text from '../Text';
import { TransactionList } from './MobileTransaction';
class TransactionSearchInput extends React.Component {
state = { text: '' };
function TransactionSearchInput({ accountName, onSearch }) {
const [text, setText] = useState('');
performSearch = () => {
this.props.onSearch(this.state.text);
};
onChange = text => {
this.setState({ text }, this.performSearch);
};
render() {
const { accountName } = this.props;
const { text } = this.state;
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.n11,
margin: '11px auto 4px',
borderRadius: 4,
padding: 10,
width: '100%',
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.n11,
margin: '11px auto 4px',
borderRadius: 4,
padding: 10,
width: '100%',
}}
>
<InputWithContent
leftContent={
<SearchAlternate
style={{
width: 13,
height: 13,
flexShrink: 0,
color: text ? colors.p7 : 'inherit',
margin: 5,
marginRight: 0,
}}
/>
}
value={text}
onUpdate={text => {
setText(text);
onSearch(text);
}}
>
<InputWithContent
leftContent={
<SearchAlternate
style={{
width: 13,
height: 13,
flexShrink: 0,
color: text ? colors.p7 : 'inherit',
margin: 5,
marginRight: 0,
}}
/>
}
value={text}
onUpdate={this.onChange}
placeholder={`Search ${accountName}`}
style={{
backgroundColor: colors.n11,
border: `1px solid ${colors.n9}`,
fontSize: 15,
flex: 1,
height: 32,
marginLeft: 4,
padding: 8,
}}
/>
</View>
);
}
placeholder={`Search ${accountName}`}
style={{
backgroundColor: colors.n11,
border: `1px solid ${colors.n9}`,
fontSize: 15,
flex: 1,
height: 32,
marginLeft: 4,
padding: 8,
}}
/>
</View>
);
}
const LEFT_RIGHT_FLEX_WIDTH = 70;
@@ -149,8 +134,8 @@ export default function AccountDetails({
>
{account.name}
</View>
{/*
TODO: connect to an add transaction modal
{/*
TODO: connect to an add transaction modal
Only left here but hidden for flex centering of the account name.
*/}
<Link to="transaction/new" style={{ visibility: 'hidden' }}>

View File

@@ -5,16 +5,12 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import * as actions from 'loot-core/src/client/actions';
import * as queries from 'loot-core/src/client/queries';
import { prettyAccountType } from 'loot-core/src/shared/accounts';
import {
Button,
Text,
TextOneLine,
View,
} from 'loot-design/src/components/common';
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
import { colors, styles } from 'loot-design/src/style';
import Wallet from 'loot-design/src/svg/v1/Wallet';
import { withThemeColor } from 'loot-design/src/util/withThemeColor';
import Wallet from '../../icons/v1/Wallet';
import { colors, styles } from '../../style';
import { withThemeColor } from '../../util/withThemeColor';
import { Button, Text, TextOneLine, View } from '../common';
import CellValue from '../spreadsheet/CellValue';
export function AccountHeader({ name, amount }) {
return (
@@ -306,13 +302,14 @@ function Accounts(props) {
let { accounts, categories, newTransactions, updatedAccounts, prefs } = props;
let numberFormat = prefs.numberFormat || 'comma-dot';
let hideFraction = prefs.hideFraction || false;
return (
<View style={{ flex: 1 }}>
<AccountList
// This key forces the whole table rerender when the number
// format changes
key={numberFormat}
key={numberFormat + hideFraction}
accounts={accounts.filter(account => !account.closed)}
categories={categories}
transactions={transactions || []}

View File

@@ -11,10 +11,11 @@ import * as monthUtils from 'loot-core/src/shared/months';
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
import { titleFirst } from 'loot-core/src/shared/util';
import { integerToCurrency, groupById } from 'loot-core/src/shared/util';
import { Text, TextOneLine, View } from 'loot-design/src/components/common';
import { styles, colors } from 'loot-design/src/style';
import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
import CheckCircle1 from 'loot-design/src/svg/v2/CheckCircle1';
import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
import CheckCircle1 from '../../icons/v2/CheckCircle1';
import { styles, colors } from '../../style';
import { Text, TextOneLine, View } from '../common';
const zIndices = { SECTION_HEADING: 10 };

View File

@@ -12,20 +12,11 @@ import {
getCategoriesById,
} from 'loot-core/src/client/reducers/queries';
import { integerToCurrency } from 'loot-core/src/shared/util';
import {
Table,
Row,
Field,
Cell,
SelectCell,
} from 'loot-design/src/components/table';
import {
useSelectedItems,
useSelectedDispatch,
} from 'loot-design/src/components/useSelected';
import { styles } from 'loot-design/src/style';
import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
import { useSelectedItems, useSelectedDispatch } from '../../hooks/useSelected';
import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
import { styles } from '../../style';
import { Table, Row, Field, Cell, SelectCell } from '../table';
import DisplayId from '../util/DisplayId';
function serializeTransaction(transaction, dateFormat) {

View File

@@ -71,6 +71,7 @@ export default function TransactionList({
isMatched,
isFiltered,
dateFormat,
hideFraction,
addNotification,
renderEmpty,
onChange,
@@ -170,6 +171,7 @@ export default function TransactionList({
isMatched={isMatched}
isFiltered={isFiltered}
dateFormat={dateFormat}
hideFraction={hideFraction}
addNotification={addNotification}
headerContent={headerContent}
renderEmpty={renderEmpty}

View File

@@ -36,11 +36,27 @@ import {
amountToInteger,
titleFirst,
} from 'loot-core/src/shared/util';
import AccountAutocomplete from 'loot-design/src/components/AccountAutocomplete';
import CategoryAutocomplete from 'loot-design/src/components/CategorySelect';
import { View, Text, Tooltip, Button } from 'loot-design/src/components/common';
import DateSelect from 'loot-design/src/components/DateSelect';
import PayeeAutocomplete from 'loot-design/src/components/PayeeAutocomplete';
import useFeatureFlag from '../../hooks/useFeatureFlag';
import { useMergedRefs } from '../../hooks/useMergedRefs';
import usePrevious from '../../hooks/usePrevious';
import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected';
import LeftArrow2 from '../../icons/v0/LeftArrow2';
import RightArrow2 from '../../icons/v0/RightArrow2';
import CheveronDown from '../../icons/v1/CheveronDown';
import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
import CalendarIcon from '../../icons/v2/Calendar';
import Hyperlink2 from '../../icons/v2/Hyperlink2';
import { styles, colors } from '../../style';
import LegacyAccountAutocomplete from '../autocomplete/AccountAutocomplete';
import NewCategoryAutocomplete from '../autocomplete/CategoryAutocomplete';
import LegacyCategoryAutocomplete from '../autocomplete/CategorySelect';
import NewAccountAutocomplete from '../autocomplete/NewAccountAutocomplete';
import NewPayeeAutocomplete from '../autocomplete/NewPayeeAutocomplete';
import LegacyPayeeAutocomplete from '../autocomplete/PayeeAutocomplete';
import { View, Text, Tooltip, Button } from '../common';
import { getStatusProps } from '../schedules/StatusBadge';
import DateSelect from '../select/DateSelect';
import {
Cell,
Field,
@@ -52,27 +68,13 @@ import {
CellButton,
useTableNavigator,
Table,
} from 'loot-design/src/components/table';
import { useMergedRefs } from 'loot-design/src/components/useMergedRefs';
import {
useSelectedDispatch,
useSelectedItems,
} from 'loot-design/src/components/useSelected';
import { styles, colors } from 'loot-design/src/style';
import LeftArrow2 from 'loot-design/src/svg/v0/LeftArrow2';
import RightArrow2 from 'loot-design/src/svg/v0/RightArrow2';
import CheveronDown from 'loot-design/src/svg/v1/CheveronDown';
import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
import CalendarIcon from 'loot-design/src/svg/v2/Calendar';
import Hyperlink2 from 'loot-design/src/svg/v2/Hyperlink2';
import { getStatusProps } from '../schedules/StatusBadge';
} from '../table';
function getDisplayValue(obj, name) {
return obj ? obj[name] : '';
}
function serializeTransaction(transaction, showZeroInDeposit, dateFormat) {
function serializeTransaction(transaction, showZeroInDeposit) {
let { amount, date } = transaction;
if (isPreviewId(transaction.id)) {
@@ -107,7 +109,7 @@ function serializeTransaction(transaction, showZeroInDeposit, dateFormat) {
};
}
function deserializeTransaction(transaction, originalTransaction, dateFormat) {
function deserializeTransaction(transaction, originalTransaction) {
let { debit, credit, date, ...realTransaction } = transaction;
let amount;
@@ -128,20 +130,6 @@ function deserializeTransaction(transaction, originalTransaction, dateFormat) {
return { ...realTransaction, date, amount };
}
function getParentTransaction(transactions, fromIndex) {
let trans = transactions[fromIndex];
let parentIdx = fromIndex;
while (parentIdx >= 0) {
if (transactions[parentIdx].id === trans.parent_id) {
// Found the parent
return transactions[parentIdx];
}
parentIdx--;
}
return null;
}
function isLastChild(transactions, index) {
let trans = transactions[index];
return (
@@ -275,7 +263,7 @@ export const TransactionHeader = React.memo(
{showCategory && <Cell value="Category" width="flex" />}
<Cell value="Payment" width={80} textAlign="right" />
<Cell value="Deposit" width={80} textAlign="right" />
{showBalance && <Cell value="Balance" width={85} textAlign="right" />}
{showBalance && <Cell value="Balance" width={88} textAlign="right" />}
{showCleared && <Field width={21} truncate={false} />}
<Cell value="" width={15 + styles.scrollbarWidth} />
</Row>
@@ -399,13 +387,13 @@ function PayeeCell({
transaction,
payee,
transferAcct,
importedPayee,
isPreview,
onEdit,
onUpdate,
onCreatePayee,
onManagePayees,
}) {
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
let isCreatingPayee = useRef(false);
return (
@@ -414,7 +402,7 @@ function PayeeCell({
name="payee"
value={payeeId}
valueStyle={[valueStyle, inherited && { color: colors.n8 }]}
formatter={value => getPayeePretty(transaction, payee, transferAcct)}
formatter={() => getPayeePretty(transaction, payee, transferAcct)}
exposed={focused}
onExpose={!isPreview && (name => onEdit(id, name))}
onUpdate={async value => {
@@ -436,6 +424,9 @@ function PayeeCell({
shouldSaveFromKey,
inputStyle,
}) => {
const PayeeAutocomplete = isNewAutocompleteEnabled
? NewPayeeAutocomplete
: LegacyPayeeAutocomplete;
return (
<>
<PayeeAutocomplete
@@ -455,6 +446,8 @@ function PayeeCell({
onUpdate={onUpdate}
onSelect={onSave}
onManagePayees={() => onManagePayees(payeeId)}
isCreatable
menuPortalTarget={undefined}
/>
</>
);
@@ -523,6 +516,7 @@ export const Transaction = React.memo(function Transaction(props) {
accounts,
balance,
dateFormat = 'MM/dd/yyyy',
hideFraction,
onSave,
onEdit,
onHover,
@@ -533,12 +527,20 @@ export const Transaction = React.memo(function Transaction(props) {
onToggleSplit,
} = props;
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
const AccountAutocomplete = isNewAutocompleteEnabled
? NewAccountAutocomplete
: LegacyAccountAutocomplete;
const CategoryAutocomplete = isNewAutocompleteEnabled
? NewCategoryAutocomplete
: LegacyCategoryAutocomplete;
let dispatchSelected = useSelectedDispatch();
let [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit);
let [prevTransaction, setPrevTransaction] = useState(originalTransaction);
let [transaction, setTransaction] = useState(
serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat),
serializeTransaction(originalTransaction, showZeroInDeposit),
);
let isPreview = isPreviewId(transaction.id);
@@ -547,7 +549,7 @@ export const Transaction = React.memo(function Transaction(props) {
showZeroInDeposit !== prevShowZero
) {
setTransaction(
serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat),
serializeTransaction(originalTransaction, showZeroInDeposit),
);
setPrevTransaction(originalTransaction);
setPrevShowZero(showZeroInDeposit);
@@ -588,13 +590,10 @@ export const Transaction = React.memo(function Transaction(props) {
let deserialized = deserializeTransaction(
newTransaction,
originalTransaction,
dateFormat,
);
// Run the transaction through the formatting so that we know
// it's always showing the formatted result
setTransaction(
serializeTransaction(deserialized, showZeroInDeposit, dateFormat),
);
setTransaction(serializeTransaction(deserialized, showZeroInDeposit));
onSave(deserialized);
}
}
@@ -630,6 +629,7 @@ export const Transaction = React.memo(function Transaction(props) {
let valueStyle = added ? { fontWeight: 600 } : null;
let backgroundFocus = hovered || focusedField === 'select';
let amountStyle = hideFraction ? { letterSpacing: -0.5 } : null;
return (
<Row
@@ -776,6 +776,7 @@ export const Transaction = React.memo(function Transaction(props) {
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
onUpdate={onUpdate}
onSelect={onSave}
menuPortalTarget={undefined}
/>
)}
</CustomCell>
@@ -985,6 +986,7 @@ export const Transaction = React.memo(function Transaction(props) {
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
onUpdate={onUpdate}
onSelect={onSave}
menuPortalTarget={undefined}
/>
)}
</CustomCell>
@@ -1001,7 +1003,7 @@ export const Transaction = React.memo(function Transaction(props) {
textAlign="right"
title={debit}
onExpose={!isPreview && (name => onEdit(id, name))}
style={[isParent && { fontStyle: 'italic' }, styles.tnum]}
style={[isParent && { fontStyle: 'italic' }, styles.tnum, amountStyle]}
inputProps={{
value: debit,
onUpdate: onUpdate.bind(null, 'debit'),
@@ -1019,7 +1021,7 @@ export const Transaction = React.memo(function Transaction(props) {
textAlign="right"
title={credit}
onExpose={!isPreview && (name => onEdit(id, name))}
style={[isParent && { fontStyle: 'italic' }, styles.tnum]}
style={[isParent && { fontStyle: 'italic' }, styles.tnum, amountStyle]}
inputProps={{
value: credit,
onUpdate: onUpdate.bind(null, 'credit'),
@@ -1035,8 +1037,8 @@ export const Transaction = React.memo(function Transaction(props) {
: integerToCurrency(balance)
}
valueStyle={{ color: balance < 0 ? colors.r4 : colors.g4 }}
style={styles.tnum}
width={85}
style={[styles.tnum, amountStyle]}
width={88}
textAlign="right"
/>
)}
@@ -1123,7 +1125,6 @@ export function isPreviewId(id) {
function NewTransaction({
transactions,
accounts,
currentAccountId,
categoryGroups,
payees,
editingTransaction,
@@ -1134,6 +1135,7 @@ function NewTransaction({
showBalance,
showCleared,
dateFormat,
hideFraction,
onHover,
onClose,
onSplit,
@@ -1157,7 +1159,7 @@ function NewTransaction({
}}
data-testid="new-transaction"
onKeyDown={e => {
if (e.keyCode === 27) {
if (e.code === 'Escape') {
onClose();
}
}}
@@ -1179,6 +1181,7 @@ function NewTransaction({
categoryGroups={categoryGroups}
payees={payees}
dateFormat={dateFormat}
hideFraction={hideFraction}
expanded={true}
onHover={onHover}
onEdit={onEdit}
@@ -1228,46 +1231,26 @@ function NewTransaction({
);
}
class TransactionTable_ extends React.Component {
container = React.createRef();
state = { highlightedRows: null };
function TransactionTableInner({
tableNavigator,
tableRef,
dateFormat = 'MM/dd/yyyy',
newNavigator,
renderEmpty,
onHover,
onScroll,
...props
}) {
const containerRef = React.createRef();
const isAddingPrev = usePrevious(props.isAdding);
componentDidMount() {
this.highlight = ids => {
this.setState({ highlightedRows: new Set(ids) }, () => {
this.setState({ highlightedRows: null });
});
};
}
componentWillReceiveProps(nextProps) {
const { isAdding } = this.props;
if (!isAdding && nextProps.isAdding) {
this.props.newNavigator.onEdit('temp', 'date');
useEffect(() => {
if (!isAddingPrev && props.isAdding) {
newNavigator.onEdit('temp', 'date');
}
}
}, [isAddingPrev, props.isAdding, newNavigator]);
componentDidUpdate() {
this._cachedParent = null;
}
getParent(trans, index) {
let { transactions } = this.props;
if (this._cachedParent && this._cachedParent.id === trans.parent_id) {
return this._cachedParent;
}
if (trans.parent_id) {
this._cachedParent = getParentTransaction(transactions, index);
return this._cachedParent;
}
return null;
}
renderRow = ({ item, index, position, editing, focusedFied, onEdit }) => {
const { highlightedRows } = this.state;
const renderRow = ({ item, index, position, editing }) => {
const {
transactions,
selectedItems,
@@ -1279,20 +1262,17 @@ class TransactionTable_ extends React.Component {
showAccount,
showCategory,
balances,
dateFormat = 'MM/dd/yyyy',
tableNavigator,
hideFraction,
isNew,
isMatched,
isExpanded,
} = this.props;
} = props;
let trans = item;
let hovered = hoveredTransaction === trans.id;
let selected = selectedItems.has(trans.id);
let highlighted =
!selected && (highlightedRows ? highlightedRows.has(trans.id) : false);
let parent = this.getParent(trans, index);
let parent = props.transactionMap.get(trans.parent_id);
let isChildDeposit = parent && parent.amount > 0;
let expanded = isExpanded && isExpanded((parent || trans).id);
@@ -1318,7 +1298,7 @@ class TransactionTable_ extends React.Component {
<TransactionError
error={error}
isDeposit={isChildDeposit}
onAddSplit={() => this.props.onAddSplit(trans.id)}
onAddSplit={() => props.onAddSplit(trans.id)}
/>
</Tooltip>
)}
@@ -1331,7 +1311,7 @@ class TransactionTable_ extends React.Component {
showCleared={showCleared}
hovered={hovered}
selected={selected}
highlighted={highlighted}
highlighted={false}
added={isNew && isNew(trans.id)}
expanded={isExpanded && isExpanded(trans.id)}
matched={isMatched && isMatched(trans.id)}
@@ -1347,117 +1327,105 @@ class TransactionTable_ extends React.Component {
: new Set()
}
dateFormat={dateFormat}
onHover={this.props.onHover}
hideFraction={hideFraction}
onHover={props.onHover}
onEdit={tableNavigator.onEdit}
onSave={this.props.onSave}
onDelete={this.props.onDelete}
onSplit={this.props.onSplit}
onManagePayees={this.props.onManagePayees}
onCreatePayee={this.props.onCreatePayee}
onToggleSplit={this.props.onToggleSplit}
onSave={props.onSave}
onDelete={props.onDelete}
onSplit={props.onSplit}
onManagePayees={props.onManagePayees}
onCreatePayee={props.onCreatePayee}
onToggleSplit={props.onToggleSplit}
/>
</>
);
};
render() {
let { props } = this;
let {
tableNavigator,
tableRef,
dateFormat = 'MM/dd/yyyy',
newNavigator,
renderEmpty,
onHover,
onScroll,
} = props;
return (
<View
innerRef={containerRef}
style={[{ flex: 1, cursor: 'default' }, props.style]}
>
<View>
<TransactionHeader
hasSelected={props.selectedItems.size > 0}
showAccount={props.showAccount}
showCategory={props.showCategory}
showBalance={!!props.balances}
showCleared={props.showCleared}
/>
return (
<View
innerRef={this.container}
style={[{ flex: 1, cursor: 'default' }, props.style]}
>
<View>
<TransactionHeader
hasSelected={props.selectedItems.size > 0}
showAccount={props.showAccount}
showCategory={props.showCategory}
showBalance={!!props.balances}
showCleared={props.showCleared}
/>
{props.isAdding && (
<View
{...newNavigator.getNavigatorProps({
onKeyDown: e => props.onCheckNewEnter(e),
})}
>
<NewTransaction
transactions={props.newTransactions}
editingTransaction={newNavigator.editingId}
hoveredTransaction={props.hoveredTransaction}
focusedField={newNavigator.focusedField}
accounts={props.accounts}
currentAccountId={props.currentAccountId}
categoryGroups={props.categoryGroups}
payees={this.props.payees || []}
showAccount={props.showAccount}
showCategory={props.showCategory}
showBalance={!!props.balances}
showCleared={props.showCleared}
dateFormat={dateFormat}
onClose={props.onCloseAddTransaction}
onAdd={this.props.onAddTemporary}
onAddSplit={this.props.onAddSplit}
onSplit={this.props.onSplit}
onEdit={newNavigator.onEdit}
onSave={this.props.onSave}
onDelete={this.props.onDelete}
onHover={this.props.onHover}
onManagePayees={this.props.onManagePayees}
onCreatePayee={this.props.onCreatePayee}
/>
</View>
)}
</View>
{/*// * On Windows, makes the scrollbar always appear
{props.isAdding && (
<View
{...newNavigator.getNavigatorProps({
onKeyDown: e => props.onCheckNewEnter(e),
})}
>
<NewTransaction
transactions={props.newTransactions}
editingTransaction={newNavigator.editingId}
hoveredTransaction={props.hoveredTransaction}
focusedField={newNavigator.focusedField}
accounts={props.accounts}
categoryGroups={props.categoryGroups}
payees={props.payees || []}
showAccount={props.showAccount}
showCategory={props.showCategory}
showBalance={!!props.balances}
showCleared={props.showCleared}
dateFormat={dateFormat}
hideFraction={props.hideFraction}
onClose={props.onCloseAddTransaction}
onAdd={props.onAddTemporary}
onAddSplit={props.onAddSplit}
onSplit={props.onSplit}
onEdit={newNavigator.onEdit}
onSave={props.onSave}
onDelete={props.onDelete}
onHover={onHover}
onManagePayees={props.onManagePayees}
onCreatePayee={props.onCreatePayee}
/>
</View>
)}
</View>
{/*// * On Windows, makes the scrollbar always appear
// the full height of the container ??? */}
<View
style={[{ flex: 1, overflow: 'hidden' }]}
data-testid="transaction-table"
onMouseLeave={() => onHover(null)}
>
<Table
navigator={tableNavigator}
ref={tableRef}
items={props.transactions}
renderItem={this.renderRow}
renderEmpty={renderEmpty}
loadMore={props.loadMoreTransactions}
isSelected={id => props.selectedItems.has(id)}
onKeyDown={e => props.onCheckEnter(e)}
onScroll={onScroll}
/>
<View
style={[{ flex: 1, overflow: 'hidden' }]}
data-testid="transaction-table"
onMouseLeave={() => onHover(null)}
>
<Table
navigator={tableNavigator}
ref={tableRef}
items={props.transactions}
renderItem={renderRow}
renderEmpty={renderEmpty}
loadMore={props.loadMoreTransactions}
isSelected={id => props.selectedItems.has(id)}
onKeyDown={e => props.onCheckEnter(e)}
onScroll={onScroll}
/>
{props.isAdding && (
<div
key="shadow"
style={{
position: 'absolute',
top: -20,
left: 0,
right: 0,
height: 20,
backgroundColor: 'red',
boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
}}
/>
)}
</View>
{props.isAdding && (
<div
key="shadow"
style={{
position: 'absolute',
top: -20,
left: 0,
right: 0,
height: 20,
backgroundColor: 'red',
boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
}}
/>
)}
</View>
);
}
</View>
);
}
export let TransactionTable = React.forwardRef((props, ref) => {
@@ -1509,6 +1477,9 @@ export let TransactionTable = React.forwardRef((props, ref) => {
prevSplitsExpanded.current = splitsExpanded;
return result;
}, [props.transactions, splitsExpanded]);
const transactionMap = useMemo(() => {
return new Map(transactions.map(trans => [trans.id, trans]));
}, [transactions]);
useEffect(() => {
// If it's anchored that means we've also disabled animations. To
@@ -1619,9 +1590,7 @@ export let TransactionTable = React.forwardRef((props, ref) => {
}
function onCheckNewEnter(e) {
const ENTER = 13;
if (e.keyCode === ENTER) {
if (e.code === 'Enter') {
if (e.metaKey) {
e.stopPropagation();
onAddTemporary();
@@ -1665,15 +1634,13 @@ export let TransactionTable = React.forwardRef((props, ref) => {
}
function onCheckEnter(e) {
const ENTER = 13;
if (e.keyCode === ENTER && !e.shiftKey) {
if (e.code === 'Enter' && !e.shiftKey) {
let { editingId: id, focusedField } = tableNavigator;
afterSave(props => {
afterSave(() => {
let transactions = latestState.current.transactions;
let idx = transactions.findIndex(t => t.id === id);
let parent = getParentTransaction(transactions, idx);
let parent = transactionMap.get(transactions[idx]?.parent_id);
if (
isLastChild(transactions, idx) &&
@@ -1798,11 +1765,11 @@ export let TransactionTable = React.forwardRef((props, ref) => {
);
return (
// eslint-disable-next-line react/jsx-pascal-case
<TransactionTable_
<TransactionTableInner
tableRef={mergedRef}
{...props}
transactions={transactions}
transactionMap={transactionMap}
selectedItems={selectedItems}
hoveredTransaction={hoveredTransaction}
isExpanded={splitsExpanded.expanded}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { format as formatDate, parse as parseDate } from 'date-fns';
import { act } from 'react-dom/test-utils';
import {
generateTransaction,
@@ -18,13 +18,15 @@ import {
updateTransaction,
} from 'loot-core/src/shared/transactions';
import { integerToCurrency } from 'loot-core/src/shared/util';
import { SelectedProviderWithItems } from 'loot-design/src/components/useSelected';
import { SelectedProviderWithItems } from '../../hooks/useSelected';
import { SplitsExpandedProvider, TransactionTable } from './TransactionsTable';
const uuid = require('loot-core/src/platform/uuid');
jest.mock('loot-core/src/platform/client/fetch');
jest.mock('../../hooks/useFeatureFlag', () => jest.fn().mockReturnValue(false));
const accounts = [generateAccount('Bank of America')];
const payees = [
@@ -76,99 +78,66 @@ function generateTransactions(count, splitAtIndexes = [], showError = false) {
return transactions;
}
class LiveTransactionTable extends React.Component {
constructor(props) {
super(props);
this.state = { transactions: props.transactions };
}
function LiveTransactionTable(props) {
const [transactions, setTransactions] = useState(props.transactions);
componentWillReceiveProps(nextProps) {
if (this.state.transactions !== nextProps.transactions) {
this.setState({ transactions: nextProps.transactions });
}
}
useEffect(() => {
if (transactions === props.transactions) return;
props.onTransactionsChange && props.onTransactionsChange(transactions);
}, [transactions]);
notifyChange = () => {
const { onTransactionsChange } = this.props;
onTransactionsChange && onTransactionsChange(this.state.transactions);
};
onSplit = id => {
let { state } = this;
let { data, diff } = splitTransaction(state.transactions, id);
this.setState({ transactions: data }, this.notifyChange);
const onSplit = id => {
let { data, diff } = splitTransaction(transactions, id);
setTransactions(data);
return diff.added[0].id;
};
// onDelete = id => {
// let { state } = this;
// this.setState(
// {
// transactions: applyChanges(
// deleteTransaction(state.transactions, id),
// state.transactions
// )
// },
// this.notifyChange
// );
// };
onSave = transaction => {
let { state } = this;
let { data } = updateTransaction(state.transactions, transaction);
this.setState({ transactions: data }, this.notifyChange);
const onSave = transaction => {
let { data } = updateTransaction(transactions, transaction);
setTransactions(data);
};
onAdd = newTransactions => {
let { state } = this;
const onAdd = newTransactions => {
newTransactions = realizeTempTransactions(newTransactions);
this.setState(
{ transactions: [...newTransactions, ...state.transactions] },
this.notifyChange,
);
setTransactions(trans => [...newTransactions, ...trans]);
};
onAddSplit = id => {
let { state } = this;
let { data, diff } = addSplitTransaction(state.transactions, id);
this.setState({ transactions: data }, this.notifyChange);
const onAddSplit = id => {
let { data, diff } = addSplitTransaction(transactions, id);
setTransactions(data);
return diff.added[0].id;
};
onCreatePayee = name => 'id';
const onCreatePayee = () => 'id';
render() {
const { state } = this;
// It's important that these functions are they same instances
// across renders. Doing so tests that the transaction table
// implementation properly uses the right latest state even if the
// hook dependencies haven't changed
return (
<TestProvider>
<SelectedProviderWithItems
name="transactions"
items={state.transactions}
fetchAllIds={() => state.transactions.map(t => t.id)}
>
<SplitsExpandedProvider>
<TransactionTable
{...this.props}
transactions={state.transactions}
loadMoreTransactions={() => {}}
payees={payees}
addNotification={n => console.log(n)}
onSave={this.onSave}
onSplit={this.onSplit}
onAdd={this.onAdd}
onAddSplit={this.onAddSplit}
onCreatePayee={this.onCreatePayee}
/>
</SplitsExpandedProvider>
</SelectedProviderWithItems>
</TestProvider>
);
}
// It's important that these functions are they same instances
// across renders. Doing so tests that the transaction table
// implementation properly uses the right latest state even if the
// hook dependencies haven't changed
return (
<TestProvider>
<SelectedProviderWithItems
name="transactions"
items={transactions}
fetchAllIds={() => transactions.map(t => t.id)}
>
<SplitsExpandedProvider>
<TransactionTable
{...props}
transactions={transactions}
loadMoreTransactions={() => {}}
payees={payees}
addNotification={n => console.log(n)}
onSave={onSave}
onSplit={onSplit}
onAdd={onAdd}
onAddSplit={onAddSplit}
onCreatePayee={onCreatePayee}
/>
</SplitsExpandedProvider>
</SelectedProviderWithItems>
</TestProvider>
);
}
function initBasicServer() {
@@ -204,52 +173,10 @@ const categories = categoryGroups.reduce(
[],
);
const keys = {
ESC: {
key: 'Esc',
keyCode: 27,
which: 27,
},
ENTER: {
key: 'Enter',
keyCode: 13,
which: 13,
},
TAB: {
key: 'Tab',
keyCode: 9,
which: 9,
},
DOWN: {
key: 'Down',
keyCode: 40,
which: 40,
},
UP: {
key: 'Up',
keyCode: 38,
which: 38,
},
LEFT: {
key: 'Left',
keyCode: 37,
which: 37,
},
RIGHT: {
key: 'Right',
keyCode: 39,
which: 39,
},
};
function prettyDate(date) {
return formatDate(parseDate(date, 'yyyy-MM-dd', new Date()), 'MM/dd/yyyy');
}
function keyWithShift(key) {
return { ...key, shiftKey: true };
}
function renderTransactions(extraProps) {
let transactions = generateTransactions(5, [6]);
// Hardcoding the first value makes it easier for tests to do
@@ -305,7 +232,7 @@ function queryField(container, name, subSelector = '', idx) {
return field;
}
function _editField(field, container) {
async function _editField(field, container) {
// We only short-circuit this for inputs
let input = field.querySelector(`input`);
if (input) {
@@ -318,11 +245,11 @@ function _editField(field, container) {
if (field.querySelector(buttonQuery)) {
let btn = field.querySelector(buttonQuery);
fireEvent.click(btn);
await userEvent.click(btn);
element = field.querySelector(':focus');
expect(element).toBeTruthy();
} else {
fireEvent.click(field.querySelector('div'));
await userEvent.click(field.querySelector('div'));
element = field.querySelector('input');
expect(element).toBeTruthy();
expect(container.ownerDocument.activeElement).toBe(element);
@@ -393,123 +320,123 @@ describe('Transactions', () => {
});
});
test('keybindings enter/tab/alt should move around', () => {
test('keybindings enter/tab/alt should move around', async () => {
const { container } = renderTransactions();
// Enter/tab goes down/right
let input = editField(container, 'notes', 2);
fireEvent.keyDown(input, keys.ENTER);
let input = await editField(container, 'notes', 2);
await userEvent.type(input, '[Enter]');
expectToBeEditingField(container, 'notes', 3);
input = editField(container, 'payee', 2);
fireEvent.keyDown(input, keys.TAB);
input = await editField(container, 'payee', 2);
await userEvent.type(input, '[Tab]');
expectToBeEditingField(container, 'notes', 2);
// Shift+enter/tab goes up/left
input = editField(container, 'notes', 2);
fireEvent.keyDown(input, keyWithShift(keys.ENTER));
input = await editField(container, 'notes', 2);
await userEvent.type(input, '{Shift>}[Enter]{/Shift}');
expectToBeEditingField(container, 'notes', 1);
input = editField(container, 'payee', 2);
fireEvent.keyDown(input, keyWithShift(keys.TAB));
input = await editField(container, 'payee', 2);
await userEvent.type(input, '{Shift>}[Tab]{/Shift}');
expectToBeEditingField(container, 'account', 2);
// Moving forward on the last cell moves to the next row
input = editField(container, 'cleared', 2);
fireEvent.keyDown(input, keys.TAB);
input = await editField(container, 'cleared', 2);
await userEvent.type(input, '[Tab]');
expectToBeEditingField(container, 'select', 3);
// Moving backward on the first cell moves to the previous row
editField(container, 'date', 2);
input = editField(container, 'select', 2);
fireEvent.keyDown(input, keyWithShift(keys.TAB));
await editField(container, 'date', 2);
input = await editField(container, 'select', 2);
await userEvent.type(input, '{Shift>}[Tab]{/Shift}');
expectToBeEditingField(container, 'cleared', 1);
// Blurring should close the input
input = editField(container, 'credit', 1);
input = await editField(container, 'credit', 1);
fireEvent.blur(input);
expect(container.querySelector('input')).toBe(null);
// When reaching the bottom it shouldn't error
input = editField(container, 'notes', 4);
fireEvent.keyDown(input, keys.ENTER);
input = await editField(container, 'notes', 4);
await userEvent.type(input, '[Enter]');
// TODO: fix flakiness and re-enable
// When reaching the top it shouldn't error
input = editField(container, 'notes', 0);
fireEvent.keyDown(input, keyWithShift(keys.ENTER));
// input = await editField(container, 'notes', 0);
// await userEvent.type(input, '{Shift>}[Enter]{/Shift}');
});
test('keybinding escape resets the value', () => {
test('keybinding escape resets the value', async () => {
const { container } = renderTransactions();
let input = editField(container, 'notes', 2);
let input = await editField(container, 'notes', 2);
let oldValue = input.value;
fireEvent.change(input, { target: { value: 'yo new value' } });
await userEvent.clear(input);
await userEvent.type(input, 'yo new value');
expect(input.value).toEqual('yo new value');
fireEvent.keyDown(input, keys.ESC);
await userEvent.type(input, '[Escape]');
expect(input.value).toEqual(oldValue);
input = editField(container, 'category', 2);
input = await editField(container, 'category', 2);
oldValue = input.value;
fireEvent.change(input, { target: { value: 'Gener' } });
await userEvent.clear(input);
await userEvent.type(input, 'Gener');
expect(input.value).toEqual('Gener');
fireEvent.keyDown(input, keys.ESC);
await userEvent.type(input, '[Escape]');
expect(input.value).toEqual(oldValue);
});
test('text fields save when moved away from', () => {
test('text fields save when moved away from', async () => {
const { container, getTransactions } = renderTransactions();
function runWithMovementKeys(func) {
// All of these keys move to a different field, and the value in
// the previous input should be saved
const ks = [
keys.TAB,
keys.ENTER,
keyWithShift(keys.TAB),
keyWithShift(keys.ENTER),
];
// All of these keys move to a different field, and the value in
// the previous input should be saved
const ks = [
'[Tab]',
'[Enter]',
'{Shift>}[Tab]{/Shift}',
'{Shift>}[Enter]{/Shift}',
];
ks.forEach((k, i) => func(k, i));
}
runWithMovementKeys((key, idx) => {
let input = editField(container, 'notes', 2);
for (let idx in ks) {
let input = await editField(container, 'notes', 2);
let oldValue = input.value;
fireEvent.change(input, {
target: { value: 'a happy little note' + idx },
});
await userEvent.clear(input);
await userEvent.type(input, 'a happy little note' + idx);
// It's not saved yet
expect(getTransactions()[2].notes).toBe(oldValue);
fireEvent.keyDown(input, keys.TAB);
await userEvent.type(input, '[Tab]');
// Now it should be saved!
expect(getTransactions()[2].notes).toBe('a happy little note' + idx);
expect(queryField(container, 'notes', 'div', 2).textContent).toBe(
'a happy little note' + idx,
);
});
}
let input = editField(container, 'notes', 2);
let input = await editField(container, 'notes', 2);
let oldValue = input.value;
fireEvent.change(input, { target: { value: 'another happy note' } });
await userEvent.clear(input);
await userEvent.type(input, 'another happy note');
// It's not saved yet
expect(getTransactions()[2].notes).toBe(oldValue);
// Blur the input to make it stop editing
fireEvent.blur(input);
await userEvent.tab();
expect(getTransactions()[2].notes).toBe('another happy note');
});
test('dropdown automatically opens and can be filtered', () => {
test('dropdown automatically opens and can be filtered', async () => {
const { container } = renderTransactions();
let input = editField(container, 'category', 2);
let input = await editField(container, 'category', 2);
let tooltip = container.querySelector('[data-testid="tooltip"]');
expect(tooltip).toBeTruthy();
expect(
[...tooltip.querySelectorAll('[data-testid*="category-item"]')].length,
).toBe(9);
fireEvent.change(input, { target: { value: 'Gener' } });
await userEvent.clear(input);
await userEvent.type(input, 'Gener');
// Make sure the list is filtered, the right items exist, and the
// first item is highlighted
@@ -520,7 +447,8 @@ describe('Transactions', () => {
expect(items[1].dataset['testid']).toBe('category-item-highlighted');
// It should also allow filtering on group names
fireEvent.change(input, { target: { value: 'Usual' } });
await userEvent.clear(input);
await userEvent.type(input, 'Usual');
items = tooltip.querySelectorAll('[data-testid*="category-item"]');
expect(items.length).toBe(4);
@@ -534,7 +462,7 @@ describe('Transactions', () => {
test('dropdown selects an item with keyboard', async () => {
const { container, getTransactions } = renderTransactions();
let input = editField(container, 'category', 2);
let input = await editField(container, 'category', 2);
let tooltip = container.querySelector('[data-testid="tooltip"]');
// No item should be highlighted
@@ -543,10 +471,7 @@ describe('Transactions', () => {
);
expect(highlighted).toBe(null);
fireEvent.keyDown(input, keys.DOWN);
fireEvent.keyDown(input, keys.DOWN);
fireEvent.keyDown(input, keys.DOWN);
fireEvent.keyDown(input, keys.DOWN);
await userEvent.keyboard('[ArrowDown][ArrowDown][ArrowDown][ArrowDown]');
// The right item should be highlighted
highlighted = tooltip.querySelector(
@@ -559,7 +484,7 @@ describe('Transactions', () => {
categories.find(category => category.name === 'Food').id,
);
fireEvent.keyDown(input, keys.ENTER);
await userEvent.type(input, '[Enter]');
await waitForAutocomplete();
// The transactions data should be updated with the right category
@@ -573,14 +498,14 @@ describe('Transactions', () => {
expect(container.querySelector('[data-testid="tooltip"]')).toBe(null);
// Pressing enter should now move down
fireEvent.keyDown(input, keys.ENTER);
await userEvent.type(input, '[Enter]');
expectToBeEditingField(container, 'category', 3);
});
test('dropdown selects an item when clicking', async () => {
const { container, getTransactions } = renderTransactions();
editField(container, 'category', 2);
await editField(container, 'category', 2);
let tooltip = container.querySelector('[data-testid="tooltip"]');
@@ -592,7 +517,7 @@ describe('Transactions', () => {
expect(highlighted).toBe(null);
// Hover over an item
fireEvent.mouseMove(items[2]);
await userEvent.hover(items[2]);
// Make sure the expected category is highlighted
highlighted = tooltip.querySelector(
@@ -605,7 +530,7 @@ describe('Transactions', () => {
expect(getTransactions()[2].category).toBe(
categories.find(c => c.name === 'Food').id,
);
fireEvent.click(items[2]);
await userEvent.click(items[2]);
await waitForAutocomplete();
expect(getTransactions()[2].category).toBe(
categories.find(c => c.name === 'General').id,
@@ -617,18 +542,18 @@ describe('Transactions', () => {
expectToBeEditingField(container, 'category', 2);
});
test("dropdown hovers but doesn't change value", () => {
test('dropdown hovers but doesnt change value', async () => {
const { container, getTransactions } = renderTransactions();
let input = editField(container, 'category', 2);
let input = await editField(container, 'category', 2);
let oldCategory = getTransactions()[2].category;
let tooltip = container.querySelector('[data-testid="tooltip"]');
let items = tooltip.querySelectorAll('[data-testid="category-item"]');
// Hover over a few of the items to highlight them
fireEvent.mouseMove(items[2]);
fireEvent.mouseMove(items[3]);
await userEvent.hover(items[2]);
await userEvent.hover(items[3]);
// Make sure one of them is highlighted
let highlighted = tooltip.querySelector(
@@ -637,7 +562,7 @@ describe('Transactions', () => {
expect(highlighted).toBeTruthy();
// Navigate away from the field with the keyboard
fireEvent.keyDown(input, keys.TAB);
await userEvent.type(input, '[Tab]');
// Make sure the category didn't update, and that the highlighted
// field was different than the transactions' category
@@ -653,8 +578,9 @@ describe('Transactions', () => {
const { container, getTransactions } = renderTransactions();
// Invalid values should be rejected and nullified
let input = editField(container, 'category', 2);
fireEvent.change(input, { target: { value: 'aaabbbccc' } });
let input = await editField(container, 'category', 2);
await userEvent.clear(input);
await userEvent.type(input, 'aaabbbccc');
// For this first test case, make sure the tooltip is gone. We
// don't need to check this in all the other cases
@@ -664,36 +590,35 @@ describe('Transactions', () => {
expect(tooltipItems.length).toBe(0);
expect(getTransactions()[2].category).not.toBe(null);
fireEvent.keyDown(input, keys.TAB);
await userEvent.tab();
expect(getTransactions()[2].category).toBe(null);
// Clear out the category value
input = editField(container, 'category', 3);
fireEvent.change(input, { target: { value: '' } });
input = await editField(container, 'category', 3);
await userEvent.clear(input);
// The category should be null when the value is cleared
expect(getTransactions()[3].category).not.toBe(null);
fireEvent.keyDown(input, keys.TAB);
await userEvent.tab();
expect(getTransactions()[3].category).toBe(null);
// Clear out the payee value
input = editField(container, 'payee', 3);
input = await editField(container, 'payee', 3);
await new Promise(resolve => setTimeout(resolve, 10));
fireEvent.change(input, { target: { value: '' } });
await userEvent.clear(input);
// The payee should be empty when the value is cleared
expect(getTransactions()[3].payee).not.toBe('');
fireEvent.keyDown(input, keys.TAB);
await userEvent.tab();
expect(getTransactions()[3].payee).toBe(null);
});
test('dropdown escape resets the value ', () => {
test('dropdown escape resets the value ', async () => {
const { container } = renderTransactions();
let input = editField(container, 'category', 2);
let input = await editField(container, 'category', 2);
let oldValue = input.value;
fireEvent.change(input, { target: { value: 'aaabbbccc' } });
fireEvent.keyDown(input, keys.ESC);
await userEvent.type(input, 'aaabbbccc[Escape]');
expect(input.value).toBe(oldValue);
// The tooltip be closed
@@ -717,17 +642,15 @@ describe('Transactions', () => {
expect(container.ownerDocument.activeElement).toBe(input);
expect(input.value).not.toBe('');
input = editNewField(container, 'notes');
fireEvent.change(input, { target: { value: 'a transaction' } });
fireEvent.keyDown(input, keys.ENTER);
input = await editNewField(container, 'notes');
await userEvent.clear(input);
await userEvent.type(input, 'a transaction[Enter]');
input = editNewField(container, 'debit');
input = await editNewField(container, 'debit');
expect(input.value).toBe('0.00');
fireEvent.change(input, { target: { value: '100' } });
await userEvent.clear(input);
await userEvent.type(input, '100[Enter]');
act(() => {
fireEvent.keyDown(input, keys.ENTER);
});
expect(getTransactions().length).toBe(6);
expect(getTransactions()[0].amount).toBe(-10000);
expect(getTransactions()[0].notes).toBe('a transaction');
@@ -743,33 +666,34 @@ describe('Transactions', () => {
const { container, getTransactions, updateProps } = renderTransactions();
updateProps({ isAdding: true });
let input = editNewField(container, 'debit');
fireEvent.change(input, { target: { value: '55.00' } });
fireEvent.blur(input);
let input = await editNewField(container, 'debit');
await userEvent.clear(input);
await userEvent.type(input, '55.00');
editNewField(container, 'category');
await editNewField(container, 'category');
let splitButton = document.body.querySelector(
'[data-testid="tooltip"] [data-testid="split-transaction-button"]',
);
fireEvent.click(splitButton);
await userEvent.click(splitButton);
await waitForAutocomplete();
await waitForAutocomplete();
await waitForAutocomplete();
fireEvent.click(
await userEvent.click(
container.querySelector('[data-testid="transaction-error"] button'),
);
input = editNewField(container, 'debit', 1);
fireEvent.change(input, { target: { value: '45.00' } });
fireEvent.blur(input);
input = await editNewField(container, 'debit', 1);
await userEvent.clear(input);
await userEvent.type(input, '45.00');
expect(
container.querySelector('[data-testid="transaction-error"]'),
).toBeTruthy();
input = editNewField(container, 'debit', 2);
fireEvent.change(input, { target: { value: '10.00' } });
fireEvent.blur(input);
input = await editNewField(container, 'debit', 2);
await userEvent.clear(input);
await userEvent.type(input, '10.00');
await userEvent.tab();
expect(container.querySelector('[data-testid="transaction-error"]')).toBe(
null,
);
@@ -777,7 +701,7 @@ describe('Transactions', () => {
let addButton = container.querySelector('[data-testid="add-button"]');
expect(getTransactions().length).toBe(5);
fireEvent.click(addButton);
await userEvent.click(addButton);
expect(getTransactions().length).toBe(8);
expect(getTransactions()[0].is_parent).toBe(true);
expect(getTransactions()[0].amount).toBe(-5500);
@@ -785,10 +709,9 @@ describe('Transactions', () => {
expect(getTransactions()[1].amount).toBe(-4500);
expect(getTransactions()[2].is_child).toBe(true);
expect(getTransactions()[2].amount).toBe(-1000);
expect(getTransactions().slice(0, 3)).toMatchSnapshot();
});
test('escape closes the new transaction rows', () => {
test('escape closes the new transaction rows', async () => {
const { container, updateProps } = renderTransactions({
onCloseAddTransaction: () => {
updateProps({ isAdding: false });
@@ -799,17 +722,17 @@ describe('Transactions', () => {
// While adding a transaction, pressing escape should close the
// new transaction form
let input = expectToBeEditingField(container, 'date', 0, true);
fireEvent.keyDown(input, keys.TAB);
await userEvent.type(input, '[Tab]');
input = expectToBeEditingField(container, 'account', 0, true);
// The first escape closes the dropdown
fireEvent.keyDown(input, keys.ESC);
await userEvent.type(input, '[Escape]');
expect(
container.querySelector('[data-testid="new-transaction"]'),
).toBeTruthy();
// TOOD: Fix this
// TODO: Fix this
// Now it should close the new transaction form
// fireEvent.keyDown(input, keys.ESC);
// await userEvent.type(input, '[Escape]');
// expect(
// container.querySelector('[data-testid="new-transaction"]')
// ).toBeNull();
@@ -819,16 +742,16 @@ describe('Transactions', () => {
let cancelButton = container.querySelectorAll(
'[data-testid="new-transaction"] [data-testid="cancel-button"]',
)[0];
fireEvent.click(cancelButton);
await userEvent.click(cancelButton);
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
null,
);
});
test('transaction can be selected', () => {
test('transaction can be selected', async () => {
const { container } = renderTransactions();
editField(container, 'date', 2);
await editField(container, 'date', 2);
const selectCell = queryField(
container,
'select',
@@ -836,7 +759,7 @@ describe('Transactions', () => {
2,
);
fireEvent.click(selectCell);
await userEvent.click(selectCell);
// The header is is selected as well as the single transaction
expect(container.querySelectorAll('[data-testid=select] svg').length).toBe(
2,
@@ -869,7 +792,7 @@ describe('Transactions', () => {
});
}
let input = editField(container, 'category', 0);
let input = await editField(container, 'category', 0);
let tooltip = container.querySelector('[data-testid="tooltip"]');
let splitButton = tooltip.querySelector(
'[data-testid="split-transaction-button"]',
@@ -881,7 +804,7 @@ describe('Transactions', () => {
// Make sure splitting a transaction works
expect(getTransactions().length).toBe(5);
fireEvent.click(splitButton);
await userEvent.click(splitButton);
await waitForAutocomplete();
expect(getTransactions().length).toBe(6);
expect(getTransactions()[0].is_parent).toBe(true);
@@ -898,31 +821,71 @@ describe('Transactions', () => {
// Enter an amount for the new split transaction and make sure the
// toolbar updates
input = editField(container, 'debit', 1);
fireEvent.change(input, { target: { value: '10.00' } });
fireEvent.keyDown(input, keys.TAB);
input = await editField(container, 'debit', 1);
await userEvent.clear(input);
await userEvent.type(input, '10.00[tab]');
expect(toolbar.innerHTML.includes('17.77')).toBeTruthy();
// Add another split transaction and make sure everything is
// updated properly
fireEvent.click(toolbar.querySelector('button'));
await userEvent.click(toolbar.querySelector('button'));
expect(getTransactions().length).toBe(7);
expect(getTransactions()[2].amount).toBe(0);
expectErrorToExist(getTransactions().slice(0, 3));
// Change the amount to resolve the whole transaction. The toolbar
// should disappear and no error should exist
input = editField(container, 'debit', 2);
fireEvent.change(input, { target: { value: '17.77' } });
fireEvent.keyDown(input, keys.TAB);
expect(
container.querySelectorAll('[data-testid="transaction-error"]').length,
).toBe(0);
input = await editField(container, 'debit', 2);
await userEvent.clear(input);
await userEvent.type(input, '17.77[tab]');
await userEvent.tab();
expect(screen.queryAllByTestId('transaction-error')).toHaveLength(0);
expectErrorToNotExist(getTransactions().slice(0, 3));
// This snapshot makes sure the data is as we expect. It also
// shows the sort order and makes sure that is correct
expect(getTransactions().slice(0, 3)).toMatchSnapshot();
const parentId = getTransactions()[0].id;
expect(getTransactions().slice(0, 3)).toEqual([
{
account: accounts[0].id,
amount: -2777,
category: null,
cleared: false,
date: '2017-01-01',
error: null,
id: expect.any(String),
is_parent: true,
notes: 'Notes',
payee: 'payed-to',
sort_order: 0,
},
{
account: accounts[0].id,
amount: -1000,
cleared: false,
date: '2017-01-01',
error: null,
id: expect.any(String),
is_child: true,
parent_id: parentId,
payee: 'payed-to',
sort_order: -1,
starting_balance_flag: null,
},
{
account: accounts[0].id,
amount: -1777,
cleared: false,
date: '2017-01-01',
error: null,
id: expect.any(String),
is_child: true,
parent_id: parentId,
payee: 'payed-to',
sort_order: -2,
starting_balance_flag: null,
},
]);
// Make sure deleting a split transaction updates the state again,
// and deleting all split transactions turns it into a normal
@@ -932,20 +895,20 @@ describe('Transactions', () => {
// yet because it doesn't do any batch editing
//
// const deleteCell = queryField(container, 'delete', '', 2);
// fireEvent.click(deleteCell);
// await userEvent.click(deleteCell);
// expect(getTransactions().length).toBe(6);
// toolbar = container.querySelector('[data-testid="transaction-error"]');
// expect(toolbar).toBeTruthy();
// expect(toolbar.innerHTML.includes('17.77')).toBeTruthy();
// fireEvent.click(queryField(container, 'delete', '', 1));
// await userEvent.click(queryField(container, 'delete', '', 1));
// expect(getTransactions()[0].isParent).toBe(false);
});
test('transaction with splits shows 0 in correct column', async () => {
const { container, getTransactions } = renderTransactions();
let input = editField(container, 'category', 0);
let input = await editField(container, 'category', 0);
let tooltip = container.querySelector('[data-testid="tooltip"]');
let splitButton = tooltip.querySelector(
'[data-testid="split-transaction-button"',
@@ -956,9 +919,9 @@ describe('Transactions', () => {
// Add two new split transactions
expect(getTransactions().length).toBe(5);
fireEvent.click(splitButton);
await userEvent.click(splitButton);
await waitForAutocomplete();
fireEvent.click(
await userEvent.click(
container.querySelector('[data-testid="transaction-error"] button'),
);
expect(getTransactions().length).toBe(7);
@@ -970,9 +933,8 @@ describe('Transactions', () => {
expect(queryField(container, 'credit', '', 2).textContent).toBe('');
// Change it to a credit transaction
input = editField(container, 'credit', 0);
fireEvent.change(input, { target: { value: '55.00' } });
fireEvent.keyDown(input, keys.TAB);
input = await editField(container, 'credit', 0);
await userEvent.type(input, '55.00{Tab}');
// The zeros should now display in the credit column
expect(queryField(container, 'debit', '', 1).textContent).toBe('');

View File

@@ -1,85 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transactions adding a new split transaction works 1`] = `
Array [
Object {
"account": "testing-uuid-975046",
"amount": -5500,
"cleared": false,
"date": "2017-01-01",
"error": null,
"id": "testing-uuid-759284",
"is_parent": true,
},
Object {
"account": "testing-uuid-975046",
"amount": -4500,
"cleared": false,
"date": "2017-01-01",
"error": null,
"id": "testing-uuid-57883",
"is_child": true,
"parent_id": "testing-uuid-759284",
"payee": undefined,
"sort_order": -1,
"starting_balance_flag": null,
},
Object {
"account": "testing-uuid-975046",
"amount": -1000,
"cleared": false,
"date": "2017-01-01",
"error": null,
"id": "testing-uuid-661157",
"is_child": true,
"parent_id": "testing-uuid-759284",
"payee": undefined,
"sort_order": -2,
"starting_balance_flag": null,
},
]
`;
exports[`Transactions transaction can be split, updated, and deleted 1`] = `
Array [
Object {
"account": "testing-uuid-975046",
"amount": -2777,
"category": null,
"cleared": false,
"date": "2017-01-01",
"error": null,
"id": "testing-uuid-795958",
"is_parent": true,
"notes": "Notes",
"payee": "guy",
"sort_order": 0,
},
Object {
"account": "testing-uuid-975046",
"amount": -1000,
"cleared": false,
"date": "2017-01-01",
"error": null,
"id": "testing-uuid-216379",
"is_child": true,
"parent_id": "testing-uuid-795958",
"payee": "guy",
"sort_order": -1,
"starting_balance_flag": null,
},
Object {
"account": "testing-uuid-975046",
"amount": -1777,
"cleared": false,
"date": "2017-01-01",
"error": null,
"id": "testing-uuid-482499",
"is_child": true,
"parent_id": "testing-uuid-795958",
"payee": "guy",
"sort_order": -2,
"starting_balance_flag": null,
},
]
`;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import ExclamationOutline from '../icons/v1/ExclamationOutline';
import InformationOutline from '../icons/v1/InformationOutline';
import { styles, colors } from '../style';
import ExclamationOutline from '../svg/v1/ExclamationOutline';
import InformationOutline from '../svg/v1/InformationOutline';
import { View, Text } from './common';

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import { colors } from '../style';
import { colors } from '../../style';
import { View } from '../common';
import Autocomplete from './Autocomplete';
import { View } from './common';
export function AccountList({
items,

View File

@@ -4,10 +4,9 @@ import lively from '@jlongster/lively';
import Downshift from 'downshift';
import { css } from 'glamor';
import { colors } from '../style';
import Remove from '../svg/v2/Remove';
import { View, Input, Tooltip, Button } from './common';
import Remove from '../../icons/v2/Remove';
import { colors } from '../../style';
import { View, Input, Tooltip, Button } from '../common';
function findItem(strict, suggestions, value) {
if (strict) {
@@ -328,14 +327,12 @@ function onKeyDown(
},
e,
) {
let ENTER = 13;
let ESC = 27;
let { onKeyDown } = inputProps || {};
// If the dropdown is open, an item is highlighted, and the user
// pressed enter, always capture that and handle it ourselves
if (isOpen) {
if (e.keyCode === ENTER) {
if (e.code === 'Enter') {
if (highlightedIndex != null) {
if (inst.lastChangeType === Downshift.stateChangeTypes.itemMouseEnter) {
// If the last thing the user did was hover an item, intentionally
@@ -365,7 +362,7 @@ function onKeyDown(
}
// Handle escape ourselves
if (e.keyCode === ESC) {
if (e.code === 'Escape') {
e.preventDefault();
if (!embedded) {
@@ -414,8 +411,7 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) {
}
function defaultShouldSaveFromKey(e) {
// Enter
return e.keyCode === 13;
return e.code === 'Enter';
}
function onFocus({ inst, props: { inputProps = {}, openOnFocus = true } }, e) {

View File

@@ -0,0 +1,91 @@
import React, { useMemo } from 'react';
import { components as SelectComponents } from 'react-select';
import Split from '../../icons/v0/Split';
import { colors } from '../../style';
import { View } from '../common';
import Autocomplete from './NewAutocomplete';
const SPLIT_TRANSACTION_KEY = 'split';
export default function CategoryAutocomplete({
value,
categoryGroups,
showSplitOption = false,
multi = false,
onSplit,
...props
}) {
const options = useMemo(() => {
const suggestions = categoryGroups.map(group => ({
label: group.name,
options: group.categories.map(categ => ({
value: categ.id,
label: categ.name,
})),
}));
if (showSplitOption) {
suggestions.unshift({
value: SPLIT_TRANSACTION_KEY,
label: SPLIT_TRANSACTION_KEY,
});
}
return suggestions;
}, [categoryGroups, showSplitOption]);
const allOptions = useMemo(
() =>
options.reduce(
(carry, { options }) => [...carry, ...(options || [])],
[],
),
[options],
);
return (
<Autocomplete
options={options}
value={
multi
? allOptions.filter(item => value.includes(item.value))
: allOptions.find(item => item.value === value)
}
isMulti={multi}
components={{
Option,
}}
{...props}
/>
);
}
function Option(props) {
if (props.value === SPLIT_TRANSACTION_KEY) {
return (
<SelectComponents.Option {...props}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
fontSize: 11,
color: colors.g8,
marginLeft: -12,
padding: '4px 0',
}}
data-testid="split-transaction-button"
>
<Split
width={10}
height={10}
style={{ marginRight: 5, color: 'inherit' }}
/>
Split Transaction
</View>
</SelectComponents.Option>
);
}
return <SelectComponents.Option {...props} />;
}

View File

@@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import { colors } from '../style';
import Split from '../svg/v0/Split';
import Split from '../../icons/v0/Split';
import { colors } from '../../style';
import { View, Text, Select } from '../common';
import Autocomplete, { defaultFilterSuggestion } from './Autocomplete';
import { View, Text, Select } from './common';
export const NativeCategorySelect = React.forwardRef(
({ categoryGroups, emptyLabel, ...nativeProps }, ref) => {

View File

@@ -0,0 +1,62 @@
import React, { useMemo } from 'react';
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import Autocomplete from './NewAutocomplete';
export default function AccountAutocomplete({
value,
includeClosedAccounts = true,
multi = false,
...props
}) {
const accounts = useCachedAccounts() || [];
const availableAccounts = useMemo(
() =>
includeClosedAccounts ? accounts : accounts.filter(item => !item.closed),
[accounts, includeClosedAccounts],
);
const options = useMemo(
() => [
{
label: 'For Budget',
options: availableAccounts
.filter(item => !item.offbudget)
.map(item => ({
label: item.name,
value: item.id,
})),
},
{
label: 'Off Budget',
options: availableAccounts
.filter(item => item.offbudget)
.map(item => ({
label: item.name,
value: item.id,
})),
},
],
[availableAccounts],
);
const allOptions = useMemo(
() => options.reduce((carry, { options }) => [...carry, ...options], []),
[options],
);
return (
<Autocomplete
options={options}
value={
multi
? allOptions.filter(item => value.includes(item.value))
: allOptions.find(item => item.value === value)
}
isMulti={multi}
{...props}
/>
);
}

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import Select from 'react-select';
import type {
GroupBase,
Props as SelectProps,
PropsValue,
SingleValue,
SelectInstance,
} from 'react-select';
import type { CreatableProps } from 'react-select/creatable';
import CreatableSelect from 'react-select/creatable';
import { NullComponent } from '../common';
import styles from './autocomplete-styles';
type OptionValue = {
__isNew__?: boolean;
label: string;
value: string;
};
interface BaseAutocompleteProps {
focused?: boolean;
embedded?: boolean;
onSelect: (value: string | string[]) => void;
onCreateOption?: (value: string) => void;
isCreatable?: boolean;
}
type SimpleAutocompleteProps = BaseAutocompleteProps & SelectProps<OptionValue>;
type CreatableAutocompleteProps = BaseAutocompleteProps &
CreatableProps<OptionValue, true, GroupBase<OptionValue>> & {
isCreatable: true;
};
type AutocompleteProps = SimpleAutocompleteProps | CreatableAutocompleteProps;
const isSingleValue = (
value: PropsValue<OptionValue>,
): value is SingleValue<OptionValue> => {
return !Array.isArray(value);
};
const Autocomplete = React.forwardRef<SelectInstance, AutocompleteProps>(
(
{
value,
options = [],
focused = false,
embedded = false,
onSelect,
onCreateOption,
isCreatable = false,
components = {},
...props
},
ref,
) => {
const [initialValue] = useState(value);
const [isOpen, setIsOpen] = useState(focused || embedded);
const [inputValue, setInputValue] = useState<
AutocompleteProps['inputValue']
>(() => (isSingleValue(value) ? value?.label : undefined));
const [isInitialInputValue, setInitialInputValue] = useState(true);
const onInputChange: AutocompleteProps['onInputChange'] = value => {
setInputValue(value);
setInitialInputValue(false);
};
const filterOption: AutocompleteProps['filterOption'] = (option, input) => {
if (isInitialInputValue) {
return true;
}
return (
option.data?.__isNew__ ||
option.label.toLowerCase().includes(input?.toLowerCase())
);
};
const onChange: AutocompleteProps['onChange'] = (
selected: PropsValue<OptionValue>,
) => {
// Clear button clicked
if (!selected) {
onSelect(null);
return;
}
// Create a new option
if (isSingleValue(selected) && selected.__isNew__) {
onCreateOption(selected.value);
return;
}
// Close the menu when making a successful selection
if (isSingleValue(selected)) {
setIsOpen(false);
}
// Multi-select has multiple selections
if (!isSingleValue(selected)) {
onSelect(selected.map(option => option.value));
return;
}
onSelect(selected.value);
};
const onKeyDown: AutocompleteProps['onKeyDown'] = event => {
if (event.code === 'Escape') {
onSelect(
isSingleValue(initialValue)
? initialValue?.value
: initialValue.map(val => val.value),
);
setIsOpen(false);
return;
}
if (!isOpen) {
setIsOpen(true);
}
};
const Component = isCreatable ? CreatableSelect : Select;
return (
<Component
ref={ref}
value={value}
menuIsOpen={isOpen}
autoFocus={embedded}
options={options}
placeholder="(none)"
captureMenuScroll={false}
onChange={onChange}
onKeyDown={onKeyDown}
onCreateOption={onCreateOption}
onBlur={() => setIsOpen(false)}
onFocus={() => setIsOpen(true)}
isClearable
filterOption={filterOption}
components={{
IndicatorSeparator: NullComponent,
DropdownIndicator: NullComponent,
...components,
}}
maxMenuHeight={200}
styles={styles}
data-embedded={embedded}
menuPlacement="auto"
menuPortalTarget={embedded ? undefined : document.body}
inputValue={inputValue}
onInputChange={onInputChange}
{...props}
/>
);
},
);
export default Autocomplete;

View File

@@ -0,0 +1,168 @@
import React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { components as SelectComponents } from 'react-select';
import { createPayee } from 'loot-core/src/client/actions/queries';
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
import Add from '../../icons/v1/Add';
import { colors } from '../../style';
import { View } from '../common';
import { AutocompleteFooter, AutocompleteFooterButton } from './Autocomplete';
import Autocomplete from './NewAutocomplete';
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
let activePayees =
(accounts ? getActivePayees(payees, accounts) : payees) || [];
function formatOptions(options) {
return options.map(row => ({
value: row.id,
label: row.name,
}));
}
return [
...(focusTransferPayees
? []
: [
{
label: 'Payees',
options: formatOptions(activePayees.filter(p => !p.transfer_acct)),
},
]),
{
label: 'Transfer To/From',
options: formatOptions(activePayees.filter(p => p.transfer_acct)),
},
];
}
function MenuListWithFooter(props) {
return (
<>
<SelectComponents.MenuList {...props} />
{props.selectProps.footer}
</>
);
}
export default function PayeeAutocomplete({
value,
multi = false,
showMakeTransfer = true,
showManagePayees = false,
defaultFocusTransferPayees = false,
onSelect,
onManagePayees,
...props
}) {
const payees = useCachedPayees();
const accounts = useCachedAccounts();
const [focusTransferPayees, setFocusTransferPayees] = useState(
defaultFocusTransferPayees,
);
const options = useMemo(
() => getPayeeSuggestions(payees, focusTransferPayees, accounts),
[payees, focusTransferPayees, accounts],
);
const allOptions = useMemo(
() => options.reduce((carry, { options }) => [...carry, ...options], []),
[options],
);
const dispatch = useDispatch();
return (
<Autocomplete
options={options}
value={
multi
? allOptions.filter(item => value.includes(item.value))
: allOptions.find(item => item.value === value)
}
isValidNewOption={input => input && !focusTransferPayees}
isMulti={multi}
onSelect={onSelect}
onCreateOption={async selectedValue => {
const existingOption = allOptions.find(option =>
option.label.toLowerCase().includes(selectedValue?.toLowerCase()),
);
// Prevent creating duplicates
if (existingOption) {
onSelect(existingOption.value);
return;
}
// This is actually a new option, so create it
onSelect(await dispatch(createPayee(selectedValue)));
}}
createOptionPosition="first"
formatCreateLabel={inputValue => (
<View
style={{
display: 'block',
color: colors.g8,
fontSize: 11,
fontWeight: 500,
marginLeft: -10,
padding: '4px 0',
}}
>
<Add
width={8}
height={8}
style={{
color: colors.g8,
marginRight: 5,
display: 'inline-block',
}}
/>
Create Payee {inputValue}
</View>
)}
components={{
MenuList: MenuListWithFooter,
}}
minMenuHeight={300}
footer={
<AutocompleteFooter show={showMakeTransfer || showManagePayees}>
{showMakeTransfer && (
<AutocompleteFooterButton
title="Make Transfer"
style={[
showManagePayees && { marginBottom: 5 },
focusTransferPayees && {
backgroundColor: colors.y8,
color: colors.g2,
borderColor: colors.y8,
},
]}
hoveredStyle={
focusTransferPayees && {
backgroundColor: colors.y8,
colors: colors.y2,
}
}
onClick={() => {
setFocusTransferPayees(!focusTransferPayees);
}}
/>
)}
{showManagePayees && (
<AutocompleteFooterButton
title="Manage Payees"
onClick={onManagePayees}
/>
)}
</AutocompleteFooter>
}
{...props}
/>
);
}

View File

@@ -6,15 +6,15 @@ import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
import { colors } from '../style';
import Add from '../svg/v1/Add';
import Add from '../../icons/v1/Add';
import { colors } from '../../style';
import { View } from '../common';
import Autocomplete, {
defaultFilterSuggestion,
AutocompleteFooter,
AutocompleteFooterButton,
} from './Autocomplete';
import { View } from './common';
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
@@ -103,7 +103,7 @@ export function PayeeList({
display: 'inline-block',
}}
/>
Create Payee "{inputValue}"
Create Payee {inputValue}
</View>
</View>
)}

View File

@@ -0,0 +1,87 @@
import { styles as actualStyles, colors } from '../../style';
const colourStyles = {
...actualStyles.lightScrollbar,
control: styles => ({
...styles,
backgroundColor: 'white',
border: '1px solid rgb(208, 208, 208)',
borderRadius: 4,
outline: 0,
marginLeft: -1,
marginRight: 1,
padding: '5px 2px',
fontSize: '13px',
minHeight: 'auto',
}),
input: styles => ({
...styles,
padding: '0 2px',
margin: 0,
overflow: 'hidden',
}),
menuPortal: styles => ({
...styles,
zIndex: 5000,
minWidth: 200,
}),
menu: (styles, { selectProps }) => ({
...styles,
minWidth: 200,
backgroundColor: colors.n1,
marginTop: 2,
marginBottom: 2,
position: selectProps['data-embedded'] ? 'relative' : styles.position,
overflow: 'hidden',
}),
menuList: styles => ({
...styles,
padding: 0,
// Custom scrollbar styling
...Object.entries(actualStyles.lightScrollbar).reduce(
(carry, [key, value]) => ({
...carry,
[key.replace('& ', '')]: value,
}),
{},
),
}),
group: styles => ({
...styles,
padding: '5px 0 0',
}),
groupHeading: styles => ({
...styles,
color: colors.y9,
textTransform: 'none',
paddingLeft: '9px',
fontSize: '100%',
fontWeight: 'normal',
}),
option: (styles, { isFocused }) => ({
...styles,
backgroundColor: isFocused ? colors.n5 : undefined,
color: 'white',
padding: '3px 20px',
fontSize: 13,
}),
valueContainer: (styles, { isMulti, selectProps }) => ({
...styles,
padding: 'none',
overflow: 'visible',
marginTop: isMulti && selectProps.value?.length ? -4 : undefined,
marginBottom: isMulti && selectProps.value?.length ? -4 : undefined,
}),
clearIndicator: styles => ({
...styles,
padding: 'none',
'> svg': { height: 15, width: 15 },
}),
multiValue: styles => ({
...styles,
backgroundColor: colors.b9,
}),
};
export default colourStyles;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import ArrowThinRight from '../../svg/v1/ArrowThinRight';
import ArrowThinRight from '../../icons/v1/ArrowThinRight';
import { View } from '../common';
import CellValue from '../spreadsheet/CellValue';
import useSheetValue from '../spreadsheet/useSheetValue';

View File

@@ -11,8 +11,8 @@ import { Spring } from 'wobble';
import * as monthUtils from 'loot-core/src/shared/months';
import useResizeObserver from '../../hooks/useResizeObserver';
import { View } from '../common';
import useResizeObserver from '../useResizeObserver';
import { MonthsContext } from './MonthsContext';

View File

@@ -4,10 +4,9 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { View } from '../common';
import { useBudgetMonthCount } from './BudgetMonthCountContext';
import { BudgetPageHeader, BudgetTable } from './misc';
import { CategoryGroupsContext } from './util';
import { BudgetPageHeader, BudgetTable } from './index';
function getNumPossibleMonths(width) {
let estimatedTableWidth = width - 200;

View File

@@ -1,20 +1,20 @@
import React, { useContext } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import * as actions from 'loot-core/src/client/actions';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import { send, listen } from 'loot-core/src/platform/client/fetch';
import {
addCategory,
moveCategory,
moveCategoryGroup,
} from 'loot-core/src/shared/categories.js';
} from 'loot-core/src/shared/categories';
import * as monthUtils from 'loot-core/src/shared/months';
import { View } from 'loot-design/src/components/common';
import SpreadsheetContext from 'loot-design/src/components/spreadsheet/SpreadsheetContext';
import { colors } from 'loot-design/src/style';
import AnimatedLoading from 'loot-design/src/svg/AnimatedLoading';
import { withThemeColor } from 'loot-design/src/util/withThemeColor';
import AnimatedLoading from '../../icons/AnimatedLoading';
import { colors } from '../../style';
import { withThemeColor } from '../../util/withThemeColor';
import { View } from '../common';
import SyncRefresh from '../SyncRefresh';
import { BudgetTable } from './MobileBudgetTable';
@@ -192,7 +192,7 @@ class Budget extends React.Component {
let options = [
'Edit Categories',
"Copy last month's budget",
'Copy last months budget',
'Set budgets to zero',
'Set budgets to 3 month average',
budgetType === 'report' && 'Apply to all future budgets',
@@ -241,6 +241,7 @@ class Budget extends React.Component {
applyBudgetAction,
} = this.props;
let numberFormat = prefs.numberFormat || 'comma-dot';
let hideFraction = prefs.hideFraction || false;
if (!categoryGroups || !initialized) {
return (
@@ -264,7 +265,7 @@ class Budget extends React.Component {
<BudgetTable
// This key forces the whole table rerender when the number
// format changes
key={numberFormat}
key={numberFormat + hideFraction}
categories={categories}
categoryGroups={categoryGroups}
type={budgetType}
@@ -292,7 +293,7 @@ class Budget extends React.Component {
}
function BudgetWrapper(props) {
let spreadsheet = useContext(SpreadsheetContext);
let spreadsheet = useSpreadsheet();
return <Budget {...props} spreadsheet={spreadsheet} />;
}

View File

@@ -14,32 +14,25 @@ import * as actions from 'loot-core/src/client/actions';
import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries';
import * as monthUtils from 'loot-core/src/shared/months';
import { amountToInteger, integerToAmount } from 'loot-core/src/shared/util';
import {
Button,
Card,
Label,
Text,
View,
} from 'loot-design/src/components/common';
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
import format from 'loot-design/src/components/spreadsheet/format';
import NamespaceContext from 'loot-design/src/components/spreadsheet/NamespaceContext';
import SheetValue from 'loot-design/src/components/spreadsheet/SheetValue';
import useSheetValue from 'loot-design/src/components/spreadsheet/useSheetValue';
import { colors, styles } from 'loot-design/src/style';
import Add from 'loot-design/src/svg/v1/Add';
import ArrowThinLeft from 'loot-design/src/svg/v1/ArrowThinLeft';
import ArrowThinRight from 'loot-design/src/svg/v1/ArrowThinRight';
import Add from '../../icons/v1/Add';
import ArrowThinLeft from '../../icons/v1/ArrowThinLeft';
import ArrowThinRight from '../../icons/v1/ArrowThinRight';
import { colors, styles } from '../../style';
import { Button, Card, Label, Text, View } from '../common';
import CellValue from '../spreadsheet/CellValue';
import format from '../spreadsheet/format';
import NamespaceContext from '../spreadsheet/NamespaceContext';
import SheetValue from '../spreadsheet/SheetValue';
import useSheetValue from '../spreadsheet/useSheetValue';
import { SyncButton } from '../Titlebar';
import { AmountInput } from '../util/AmountInput';
// import {
// AmountAccessoryContext,
// MathOperations
// } from 'loot-design/src/components/mobile/AmountInput';
// } from '../mobile/AmountInput';
// import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop';
import { SyncButton } from '../Titlebar';
import { AmountInput } from '../util/AmountInput';
import { ListItem, ROW_HEIGHT } from './MobileTable';
export function ToBudget({ toBudget, onClick }) {
@@ -1086,6 +1079,7 @@ function UnconnectedBudgetHeader({
},
]}
>
{/* eslint-disable-next-line rulesdir/typography */}
{monthUtils.format(currentMonth, "MMMM ''yy")}
</Text>
{editMode ? (

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { View } from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import { colors } from '../../style';
import { View } from '../common';
export const ROW_HEIGHT = 50;

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { useBudgetMonthCount } from 'loot-design/src/components/budget/BudgetMonthCountContext';
import { View } from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
import CalendarIcon from 'loot-design/src/svg/v2/Calendar';
import CalendarIcon from '../../icons/v2/Calendar';
import { colors } from '../../style';
import { View } from '../common';
import { useBudgetMonthCount } from './BudgetMonthCountContext';
function Calendar({ color, onClick }) {
return (

View File

@@ -2,6 +2,7 @@ import React, { useContext, useMemo } from 'react';
import { connect } from 'react-redux';
import * as actions from 'loot-core/src/client/actions';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import { send, listen } from 'loot-core/src/platform/client/fetch';
import {
addCategory,
@@ -12,20 +13,21 @@ import {
addGroup,
updateGroup,
deleteGroup,
} from 'loot-core/src/shared/categories.js';
} from 'loot-core/src/shared/categories';
import * as monthUtils from 'loot-core/src/shared/months';
import DynamicBudgetTable from 'loot-design/src/components/budget/DynamicBudgetTable';
import { getValidMonthBounds } from 'loot-design/src/components/budget/MonthsContext';
import * as report from 'loot-design/src/components/budget/report/components';
import { ReportProvider } from 'loot-design/src/components/budget/report/ReportContext';
import * as rollover from 'loot-design/src/components/budget/rollover/rollover-components';
import { RolloverContext } from 'loot-design/src/components/budget/rollover/RolloverContext';
import { View } from 'loot-design/src/components/common';
import SpreadsheetContext from 'loot-design/src/components/spreadsheet/SpreadsheetContext';
import { styles } from 'loot-design/src/style';
import useFeatureFlag from '../../hooks/useFeatureFlag';
import { styles } from '../../style';
import { View } from '../common';
import { TitlebarContext } from '../Titlebar';
import DynamicBudgetTable from './DynamicBudgetTable';
import { getValidMonthBounds } from './MonthsContext';
import * as report from './report/components';
import { ReportProvider } from './report/ReportContext';
import * as rollover from './rollover/rollover-components';
import { RolloverContext } from './rollover/RolloverContext';
let _initialBudgetMonth = null;
class Budget extends React.PureComponent {
@@ -495,8 +497,20 @@ class Budget extends React.PureComponent {
}
}
const RolloverBudgetSummary = React.memo(props => {
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
return (
<rollover.BudgetSummary
{...props}
isGoalTemplatesEnabled={isGoalTemplatesEnabled}
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
/>
);
});
function BudgetWrapper(props) {
let spreadsheet = useContext(SpreadsheetContext);
let spreadsheet = useSpreadsheet();
let titlebar = useContext(TitlebarContext);
let reportComponents = useMemo(
@@ -514,7 +528,7 @@ function BudgetWrapper(props) {
let rolloverComponents = useMemo(
() => ({
SummaryComponent: rollover.BudgetSummary,
SummaryComponent: RolloverBudgetSummary,
ExpenseCategoryComponent: rollover.ExpenseCategoryMonth,
ExpenseGroupComponent: rollover.ExpenseGroupMonth,
IncomeCategoryComponent: rollover.IncomeCategoryMonth,

View File

@@ -1,12 +1,14 @@
import React, { useContext, useState, useMemo } from 'react';
import { connect } from 'react-redux';
import * as monthUtils from 'loot-core/src/shared/months';
import useResizeObserver from '../../hooks/useResizeObserver';
import ExpandArrow from '../../icons/v0/ExpandArrow';
import ArrowThinLeft from '../../icons/v1/ArrowThinLeft';
import ArrowThinRight from '../../icons/v1/ArrowThinRight';
import CheveronDown from '../../icons/v1/CheveronDown';
import { styles, colors } from '../../style';
import ExpandArrow from '../../svg/v0/ExpandArrow';
import ArrowThinLeft from '../../svg/v1/ArrowThinLeft';
import ArrowThinRight from '../../svg/v1/ArrowThinRight';
import CheveronDown from '../../svg/v1/CheveronDown';
import {
View,
Text,
@@ -24,7 +26,6 @@ import {
} from '../sort.js';
import NamespaceContext from '../spreadsheet/NamespaceContext';
import { Row, InputCell, ROW_HEIGHT } from '../table';
import useResizeObserver from '../useResizeObserver';
import BudgetSummaries from './BudgetSummaries';
import { INCOME_HEADER_HEIGHT, MONTH_BOX_SHADOW } from './constants';
@@ -133,14 +134,11 @@ export class BudgetTable extends React.Component {
};
onKeyDown = e => {
const TAB = 9;
const ENTER = 13;
if (!this.state.editing) {
return null;
}
if (e.keyCode === ENTER || e.keyCode === TAB) {
if (e.code === 'Enter' || e.code === 'Tab') {
e.preventDefault();
this.moveVertically(e.shiftKey ? -1 : 1);
}
@@ -188,6 +186,7 @@ export class BudgetTable extends React.Component {
return (
<View
data-testid="budget-table"
style={[
{ flex: 1 },
styles.lightScrollbar && {
@@ -311,6 +310,7 @@ export function SidebarCategory({
}}
>
<div
data-testid="category-name"
style={{
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -392,8 +392,7 @@ export function SidebarCategory({
style,
]}
onKeyDown={e => {
const ENTER = 13;
if (e.keyCode === ENTER) {
if (e.code === 'Enter') {
onEditName(null);
e.stopPropagation();
}
@@ -548,8 +547,7 @@ export function SidebarGroup({
},
]}
onKeyDown={e => {
const ENTER = 13;
if (e.keyCode === ENTER) {
if (e.code === 'Enter') {
onEdit(null);
e.stopPropagation();
}
@@ -611,6 +609,7 @@ function RenderMonths({ component: Component, editingIndex, args, style }) {
const BudgetTotals = React.memo(function BudgetTotals({ MonthComponent }) {
return (
<View
data-testid="budget-totals"
style={{
backgroundColor: 'white',
flexDirection: 'row',
@@ -734,7 +733,11 @@ function ExpenseGroup({
);
}
function ExpenseCategory({
const ExpenseCategory = connect(state => ({
isNewAutocompleteEnabled: state.prefs.local['flags.newAutocomplete'],
}))(ExpenseCategoryInternal);
function ExpenseCategoryInternal({
cat,
budgetArray,
editingCell,
@@ -748,6 +751,7 @@ function ExpenseCategory({
onShowActivity,
onDragChange,
onReorder,
isNewAutocompleteEnabled,
}) {
let dragging = dragState && dragState.item === cat;
@@ -806,6 +810,7 @@ function ExpenseCategory({
onEdit: onEditMonth,
onBudgetAction,
onShowActivity,
isNewAutocompleteEnabled,
}}
/>
</View>

View File

@@ -5,10 +5,10 @@ import { css } from 'glamor';
import { reportBudget } from 'loot-core/src/client/queries';
import * as monthUtils from 'loot-core/src/shared/months';
import DotsHorizontalTriple from '../../../icons/v1/DotsHorizontalTriple';
import ArrowButtonDown1 from '../../../icons/v2/ArrowButtonDown1';
import ArrowButtonUp1 from '../../../icons/v2/ArrowButtonUp1';
import { colors, styles } from '../../../style';
import DotsHorizontalTriple from '../../../svg/v1/DotsHorizontalTriple';
import ArrowButtonDown1 from '../../../svg/v2/ArrowButtonDown1';
import ArrowButtonUp1 from '../../../svg/v2/ArrowButtonUp1';
import {
View,
Text,
@@ -364,7 +364,7 @@ export const BudgetSummary = React.memo(function BudgetSummary({ month }) {
onBudgetAction(month, type);
}}
items={[
{ name: 'copy-last', text: "Copy last month's budget" },
{ name: 'copy-last', text: 'Copy last months budget' },
{ name: 'set-zero', text: 'Set budgets to zero' },
{
name: 'set-3-avg',

View File

@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { connect } from 'react-redux';
import Component from '@reactions/component';
import { css } from 'glamor';
@@ -7,11 +6,10 @@ import { css } from 'glamor';
import { rolloverBudget } from 'loot-core/src/client/queries';
import * as monthUtils from 'loot-core/src/shared/months';
import * as actions from '../../../../../loot-core/src/client/actions';
import DotsHorizontalTriple from '../../../icons/v1/DotsHorizontalTriple';
import ArrowButtonDown1 from '../../../icons/v2/ArrowButtonDown1';
import ArrowButtonUp1 from '../../../icons/v2/ArrowButtonUp1';
import { colors, styles } from '../../../style';
import DotsHorizontalTriple from '../../../svg/v1/DotsHorizontalTriple';
import ArrowButtonDown1 from '../../../svg/v2/ArrowButtonDown1';
import ArrowButtonUp1 from '../../../svg/v2/ArrowButtonUp1';
import {
View,
Block,
@@ -136,7 +134,13 @@ function TotalsList({ prevMonthName, collapsed }) {
);
}
function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
function ToBudget({
month,
prevMonthName,
collapsed,
onBudgetAction,
isNewAutocompleteEnabled,
}) {
return (
<SheetValue binding={rolloverBudget.toBudget} initialValue={0}>
{node => {
@@ -209,7 +213,7 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
},
{
name: 'reset-buffer',
text: "Reset next month's buffer",
text: 'Reset next months buffer',
},
]}
/>
@@ -233,6 +237,7 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
category,
});
}}
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
/>
)}
</View>
@@ -245,7 +250,11 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
);
}
function BudgetSummaryComponent({ month, localPrefs }) {
export function BudgetSummary({
month,
isGoalTemplatesEnabled,
isNewAutocompleteEnabled,
}) {
let {
currentMonth,
summaryCollapsed: collapsed,
@@ -266,10 +275,9 @@ function BudgetSummaryComponent({ month, localPrefs }) {
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
let goalTemplatesEnabled = localPrefs['flags.goalTemplatesEnabled'];
return (
<View
data-testid="budget-summary"
style={{
backgroundColor: 'white',
boxShadow: MONTH_BOX_SHADOW,
@@ -372,17 +380,17 @@ function BudgetSummaryComponent({ month, localPrefs }) {
onBudgetAction(month, type);
}}
items={[
{ name: 'copy-last', text: "Copy last month's budget" },
{ name: 'copy-last', text: 'Copy last months budget' },
{ name: 'set-zero', text: 'Set budgets to zero' },
{
name: 'set-3-avg',
text: 'Set budgets to 3 month avg',
},
goalTemplatesEnabled && {
isGoalTemplatesEnabled && {
name: 'apply-goal-template',
text: 'Apply budget template',
},
goalTemplatesEnabled && {
isGoalTemplatesEnabled && {
name: 'overwrite-goal-template',
text: 'Overwrite with budget template',
},
@@ -409,13 +417,18 @@ function BudgetSummaryComponent({ month, localPrefs }) {
prevMonthName={prevMonthName}
month={month}
onBudgetAction={onBudgetAction}
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
/>
</View>
) : (
<>
<TotalsList prevMonthName={prevMonthName} />
<View style={{ margin: '23px 0' }}>
<ToBudget month={month} onBudgetAction={onBudgetAction} />
<ToBudget
month={month}
onBudgetAction={onBudgetAction}
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
/>
</View>
</>
)}
@@ -423,8 +436,3 @@ function BudgetSummaryComponent({ month, localPrefs }) {
</View>
);
}
export const BudgetSummary = connect(
state => ({ localPrefs: state.prefs.local }),
actions,
)(BudgetSummaryComponent);

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