Compare commits
110 Commits
ts-LoadBac
...
v24.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f634099e2 | ||
|
|
7ee48e4c1d | ||
|
|
270705b3cd | ||
|
|
9588a76109 | ||
|
|
ada9e7da31 | ||
|
|
38ffccb903 | ||
|
|
f5023a7c07 | ||
|
|
bc6a0f8e60 | ||
|
|
5ee7d336ef | ||
|
|
24e42daa51 | ||
|
|
4bafd13c55 | ||
|
|
afaee6bc16 | ||
|
|
e44fbb3847 | ||
|
|
ff70c654a2 | ||
|
|
586a26968c | ||
|
|
107acdb36b | ||
|
|
5343030800 | ||
|
|
d7635755f2 | ||
|
|
501c6a02cc | ||
|
|
6281cc751e | ||
|
|
dc7cab501e | ||
|
|
f51376a4a5 | ||
|
|
6f600a4fee | ||
|
|
c82a6dc5ef | ||
|
|
ab97a3fbcd | ||
|
|
8b15c8cc17 | ||
|
|
02ec279a64 | ||
|
|
ab8c3c018a | ||
|
|
8356640e45 | ||
|
|
1767a32b3d | ||
|
|
5bcfc71be6 | ||
|
|
998efb9447 | ||
|
|
dc159d71a2 | ||
|
|
7db7b5c400 | ||
|
|
823b426952 | ||
|
|
8827169bfa | ||
|
|
90eaf2ba17 | ||
|
|
a5fa0f3bb6 | ||
|
|
4570459d85 | ||
|
|
76cbd44c75 | ||
|
|
0e0d960cd4 | ||
|
|
98c17bd5e0 | ||
|
|
601c9aa7df | ||
|
|
3b77609159 | ||
|
|
66261641a0 | ||
|
|
4b034468e3 | ||
|
|
b700aee87d | ||
|
|
9fca85209f | ||
|
|
a9362cc6f9 | ||
|
|
40296dc876 | ||
|
|
ed1e0ceb30 | ||
|
|
55817b0e70 | ||
|
|
6d7d12138c | ||
|
|
52f1f79c01 | ||
|
|
991fc4f450 | ||
|
|
4a8c692d06 | ||
|
|
0609f47cc3 | ||
|
|
0d1e6f2ee7 | ||
|
|
8568aebdbb | ||
|
|
bfb7c1d213 | ||
|
|
e526555748 | ||
|
|
45c4b262a2 | ||
|
|
e1f805b9c9 | ||
|
|
d0c11cd3af | ||
|
|
0c5bce8baf | ||
|
|
e650e00cb8 | ||
|
|
422996f8a7 | ||
|
|
9cad57c607 | ||
|
|
2a0f8335ed | ||
|
|
08cbdab2a1 | ||
|
|
ec2de3b387 | ||
|
|
65372e86a5 | ||
|
|
53a61000a4 | ||
|
|
485902af6b | ||
|
|
5a67b7e822 | ||
|
|
b994a6a74a | ||
|
|
b1b266e83c | ||
|
|
58626c0026 | ||
|
|
45094daf2f | ||
|
|
2bb7b3c2ee | ||
|
|
d6f610a326 | ||
|
|
8a8113a648 | ||
|
|
029e2f09bf | ||
|
|
86007e392f | ||
|
|
4c4f2fd426 | ||
|
|
7a18827b1d | ||
|
|
77fd65b2e7 | ||
|
|
30f03e8079 | ||
|
|
6e16262b63 | ||
|
|
29a515f3fe | ||
|
|
a5ab1a8fae | ||
|
|
d37622162a | ||
|
|
e3a8366dd7 | ||
|
|
d5e49dde59 | ||
|
|
f5258e6ebe | ||
|
|
f4d80fad92 | ||
|
|
3324dd5fa0 | ||
|
|
14509d15df | ||
|
|
9b461c48c9 | ||
|
|
55f2d126b3 | ||
|
|
6ae2047ab8 | ||
|
|
9fdffcc8e9 | ||
|
|
3daff4381f | ||
|
|
5914469b11 | ||
|
|
39e7f2598b | ||
|
|
c8d326d24b | ||
|
|
d8639a2a71 | ||
|
|
734191424b | ||
|
|
5d4fcfde00 | ||
|
|
54d7e5460a |
74
.eslintrc.js
@@ -38,6 +38,7 @@ module.exports = {
|
||||
extends: [
|
||||
'react-app',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:import/typescript',
|
||||
@@ -57,7 +58,7 @@ module.exports = {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
varsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
@@ -90,15 +91,7 @@ module.exports = {
|
||||
'react/prop-types': 'off',
|
||||
|
||||
// TODO: re-enable these rules
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/display-name': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
// 'react-hooks/exhaustive-deps': [
|
||||
// 'warn',
|
||||
// {
|
||||
// additionalHooks: 'useLiveQuery',
|
||||
// },
|
||||
// ],
|
||||
|
||||
'no-var': 'warn',
|
||||
'react/jsx-curly-brace-presence': 'warn',
|
||||
@@ -277,6 +270,69 @@ module.exports = {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO: fix the issues in these files
|
||||
files: [
|
||||
'./packages/desktop-client/src/components/accounts/Account.jsx',
|
||||
'./packages/desktop-client/src/components/accounts/MobileAccount.jsx',
|
||||
'./packages/desktop-client/src/components/accounts/MobileAccounts.jsx',
|
||||
'./packages/desktop-client/src/components/App.tsx',
|
||||
'./packages/desktop-client/src/components/budget/BudgetCategories.jsx',
|
||||
'./packages/desktop-client/src/components/budget/BudgetSummaries.tsx',
|
||||
'./packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx',
|
||||
'./packages/desktop-client/src/components/budget/index.tsx',
|
||||
'./packages/desktop-client/src/components/budget/MobileBudget.tsx',
|
||||
'./packages/desktop-client/src/components/budget/rollover/HoldTooltip.tsx',
|
||||
'./packages/desktop-client/src/components/budget/rollover/TransferTooltip.tsx',
|
||||
'./packages/desktop-client/src/components/common/Menu.tsx',
|
||||
'./packages/desktop-client/src/components/FinancesApp.tsx',
|
||||
'./packages/desktop-client/src/components/GlobalKeys.ts',
|
||||
'./packages/desktop-client/src/components/LoggedInUser.tsx',
|
||||
'./packages/desktop-client/src/components/manager/ManagementApp.jsx',
|
||||
'./packages/desktop-client/src/components/manager/subscribe/common.tsx',
|
||||
'./packages/desktop-client/src/components/ManageRules.tsx',
|
||||
'./packages/desktop-client/src/components/mobile/MobileAmountInput.jsx',
|
||||
'./packages/desktop-client/src/components/mobile/MobileNavTabs.tsx',
|
||||
'./packages/desktop-client/src/components/Modals.tsx',
|
||||
'./packages/desktop-client/src/components/modals/EditRule.jsx',
|
||||
'./packages/desktop-client/src/components/modals/ImportTransactions.jsx',
|
||||
'./packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx',
|
||||
'./packages/desktop-client/src/components/Notifications.tsx',
|
||||
'./packages/desktop-client/src/components/payees/ManagePayees.jsx',
|
||||
'./packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx',
|
||||
'./packages/desktop-client/src/components/payees/PayeeTable.tsx',
|
||||
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx',
|
||||
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx',
|
||||
'./packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx',
|
||||
'./packages/desktop-client/src/components/reports/reports/CustomReport.jsx',
|
||||
'./packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx',
|
||||
'./packages/desktop-client/src/components/reports/SaveReportName.tsx',
|
||||
'./packages/desktop-client/src/components/reports/useReport.ts',
|
||||
'./packages/desktop-client/src/components/schedules/ScheduleDetails.jsx',
|
||||
'./packages/desktop-client/src/components/schedules/SchedulesTable.tsx',
|
||||
'./packages/desktop-client/src/components/select/DateSelect.tsx',
|
||||
'./packages/desktop-client/src/components/sidebar/Tools.tsx',
|
||||
'./packages/desktop-client/src/components/sort.tsx',
|
||||
'./packages/desktop-client/src/components/spreadsheet/useSheetValue.ts',
|
||||
'./packages/desktop-client/src/components/table.tsx',
|
||||
'./packages/desktop-client/src/components/Titlebar.tsx',
|
||||
'./packages/desktop-client/src/components/transactions/MobileTransaction.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/SelectedTransactions.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/TransactionList.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/TransactionsTable.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx',
|
||||
'./packages/desktop-client/src/hooks/useAccounts.ts',
|
||||
'./packages/desktop-client/src/hooks/useCategories.ts',
|
||||
'./packages/desktop-client/src/hooks/usePayees.ts',
|
||||
'./packages/desktop-client/src/hooks/useProperFocus.tsx',
|
||||
'./packages/desktop-client/src/hooks/useSelected.tsx',
|
||||
'./packages/loot-core/src/client/query-hooks.tsx',
|
||||
],
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
|
||||
4
.github/actions/setup/action.yml
vendored
@@ -4,11 +4,11 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.16.0
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
|
||||
14
.github/workflows/build.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build API
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Create package tgz
|
||||
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
crdt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build CRDT
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
@@ -53,18 +53,18 @@ jobs:
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: ./bin/package-browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
10
.github/workflows/check.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Lint
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test
|
||||
@@ -40,8 +40,8 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '19'
|
||||
- name: Check migrations
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
@@ -22,14 +22,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
|
||||
12
.github/workflows/e2e-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
netlify_url: ${{ steps.netlify.outputs.url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Wait for Netlify build to finish
|
||||
@@ -33,19 +33,20 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run E2E Tests on Netlify URL
|
||||
run: yarn e2e
|
||||
env:
|
||||
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results
|
||||
path: packages/desktop-client/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
vrt:
|
||||
name: Visual regression
|
||||
needs: netlify
|
||||
@@ -53,16 +54,17 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt
|
||||
env:
|
||||
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results
|
||||
path: packages/desktop-client/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
4
.github/workflows/electron-master.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
4
.github/workflows/electron-pr.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '19'
|
||||
- name: Handle feature requests
|
||||
|
||||
2
.github/workflows/release-notes.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Check release notes
|
||||
if: startsWith(github.head_ref, 'release/') == false
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
|
||||
4
.github/workflows/size-compare.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
echo "Build failed on PR branch or ${{github.base_ref}}"
|
||||
exit 1
|
||||
- name: Download build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
id: pr-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
path: base
|
||||
|
||||
- name: Download build artifact from PR
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
|
||||
27
.github/workflows/wip.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Add WIP
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add_wip_prefix:
|
||||
if: |
|
||||
join(github.event.pull_request.requested_reviewers) == ''
|
||||
&& !contains(github.event.pull_request.title, 'WIP')
|
||||
&& !contains(github.event.pull_request.labels.*.name, 'WIP')
|
||||
&& github.event.pull_request.draft != true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Add WIP
|
||||
env:
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token
|
||||
gh pr edit ${{ github.event.pull_request.number }} -t "[WIP] ${TITLE}"
|
||||
@@ -4,4 +4,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.0.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||
|
||||
@@ -10,4 +10,4 @@ if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
|
||||
yarn
|
||||
fi
|
||||
|
||||
yarn start:browser
|
||||
BROWSER=0 yarn start:browser
|
||||
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
actual-development:
|
||||
build: .
|
||||
image: actual-development
|
||||
environment:
|
||||
- HTTPS
|
||||
ports:
|
||||
- '3001:3001'
|
||||
volumes:
|
||||
|
||||
@@ -52,17 +52,19 @@
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"node-jq": "^4.0.1",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "3.2.4",
|
||||
"react-refresh": "^0.14.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2",
|
||||
"typescript-strict-plugin": "^2.2.2-beta.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"rollup": "4.9.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.0.1",
|
||||
"packageManager": "yarn@4.0.2",
|
||||
"browserslist": [
|
||||
"electron 24.0",
|
||||
"defaults"
|
||||
|
||||
@@ -58,11 +58,42 @@ describe('API CRUD operations', () => {
|
||||
await api.loadBudget(budgetName);
|
||||
});
|
||||
|
||||
// apis: createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
|
||||
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
|
||||
test('CategoryGroups: successfully update category groups', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// get existing category groups
|
||||
const groups = await api.getCategoryGroups();
|
||||
expect(groups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
is_income: 0,
|
||||
name: 'Usual Expenses',
|
||||
sort_order: 16384,
|
||||
tombstone: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
|
||||
is_income: 0,
|
||||
name: 'Investments and Savings',
|
||||
sort_order: 32768,
|
||||
tombstone: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
|
||||
is_income: 1,
|
||||
name: 'Income',
|
||||
sort_order: 32768,
|
||||
tombstone: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// create our test category group
|
||||
const mainGroupId = await api.createCategoryGroup({
|
||||
name: 'test-group',
|
||||
|
||||
@@ -121,6 +121,10 @@ export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function getCategoryGroups() {
|
||||
return send('api/category-groups-get');
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "6.4.0",
|
||||
"version": "6.7.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
@@ -21,17 +21,17 @@
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"better-sqlite3": "^9.3.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.12.0-rc.1",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ First start a dev instance:
|
||||
```sh
|
||||
HTTPS=true yarn start
|
||||
```
|
||||
|
||||
or using the dev container:
|
||||
```
|
||||
HTTPS=true docker compose up --build
|
||||
```
|
||||
|
||||
Note the network IP address and port the dev instance is listening on.
|
||||
|
||||
Next, navigate to the root of your project folder, run the standartised docker container, and launch the visual regression tests from within it.
|
||||
|
||||
@@ -7,6 +7,7 @@ test.describe('Accounts', () => {
|
||||
let page;
|
||||
let navigation;
|
||||
let configurationPage;
|
||||
let accountPage;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
@@ -22,7 +23,7 @@ test.describe('Accounts', () => {
|
||||
});
|
||||
|
||||
test('creates a new account and views the initial balance transaction', async () => {
|
||||
const accountPage = await navigation.createAccount({
|
||||
accountPage = await navigation.createAccount({
|
||||
name: 'New Account',
|
||||
offBudget: false,
|
||||
balance: 100,
|
||||
@@ -38,7 +39,7 @@ test.describe('Accounts', () => {
|
||||
});
|
||||
|
||||
test('closes an account', async () => {
|
||||
const accountPage = await navigation.goToAccountPage('Roth IRA');
|
||||
accountPage = await navigation.goToAccountPage('Roth IRA');
|
||||
|
||||
await expect(accountPage.accountName).toHaveText('Roth IRA');
|
||||
|
||||
@@ -50,4 +51,52 @@ test.describe('Accounts', () => {
|
||||
await expect(accountPage.accountName).toHaveText('Closed: Roth IRA');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test.describe('Budgeted Accounts', () => {
|
||||
// Reset filters
|
||||
test.afterEach(async () => {
|
||||
await accountPage.removeFilter(0);
|
||||
});
|
||||
|
||||
test('creates a transfer from two existing transactions', async () => {
|
||||
accountPage = await navigation.goToAccountPage('For budget');
|
||||
await expect(accountPage.accountName).toHaveText('Budgeted Accounts');
|
||||
|
||||
await accountPage.filterByNote('Test Acc Transfer');
|
||||
|
||||
await accountPage.createSingleTransaction({
|
||||
account: 'Ally Savings',
|
||||
payee: '',
|
||||
notes: 'Test Acc Transfer',
|
||||
category: 'Food',
|
||||
debit: '34.56',
|
||||
});
|
||||
|
||||
await accountPage.createSingleTransaction({
|
||||
account: 'HSBC',
|
||||
payee: '',
|
||||
notes: 'Test Acc Transfer',
|
||||
category: 'Food',
|
||||
credit: '34.56',
|
||||
});
|
||||
|
||||
await page.waitForTimeout(100); // Give time for the previous transaction to be rendered
|
||||
|
||||
await accountPage.selectNthTransaction(0);
|
||||
await accountPage.selectNthTransaction(1);
|
||||
await accountPage.clickSelectAction('Make transfer');
|
||||
|
||||
let transaction = accountPage.getNthTransaction(0);
|
||||
await expect(transaction.payee).toHaveText('Ally Savings');
|
||||
await expect(transaction.category).toHaveText('Transfer');
|
||||
await expect(transaction.credit).toHaveText('34.56');
|
||||
await expect(transaction.account).toHaveText('HSBC');
|
||||
|
||||
transaction = accountPage.getNthTransaction(1);
|
||||
await expect(transaction.payee).toHaveText('HSBC');
|
||||
await expect(transaction.category).toHaveText('Transfer');
|
||||
await expect(transaction.debit).toHaveText('34.56');
|
||||
await expect(transaction.account).toHaveText('Ally Savings');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
@@ -59,12 +59,9 @@ test.describe('Budget', () => {
|
||||
});
|
||||
|
||||
test('clicking on spent amounts opens a transaction page', async () => {
|
||||
const categoryName = await budgetPage.getCategoryNameForRow(1);
|
||||
const accountPage = await budgetPage.clickOnSpentAmountForRow(1);
|
||||
expect(page.url()).toContain('/accounts');
|
||||
expect(await accountPage.accountName.textContent()).toMatch(
|
||||
new RegExp(String.raw`${categoryName} \(\w+ \d+\)`),
|
||||
);
|
||||
expect(await accountPage.accountName.textContent()).toMatch('All Accounts');
|
||||
await page.getByRole('button', { name: 'Back' }).click();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1597,7 +1597,7 @@
|
||||
"date": "2023-08-04",
|
||||
"amount": 0,
|
||||
"memo": "getting paid",
|
||||
"cleared": "cleared",
|
||||
"cleared": "reconciled",
|
||||
"approved": true,
|
||||
"flag_color": null,
|
||||
"account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32",
|
||||
@@ -1657,7 +1657,7 @@
|
||||
"date": "2023-08-04",
|
||||
"amount": 1000000,
|
||||
"memo": "",
|
||||
"cleared": "cleared",
|
||||
"cleared": "reconciled",
|
||||
"approved": true,
|
||||
"flag_color": null,
|
||||
"account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32",
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -25,6 +25,9 @@ export class AccountPage {
|
||||
|
||||
this.filterButton = this.page.getByRole('button', { name: 'Filter' });
|
||||
this.filterSelectTooltip = this.page.getByTestId('filters-select-tooltip');
|
||||
|
||||
this.selectButton = this.page.getByTestId('transactions-select-button');
|
||||
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,14 +71,21 @@ export class AccountPage {
|
||||
await this.cancelTransactionButton.click();
|
||||
}
|
||||
|
||||
async selectNthTransaction(index) {
|
||||
const row = this.transactionTableRow.nth(index);
|
||||
await row.getByTestId('select').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the data for the nth-transaction.
|
||||
* 0-based index
|
||||
*/
|
||||
getNthTransaction(index) {
|
||||
const row = this.transactionTableRow.nth(index);
|
||||
const account = row.getByTestId('account');
|
||||
|
||||
return {
|
||||
...(account ? { account } : {}),
|
||||
payee: row.getByTestId('payee'),
|
||||
notes: row.getByTestId('notes'),
|
||||
category: row.getByTestId('category'),
|
||||
@@ -84,6 +94,11 @@ export class AccountPage {
|
||||
};
|
||||
}
|
||||
|
||||
async clickSelectAction(action) {
|
||||
await this.selectButton.click();
|
||||
await this.selectTooltip.getByRole('button', { name: action }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal for closing the account.
|
||||
*/
|
||||
@@ -106,6 +121,15 @@ export class AccountPage {
|
||||
return new FilterTooltip(this.page.getByTestId('filters-menu-tooltip'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to a specific note
|
||||
*/
|
||||
async filterByNote(note) {
|
||||
const filterTooltip = await this.filterBy('Note');
|
||||
await this.page.keyboard.type(note);
|
||||
await filterTooltip.applyButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the nth filter
|
||||
*/
|
||||
@@ -117,6 +141,12 @@ export class AccountPage {
|
||||
}
|
||||
|
||||
async _fillTransactionFields(transactionRow, transaction) {
|
||||
if (transaction.account) {
|
||||
await transactionRow.getByTestId('account').click();
|
||||
await this.page.keyboard.type(transaction.account);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.payee) {
|
||||
await transactionRow.getByTestId('payee').click();
|
||||
await this.page.keyboard.type(transaction.payee);
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "24.1.0",
|
||||
"version": "24.4.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@juggle/resize-observer": "^3.1.2",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@reach/listbox": "^0.18.0",
|
||||
"@react-aria/focus": "^3.14.0",
|
||||
"@react-aria/listbox": "^3.10.1",
|
||||
"@react-aria/utils": "^3.19.0",
|
||||
"@react-stately/collections": "^3.10.0",
|
||||
"@react-stately/list": "^3.9.1",
|
||||
"@react-aria/focus": "^3.16.0",
|
||||
"@react-aria/listbox": "^3.11.3",
|
||||
"@react-aria/utils": "^3.23.0",
|
||||
"@react-stately/collections": "^3.10.4",
|
||||
"@react-stately/list": "^3.10.2",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@svgr/cli": "^8.0.1",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/helpers": "^0.5.3",
|
||||
"@swc/plugin-react-remove-properties": "^1.5.108",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@testing-library/react": "14.1.2",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.1",
|
||||
"@types/react-modal": "^3.16.0",
|
||||
@@ -32,14 +32,13 @@
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^2.29.3",
|
||||
"debounce": "^1.2.0",
|
||||
"downshift": "7.6.0",
|
||||
"focus-visible": "^4.1.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"debounce": "^1.2.1",
|
||||
"downshift": "7.6.2",
|
||||
"focus-visible": "^4.1.5",
|
||||
"glamor": "^2.20.40",
|
||||
"hotkeys-js": "3.10.3",
|
||||
"inter-ui": "^3.19.3",
|
||||
"jest": "^27.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"jest-watch-typeahead": "^2.2.2",
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -48,27 +47,28 @@
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-error-boundary": "^4.0.12",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-modal": "3.16.1",
|
||||
"react-redux": "7.2.1",
|
||||
"react-router-dom": "6.11.2",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router-dom": "6.21.3",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "^9.7.1",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"recharts": "^2.8.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"react-spring": "^9.7.3",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"recharts": "^2.10.4",
|
||||
"redux": "^4.2.1",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rollup-plugin-visualizer": "^5.11.0",
|
||||
"sass": "^1.63.6",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.70.0",
|
||||
"swc-loader": "^0.2.3",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"typescript": "^5.0.2",
|
||||
"uuid": "^9.0.0",
|
||||
"victory": "^36.6.8",
|
||||
"terser-webpack-plugin": "^5.3.10",
|
||||
"usehooks-ts": "^3.0.1",
|
||||
"uuid": "^9.0.1",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-pwa": "^0.19.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.1",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
|
||||
BIN
packages/desktop-client/public/maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
packages/desktop-client/public/maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
packages/desktop-client/public/screenshot_narrow.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
packages/desktop-client/public/screenshot_wide.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
@@ -1,20 +1,51 @@
|
||||
{
|
||||
"name": "Actual",
|
||||
"short_name": "Actual",
|
||||
"description": "A local-first personal finance tool",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshot_wide.png",
|
||||
"form_factor": "wide",
|
||||
"label": "Actual Budget Homepage",
|
||||
"type": "image/png",
|
||||
"sizes": "1280x720"
|
||||
},
|
||||
{
|
||||
"src": "/screenshot_narrow.png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Actual Budget Mobile Homepage",
|
||||
"type": "image/png",
|
||||
"sizes": "350x600"
|
||||
}
|
||||
],
|
||||
"theme_color": "#8812E1",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"start_url": "."
|
||||
"start_url": "./"
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
useErrorBoundary,
|
||||
type FallbackProps,
|
||||
} from 'react-error-boundary';
|
||||
import { HotkeysProvider } from 'react-hotkeys-hook';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import {
|
||||
init as initConnection,
|
||||
send,
|
||||
} from 'loot-core/src/platform/client/fetch';
|
||||
import { type GlobalPrefs } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { installPolyfills } from '../polyfills';
|
||||
import { ResponsiveProvider } from '../ResponsiveProvider';
|
||||
import { styles, hasHiddenScrollbars, ThemeStyle } from '../style';
|
||||
@@ -31,26 +33,13 @@ import { UpdateNotification } from './UpdateNotification';
|
||||
type AppInnerProps = {
|
||||
budgetId: string;
|
||||
cloudFileId: string;
|
||||
loadingText: string;
|
||||
loadBudget: (
|
||||
id: string,
|
||||
loadingText?: string,
|
||||
options?: object,
|
||||
) => Promise<void>;
|
||||
closeBudget: () => Promise<void>;
|
||||
loadGlobalPrefs: () => Promise<GlobalPrefs>;
|
||||
};
|
||||
|
||||
function AppInner({
|
||||
budgetId,
|
||||
cloudFileId,
|
||||
loadingText,
|
||||
loadBudget,
|
||||
closeBudget,
|
||||
loadGlobalPrefs,
|
||||
}: AppInnerProps) {
|
||||
function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const { showBoundary: showErrorBoundary } = useErrorBoundary();
|
||||
const loadingText = useSelector((state: State) => state.app.loadingText);
|
||||
const { loadBudget, closeBudget, loadGlobalPrefs } = useActions();
|
||||
|
||||
async function init() {
|
||||
const socketName = await global.Actual.getServerSocket();
|
||||
@@ -123,14 +112,9 @@ function ErrorFallback({ error }: FallbackProps) {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const budgetId = useSelector(
|
||||
state => state.prefs.local && state.prefs.local.id,
|
||||
);
|
||||
const cloudFileId = useSelector(
|
||||
state => state.prefs.local && state.prefs.local.cloudFileId,
|
||||
);
|
||||
const loadingText = useSelector(state => state.app.loadingText);
|
||||
const { loadBudget, closeBudget, loadGlobalPrefs, sync } = useActions();
|
||||
const [budgetId] = useLocalPref('id');
|
||||
const [cloudFileId] = useLocalPref('cloudFileId');
|
||||
const { sync } = useActions();
|
||||
const [hiddenScrollbars, setHiddenScrollbars] = useState(
|
||||
hasHiddenScrollbars(),
|
||||
);
|
||||
@@ -163,34 +147,29 @@ export function App() {
|
||||
}, [sync]);
|
||||
|
||||
return (
|
||||
<ResponsiveProvider>
|
||||
<View
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<HotkeysProvider initiallyActiveScopes={['*']}>
|
||||
<ResponsiveProvider>
|
||||
<View
|
||||
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
...styles.lightScrollbar,
|
||||
}}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
|
||||
<DevelopmentTopBar />
|
||||
)}
|
||||
<AppInner
|
||||
budgetId={budgetId}
|
||||
cloudFileId={cloudFileId}
|
||||
loadingText={loadingText}
|
||||
loadBudget={loadBudget}
|
||||
closeBudget={closeBudget}
|
||||
loadGlobalPrefs={loadGlobalPrefs}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ThemeStyle />
|
||||
<View
|
||||
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
...styles.lightScrollbar,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
|
||||
<DevelopmentTopBar />
|
||||
)}
|
||||
<AppInner budgetId={budgetId} cloudFileId={cloudFileId} />
|
||||
</ErrorBoundary>
|
||||
<ThemeStyle />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ResponsiveProvider>
|
||||
</ResponsiveProvider>
|
||||
</HotkeysProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTransition, animated } from 'react-spring';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { AnimatedRefresh } from './AnimatedRefresh';
|
||||
@@ -9,20 +11,20 @@ import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
|
||||
export function BankSyncStatus() {
|
||||
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
|
||||
const accountsSyncing = useSelector(
|
||||
(state: State) => state.account.accountsSyncing,
|
||||
);
|
||||
const accountsSyncingCount = accountsSyncing.length;
|
||||
|
||||
const name = accountsSyncing
|
||||
? accountsSyncing === '__all'
|
||||
? 'accounts'
|
||||
: accountsSyncing
|
||||
: null;
|
||||
|
||||
const transitions = useTransition(name, {
|
||||
from: { opacity: 0, transform: 'translateY(-100px)' },
|
||||
enter: { opacity: 1, transform: 'translateY(0)' },
|
||||
leave: { opacity: 0, transform: 'translateY(-100px)' },
|
||||
unique: true,
|
||||
});
|
||||
const transitions = useTransition(
|
||||
accountsSyncingCount > 0 ? 'syncing' : null,
|
||||
{
|
||||
from: { opacity: 0, transform: 'translateY(-100px)' },
|
||||
enter: { opacity: 1, transform: 'translateY(0)' },
|
||||
leave: { opacity: 0, transform: 'translateY(-100px)' },
|
||||
unique: true,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -52,10 +54,13 @@ export function BankSyncStatus() {
|
||||
}}
|
||||
>
|
||||
<AnimatedRefresh
|
||||
animating={true}
|
||||
animating
|
||||
iconStyle={{ color: theme.pillTextSelected }}
|
||||
/>
|
||||
<Text>Syncing {item}</Text>
|
||||
<Text style={{ marginLeft: 5 }}>
|
||||
Syncing... {accountsSyncingCount} account
|
||||
{accountsSyncingCount > 1 && 's'} remaining
|
||||
</Text>
|
||||
</View>
|
||||
</animated.div>
|
||||
),
|
||||
|
||||
@@ -166,7 +166,9 @@ export function FatalError({ buttonText, error }: FatalErrorProps) {
|
||||
>
|
||||
{showSimpleRender ? <RenderSimple error={error} /> : <RenderUIError />}
|
||||
<Paragraph>
|
||||
<Button onClick={() => window.Actual.relaunch()}>{buttonText}</Button>
|
||||
<Button onClick={() => window.Actual?.relaunch()}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Paragraph>
|
||||
<Paragraph isLast={true} style={{ fontSize: 11 }}>
|
||||
<LinkButton onClick={() => setShowError(true)}>Show Error</LinkButton>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { type ReactElement, useEffect, useMemo } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend as Backend } from 'react-dnd-html5-backend';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Route,
|
||||
Routes,
|
||||
@@ -11,14 +12,12 @@ import {
|
||||
useHref,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import hotkeys from 'hotkeys-js';
|
||||
|
||||
import { AccountsProvider } from 'loot-core/src/client/data-hooks/accounts';
|
||||
import { PayeesProvider } from 'loot-core/src/client/data-hooks/payees';
|
||||
import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import { checkForUpdateNotification } from 'loot-core/src/client/update-notification';
|
||||
import * as undo from 'loot-core/src/platform/client/undo';
|
||||
|
||||
import { useAccounts } from '../hooks/useAccounts';
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
@@ -32,6 +31,7 @@ import { View } from './common/View';
|
||||
import { GlobalKeys } from './GlobalKeys';
|
||||
import { ManageRulesPage } from './ManageRulesPage';
|
||||
import { MobileNavTabs } from './mobile/MobileNavTabs';
|
||||
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
|
||||
import { Modals } from './Modals';
|
||||
import { Notifications } from './Notifications';
|
||||
import { ManagePayeesPage } from './payees/ManagePayeesPage';
|
||||
@@ -39,9 +39,9 @@ import { Reports } from './reports';
|
||||
import { NarrowAlternate, WideComponent } from './responsive';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { Settings } from './settings';
|
||||
import { FloatableSidebar, SidebarProvider } from './sidebar';
|
||||
import { FloatableSidebar } from './sidebar';
|
||||
import { SidebarProvider } from './sidebar/SidebarProvider';
|
||||
import { Titlebar, TitlebarProvider } from './Titlebar';
|
||||
import { TransactionEdit } from './transactions/MobileTransaction';
|
||||
|
||||
function NarrowNotSupported({
|
||||
redirectTo = '/budget',
|
||||
@@ -71,18 +71,19 @@ function WideNotSupported({ children, redirectTo = '/budget' }) {
|
||||
return isNarrowWidth ? children : null;
|
||||
}
|
||||
|
||||
function RouterBehaviors({ getAccounts }) {
|
||||
function RouterBehaviors() {
|
||||
const navigate = useNavigate();
|
||||
const accounts = useAccounts();
|
||||
const accountsLoaded = useSelector(
|
||||
(state: State) => state.queries.accountsLoaded,
|
||||
);
|
||||
useEffect(() => {
|
||||
// Get the accounts and check if any exist. If there are no
|
||||
// accounts, we want to redirect the user to the All Accounts
|
||||
// screen which will prompt them to add an account
|
||||
getAccounts().then(accounts => {
|
||||
if (accounts.length === 0) {
|
||||
navigate('/accounts');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
// If there are no accounts, we want to redirect the user to
|
||||
// the All Accounts screen which will prompt them to add an account
|
||||
if (accountsLoaded && accounts.length === 0) {
|
||||
navigate('/accounts');
|
||||
}
|
||||
}, [accountsLoaded, accounts]);
|
||||
|
||||
const location = useLocation();
|
||||
const href = useHref(location);
|
||||
@@ -96,9 +97,6 @@ function RouterBehaviors({ getAccounts }) {
|
||||
function FinancesAppWithoutContext() {
|
||||
const actions = useActions();
|
||||
useEffect(() => {
|
||||
// The default key handler scope
|
||||
hotkeys.setScope('app');
|
||||
|
||||
// Wait a little bit to make sure the sync button will get the
|
||||
// sync start event. This can be improved later.
|
||||
setTimeout(async () => {
|
||||
@@ -116,7 +114,7 @@ function FinancesAppWithoutContext() {
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<RouterBehaviors getAccounts={actions.getAccounts} />
|
||||
<RouterBehaviors />
|
||||
<ExposeNavigate />
|
||||
|
||||
<View style={{ height: '100%' }}>
|
||||
@@ -265,13 +263,9 @@ export function FinancesApp() {
|
||||
<TitlebarProvider>
|
||||
<SidebarProvider>
|
||||
<BudgetMonthCountProvider>
|
||||
<PayeesProvider>
|
||||
<AccountsProvider>
|
||||
<DndProvider backend={Backend}>
|
||||
<ScrollProvider>{app}</ScrollProvider>
|
||||
</DndProvider>
|
||||
</AccountsProvider>
|
||||
</PayeesProvider>
|
||||
<DndProvider backend={Backend}>
|
||||
<ScrollProvider>{app}</ScrollProvider>
|
||||
</DndProvider>
|
||||
</BudgetMonthCountProvider>
|
||||
</SidebarProvider>
|
||||
</TitlebarProvider>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-strict-ignore
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
@@ -8,7 +7,7 @@ import { useNavigate } from '../hooks/useNavigate';
|
||||
export function GlobalKeys() {
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const handleKeys = e => {
|
||||
const handleKeys = (e: KeyboardEvent) => {
|
||||
if (Platform.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { createContext, useEffect, useContext } from 'react';
|
||||
|
||||
import hotkeys, { type KeyHandler as HotKeyHandler } from 'hotkeys-js';
|
||||
|
||||
const KeyScopeContext = createContext('app');
|
||||
|
||||
hotkeys.filter = event => {
|
||||
const target = (event.target || event.srcElement) as HTMLElement;
|
||||
const tagName = target.tagName;
|
||||
|
||||
// This is the default behavior of hotkeys, except we only suppress
|
||||
// key presses if the meta key is not pressed
|
||||
if (
|
||||
!event.metaKey &&
|
||||
(target.isContentEditable ||
|
||||
((tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
tagName === 'SELECT') &&
|
||||
!target['readOnly']))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
type KeyHandlerProps = {
|
||||
keyName: string;
|
||||
eventType?: string;
|
||||
handler: HotKeyHandler;
|
||||
};
|
||||
function KeyHandler({
|
||||
keyName,
|
||||
eventType = 'keydown',
|
||||
handler,
|
||||
}: KeyHandlerProps) {
|
||||
const scope = useContext(KeyScopeContext);
|
||||
|
||||
if (eventType !== 'keyup' && eventType !== 'keydown') {
|
||||
throw new Error('KeyHandler: unknown event type: ' + eventType);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function _handler(event, hk) {
|
||||
// Right now it always overrides the default behavior, but in
|
||||
// the future we can make this customizable
|
||||
event.preventDefault();
|
||||
|
||||
if (event.type === eventType && handler) {
|
||||
return handler(event, hk);
|
||||
}
|
||||
}
|
||||
hotkeys(keyName, { scope, keyup: true }, _handler);
|
||||
|
||||
return () => {
|
||||
// @ts-expect-error unbind args typedef does not expect an object
|
||||
hotkeys.unbind({
|
||||
key: keyName,
|
||||
scope,
|
||||
method: _handler,
|
||||
});
|
||||
};
|
||||
}, [keyName, handler, scope]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type KeyHandlersProps = {
|
||||
eventType?: string;
|
||||
keys: Record<string, HotKeyHandler>;
|
||||
};
|
||||
export function KeyHandlers({ eventType, keys = {} }: KeyHandlersProps) {
|
||||
const handlers = Object.keys(keys).map(key => {
|
||||
return (
|
||||
<KeyHandler
|
||||
key={key}
|
||||
keyName={key}
|
||||
eventType={eventType}
|
||||
handler={keys[key]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{handlers}</>;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { theme, styles, type CSSProperties } from '../style';
|
||||
|
||||
@@ -22,7 +24,7 @@ export function LoggedInUser({
|
||||
style,
|
||||
color,
|
||||
}: LoggedInUserProps) {
|
||||
const userData = useSelector(state => state.user.data);
|
||||
const userData = useSelector((state: State) => state.user.data);
|
||||
const { getUserData, signOut, closeBudget } = useActions();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
type SetStateAction,
|
||||
type Dispatch,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { pushModal } from 'loot-core/src/client/actions/modals';
|
||||
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
|
||||
@@ -17,7 +17,9 @@ import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
|
||||
import { describeSchedule } from 'loot-core/src/shared/schedules';
|
||||
import { type RuleEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../hooks/useAccounts';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
import { usePayees } from '../hooks/usePayees';
|
||||
import { useSelected, SelectedProvider } from '../hooks/useSelected';
|
||||
import { theme } from '../style';
|
||||
|
||||
@@ -103,11 +105,13 @@ function ManageRulesContent({
|
||||
|
||||
const { data: schedules } = SchedulesQuery.useQuery();
|
||||
const { list: categories } = useCategories();
|
||||
const state = useSelector(state => ({
|
||||
payees: state.queries.payees,
|
||||
accounts: state.queries.accounts,
|
||||
const payees = usePayees();
|
||||
const accounts = useAccounts();
|
||||
const state = {
|
||||
payees,
|
||||
accounts,
|
||||
schedules,
|
||||
}));
|
||||
};
|
||||
const filterData = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePrefs } from 'loot-core/src/client/actions';
|
||||
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
@@ -14,27 +12,24 @@ import { Checkbox } from './forms';
|
||||
const buttonStyle = { border: 0, fontSize: 15, padding: '10px 13px' };
|
||||
|
||||
export function MobileWebMessage() {
|
||||
const hideMobileMessagePref = useSelector(state => {
|
||||
return (state.prefs.local && state.prefs.local.hideMobileMessage) || true;
|
||||
});
|
||||
const [hideMobileMessage = true, setHideMobileMessagePref] =
|
||||
useLocalPref('hideMobileMessage');
|
||||
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const [show, setShow] = useState(
|
||||
isNarrowWidth &&
|
||||
!hideMobileMessagePref &&
|
||||
!hideMobileMessage &&
|
||||
!document.cookie.match(/hideMobileMessage=true/),
|
||||
);
|
||||
const [requestDontRemindMe, setRequestDontRemindMe] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
function onTry() {
|
||||
setShow(false);
|
||||
|
||||
if (requestDontRemindMe) {
|
||||
// remember the pref indefinitely
|
||||
dispatch(savePrefs({ hideMobileMessage: true }));
|
||||
setHideMobileMessagePref(true);
|
||||
} else {
|
||||
// Set a cookie for 5 minutes
|
||||
const d = new Date();
|
||||
|
||||
@@ -3,18 +3,19 @@ import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import { type PopModalAction } from 'loot-core/src/client/state-types/modals';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
import { useSyncServerStatus } from '../hooks/useSyncServerStatus';
|
||||
import { type CommonModalProps } from '../types/modals';
|
||||
|
||||
import { CategoryGroupMenu } from './modals/CategoryGroupMenu';
|
||||
import { CategoryMenu } from './modals/CategoryMenu';
|
||||
import { CloseAccount } from './modals/CloseAccount';
|
||||
import { ConfirmCategoryDelete } from './modals/ConfirmCategoryDelete';
|
||||
import { ConfirmTransactionEdit } from './modals/ConfirmTransactionEdit';
|
||||
import { ConfirmUnlinkAccount } from './modals/ConfirmUnlinkAccount';
|
||||
import { CreateAccount } from './modals/CreateAccount';
|
||||
import { CreateEncryptionKey } from './modals/CreateEncryptionKey';
|
||||
import { CreateLocalAccount } from './modals/CreateLocalAccount';
|
||||
@@ -40,14 +41,18 @@ import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
||||
import { ScheduleDetails } from './schedules/ScheduleDetails';
|
||||
import { ScheduleLink } from './schedules/ScheduleLink';
|
||||
|
||||
export type CommonModalProps = {
|
||||
onClose: () => PopModalAction;
|
||||
onBack: () => PopModalAction;
|
||||
showBack: boolean;
|
||||
isCurrent: boolean;
|
||||
isHidden: boolean;
|
||||
stackIndex: number;
|
||||
};
|
||||
|
||||
export function Modals() {
|
||||
const modalStack = useSelector(state => state.modals.modalStack);
|
||||
const isHidden = useSelector(state => state.modals.isHidden);
|
||||
const accounts = useSelector(state => state.queries.accounts);
|
||||
const { grouped: categoryGroups, list: categories } = useCategories();
|
||||
const budgetId = useSelector(
|
||||
state => state.prefs.local && state.prefs.local.id,
|
||||
);
|
||||
const modalStack = useSelector((state: State) => state.modals.modalStack);
|
||||
const isHidden = useSelector((state: State) => state.modals.isHidden);
|
||||
const actions = useActions();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -97,8 +102,6 @@ export function Modals() {
|
||||
account={options.account}
|
||||
balance={options.balance}
|
||||
canDelete={options.canDelete}
|
||||
accounts={accounts.filter(acct => acct.closed === 0)}
|
||||
categoryGroups={categoryGroups}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
@@ -109,7 +112,6 @@ export function Modals() {
|
||||
modalProps={modalProps}
|
||||
externalAccounts={options.accounts}
|
||||
requisitionId={options.requisitionId}
|
||||
localAccounts={accounts.filter(acct => acct.closed === 0)}
|
||||
actions={actions}
|
||||
syncSource={options.syncSource}
|
||||
/>
|
||||
@@ -119,19 +121,21 @@ export function Modals() {
|
||||
return (
|
||||
<ConfirmCategoryDelete
|
||||
modalProps={modalProps}
|
||||
category={
|
||||
'category' in options &&
|
||||
categories.find(c => c.id === options.category)
|
||||
}
|
||||
group={
|
||||
'group' in options &&
|
||||
categoryGroups.find(g => g.id === options.group)
|
||||
}
|
||||
categoryGroups={categoryGroups}
|
||||
category={options.category}
|
||||
group={options.group}
|
||||
onDelete={options.onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'confirm-unlink-account':
|
||||
return (
|
||||
<ConfirmUnlinkAccount
|
||||
modalProps={modalProps}
|
||||
accountName={options.accountName}
|
||||
onUnlink={options.onUnlink}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'confirm-transaction-edit':
|
||||
return (
|
||||
<ConfirmTransactionEdit
|
||||
@@ -145,7 +149,7 @@ export function Modals() {
|
||||
return (
|
||||
<LoadBackup
|
||||
watchUpdates
|
||||
budgetId={budgetId}
|
||||
budgetId={options.budgetId}
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
backupDisabled={false}
|
||||
@@ -301,6 +305,7 @@ export function Modals() {
|
||||
modalProps={modalProps}
|
||||
id={options?.id || null}
|
||||
actions={actions}
|
||||
transaction={options?.transaction || null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -311,6 +316,8 @@ export function Modals() {
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
transactionIds={options?.transactionIds}
|
||||
getTransaction={options?.getTransaction}
|
||||
pushModal={options?.pushModal}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, {
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import type { NotificationWithId } from 'loot-core/src/client/state-types/notifications';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
@@ -238,7 +239,9 @@ function Notification({
|
||||
|
||||
export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
const { removeNotification } = useActions();
|
||||
const notifications = useSelector(state => state.notifications.notifications);
|
||||
const notifications = useSelector(
|
||||
(state: State) => state.notifications.notifications,
|
||||
);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -7,8 +7,7 @@ import React, {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { usePrivacyMode } from 'loot-core/src/client/privacy';
|
||||
|
||||
import { usePrivacyMode } from '../hooks/usePrivacyMode';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
|
||||
import { View } from './common/View';
|
||||
|
||||
@@ -5,13 +5,14 @@ import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import debounce from 'debounce';
|
||||
|
||||
type IScrollContext = {
|
||||
scrollY: number | undefined;
|
||||
isBottomReached: boolean;
|
||||
hasScrolledToBottom: (tolerance?: number) => boolean;
|
||||
};
|
||||
|
||||
const ScrollContext = createContext<IScrollContext | undefined>(undefined);
|
||||
@@ -22,14 +23,20 @@ type ScrollProviderProps = {
|
||||
|
||||
export function ScrollProvider({ children }: ScrollProviderProps) {
|
||||
const [scrollY, setScrollY] = useState(undefined);
|
||||
const [isBottomReached, setIsBottomReached] = useState(false);
|
||||
const [scrollHeight, setScrollHeight] = useState(undefined);
|
||||
const [clientHeight, setClientHeight] = useState(undefined);
|
||||
|
||||
const hasScrolledToBottom = useCallback(
|
||||
(tolerance = 1) => scrollHeight - scrollY <= clientHeight + tolerance,
|
||||
[clientHeight, scrollHeight, scrollY],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listenToScroll = debounce(e => {
|
||||
setScrollY(e.target?.scrollTop || 0);
|
||||
setIsBottomReached(
|
||||
e.target?.scrollHeight - e.target?.scrollTop <= e.target?.clientHeight,
|
||||
);
|
||||
const target = e.target;
|
||||
setScrollY(target?.scrollTop || 0);
|
||||
setScrollHeight(target?.scrollHeight || 0);
|
||||
setClientHeight(target?.clientHeight || 0);
|
||||
}, 10);
|
||||
|
||||
window.addEventListener('scroll', listenToScroll, {
|
||||
@@ -43,7 +50,7 @@ export function ScrollProvider({ children }: ScrollProviderProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider value={{ scrollY, isBottomReached }}>
|
||||
<ScrollContext.Provider value={{ scrollY, hasScrolledToBottom }}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
||||
|
||||
import type { Theme } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { type CSSProperties, themeOptions, useTheme } from '../style';
|
||||
@@ -16,8 +15,7 @@ type ThemeSelectorProps = {
|
||||
};
|
||||
|
||||
export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
const theme = useTheme();
|
||||
const { saveGlobalPrefs } = useActions();
|
||||
const [theme, switchTheme] = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
@@ -26,17 +24,15 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
light: SvgSun,
|
||||
dark: SvgMoonStars,
|
||||
auto: SvgSystem,
|
||||
midnight: SvgMoonStars,
|
||||
} as const;
|
||||
|
||||
async function onMenuSelect(newTheme: string) {
|
||||
function onMenuSelect(newTheme: Theme) {
|
||||
setMenuOpen(false);
|
||||
|
||||
saveGlobalPrefs({
|
||||
theme: newTheme as Theme,
|
||||
});
|
||||
switchTheme(newTheme);
|
||||
}
|
||||
|
||||
const Icon = themeIcons?.[theme] || SvgSun;
|
||||
const Icon = themeIcons[theme] || SvgSun;
|
||||
|
||||
return isNarrowWidth ? null : (
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
@@ -16,6 +16,8 @@ import { type LocalPrefs } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useFeatureFlag } from '../hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
import { SvgArrowLeft } from '../icons/v1';
|
||||
import {
|
||||
@@ -36,10 +38,9 @@ import { Link } from './common/Link';
|
||||
import { Paragraph } from './common/Paragraph';
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { KeyHandlers } from './KeyHandlers';
|
||||
import { LoggedInUser } from './LoggedInUser';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import { useSidebar } from './sidebar';
|
||||
import { useSidebar } from './sidebar/SidebarProvider';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import { Tooltip } from './tooltips';
|
||||
@@ -118,10 +119,8 @@ type PrivacyButtonProps = {
|
||||
};
|
||||
|
||||
function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
const isPrivacyEnabled = useSelector(
|
||||
state => state.prefs.local?.isPrivacyEnabled,
|
||||
);
|
||||
const { savePrefs } = useActions();
|
||||
const [isPrivacyEnabled, setPrivacyEnabledPref] =
|
||||
useLocalPref('isPrivacyEnabled');
|
||||
|
||||
const privacyIconStyle = { width: 15, height: 15 };
|
||||
|
||||
@@ -129,7 +128,7 @@ function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
<Button
|
||||
type="bare"
|
||||
aria-label={`${isPrivacyEnabled ? 'Disable' : 'Enable'} privacy mode`}
|
||||
onClick={() => savePrefs({ isPrivacyEnabled: !isPrivacyEnabled })}
|
||||
onClick={() => setPrivacyEnabledPref(!isPrivacyEnabled)}
|
||||
style={style}
|
||||
>
|
||||
{isPrivacyEnabled ? (
|
||||
@@ -146,7 +145,7 @@ type SyncButtonProps = {
|
||||
isMobile?: boolean;
|
||||
};
|
||||
function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
const cloudFileId = useSelector(state => state.prefs.local?.cloudFileId);
|
||||
const [cloudFileId] = useLocalPref('cloudFileId');
|
||||
const { sync } = useActions();
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
@@ -232,63 +231,63 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
marginRight: 5,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyHandlers
|
||||
keys={{
|
||||
'ctrl+s, cmd+s': () => {
|
||||
sync();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
useHotkeys(
|
||||
'ctrl+s, cmd+s, meta+s',
|
||||
sync,
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[sync],
|
||||
);
|
||||
|
||||
<Button
|
||||
type="bare"
|
||||
aria-label="Sync"
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
...style,
|
||||
WebkitAppRegion: 'none',
|
||||
...mobileIconStyle,
|
||||
}
|
||||
: {
|
||||
...style,
|
||||
WebkitAppRegion: 'none',
|
||||
color: desktopColor,
|
||||
}
|
||||
}
|
||||
hoveredStyle={hoveredStyle}
|
||||
activeStyle={activeStyle}
|
||||
onClick={sync}
|
||||
>
|
||||
{isMobile ? (
|
||||
syncState === 'error' ? (
|
||||
<SvgAlertTriangle width={14} height={14} />
|
||||
) : (
|
||||
<AnimatedRefresh width={18} height={18} animating={syncing} />
|
||||
)
|
||||
) : syncState === 'error' ? (
|
||||
<SvgAlertTriangle width={13} />
|
||||
return (
|
||||
<Button
|
||||
type="bare"
|
||||
aria-label="Sync"
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
...style,
|
||||
WebkitAppRegion: 'none',
|
||||
...mobileIconStyle,
|
||||
}
|
||||
: {
|
||||
...style,
|
||||
WebkitAppRegion: 'none',
|
||||
color: desktopColor,
|
||||
}
|
||||
}
|
||||
hoveredStyle={hoveredStyle}
|
||||
activeStyle={activeStyle}
|
||||
onClick={sync}
|
||||
>
|
||||
{isMobile ? (
|
||||
syncState === 'error' ? (
|
||||
<SvgAlertTriangle width={14} height={14} />
|
||||
) : (
|
||||
<AnimatedRefresh animating={syncing} />
|
||||
)}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
|
||||
{syncState === 'disabled'
|
||||
? 'Disabled'
|
||||
: syncState === 'offline'
|
||||
? 'Offline'
|
||||
: 'Sync'}
|
||||
</Text>
|
||||
</Button>
|
||||
</>
|
||||
<AnimatedRefresh width={18} height={18} animating={syncing} />
|
||||
)
|
||||
) : syncState === 'error' ? (
|
||||
<SvgAlertTriangle width={13} />
|
||||
) : (
|
||||
<AnimatedRefresh animating={syncing} />
|
||||
)}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
|
||||
{syncState === 'disabled'
|
||||
? 'Disabled'
|
||||
: syncState === 'offline'
|
||||
? 'Offline'
|
||||
: 'Sync'}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetTitlebar() {
|
||||
const maxMonths = useSelector(state => state.prefs.global?.maxMonths);
|
||||
const budgetType = useSelector(state => state.prefs.local?.budgetType);
|
||||
const { saveGlobalPrefs } = useActions();
|
||||
const [maxMonths, setMaxMonthsPref] = useGlobalPref('maxMonths');
|
||||
const [budgetType] = useLocalPref('budgetType');
|
||||
const { sendEvent } = useContext(TitlebarContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -317,7 +316,7 @@ function BudgetTitlebar() {
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MonthCountSelector
|
||||
maxMonths={maxMonths || 1}
|
||||
onChange={value => saveGlobalPrefs({ maxMonths: value })}
|
||||
onChange={value => setMaxMonthsPref(value)}
|
||||
/>
|
||||
{reportBudgetEnabled && (
|
||||
<View style={{ marginLeft: -5 }}>
|
||||
@@ -390,9 +389,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
const sidebar = useSidebar();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const serverURL = useServerURL();
|
||||
const floatingSidebar = useSelector(
|
||||
state => state.prefs.global?.floatingSidebar,
|
||||
);
|
||||
const [floatingSidebar] = useGlobalPref('floatingSidebar');
|
||||
|
||||
return isNarrowWidth ? null : (
|
||||
<View
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { SvgClose } from '../icons/v1';
|
||||
import { theme } from '../style';
|
||||
@@ -11,9 +13,9 @@ import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const updateInfo = useSelector(state => state.app.updateInfo);
|
||||
const updateInfo = useSelector((state: State) => state.app.updateInfo);
|
||||
const showUpdateNotification = useSelector(
|
||||
state => state.app.showUpdateNotification,
|
||||
(state: State) => state.app.showUpdateNotification,
|
||||
);
|
||||
|
||||
const { updateApp, setAppState } = useActions();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom';
|
||||
import { debounce } from 'debounce';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { validForTransfer } from 'loot-core/client/transfer';
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import {
|
||||
@@ -26,17 +27,22 @@ import {
|
||||
} from 'loot-core/src/shared/transactions';
|
||||
import { applyChanges, groupById } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { usePayees } from '../../hooks/usePayees';
|
||||
import { SelectedProviderWithItems } from '../../hooks/useSelected';
|
||||
import {
|
||||
SplitsExpandedProvider,
|
||||
useSplitsExpanded,
|
||||
} from '../../hooks/useSplitsExpanded';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { TransactionList } from '../transactions/TransactionList';
|
||||
import {
|
||||
SplitsExpandedProvider,
|
||||
useSplitsExpanded,
|
||||
} from '../transactions/TransactionsTable';
|
||||
|
||||
import { AccountHeader } from './Header';
|
||||
|
||||
@@ -198,7 +204,7 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
this.state = {
|
||||
search: '',
|
||||
filters: [],
|
||||
filters: props.conditions || [],
|
||||
loading: true,
|
||||
workingHard: false,
|
||||
reconcileAmount: null,
|
||||
@@ -274,7 +280,7 @@ class AccountInternal extends PureComponent {
|
||||
// Important that any async work happens last so that the
|
||||
// listeners are set up synchronously
|
||||
await this.props.initiallyLoadPayees();
|
||||
await this.fetchTransactions();
|
||||
await this.fetchTransactions(this.state.filters);
|
||||
|
||||
// If there is a pending undo, apply it immediately (this happens
|
||||
// when an undo changes the location to this page)
|
||||
@@ -331,10 +337,11 @@ class AccountInternal extends PureComponent {
|
||||
this.paged?.run();
|
||||
};
|
||||
|
||||
fetchTransactions = () => {
|
||||
fetchTransactions = filters => {
|
||||
const query = this.makeRootQuery();
|
||||
this.rootQuery = this.currentQuery = query;
|
||||
this.updateQuery(query);
|
||||
if (filters) this.applyFilters(filters);
|
||||
else this.updateQuery(query);
|
||||
|
||||
if (this.props.accountId) {
|
||||
this.props.markAccountRead(this.props.accountId);
|
||||
@@ -342,18 +349,8 @@ class AccountInternal extends PureComponent {
|
||||
};
|
||||
|
||||
makeRootQuery = () => {
|
||||
const locationState = this.props.location.state;
|
||||
const accountId = this.props.accountId;
|
||||
|
||||
if (locationState && locationState.filter) {
|
||||
return q('transactions')
|
||||
.options({ splits: 'grouped' })
|
||||
.filter({
|
||||
'account.offbudget': false,
|
||||
...locationState.filter,
|
||||
});
|
||||
}
|
||||
|
||||
return queries.makeTransactionsQuery(accountId);
|
||||
};
|
||||
|
||||
@@ -456,7 +453,7 @@ class AccountInternal extends PureComponent {
|
||||
const accountId = this.props.accountId;
|
||||
const account = this.props.accounts.find(acct => acct.id === accountId);
|
||||
|
||||
await this.props.syncAndDownload(account ? account.id : null);
|
||||
await this.props.syncAndDownload(account ? account.id : undefined);
|
||||
};
|
||||
|
||||
onImport = async () => {
|
||||
@@ -465,7 +462,7 @@ class AccountInternal extends PureComponent {
|
||||
const categories = await this.props.getCategories();
|
||||
|
||||
if (account) {
|
||||
const res = await window.Actual.openFileDialog({
|
||||
const res = await window.Actual?.openFileDialog({
|
||||
filters: [
|
||||
{
|
||||
name: 'Financial Files',
|
||||
@@ -497,7 +494,7 @@ class AccountInternal extends PureComponent {
|
||||
accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-');
|
||||
const filename = `${normalizedName || 'transactions'}.csv`;
|
||||
|
||||
window.Actual.saveFile(
|
||||
window.Actual?.saveFile(
|
||||
exportedTransactions,
|
||||
filename,
|
||||
'Export Transactions',
|
||||
@@ -593,7 +590,12 @@ class AccountInternal extends PureComponent {
|
||||
});
|
||||
break;
|
||||
case 'unlink':
|
||||
this.props.unlinkAccount(accountId);
|
||||
this.props.pushModal('confirm-unlink-account', {
|
||||
accountName: account.name,
|
||||
onUnlink: () => {
|
||||
this.props.unlinkAccount(accountId);
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'close':
|
||||
this.props.openAccountCloseModal(accountId);
|
||||
@@ -1054,6 +1056,52 @@ class AccountInternal extends PureComponent {
|
||||
this.props.pushModal('edit-rule', { rule });
|
||||
};
|
||||
|
||||
onSetTransfer = async ids => {
|
||||
const onConfirmTransfer = async ids => {
|
||||
this.setState({ workingHard: true });
|
||||
|
||||
const payees = await this.props.getPayees();
|
||||
const { data: transactions } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*'),
|
||||
);
|
||||
const [fromTrans, toTrans] = transactions;
|
||||
|
||||
if (transactions.length === 2 && validForTransfer(fromTrans, toTrans)) {
|
||||
const fromPayee = payees.find(
|
||||
p => p.transfer_acct === fromTrans.account,
|
||||
);
|
||||
const toPayee = payees.find(p => p.transfer_acct === toTrans.account);
|
||||
|
||||
const changes = {
|
||||
updated: [
|
||||
{
|
||||
...fromTrans,
|
||||
payee: toPayee.id,
|
||||
transfer_id: toTrans.id,
|
||||
},
|
||||
{
|
||||
...toTrans,
|
||||
payee: fromPayee.id,
|
||||
transfer_id: fromTrans.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await send('transactions-batch-update', changes);
|
||||
}
|
||||
|
||||
await this.refetchTransactions();
|
||||
};
|
||||
|
||||
await this.checkForReconciledTransactions(
|
||||
ids,
|
||||
'batchEditWithReconciled',
|
||||
onConfirmTransfer,
|
||||
);
|
||||
};
|
||||
|
||||
onCondOpChange = (value, filters) => {
|
||||
this.setState({ conditionsOp: value });
|
||||
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
|
||||
@@ -1199,6 +1247,7 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
applySort = (field, ascDesc, prevField, prevAscDesc) => {
|
||||
const filters = this.state.filters;
|
||||
const isFiltered = filters.length > 0;
|
||||
const sortField = getField(!field ? this.state.sort.field : field);
|
||||
const sortAscDesc = !ascDesc ? this.state.sort.ascDesc : ascDesc;
|
||||
const sortPrevField = getField(
|
||||
@@ -1208,34 +1257,78 @@ class AccountInternal extends PureComponent {
|
||||
? this.state.sort.prevAscDesc
|
||||
: prevAscDesc;
|
||||
|
||||
if (!field) {
|
||||
//no sort was made (called by applyFilters)
|
||||
this.currentQuery = this.currentQuery.orderBy({
|
||||
const sortCurrentQuery = function (that, sortField, sortAscDesc) {
|
||||
if (sortField === 'cleared') {
|
||||
that.currentQuery = that.currentQuery.orderBy({
|
||||
reconciled: sortAscDesc,
|
||||
});
|
||||
}
|
||||
|
||||
that.currentQuery = that.currentQuery.orderBy({
|
||||
[sortField]: sortAscDesc,
|
||||
});
|
||||
} else {
|
||||
//sort called directly
|
||||
if (filters.length > 0) {
|
||||
//if filters already exist then apply them
|
||||
this.applyFilters([...filters]);
|
||||
this.currentQuery = this.currentQuery.orderBy({
|
||||
[sortField]: sortAscDesc,
|
||||
};
|
||||
|
||||
const sortRootQuery = function (that, sortField, sortAscDesc) {
|
||||
if (sortField === 'cleared') {
|
||||
that.currentQuery = that.rootQuery.orderBy({
|
||||
reconciled: sortAscDesc,
|
||||
});
|
||||
that.currentQuery = that.currentQuery.orderBy({
|
||||
cleared: sortAscDesc,
|
||||
});
|
||||
} else {
|
||||
//no filters exist make new rootquery
|
||||
this.currentQuery = this.rootQuery.orderBy({
|
||||
that.currentQuery = that.rootQuery.orderBy({
|
||||
[sortField]: sortAscDesc,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (sortPrevField) {
|
||||
//apply previos sort if it exists
|
||||
this.currentQuery = this.currentQuery.orderBy({
|
||||
};
|
||||
|
||||
// sort by previously used sort field, if any
|
||||
const maybeSortByPreviousField = function (
|
||||
that,
|
||||
sortPrevField,
|
||||
sortPrevAscDesc,
|
||||
) {
|
||||
if (!sortPrevField) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortPrevField === 'cleared') {
|
||||
that.currentQuery = that.currentQuery.orderBy({
|
||||
reconciled: sortPrevAscDesc,
|
||||
});
|
||||
}
|
||||
|
||||
that.currentQuery = that.currentQuery.orderBy({
|
||||
[sortPrevField]: sortPrevAscDesc,
|
||||
});
|
||||
};
|
||||
|
||||
switch (true) {
|
||||
// called by applyFilters to sort an already filtered result
|
||||
case !field:
|
||||
sortCurrentQuery(this, sortField, sortAscDesc);
|
||||
break;
|
||||
|
||||
// called directly from UI by sorting a column.
|
||||
// active filters need to be applied before sorting
|
||||
case isFiltered:
|
||||
this.applyFilters([...filters]);
|
||||
sortCurrentQuery(this, sortField, sortAscDesc);
|
||||
break;
|
||||
|
||||
// called directly from UI by sorting a column.
|
||||
// no active filters, start a new root query.
|
||||
case !isFiltered:
|
||||
sortRootQuery(this, sortField, sortAscDesc);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
this.updateQuery(this.currentQuery, this.state.filters.length > 0);
|
||||
maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc);
|
||||
this.updateQuery(this.currentQuery, isFiltered);
|
||||
};
|
||||
|
||||
onSort = (headerClicked, ascDesc) => {
|
||||
@@ -1393,6 +1486,7 @@ class AccountInternal extends PureComponent {
|
||||
onDeleteFilter={this.onDeleteFilter}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onSetTransfer={this.onSetTransfer}
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -1487,23 +1581,46 @@ export function Account() {
|
||||
const location = useLocation();
|
||||
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const state = useSelector(state => ({
|
||||
newTransactions: state.queries.newTransactions,
|
||||
matchedTransactions: state.queries.matchedTransactions,
|
||||
accounts: state.queries.accounts,
|
||||
failedAccounts: state.account.failedAccounts,
|
||||
dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
|
||||
hideFraction: state.prefs.local.hideFraction || false,
|
||||
expandSplits: state.prefs.local['expand-splits'],
|
||||
showBalances: params.id && state.prefs.local['show-balances-' + params.id],
|
||||
showCleared: params.id && !state.prefs.local['hide-cleared-' + params.id],
|
||||
showExtraBalances:
|
||||
state.prefs.local['show-extra-balances-' + params.id || 'all-accounts'],
|
||||
payees: state.queries.payees,
|
||||
modalShowing: state.modals.modalStack.length > 0,
|
||||
accountsSyncing: state.account.accountsSyncing,
|
||||
lastUndoState: state.app.lastUndoState,
|
||||
}));
|
||||
const newTransactions = useSelector(state => state.queries.newTransactions);
|
||||
const matchedTransactions = useSelector(
|
||||
state => state.queries.matchedTransactions,
|
||||
);
|
||||
const accounts = useAccounts();
|
||||
const payees = usePayees();
|
||||
const failedAccounts = useFailedAccounts();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const [hideFraction = false] = useLocalPref('hideFraction');
|
||||
const [expandSplits] = useLocalPref('expand-splits');
|
||||
const [showBalances] = useLocalPref(`show-balances-${params.id}`);
|
||||
const [hideCleared] = useLocalPref(`hide-cleared-${params.id}`);
|
||||
const [showExtraBalances] = useLocalPref(
|
||||
`show-extra-balances-${params.id || 'all-accounts'}`,
|
||||
);
|
||||
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
|
||||
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
|
||||
const lastUndoState = useSelector(state => state.app.lastUndoState);
|
||||
const conditions =
|
||||
location.state && location.state.conditions
|
||||
? location.state.conditions
|
||||
: [];
|
||||
|
||||
const state = {
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
accounts,
|
||||
failedAccounts,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
expandSplits,
|
||||
showBalances,
|
||||
showCleared: !hideCleared,
|
||||
showExtraBalances,
|
||||
payees,
|
||||
modalShowing,
|
||||
accountsSyncing,
|
||||
lastUndoState,
|
||||
conditions,
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const filtersList = useFilters();
|
||||
@@ -1513,26 +1630,25 @@ export function Account() {
|
||||
);
|
||||
|
||||
const transform = useMemo(() => {
|
||||
let filterByAccount = queries.getAccountFilter(params.id, '_account');
|
||||
let filterByPayee = queries.getAccountFilter(
|
||||
const filterByAccount = queries.getAccountFilter(params.id, '_account');
|
||||
const filterByPayee = queries.getAccountFilter(
|
||||
params.id,
|
||||
'_payee.transfer_acct',
|
||||
);
|
||||
|
||||
// Never show schedules on these pages
|
||||
if (
|
||||
(location.state && location.state.filter) ||
|
||||
params.id === 'uncategorized'
|
||||
) {
|
||||
filterByAccount = { id: null };
|
||||
filterByPayee = { id: null };
|
||||
}
|
||||
|
||||
return q => {
|
||||
q = q.filter({
|
||||
$and: [{ '_account.closed': false }],
|
||||
$or: [filterByAccount, filterByPayee],
|
||||
});
|
||||
if (params.id) {
|
||||
if (params.id === 'uncategorized') {
|
||||
q = q.filter({ next_date: null });
|
||||
} else {
|
||||
q = q.filter({
|
||||
$or: [filterByAccount, filterByPayee],
|
||||
});
|
||||
}
|
||||
}
|
||||
return q.orderBy({ next_date: 'desc' });
|
||||
};
|
||||
}, [params.id]);
|
||||
@@ -1548,7 +1664,7 @@ export function Account() {
|
||||
{...actionCreators}
|
||||
modalShowing={state.modalShowing}
|
||||
accountId={params.id}
|
||||
categoryId={location?.state?.filter?.category}
|
||||
categoryId={location?.state?.categoryId}
|
||||
location={location}
|
||||
filtersList={filtersList}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { authorizeBank } from '../../gocardless';
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { SvgExclamationOutline } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
@@ -49,7 +50,7 @@ function getErrorMessage(type, code) {
|
||||
}
|
||||
|
||||
export function AccountSyncCheck() {
|
||||
const accounts = useSelector(state => state.queries.accounts);
|
||||
const accounts = useAccounts();
|
||||
const failedAccounts = useSelector(state => state.account.failedAccounts);
|
||||
const { unlinkAccount, pushModal } = useActions();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { isPreviewId } from 'loot-core/shared/transactions';
|
||||
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
|
||||
import { q } from 'loot-core/src/shared/query';
|
||||
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
@@ -14,7 +15,6 @@ import { PrivacyFilter } from '../PrivacyFilter';
|
||||
import { CellValue } from '../spreadsheet/CellValue';
|
||||
import { useFormat } from '../spreadsheet/useFormat';
|
||||
import { useSheetValue } from '../spreadsheet/useSheetValue';
|
||||
import { isPreviewId } from '../transactions/TransactionsTable';
|
||||
|
||||
function DetailedBalance({ name, balance, isExactBalance = true }) {
|
||||
const format = useFormat();
|
||||
@@ -104,6 +104,20 @@ function SelectedBalance({ selectedItems, account }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilteredBalance({ selectedItems }) {
|
||||
const balance = selectedItems
|
||||
.filter(item => !item._unmatched && !item.is_parent)
|
||||
.reduce((sum, product) => sum + product.amount, 0);
|
||||
|
||||
return (
|
||||
<DetailedBalance
|
||||
name="Filtered balance:"
|
||||
balance={balance}
|
||||
isExactBalance={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreBalances({ balanceQuery }) {
|
||||
const cleared = useSheetValue({
|
||||
name: balanceQuery.name + '-cleared',
|
||||
@@ -127,6 +141,8 @@ export function Balances({
|
||||
showExtraBalances,
|
||||
onToggleExtraBalances,
|
||||
account,
|
||||
filteredItems,
|
||||
transactions,
|
||||
}) {
|
||||
const selectedItems = useSelectedItems();
|
||||
|
||||
@@ -148,6 +164,8 @@ export function Balances({
|
||||
opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0,
|
||||
},
|
||||
'&:hover svg': { opacity: 1 },
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
}}
|
||||
>
|
||||
<CellValue
|
||||
@@ -182,6 +200,9 @@ export function Balances({
|
||||
{selectedItems.size > 0 && (
|
||||
<SelectedBalance selectedItems={selectedItems} account={account} />
|
||||
)}
|
||||
{filteredItems.length > 0 && (
|
||||
<FilteredBalance selectedItems={transactions} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
|
||||
import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
|
||||
import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
@@ -21,11 +24,9 @@ import { Search } from '../common/Search';
|
||||
import { Stack } from '../common/Stack';
|
||||
import { View } from '../common/View';
|
||||
import { FilterButton } from '../filters/FiltersMenu';
|
||||
import { FiltersStack } from '../filters/SavedFilters';
|
||||
import { KeyHandlers } from '../KeyHandlers';
|
||||
import { FiltersStack } from '../filters/FiltersStack';
|
||||
import { NotesButton } from '../NotesButton';
|
||||
import { SelectedTransactionsButton } from '../transactions/SelectedTransactions';
|
||||
import { useSplitsExpanded } from '../transactions/TransactionsTable';
|
||||
|
||||
import { Balances } from './Balance';
|
||||
import { ReconcilingMessage, ReconcileTooltip } from './Reconcile';
|
||||
@@ -53,7 +54,6 @@ export function AccountHeader({
|
||||
search,
|
||||
filters,
|
||||
conditionsOp,
|
||||
savePrefs,
|
||||
pushModal,
|
||||
onSearch,
|
||||
onAddTransaction,
|
||||
@@ -79,17 +79,25 @@ export function AccountHeader({
|
||||
onCondOpChange,
|
||||
onDeleteFilter,
|
||||
onScheduleAction,
|
||||
onSetTransfer,
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const searchInput = useRef(null);
|
||||
const splitsExpanded = useSplitsExpanded();
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const isUsingServer = syncServerStatus !== 'no-server';
|
||||
const isServerOffline = syncServerStatus === 'offline';
|
||||
const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
|
||||
|
||||
let canSync = account && account.account_id;
|
||||
let canSync = account && account.account_id && isUsingServer;
|
||||
if (!account) {
|
||||
// All accounts - check for any syncable account
|
||||
canSync = !!accounts.find(account => !!account.account_id);
|
||||
canSync = !!accounts.find(account => !!account.account_id) && isUsingServer;
|
||||
}
|
||||
|
||||
// Only show the ability to make linked transfers on multi-account views.
|
||||
const showMakeTransfer = !account;
|
||||
|
||||
function onToggleSplits() {
|
||||
if (tableRef.current) {
|
||||
splitsExpanded.dispatch({
|
||||
@@ -97,39 +105,47 @@ export function AccountHeader({
|
||||
id: tableRef.current.getScrolledItem(),
|
||||
});
|
||||
|
||||
savePrefs({
|
||||
'expand-splits': !(splitsExpanded.state.mode === 'expand'),
|
||||
});
|
||||
setExpandSplitsPref(!(splitsExpanded.state.mode === 'expand'));
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+f, cmd+f, meta+f',
|
||||
() => {
|
||||
if (searchInput.current) {
|
||||
searchInput.current.focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[searchInput],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyHandlers
|
||||
keys={{
|
||||
'ctrl+f, cmd+f': () => {
|
||||
if (searchInput.current) {
|
||||
searchInput.current.focus();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={{ ...styles.pageContent, paddingBottom: 10, flexShrink: 0 }}>
|
||||
<View style={{ marginTop: 2, alignItems: 'flex-start' }}>
|
||||
<View
|
||||
style={{ marginTop: 2, marginBottom: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
<View>
|
||||
{editingName ? (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.target.value)}
|
||||
onBlur={() => onExposeName(false)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -5,
|
||||
marginBottom: -2,
|
||||
marginLeft: -5,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
@@ -153,7 +169,7 @@ export function AccountHeader({
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginRight: 5,
|
||||
marginBottom: 5,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
data-testid="account-name"
|
||||
>
|
||||
@@ -185,7 +201,7 @@ export function AccountHeader({
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }}
|
||||
style={{ fontSize: 25, fontWeight: 500, marginBottom: -1 }}
|
||||
data-testid="account-name"
|
||||
>
|
||||
{account && account.closed
|
||||
@@ -201,6 +217,8 @@ export function AccountHeader({
|
||||
showExtraBalances={showExtraBalances}
|
||||
onToggleExtraBalances={onToggleExtraBalances}
|
||||
account={account}
|
||||
filteredItems={filters}
|
||||
transactions={transactions}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
@@ -210,19 +228,24 @@ export function AccountHeader({
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{((account && !account.closed) || canSync) && (
|
||||
<Button type="bare" onClick={canSync ? onSync : onImport}>
|
||||
<Button
|
||||
type="bare"
|
||||
onClick={canSync ? onSync : onImport}
|
||||
disabled={canSync && isServerOffline}
|
||||
>
|
||||
{canSync ? (
|
||||
<>
|
||||
<AnimatedRefresh
|
||||
width={13}
|
||||
height={13}
|
||||
animating={
|
||||
(account && accountsSyncing === account.name) ||
|
||||
accountsSyncing === '__all'
|
||||
account
|
||||
? accountsSyncing.includes(account.id)
|
||||
: accountsSyncing.length > 0
|
||||
}
|
||||
style={{ marginRight: 4 }}
|
||||
/>{' '}
|
||||
Sync
|
||||
{isServerOffline ? 'Sync offline' : 'Sync'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -265,8 +288,10 @@ export function AccountHeader({
|
||||
onEdit={onBatchEdit}
|
||||
onUnlink={onBatchUnlink}
|
||||
onCreateRule={onCreateRule}
|
||||
onSetTransfer={onSetTransfer}
|
||||
onScheduleAction={onScheduleAction}
|
||||
pushModal={pushModal}
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { Fragment, type ComponentProps, type ReactNode } from 'react';
|
||||
import React, {
|
||||
Fragment,
|
||||
type ComponentProps,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
type AccountAutocompleteItem = AccountEntity;
|
||||
|
||||
type AccountListProps = {
|
||||
items: AccountAutocompleteItem[];
|
||||
getItemProps: (arg: {
|
||||
item: AccountAutocompleteItem;
|
||||
}) => ComponentProps<typeof View>;
|
||||
highlightedIndex: number;
|
||||
embedded: boolean;
|
||||
renderAccountItemGroupHeader?: (
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
) => ReactElement<typeof ItemHeader>;
|
||||
renderAccountItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof AccountItem>,
|
||||
) => ReactElement<typeof AccountItem>;
|
||||
};
|
||||
|
||||
function AccountList({
|
||||
items,
|
||||
@@ -19,7 +43,7 @@ function AccountList({
|
||||
embedded,
|
||||
renderAccountItemGroupHeader = defaultRenderAccountItemGroupHeader,
|
||||
renderAccountItem = defaultRenderAccountItem,
|
||||
}) {
|
||||
}: AccountListProps) {
|
||||
let lastItem = null;
|
||||
|
||||
return (
|
||||
@@ -68,15 +92,19 @@ function AccountList({
|
||||
);
|
||||
}
|
||||
|
||||
type AccountAutoCompleteProps = {
|
||||
type AccountAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<AccountAutocompleteItem>
|
||||
> & {
|
||||
embedded?: boolean;
|
||||
includeClosedAccounts: boolean;
|
||||
includeClosedAccounts?: boolean;
|
||||
renderAccountItemGroupHeader?: (
|
||||
props: AccountItemGroupHeaderProps,
|
||||
) => ReactNode;
|
||||
renderAccountItem?: (props: AccountItemProps) => ReactNode;
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
) => ReactElement<typeof ItemHeader>;
|
||||
renderAccountItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof AccountItem>,
|
||||
) => ReactElement<typeof AccountItem>;
|
||||
closeOnBlur?: boolean;
|
||||
} & ComponentProps<typeof Autocomplete>;
|
||||
};
|
||||
|
||||
export function AccountAutocomplete({
|
||||
embedded,
|
||||
@@ -85,12 +113,12 @@ export function AccountAutocomplete({
|
||||
renderAccountItem,
|
||||
closeOnBlur,
|
||||
...props
|
||||
}: AccountAutoCompleteProps) {
|
||||
let accounts = useCachedAccounts() || [];
|
||||
}: AccountAutocompleteProps) {
|
||||
const accounts = useAccounts() || [];
|
||||
|
||||
//remove closed accounts if needed
|
||||
//then sort by closed, then offbudget
|
||||
accounts = accounts
|
||||
const accountSuggestions: AccountAutocompleteItem[] = accounts
|
||||
.filter(item => {
|
||||
return includeClosedAccounts ? item : !item.closed;
|
||||
})
|
||||
@@ -108,7 +136,7 @@ export function AccountAutocomplete({
|
||||
highlightFirst={true}
|
||||
embedded={embedded}
|
||||
closeOnBlur={closeOnBlur}
|
||||
suggestions={accounts}
|
||||
suggestions={accountSuggestions}
|
||||
renderItems={(items, getItemProps, highlightedIndex) => (
|
||||
<AccountList
|
||||
items={items}
|
||||
@@ -124,39 +152,14 @@ export function AccountAutocomplete({
|
||||
);
|
||||
}
|
||||
|
||||
type AccountItemGroupHeaderProps = {
|
||||
title: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function AccountItemGroupHeader({
|
||||
title,
|
||||
style,
|
||||
...props
|
||||
}: AccountItemGroupHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
color: theme.menuAutoCompleteTextHeader,
|
||||
padding: '4px 9px',
|
||||
...style,
|
||||
}}
|
||||
data-testid={`${title}-account-item-group`}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderAccountItemGroupHeader(
|
||||
props: AccountItemGroupHeaderProps,
|
||||
): ReactNode {
|
||||
return <AccountItemGroupHeader {...props} />;
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
): ReactElement<typeof ItemHeader> {
|
||||
return <ItemHeader {...props} type="account" />;
|
||||
}
|
||||
|
||||
type AccountItemProps = {
|
||||
item: AccountEntity;
|
||||
item: AccountAutocompleteItem;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
highlighted?: boolean;
|
||||
@@ -171,6 +174,14 @@ export function AccountItem({
|
||||
...props
|
||||
}: AccountItemProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
borderRadius: 0,
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
// List each account up to a max
|
||||
@@ -199,24 +210,25 @@ export function AccountItem({
|
||||
className={`${className} ${css([
|
||||
{
|
||||
backgroundColor: highlighted
|
||||
? embedded && isNarrowWidth
|
||||
? theme.menuItemBackgroundHover
|
||||
: theme.menuAutoCompleteBackgroundHover
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
...narrowStyle,
|
||||
},
|
||||
])}`}
|
||||
data-testid={`${item.name}-account-item`}
|
||||
data-highlighted={highlighted || undefined}
|
||||
{...props}
|
||||
>
|
||||
{item.name}
|
||||
<TextOneLine>{item.name}</TextOneLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderAccountItem(props: AccountItemProps): ReactNode {
|
||||
function defaultRenderAccountItem(
|
||||
props: ComponentPropsWithoutRef<typeof AccountItem>,
|
||||
): ReactElement<typeof AccountItem> {
|
||||
return <AccountItem {...props} />;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,45 @@ import Downshift, { type StateChangeTypes } from 'downshift';
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { SvgRemove } from '../../icons/v2';
|
||||
import { theme, type CSSProperties } from '../../style';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, type CSSProperties, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Input } from '../common/Input';
|
||||
import { View } from '../common/View';
|
||||
import { Tooltip } from '../tooltips';
|
||||
|
||||
type CommonAutocompleteProps<T extends Item> = {
|
||||
focused?: boolean;
|
||||
embedded?: boolean;
|
||||
containerProps?: HTMLProps<HTMLDivElement>;
|
||||
labelProps?: { id?: string };
|
||||
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
suggestions?: T[];
|
||||
tooltipStyle?: CSSProperties;
|
||||
tooltipProps?: ComponentProps<typeof Tooltip>;
|
||||
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
|
||||
renderItems?: (
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
idx: number,
|
||||
value?: string,
|
||||
) => ReactNode;
|
||||
itemToString?: (item: T) => string;
|
||||
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
|
||||
filterSuggestions?: (suggestions: T[], value: string) => T[];
|
||||
openOnFocus?: boolean;
|
||||
getHighlightedIndex?: (suggestions: T[]) => number | null;
|
||||
highlightFirst?: boolean;
|
||||
onUpdate?: (id: T['id'], value: string) => void;
|
||||
strict?: boolean;
|
||||
clearOnBlur?: boolean;
|
||||
clearOnSelect?: boolean;
|
||||
closeOnBlur?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
type Item = {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -41,7 +74,7 @@ function findItem<T extends Item>(
|
||||
return value;
|
||||
}
|
||||
|
||||
function getItemName(item: null | string | Item): string {
|
||||
function getItemName<T extends Item>(item: T | T['name'] | null): string {
|
||||
if (item == null) {
|
||||
return '';
|
||||
} else if (typeof item === 'string') {
|
||||
@@ -50,7 +83,7 @@ function getItemName(item: null | string | Item): string {
|
||||
return item.name || '';
|
||||
}
|
||||
|
||||
function getItemId(item: Item | Item['id']) {
|
||||
function getItemId<T extends Item>(item: T | T['id']) {
|
||||
if (typeof item === 'string') {
|
||||
return item;
|
||||
}
|
||||
@@ -168,38 +201,12 @@ function defaultItemToString<T extends Item>(item?: T) {
|
||||
return item ? getItemName(item) : '';
|
||||
}
|
||||
|
||||
type SingleAutocompleteProps<T extends Item> = {
|
||||
focused?: boolean;
|
||||
embedded?: boolean;
|
||||
containerProps?: HTMLProps<HTMLDivElement>;
|
||||
labelProps?: { id?: string };
|
||||
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
suggestions?: T[];
|
||||
tooltipStyle?: CSSProperties;
|
||||
tooltipProps?: ComponentProps<typeof Tooltip>;
|
||||
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
|
||||
renderItems?: (
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
idx: number,
|
||||
value?: string,
|
||||
) => ReactNode;
|
||||
itemToString?: (item: T) => string;
|
||||
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
|
||||
filterSuggestions?: (suggestions: T[], value: string) => T[];
|
||||
openOnFocus?: boolean;
|
||||
getHighlightedIndex?: (suggestions: T[]) => number | null;
|
||||
highlightFirst?: boolean;
|
||||
onUpdate?: (id: T['id'], value: string) => void;
|
||||
strict?: boolean;
|
||||
type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
|
||||
type?: 'single' | never;
|
||||
onSelect: (id: T['id'], value: string) => void;
|
||||
tableBehavior?: boolean;
|
||||
closeOnBlur?: boolean;
|
||||
value: T | T['id'];
|
||||
isMulti?: boolean;
|
||||
value: null | T | T['id'];
|
||||
};
|
||||
|
||||
function SingleAutocomplete<T extends Item>({
|
||||
focused,
|
||||
embedded = false,
|
||||
@@ -220,10 +227,11 @@ function SingleAutocomplete<T extends Item>({
|
||||
onUpdate,
|
||||
strict,
|
||||
onSelect,
|
||||
tableBehavior,
|
||||
clearOnBlur = true,
|
||||
clearOnSelect = false,
|
||||
closeOnBlur = true,
|
||||
onClose,
|
||||
value: initialValue,
|
||||
isMulti = false,
|
||||
}: SingleAutocompleteProps<T>) {
|
||||
const [selectedItem, setSelectedItem] = useState(() =>
|
||||
findItem(strict, suggestions, initialValue),
|
||||
@@ -239,6 +247,26 @@ function SingleAutocomplete<T extends Item>({
|
||||
);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(null);
|
||||
const [isOpen, setIsOpen] = useState(embedded);
|
||||
const open = () => setIsOpen(true);
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowInputStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
}
|
||||
: {};
|
||||
|
||||
inputProps = {
|
||||
...inputProps,
|
||||
style: {
|
||||
...narrowInputStyle,
|
||||
...inputProps.style,
|
||||
},
|
||||
};
|
||||
|
||||
// Update the selected item if the suggestion list or initial
|
||||
// input value has changed
|
||||
@@ -273,10 +301,10 @@ function SingleAutocomplete<T extends Item>({
|
||||
setSelectedItem(item);
|
||||
setHighlightedIndex(null);
|
||||
|
||||
if (isMulti) {
|
||||
if (clearOnSelect) {
|
||||
setValue('');
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
close();
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
@@ -359,10 +387,11 @@ function SingleAutocomplete<T extends Item>({
|
||||
|
||||
setValue(value);
|
||||
setIsChanged(true);
|
||||
open();
|
||||
}}
|
||||
onStateChange={changes => {
|
||||
if (
|
||||
tableBehavior &&
|
||||
!clearOnBlur &&
|
||||
changes.type === Downshift.stateChangeTypes.mouseUp
|
||||
) {
|
||||
return;
|
||||
@@ -421,7 +450,7 @@ function SingleAutocomplete<T extends Item>({
|
||||
inputProps.onFocus?.(e);
|
||||
|
||||
if (openOnFocus) {
|
||||
setIsOpen(true);
|
||||
open();
|
||||
}
|
||||
},
|
||||
onBlur: e => {
|
||||
@@ -431,11 +460,11 @@ function SingleAutocomplete<T extends Item>({
|
||||
|
||||
if (!closeOnBlur) return;
|
||||
|
||||
if (!tableBehavior) {
|
||||
if (clearOnBlur) {
|
||||
if (e.target.value === '') {
|
||||
onSelect?.(null, e.target.value);
|
||||
setSelectedItem(null);
|
||||
setIsOpen(false);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -445,7 +474,7 @@ function SingleAutocomplete<T extends Item>({
|
||||
|
||||
resetState(value);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
close();
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -505,7 +534,11 @@ function SingleAutocomplete<T extends Item>({
|
||||
setValue(getItemName(originalItem));
|
||||
setSelectedItem(findItem(strict, suggestions, originalItem));
|
||||
setHighlightedIndex(null);
|
||||
setIsOpen(embedded ? true : false);
|
||||
if (embedded) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
},
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -578,36 +611,37 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type MultiAutocompleteProps<
|
||||
T extends Item,
|
||||
Value = SingleAutocompleteProps<T>['value'],
|
||||
> = Omit<SingleAutocompleteProps<T>, 'value' | 'onSelect'> & {
|
||||
value: Value[];
|
||||
onSelect: (ids: Value[], id?: string) => void;
|
||||
type MultiAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
|
||||
type: 'multi';
|
||||
onSelect: (ids: T['id'][], id?: T['id']) => void;
|
||||
value: null | T[] | T['id'][];
|
||||
};
|
||||
|
||||
function MultiAutocomplete<T extends Item>({
|
||||
value: selectedItems,
|
||||
value: selectedItems = [],
|
||||
onSelect,
|
||||
suggestions,
|
||||
strict,
|
||||
clearOnBlur = true,
|
||||
...props
|
||||
}: MultiAutocompleteProps<T>) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const lastSelectedItems = useRef<typeof selectedItems>();
|
||||
const selectedItemIds = selectedItems.map(getItemId);
|
||||
|
||||
useEffect(() => {
|
||||
lastSelectedItems.current = selectedItems;
|
||||
});
|
||||
|
||||
function onRemoveItem(id: (typeof selectedItems)[0]) {
|
||||
const items = selectedItems.filter(i => i !== id);
|
||||
function onRemoveItem(id: T['id']) {
|
||||
const items = selectedItemIds.filter(i => i !== id);
|
||||
onSelect(items);
|
||||
}
|
||||
|
||||
function onAddItem(id: string) {
|
||||
function onAddItem(id: T['id']) {
|
||||
if (id) {
|
||||
id = id.trim();
|
||||
onSelect([...selectedItems, id], id);
|
||||
onSelect([...selectedItemIds, id], id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +650,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
|
||||
) {
|
||||
if (e.key === 'Backspace' && e.currentTarget.value === '') {
|
||||
onRemoveItem(selectedItems[selectedItems.length - 1]);
|
||||
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
|
||||
}
|
||||
|
||||
prevOnKeyDown?.(e);
|
||||
@@ -625,10 +659,12 @@ function MultiAutocomplete<T extends Item>({
|
||||
return (
|
||||
<Autocomplete
|
||||
{...props}
|
||||
isMulti
|
||||
type="single"
|
||||
value={null}
|
||||
clearOnBlur={clearOnBlur}
|
||||
clearOnSelect={true}
|
||||
suggestions={suggestions.filter(
|
||||
item => !selectedItems.includes(getItemId(item)),
|
||||
item => !selectedItemIds.includes(getItemId(item)),
|
||||
)}
|
||||
onSelect={onAddItem}
|
||||
highlightFirst
|
||||
@@ -720,18 +756,10 @@ type AutocompleteProps<T extends Item> =
|
||||
| ComponentProps<typeof SingleAutocomplete<T>>
|
||||
| ComponentProps<typeof MultiAutocomplete<T>>;
|
||||
|
||||
function isMultiAutocomplete<T extends Item>(
|
||||
_props: AutocompleteProps<T>,
|
||||
multi?: boolean,
|
||||
): _props is ComponentProps<typeof MultiAutocomplete<T>> {
|
||||
return multi;
|
||||
}
|
||||
|
||||
export function Autocomplete<T extends Item>({
|
||||
multi,
|
||||
...props
|
||||
}: AutocompleteProps<T> & { multi?: boolean }) {
|
||||
if (isMultiAutocomplete(props, multi)) {
|
||||
}: AutocompleteProps<T>) {
|
||||
if (props.type === 'multi') {
|
||||
return <MultiAutocomplete {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import React, {
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
type ComponentType,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { css } from 'glamor';
|
||||
@@ -16,27 +18,36 @@ import {
|
||||
|
||||
import { SvgSplit } from '../../icons/v0';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { Text } from '../common/Text';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
type CategoryAutocompleteItem = CategoryEntity & {
|
||||
group?: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export type CategoryListProps = {
|
||||
items: Array<CategoryEntity & { group?: CategoryGroupEntity }>;
|
||||
items: CategoryAutocompleteItem[];
|
||||
getItemProps?: (arg: {
|
||||
item: CategoryEntity;
|
||||
item: CategoryAutocompleteItem;
|
||||
}) => Partial<ComponentProps<typeof View>>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
footer?: ReactNode;
|
||||
renderSplitTransactionButton?: (
|
||||
props: SplitTransactionButtonProps,
|
||||
) => ReactNode;
|
||||
props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
|
||||
) => ReactElement<typeof SplitTransactionButton>;
|
||||
renderCategoryItemGroupHeader?: (
|
||||
props: CategoryItemGroupHeaderProps,
|
||||
) => ReactNode;
|
||||
renderCategoryItem?: (props: CategoryItemProps) => ReactNode;
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
) => ReactElement<typeof ItemHeader>;
|
||||
renderCategoryItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof CategoryItem>,
|
||||
) => ReactElement<typeof CategoryItem>;
|
||||
showHiddenItems?: boolean;
|
||||
};
|
||||
function CategoryList({
|
||||
items,
|
||||
@@ -47,6 +58,7 @@ function CategoryList({
|
||||
renderSplitTransactionButton = defaultRenderSplitTransactionButton,
|
||||
renderCategoryItemGroupHeader = defaultRenderCategoryItemGroupHeader,
|
||||
renderCategoryItem = defaultRenderCategoryItem,
|
||||
showHiddenItems,
|
||||
}: CategoryListProps) {
|
||||
let lastGroup: string | undefined | null = null;
|
||||
|
||||
@@ -69,14 +81,23 @@ function CategoryList({
|
||||
});
|
||||
}
|
||||
|
||||
if ((item.hidden || item.group?.hidden) && !showHiddenItems) {
|
||||
return <Fragment key={item.id} />;
|
||||
}
|
||||
|
||||
const showGroup = item.cat_group !== lastGroup;
|
||||
const groupName = `${item.group?.name}${item.group?.hidden ? ' (hidden)' : ''}`;
|
||||
lastGroup = item.cat_group;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showGroup && item.group?.name && (
|
||||
<Fragment key={item.group.name}>
|
||||
{renderCategoryItemGroupHeader({
|
||||
title: item.group.name,
|
||||
title: groupName,
|
||||
style: {
|
||||
...(showHiddenItems &&
|
||||
item.group?.hidden && { color: theme.pageTextSubdued }),
|
||||
},
|
||||
})}
|
||||
</Fragment>
|
||||
)}
|
||||
@@ -86,6 +107,10 @@ function CategoryList({
|
||||
item,
|
||||
highlighted: highlightedIndex === idx,
|
||||
embedded,
|
||||
style: {
|
||||
...(showHiddenItems &&
|
||||
item.hidden && { color: theme.pageTextSubdued }),
|
||||
},
|
||||
})}
|
||||
</Fragment>
|
||||
</Fragment>
|
||||
@@ -97,16 +122,21 @@ function CategoryList({
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryAutocompleteProps = ComponentProps<typeof Autocomplete> & {
|
||||
type CategoryAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<CategoryAutocompleteItem>
|
||||
> & {
|
||||
categoryGroups: Array<CategoryGroupEntity>;
|
||||
showSplitOption?: boolean;
|
||||
renderSplitTransactionButton?: (
|
||||
props: SplitTransactionButtonProps,
|
||||
) => ReactNode;
|
||||
props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
|
||||
) => ReactElement<typeof SplitTransactionButton>;
|
||||
renderCategoryItemGroupHeader?: (
|
||||
props: CategoryItemGroupHeaderProps,
|
||||
) => ReactNode;
|
||||
renderCategoryItem?: (props: CategoryItemProps) => ReactNode;
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
) => ReactElement<typeof ItemHeader>;
|
||||
renderCategoryItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof CategoryItem>,
|
||||
) => ReactElement<typeof CategoryItem>;
|
||||
showHiddenCategories?: boolean;
|
||||
};
|
||||
|
||||
export function CategoryAutocomplete({
|
||||
@@ -117,11 +147,10 @@ export function CategoryAutocomplete({
|
||||
renderSplitTransactionButton,
|
||||
renderCategoryItemGroupHeader,
|
||||
renderCategoryItem,
|
||||
showHiddenCategories,
|
||||
...props
|
||||
}: CategoryAutocompleteProps) {
|
||||
const categorySuggestions: Array<
|
||||
CategoryEntity & { group?: CategoryGroupEntity }
|
||||
> = useMemo(
|
||||
const categorySuggestions: CategoryAutocompleteItem[] = useMemo(
|
||||
() =>
|
||||
categoryGroups.reduce(
|
||||
(list, group) =>
|
||||
@@ -170,6 +199,7 @@ export function CategoryAutocomplete({
|
||||
renderSplitTransactionButton={renderSplitTransactionButton}
|
||||
renderCategoryItemGroupHeader={renderCategoryItemGroupHeader}
|
||||
renderCategoryItem={renderCategoryItem}
|
||||
showHiddenItems={showHiddenCategories}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
@@ -177,35 +207,10 @@ export function CategoryAutocomplete({
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryItemGroupHeaderProps = {
|
||||
title: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function CategoryItemGroupHeader({
|
||||
title,
|
||||
style,
|
||||
...props
|
||||
}: CategoryItemGroupHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
color: theme.menuAutoCompleteTextHeader,
|
||||
padding: '4px 9px',
|
||||
...style,
|
||||
}}
|
||||
data-testid={`${title}-category-item-group`}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderCategoryItemGroupHeader(
|
||||
props: CategoryItemGroupHeaderProps,
|
||||
) {
|
||||
return <CategoryItemGroupHeader {...props} />;
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
): ReactElement<typeof ItemHeader> {
|
||||
return <ItemHeader {...props} type="category" />;
|
||||
}
|
||||
|
||||
type SplitTransactionButtonProps = {
|
||||
@@ -222,7 +227,6 @@ function SplitTransactionButton({
|
||||
style,
|
||||
...props
|
||||
}: SplitTransactionButtonProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
return (
|
||||
<View
|
||||
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
|
||||
@@ -249,9 +253,7 @@ function SplitTransactionButton({
|
||||
role="button"
|
||||
style={{
|
||||
backgroundColor: highlighted
|
||||
? embedded && isNarrowWidth
|
||||
? theme.menuItemBackgroundHover
|
||||
: theme.menuAutoCompleteBackgroundHover
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
flexShrink: 0,
|
||||
@@ -283,12 +285,12 @@ function SplitTransactionButton({
|
||||
|
||||
function defaultRenderSplitTransactionButton(
|
||||
props: SplitTransactionButtonProps,
|
||||
) {
|
||||
): ReactElement<typeof SplitTransactionButton> {
|
||||
return <SplitTransactionButton {...props} />;
|
||||
}
|
||||
|
||||
type CategoryItemProps = {
|
||||
item: CategoryEntity & { group?: CategoryGroupEntity };
|
||||
item: CategoryAutocompleteItem;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
highlighted?: boolean;
|
||||
@@ -298,36 +300,50 @@ type CategoryItemProps = {
|
||||
export function CategoryItem({
|
||||
item,
|
||||
className,
|
||||
style,
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
}: CategoryItemProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
borderRadius: 0,
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
// See comment above.
|
||||
role="button"
|
||||
className={`${className} ${css([
|
||||
{
|
||||
backgroundColor: highlighted
|
||||
? embedded && isNarrowWidth
|
||||
? theme.menuItemBackgroundHover
|
||||
: theme.menuAutoCompleteBackgroundHover
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
...narrowStyle,
|
||||
},
|
||||
])}`}
|
||||
data-testid={`${item.name}-category-item`}
|
||||
data-highlighted={highlighted || undefined}
|
||||
{...props}
|
||||
>
|
||||
{item.name}
|
||||
<TextOneLine>
|
||||
{item.name}
|
||||
{item.hidden ? ' (hidden)' : null}
|
||||
</TextOneLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderCategoryItem(props: CategoryItemProps) {
|
||||
function defaultRenderCategoryItem(
|
||||
props: ComponentPropsWithoutRef<typeof CategoryItem>,
|
||||
): ReactElement<typeof CategoryItem> {
|
||||
return <CategoryItem {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { type ComponentProps } from 'react';
|
||||
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import { type TransactionFilterEntity } from 'loot-core/types/models/transaction-filter';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { FilterList } from './FilterList';
|
||||
|
||||
export function FilterAutocomplete({
|
||||
embedded,
|
||||
...props
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} & ComponentProps<typeof Autocomplete<TransactionFilterEntity>>) {
|
||||
const filters = useFilters() || [];
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
strict={true}
|
||||
highlightFirst={true}
|
||||
embedded={embedded}
|
||||
suggestions={filters}
|
||||
renderItems={(items, getItemProps, highlightedIndex) => (
|
||||
<FilterList
|
||||
items={items}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
embedded={embedded}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
import React, { type ComponentProps } from 'react';
|
||||
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import { type TransactionFilterEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { theme } from '../../style';
|
||||
import { theme } from '../../style/theme';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
type FilterListProps<T> = {
|
||||
items: T[];
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
function FilterList<T extends { id: string; name: string }>({
|
||||
export function FilterList<T extends { id: string; name: string }>({
|
||||
items,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
embedded,
|
||||
}: FilterListProps<T>) {
|
||||
}: {
|
||||
items: T[];
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
@@ -30,6 +25,7 @@ function FilterList<T extends { id: string; name: string }>({
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<ItemHeader title="Saved Filters" type="filter" />
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
@@ -55,32 +51,3 @@ function FilterList<T extends { id: string; name: string }>({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type SavedFilterAutocompleteProps = {
|
||||
embedded?: boolean;
|
||||
} & ComponentProps<typeof Autocomplete<TransactionFilterEntity>>;
|
||||
|
||||
export function SavedFilterAutocomplete({
|
||||
embedded,
|
||||
...props
|
||||
}: SavedFilterAutocompleteProps) {
|
||||
const filters = useFilters() || [];
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
strict={true}
|
||||
highlightFirst={true}
|
||||
embedded={embedded}
|
||||
suggestions={filters}
|
||||
renderItems={(items, getItemProps, highlightedIndex) => (
|
||||
<FilterList
|
||||
items={items}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
embedded={embedded}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { styles, theme } from '../../style';
|
||||
import { type CSSProperties } from '../../style/types';
|
||||
|
||||
type ItemHeaderProps = {
|
||||
title: string;
|
||||
style?: CSSProperties;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function ItemHeader({ title, style, type, ...props }: ItemHeaderProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.largeText,
|
||||
color: theme.menuItemTextHeader,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
color: theme.menuAutoCompleteTextHeader,
|
||||
padding: '4px 9px',
|
||||
...narrowStyle,
|
||||
...style,
|
||||
}}
|
||||
data-testid={`${title}-${type}-item-group`}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,24 +7,27 @@ import React, {
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
type SVGProps,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { createPayee } from 'loot-core/src/client/actions/queries';
|
||||
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
|
||||
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
|
||||
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type PayeeEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { usePayees } from '../../hooks/usePayees';
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import {
|
||||
@@ -32,8 +35,15 @@ import {
|
||||
defaultFilterSuggestion,
|
||||
AutocompleteFooter,
|
||||
} from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
|
||||
type PayeeAutocompleteItem = PayeeEntity;
|
||||
|
||||
function getPayeeSuggestions(
|
||||
payees: PayeeAutocompleteItem[],
|
||||
focusTransferPayees: boolean,
|
||||
accounts: AccountEntity[],
|
||||
): PayeeAutocompleteItem[] {
|
||||
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
|
||||
|
||||
if (focusTransferPayees && activePayees) {
|
||||
@@ -43,11 +53,11 @@ function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
|
||||
return activePayees || [];
|
||||
}
|
||||
|
||||
function makeNew(value, rawPayee) {
|
||||
if (value === 'new' && !rawPayee.startsWith('new:')) {
|
||||
function makeNew(id, rawPayee) {
|
||||
if (id === 'new' && !rawPayee.startsWith('new:')) {
|
||||
return 'new:' + rawPayee;
|
||||
}
|
||||
return value;
|
||||
return id;
|
||||
}
|
||||
|
||||
// Convert the fully resolved new value into the 'new' id that can be
|
||||
@@ -59,6 +69,26 @@ function stripNew(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
type PayeeListProps = {
|
||||
items: PayeeAutocompleteItem[];
|
||||
getItemProps: (arg: {
|
||||
item: PayeeAutocompleteItem;
|
||||
}) => ComponentProps<typeof View>;
|
||||
highlightedIndex: number;
|
||||
embedded: boolean;
|
||||
inputValue: string;
|
||||
renderCreatePayeeButton?: (
|
||||
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
|
||||
) => ReactNode;
|
||||
renderPayeeItemGroupHeader?: (
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
) => ReactNode;
|
||||
renderPayeeItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||
) => ReactNode;
|
||||
footer: ReactNode;
|
||||
};
|
||||
|
||||
function PayeeList({
|
||||
items,
|
||||
getItemProps,
|
||||
@@ -69,8 +99,7 @@ function PayeeList({
|
||||
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
|
||||
renderPayeeItem = defaultRenderPayeeItem,
|
||||
footer,
|
||||
}) {
|
||||
const isFiltered = items.filtered;
|
||||
}: PayeeListProps) {
|
||||
let createNew = null;
|
||||
items = [...items];
|
||||
|
||||
@@ -111,7 +140,8 @@ function PayeeList({
|
||||
} else if (type === 'account' && lastType !== type) {
|
||||
title = 'Transfer To/From';
|
||||
}
|
||||
const showMoreMessage = idx === items.length - 1 && isFiltered;
|
||||
const showMoreMessage =
|
||||
idx === items.length - 1 && items.length > 100;
|
||||
lastType = type;
|
||||
|
||||
return (
|
||||
@@ -151,22 +181,24 @@ function PayeeList({
|
||||
);
|
||||
}
|
||||
|
||||
type PayeeAutocompleteProps = {
|
||||
value: ComponentProps<typeof Autocomplete>['value'];
|
||||
inputProps: ComponentProps<typeof Autocomplete>['inputProps'];
|
||||
type PayeeAutocompleteProps = ComponentProps<
|
||||
typeof Autocomplete<PayeeAutocompleteItem>
|
||||
> & {
|
||||
showMakeTransfer?: boolean;
|
||||
showManagePayees?: boolean;
|
||||
tableBehavior: ComponentProps<typeof Autocomplete>['tableBehavior'];
|
||||
embedded?: boolean;
|
||||
closeOnBlur: ComponentProps<typeof Autocomplete>['closeOnBlur'];
|
||||
onUpdate?: (value: string) => void;
|
||||
onSelect?: (value: string) => void;
|
||||
onManagePayees: () => void;
|
||||
renderCreatePayeeButton?: (props: CreatePayeeButtonProps) => ReactNode;
|
||||
renderPayeeItemGroupHeader?: (props: PayeeItemGroupHeaderProps) => ReactNode;
|
||||
renderPayeeItem?: (props: PayeeItemProps) => ReactNode;
|
||||
onManagePayees?: () => void;
|
||||
renderCreatePayeeButton?: (
|
||||
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
|
||||
) => ReactElement<typeof CreatePayeeButton>;
|
||||
renderPayeeItemGroupHeader?: (
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
) => ReactElement<typeof ItemHeader>;
|
||||
renderPayeeItem?: (
|
||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||
) => ReactElement<typeof PayeeItem>;
|
||||
accounts?: AccountEntity[];
|
||||
payees?: PayeeEntity[];
|
||||
payees?: PayeeAutocompleteItem[];
|
||||
};
|
||||
|
||||
export function PayeeAutocomplete({
|
||||
@@ -174,9 +206,9 @@ export function PayeeAutocomplete({
|
||||
inputProps,
|
||||
showMakeTransfer = true,
|
||||
showManagePayees = false,
|
||||
tableBehavior,
|
||||
embedded,
|
||||
clearOnBlur = true,
|
||||
closeOnBlur,
|
||||
embedded,
|
||||
onUpdate,
|
||||
onSelect,
|
||||
onManagePayees,
|
||||
@@ -187,12 +219,12 @@ export function PayeeAutocomplete({
|
||||
payees,
|
||||
...props
|
||||
}: PayeeAutocompleteProps) {
|
||||
const cachedPayees = useCachedPayees();
|
||||
const retrievedPayees = usePayees();
|
||||
if (!payees) {
|
||||
payees = cachedPayees;
|
||||
payees = retrievedPayees;
|
||||
}
|
||||
|
||||
const cachedAccounts = useCachedAccounts();
|
||||
const cachedAccounts = useAccounts();
|
||||
if (!accounts) {
|
||||
accounts = cachedAccounts;
|
||||
}
|
||||
@@ -200,7 +232,7 @@ export function PayeeAutocomplete({
|
||||
const [focusTransferPayees, setFocusTransferPayees] = useState(false);
|
||||
const [rawPayee, setRawPayee] = useState('');
|
||||
const hasPayeeInput = !!rawPayee;
|
||||
const payeeSuggestions = useMemo(() => {
|
||||
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
|
||||
const suggestions = getPayeeSuggestions(
|
||||
payees,
|
||||
focusTransferPayees,
|
||||
@@ -215,20 +247,22 @@ export function PayeeAutocomplete({
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function handleSelect(value, rawInputValue) {
|
||||
if (tableBehavior) {
|
||||
onSelect?.(makeNew(value, rawInputValue));
|
||||
async function handleSelect(idOrIds, rawInputValue) {
|
||||
if (!clearOnBlur) {
|
||||
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
|
||||
} else {
|
||||
const create = () => dispatch(createPayee(rawInputValue));
|
||||
const create = payeeName => dispatch(createPayee(payeeName));
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value = await Promise.all(value.map(v => (v === 'new' ? create() : v)));
|
||||
if (Array.isArray(idOrIds)) {
|
||||
idOrIds = await Promise.all(
|
||||
idOrIds.map(v => (v === 'new' ? create(rawInputValue) : v)),
|
||||
);
|
||||
} else {
|
||||
if (value === 'new') {
|
||||
value = await create();
|
||||
if (idOrIds === 'new') {
|
||||
idOrIds = await create(rawInputValue);
|
||||
}
|
||||
}
|
||||
onSelect?.(value);
|
||||
onSelect?.(idOrIds, rawInputValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +275,7 @@ export function PayeeAutocomplete({
|
||||
embedded={embedded}
|
||||
value={stripNew(value)}
|
||||
suggestions={payeeSuggestions}
|
||||
tableBehavior={tableBehavior}
|
||||
clearOnBlur={clearOnBlur}
|
||||
closeOnBlur={closeOnBlur}
|
||||
itemToString={item => {
|
||||
if (!item) {
|
||||
@@ -261,9 +295,7 @@ export function PayeeAutocomplete({
|
||||
onFocus: () => setPayeeFieldFocused(true),
|
||||
onChange: setRawPayee,
|
||||
}}
|
||||
onUpdate={(value, inputValue) =>
|
||||
onUpdate && onUpdate(makeNew(value, inputValue))
|
||||
}
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
getHighlightedIndex={suggestions => {
|
||||
if (suggestions.length > 1 && suggestions[0].id === 'new') {
|
||||
@@ -308,10 +340,7 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
});
|
||||
|
||||
const isf = filtered.length > 100;
|
||||
filtered = filtered.slice(0, 100);
|
||||
// @ts-expect-error TODO: solve this somehow
|
||||
filtered.filtered = isf;
|
||||
|
||||
if (filtered.length >= 2 && filtered[0].id === 'new') {
|
||||
if (
|
||||
@@ -340,7 +369,7 @@ export function PayeeAutocomplete({
|
||||
type={focusTransferPayees ? 'menuSelected' : 'menu'}
|
||||
style={showManagePayees && { marginBottom: 5 }}
|
||||
onClick={() => {
|
||||
onUpdate?.(null);
|
||||
onUpdate?.(null, null);
|
||||
setFocusTransferPayees(!focusTransferPayees);
|
||||
}}
|
||||
>
|
||||
@@ -378,6 +407,13 @@ export function CreatePayeeButton({
|
||||
...props
|
||||
}: CreatePayeeButtonProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
}
|
||||
: {};
|
||||
const iconSize = isNarrowWidth ? 14 : 8;
|
||||
|
||||
return (
|
||||
<View
|
||||
data-testid="create-payee-button"
|
||||
@@ -391,13 +427,12 @@ export function CreatePayeeButton({
|
||||
fontWeight: 500,
|
||||
padding: '6px 9px',
|
||||
backgroundColor: highlighted
|
||||
? embedded && isNarrowWidth
|
||||
? theme.menuItemBackgroundHover
|
||||
: theme.menuAutoCompleteBackgroundHover
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
':active': {
|
||||
backgroundColor: 'rgba(100, 100, 100, .25)',
|
||||
},
|
||||
...narrowStyle,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
@@ -406,8 +441,8 @@ export function CreatePayeeButton({
|
||||
<Icon style={{ marginRight: 5, display: 'inline-block' }} />
|
||||
) : (
|
||||
<SvgAdd
|
||||
width={8}
|
||||
height={8}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ marginRight: 5, display: 'inline-block' }}
|
||||
/>
|
||||
)}
|
||||
@@ -417,44 +452,19 @@ export function CreatePayeeButton({
|
||||
}
|
||||
|
||||
function defaultRenderCreatePayeeButton(
|
||||
props: CreatePayeeButtonProps,
|
||||
): ReactNode {
|
||||
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
|
||||
): ReactElement<typeof CreatePayeeButton> {
|
||||
return <CreatePayeeButton {...props} />;
|
||||
}
|
||||
|
||||
type PayeeItemGroupHeaderProps = {
|
||||
title: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function PayeeItemGroupHeader({
|
||||
title,
|
||||
style,
|
||||
...props
|
||||
}: PayeeItemGroupHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
color: theme.menuAutoCompleteTextHeader,
|
||||
padding: '4px 9px',
|
||||
...style,
|
||||
}}
|
||||
data-testid={`${title}-payee-item-group`}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderPayeeItemGroupHeader(
|
||||
props: PayeeItemGroupHeaderProps,
|
||||
): ReactNode {
|
||||
return <PayeeItemGroupHeader {...props} />;
|
||||
props: ComponentPropsWithoutRef<typeof ItemHeader>,
|
||||
): ReactElement<typeof ItemHeader> {
|
||||
return <ItemHeader {...props} type="payee" />;
|
||||
}
|
||||
|
||||
type PayeeItemProps = {
|
||||
item: PayeeEntity;
|
||||
item: PayeeAutocompleteItem;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
highlighted?: boolean;
|
||||
@@ -469,6 +479,14 @@ export function PayeeItem({
|
||||
...props
|
||||
}: PayeeItemProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
borderRadius: 0,
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
|
||||
@@ -496,24 +514,25 @@ export function PayeeItem({
|
||||
className={`${className} ${css([
|
||||
{
|
||||
backgroundColor: highlighted
|
||||
? embedded && isNarrowWidth
|
||||
? theme.menuItemBackgroundHover
|
||||
: theme.menuAutoCompleteBackgroundHover
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
...narrowStyle,
|
||||
},
|
||||
])}`}
|
||||
data-testid={`${item.name}-payee-item`}
|
||||
data-highlighted={highlighted || undefined}
|
||||
{...props}
|
||||
>
|
||||
{item.name}
|
||||
<TextOneLine>{item.name}</TextOneLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRenderPayeeItem(props: PayeeItemProps): ReactNode {
|
||||
function defaultRenderPayeeItem(
|
||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||
): ReactElement<typeof PayeeItem> {
|
||||
return <PayeeItem {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { type ComponentProps } from 'react';
|
||||
|
||||
import { useReports } from 'loot-core/client/data-hooks/reports';
|
||||
import { type CustomReportEntity } from 'loot-core/src/types/models/reports';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ReportList } from './ReportList';
|
||||
|
||||
type ReportAutocompleteProps = {
|
||||
embedded?: boolean;
|
||||
} & ComponentProps<typeof Autocomplete<CustomReportEntity>>;
|
||||
|
||||
export function ReportAutocomplete({
|
||||
embedded,
|
||||
...props
|
||||
}: ReportAutocompleteProps) {
|
||||
const reports = useReports() || [];
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
strict={true}
|
||||
highlightFirst={true}
|
||||
embedded={embedded}
|
||||
suggestions={reports}
|
||||
renderItems={(items, getItemProps, highlightedIndex) => (
|
||||
<ReportList
|
||||
items={items}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
embedded={embedded}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { Fragment, type ComponentProps } from 'react';
|
||||
|
||||
import { theme } from '../../style/theme';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
export function ReportList<T extends { id: string; name: string }>({
|
||||
items,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
embedded,
|
||||
}: {
|
||||
items: T[];
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
padding: '5px 0',
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<Fragment>{ItemHeader({ title: 'Saved Reports' })}</Fragment>
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
{...(getItemProps ? getItemProps({ item }) : null)}
|
||||
key={item.id}
|
||||
style={{
|
||||
backgroundColor:
|
||||
highlightedIndex === idx
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
}}
|
||||
data-highlighted={highlightedIndex === idx || undefined}
|
||||
>
|
||||
{item.name}
|
||||
</div>,
|
||||
];
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||