mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 04:02:38 -05:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9a1b094cc | ||
|
|
5d559afe30 | ||
|
|
4d0e9cadd3 | ||
|
|
bfe896a30e | ||
|
|
44573c0fe5 | ||
|
|
7fbb26f2c9 | ||
|
|
143ddeaa96 | ||
|
|
61f1802840 | ||
|
|
e23f9d822b | ||
|
|
6ef1f3d15d | ||
|
|
0dd5536914 | ||
|
|
072c3504fe | ||
|
|
5d921f7ab1 | ||
|
|
267bd8cc07 | ||
|
|
79ad04dd88 | ||
|
|
405a92e926 | ||
|
|
69140d6290 | ||
|
|
e8da21fc80 | ||
|
|
ad9a4067a8 | ||
|
|
7e6b760796 | ||
|
|
45c06c2303 | ||
|
|
440093b30a | ||
|
|
f76a07c3cf | ||
|
|
c009a0c7fb | ||
|
|
c025e516fb | ||
|
|
649b4c90e0 | ||
|
|
28c6894021 | ||
|
|
e71d4dc680 | ||
|
|
ba778c9e9f | ||
|
|
605a0d82ed | ||
|
|
edf2122059 | ||
|
|
889ca322f1 | ||
|
|
070bd212c5 | ||
|
|
8e94d1777b | ||
|
|
3cb18683c6 | ||
|
|
6c01e6eaaf | ||
|
|
84cbe6e54c | ||
|
|
181d088e76 | ||
|
|
5a75befc05 | ||
|
|
c099e1ff10 | ||
|
|
15e6843acf | ||
|
|
e7bfd35b9a | ||
|
|
1df7acdca7 | ||
|
|
67c3be97a1 | ||
|
|
8def8393da | ||
|
|
c3c2861dbd | ||
|
|
97b1b6f815 | ||
|
|
7063af9e58 | ||
|
|
beef97d7b8 | ||
|
|
98948744ca | ||
|
|
2903fd0037 | ||
|
|
ce40e61ab7 | ||
|
|
fc9ca18f1c | ||
|
|
141035cdf0 | ||
|
|
610a044f5f | ||
|
|
26363ed82d | ||
|
|
815413e48c | ||
|
|
4a3fe1d9fb | ||
|
|
c5c4cbbeb2 | ||
|
|
5d7ead44aa | ||
|
|
d9bc64e792 | ||
|
|
d166d8f8e8 | ||
|
|
ad08494899 | ||
|
|
2762495a68 | ||
|
|
d25c31089c | ||
|
|
96c7af0c8d | ||
|
|
319679fd65 | ||
|
|
c7e531a26c | ||
|
|
21f0644987 |
23
.eslintrc.js
23
.eslintrc.js
@@ -1,6 +1,17 @@
|
||||
const path = require('path');
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir');
|
||||
rulesDirPlugin.RULES_DIR = path.join(
|
||||
__dirname,
|
||||
'packages',
|
||||
'eslint-plugin-actual',
|
||||
'lib',
|
||||
'rules',
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
plugins: ['prettier', 'import'],
|
||||
extends: ['react-app'],
|
||||
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
|
||||
extends: ['react-app', 'plugin:@typescript-eslint/recommended'],
|
||||
reportUnusedDisableDirectives: true,
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
@@ -17,6 +28,8 @@ module.exports = {
|
||||
require('confusing-browser-globals').filter(g => g !== 'self'),
|
||||
),
|
||||
|
||||
'rulesdir/typography': 'error',
|
||||
|
||||
// https://github.com/eslint/eslint/issues/16954
|
||||
// https://github.com/eslint/eslint/issues/16953
|
||||
'no-loop-func': 'off',
|
||||
@@ -53,5 +66,11 @@ module.exports = {
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
},
|
||||
],
|
||||
|
||||
// Rules disable during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes -->
|
||||
14
.github/workflows/check-release-notes.yml
vendored
Normal file
14
.github/workflows/check-release-notes.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Check release notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check release notes
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
22
.github/workflows/e2e-test.yml
vendored
22
.github/workflows/e2e-test.yml
vendored
@@ -15,24 +15,16 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup Playwright
|
||||
run: npx playwright install chromium --with-deps
|
||||
- name: Wait for Pages changed to neutral
|
||||
uses: fountainhead/action-wait-for-check@v1.1.0
|
||||
id: wait-for-Netlify
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
checkName: 'Pages changed - actualbudget'
|
||||
- name: Waiting for Netlify Preview
|
||||
if: steps.wait-for-Netlify.outputs.conclusion == 'neutral'
|
||||
uses: jakepartusch/wait-for-netlify-action@v1.4
|
||||
id: waitFor200
|
||||
with:
|
||||
site_name: 'actualbudget'
|
||||
max_timeout: 240
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./bin/netlify-wait-for-build
|
||||
- name: Run E2E Tests on Netlify URL
|
||||
run: yarn e2e
|
||||
env:
|
||||
E2E_START_URL: https://deploy-preview-${{env.GITHUB_PR_NUMBER}}--actualbudget.netlify.app
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
|
||||
17
.github/workflows/generate-release-notes.yml
vendored
Normal file
17
.github/workflows/generate-release-notes.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Generate release notes
|
||||
uses: actualbudget/actions/release-notes/generate@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
18
.github/workflows/typecheck.yml
vendored
Normal file
18
.github/workflows/typecheck.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
@@ -21,9 +21,14 @@ Here are some initial guidelines for how contributions will be treated:
|
||||
- @j-f1
|
||||
- @jlongster
|
||||
- @MatissJanis
|
||||
- @rich-howell
|
||||
- @trevdor
|
||||
|
||||
## Alumni
|
||||
|
||||
(sorted alphabetically)
|
||||
|
||||
- @rich-howell
|
||||
|
||||
## Project ideas
|
||||
|
||||
We welcome all contributions from the community. If you have an idea for a feature you want to build - please go ahead and submit a PR with the implementation or if it's a larger feature - open a new issue so we can discuss it.
|
||||
|
||||
40
bin/netlify-wait-for-build
Executable file
40
bin/netlify-wait-for-build
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
current_commit=$(git rev-parse HEAD)
|
||||
|
||||
echo "Running on commit $COMMIT_SHA"
|
||||
|
||||
function get_status() {
|
||||
echo "::group::API Response"
|
||||
curl --header "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/actualbudget/actual/commits/$COMMIT_SHA/statuses" > /tmp/status.json
|
||||
cat /tmp/status.json
|
||||
echo "::endgroup::"
|
||||
netlify=$(jq '[.[] | select(.context == "netlify/actualbudget/deploy-preview")][0]' /tmp/status.json)
|
||||
state=$(jq -r '.state' <<< "$netlify")
|
||||
echo "::group::Netlify Status"
|
||||
echo "$netlify"
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
get_status
|
||||
|
||||
while [ "$netlify" == "null" ]; do
|
||||
echo "Waiting for Netlify to start building..."
|
||||
sleep 10
|
||||
get_status
|
||||
done
|
||||
|
||||
while [ "$state" == "pending" ]; do
|
||||
echo "Waiting for Netlify to finish building..."
|
||||
sleep 10
|
||||
get_status
|
||||
done
|
||||
|
||||
if [ "$state" == "success" ]; then
|
||||
echo -e "\033[0;32mNetlify build succeeded!\033[0m"
|
||||
jq -r '"url=" + .target_url' <<< "$netlify" > $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo -e "\033[0;31mNetlify build failed. Cancelling end-to-end tests.\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
14
package.json
14
package.json
@@ -25,27 +25,35 @@
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"test": "yarn workspaces foreach --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --parallel --verbose run e2e",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "cross-env NODE_ENV=development yarn workspaces foreach --verbose run lint --max-warnings 0",
|
||||
"typecheck": "yarn tsc",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^5.1.5",
|
||||
"eslint": "8.35.0",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"patch-package": "^6.1.2",
|
||||
"prettier": "2.8.2",
|
||||
"react-refresh": "^0.14.0",
|
||||
"source-map-support": "^0.5.21"
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-error-overlay": "6.0.9"
|
||||
},
|
||||
"packageManager": "yarn@3.4.1"
|
||||
"packageManager": "yarn@3.4.1",
|
||||
"browserslist": [
|
||||
"electron 12.0",
|
||||
"defaults"
|
||||
]
|
||||
}
|
||||
|
||||
1
packages/api/.eslintignore
Normal file
1
packages/api/.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
app/bundle.api.js
|
||||
@@ -11,14 +11,14 @@ class Query {
|
||||
validateRefs: true,
|
||||
limit: null,
|
||||
offset: null,
|
||||
...state
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
filter(expr) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [...this.state.filterExpressions, expr]
|
||||
filterExpressions: [...this.state.filterExpressions, expr],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ class Query {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: this.state.filterExpressions.filter(
|
||||
expr => !exprSet.has(Object.keys(expr)[0])
|
||||
)
|
||||
expr => !exprSet.has(Object.keys(expr)[0]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class Query {
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
groupExpressions: [...this.state.groupExpressions, ...exprs]
|
||||
groupExpressions: [...this.state.groupExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class Query {
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
orderExpressions: [...this.state.orderExpressions, ...exprs]
|
||||
orderExpressions: [...this.state.orderExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,24 +99,6 @@ class Query {
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimaryOrderBy(query, defaultOrderBy) {
|
||||
let orderExprs = query.serialize().orderExpressions;
|
||||
if (orderExprs.length === 0) {
|
||||
if (defaultOrderBy) {
|
||||
return { order: 'asc', ...defaultOrderBy };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstOrder = orderExprs[0];
|
||||
if (typeof firstOrder === 'string') {
|
||||
return { field: firstOrder, order: 'asc' };
|
||||
}
|
||||
// Handle this form: { field: 'desc' }
|
||||
let [field] = Object.keys(firstOrder);
|
||||
return { field, order: firstOrder[field] };
|
||||
}
|
||||
|
||||
module.exports = function q(table) {
|
||||
return new Query({ table });
|
||||
};
|
||||
|
||||
@@ -105,10 +105,6 @@ function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
function getCategoryGroups() {
|
||||
return send('api/categories-get', { grouped: true });
|
||||
}
|
||||
|
||||
function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default async function runMigration(db, uuid) {
|
||||
db.execQuery(`
|
||||
CREATE TABLE zero_budget_months
|
||||
(id TEXT PRIMARY KEY,
|
||||
buffered INTEGER DEFAULT 0);
|
||||
buffered INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE zero_budgets
|
||||
(id TEXT PRIMARY KEY,
|
||||
@@ -34,12 +34,12 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let budget = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
|
||||
[],
|
||||
true
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
budget.map(monthBudget => {
|
||||
budget.forEach(monthBudget => {
|
||||
let match = monthBudget.name.match(
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/,
|
||||
);
|
||||
if (match == null) {
|
||||
console.log('Warning: invalid budget month name', monthBudget.name);
|
||||
@@ -60,7 +60,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let carryover = db.runQuery(
|
||||
'SELECT * FROM spreadsheet_cells WHERE name = ?',
|
||||
[`${sheetName}!carryover-${cat}`],
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
|
||||
@@ -71,8 +71,8 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
dbmonth,
|
||||
cat,
|
||||
amount,
|
||||
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0
|
||||
]
|
||||
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -81,10 +81,10 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let buffers = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
|
||||
[],
|
||||
true
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
buffers.map(buffer => {
|
||||
buffers.forEach(buffer => {
|
||||
let match = buffer.name.match(/^budget(\d+)!buffered$/);
|
||||
if (match) {
|
||||
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
|
||||
@@ -95,7 +95,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
|
||||
db.runQuery(
|
||||
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
|
||||
[month, amount]
|
||||
[month, amount],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -105,7 +105,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let notes = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
|
||||
[],
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
let parseNote = str => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"utils.js"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"build": "yarn workspace loot-core build:api"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
bundle.browser.js
|
||||
bundle.browser.js
|
||||
build/
|
||||
public/kcab/
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
addWebpackResolve,
|
||||
disableEsLint,
|
||||
override,
|
||||
overrideDevServer,
|
||||
babelInclude,
|
||||
} = require('customize-cra');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
webpack: override(
|
||||
babelInclude([
|
||||
path.resolve('src'),
|
||||
path.resolve('../loot-core'),
|
||||
path.resolve('../loot-design'),
|
||||
]),
|
||||
babelInclude([path.resolve('src'), path.resolve('../loot-core')]),
|
||||
process.env.CI && disableEsLint(),
|
||||
addWebpackResolve({
|
||||
extensions: [
|
||||
...(process.env.IS_GENERIC_BROWSER ? ['.browser.js'] : []),
|
||||
...(process.env.IS_GENERIC_BROWSER
|
||||
? ['.browser.js', '.browser.ts', '.browser.tsx']
|
||||
: []),
|
||||
'.web.js',
|
||||
'.web.ts',
|
||||
'.web.tsx',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
],
|
||||
}),
|
||||
config => {
|
||||
|
||||
54
packages/desktop-client/e2e/budget.test.js
Normal file
54
packages/desktop-client/e2e/budget.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
|
||||
test.describe('Budget', () => {
|
||||
let page;
|
||||
let navigation; // eslint-disable-line no-unused-vars
|
||||
let configurationPage;
|
||||
let budgetPage;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
navigation = new Navigation(page);
|
||||
configurationPage = new ConfigurationPage(page);
|
||||
|
||||
await page.goto('/');
|
||||
budgetPage = await configurationPage.createTestFile();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('renders the summary information: available funds, overspent, budgeted and for next month', async () => {
|
||||
const summary = budgetPage.budgetSummary.first();
|
||||
|
||||
await expect(summary.getByText('Available Funds')).toBeVisible();
|
||||
await expect(summary.getByText(/^Overspent in /)).toBeVisible();
|
||||
await expect(summary.getByText('Budgeted')).toBeVisible();
|
||||
await expect(summary.getByText('For Next Month')).toBeVisible();
|
||||
});
|
||||
|
||||
test('transfer funds to another category', async () => {
|
||||
const currentFundsA = await budgetPage.getBalanceForRow(1);
|
||||
const currentFundsB = await budgetPage.getBalanceForRow(2);
|
||||
|
||||
await budgetPage.transferAllBalance(1, 2);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(await budgetPage.getBalanceForRow(2)).toEqual(
|
||||
currentFundsA + currentFundsB,
|
||||
);
|
||||
});
|
||||
|
||||
test('budget table is rendered', async () => {
|
||||
await expect(budgetPage.budgetTable).toBeVisible();
|
||||
expect(await budgetPage.getTableTotals()).toEqual({
|
||||
budgeted: expect.any(Number),
|
||||
spent: expect.any(Number),
|
||||
balance: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
BIN
packages/desktop-client/e2e/data/actual-demo-budget.zip
Normal file
BIN
packages/desktop-client/e2e/data/actual-demo-budget.zip
Normal file
Binary file not shown.
BIN
packages/desktop-client/e2e/data/ynab4-demo-budget.zip
Normal file
BIN
packages/desktop-client/e2e/data/ynab4-demo-budget.zip
Normal file
Binary file not shown.
81
packages/desktop-client/e2e/onboarding.test.js
Normal file
81
packages/desktop-client/e2e/onboarding.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import path from 'path';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { AccountPage } from './page-models/account-page';
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
|
||||
test.describe('Onboarding', () => {
|
||||
let page;
|
||||
let navigation;
|
||||
let configurationPage;
|
||||
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
navigation = new Navigation(page);
|
||||
configurationPage = new ConfigurationPage(page);
|
||||
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('creates a new budget file by importing YNAB4 budget', async () => {
|
||||
await configurationPage.clickOnNoServer();
|
||||
const budgetPage = await configurationPage.importBudget(
|
||||
'YNAB4',
|
||||
path.resolve(__dirname, 'data/ynab4-demo-budget.zip'),
|
||||
);
|
||||
|
||||
await expect(budgetPage.budgetTable).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const accountPage = await navigation.goToAccountPage(
|
||||
'Account1 with Starting Balance',
|
||||
);
|
||||
await expect(accountPage.accountBalance).toHaveText('-400.00');
|
||||
|
||||
await navigation.goToAccountPage('Account2 no Starting Balance');
|
||||
await expect(accountPage.accountBalance).toHaveText('2,607.00');
|
||||
});
|
||||
|
||||
// TODO: implement this test once we have an example nYNAB file
|
||||
// test('creates a new budget file by importing nYNAB budget');
|
||||
|
||||
test('creates a new budget file by importing Actual budget', async () => {
|
||||
await configurationPage.clickOnNoServer();
|
||||
const budgetPage = await configurationPage.importBudget(
|
||||
'Actual',
|
||||
path.resolve(__dirname, 'data/actual-demo-budget.zip'),
|
||||
);
|
||||
|
||||
await expect(budgetPage.budgetTable).toBeVisible();
|
||||
|
||||
const accountPage = await navigation.goToAccountPage('Ally Savings');
|
||||
await expect(accountPage.accountBalance).toHaveText('1,772.80');
|
||||
|
||||
await navigation.goToAccountPage('Roth IRA');
|
||||
await expect(accountPage.accountBalance).toHaveText('2,745.81');
|
||||
});
|
||||
|
||||
test('creates a new empty budget file', async () => {
|
||||
await configurationPage.clickOnNoServer();
|
||||
await configurationPage.startFresh();
|
||||
|
||||
const accountPage = new AccountPage(page);
|
||||
await expect(accountPage.accountName).toBeVisible();
|
||||
await expect(accountPage.accountName).toHaveText('All Accounts');
|
||||
await expect(accountPage.accountBalance).toHaveText('0.00');
|
||||
});
|
||||
|
||||
test('navigates back to start page by clicking on “no server” in an empty budget file', async () => {
|
||||
await configurationPage.clickOnNoServer();
|
||||
await configurationPage.startFresh();
|
||||
|
||||
await navigation.clickOnNoServer();
|
||||
|
||||
expect(await configurationPage.heading).toHaveText('Where’s the server?');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export class AccountPage {
|
||||
this.page = page;
|
||||
|
||||
this.accountName = this.page.getByTestId('account-name');
|
||||
this.accountBalance = this.page.getByTestId('account-balance');
|
||||
this.addNewTransactionButton = this.page.getByRole('button', {
|
||||
name: 'Add New',
|
||||
});
|
||||
|
||||
74
packages/desktop-client/e2e/page-models/budget-page.js
Normal file
74
packages/desktop-client/e2e/page-models/budget-page.js
Normal file
@@ -0,0 +1,74 @@
|
||||
export class BudgetPage {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
|
||||
this.budgetSummary = page.getByTestId('budget-summary');
|
||||
this.budgetTable = page.getByTestId('budget-table');
|
||||
this.budgetTableTotals = this.budgetTable.getByTestId('budget-totals');
|
||||
}
|
||||
|
||||
async getTableTotals() {
|
||||
return {
|
||||
budgeted: parseInt(
|
||||
await this.budgetTableTotals
|
||||
.getByTestId(/total-budgeted$/)
|
||||
.textContent(),
|
||||
10,
|
||||
),
|
||||
spent: parseInt(
|
||||
await this.budgetTableTotals.getByTestId(/total-spent$/).textContent(),
|
||||
10,
|
||||
),
|
||||
balance: parseInt(
|
||||
await this.budgetTableTotals
|
||||
.getByTestId(/total-leftover$/)
|
||||
.textContent(),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async showMoreMonths() {
|
||||
await this.page.getByTestId('calendar-icon').first().click();
|
||||
}
|
||||
|
||||
async getBalanceForRow(idx) {
|
||||
return Math.round(
|
||||
parseFloat(
|
||||
(
|
||||
await this.budgetTable
|
||||
.getByTestId('row')
|
||||
.nth(idx)
|
||||
.getByTestId('balance')
|
||||
.textContent()
|
||||
).replace(/,/g, ''),
|
||||
) * 100,
|
||||
);
|
||||
}
|
||||
|
||||
async transferAllBalance(fromIdx, toIdx) {
|
||||
const toName = await this.budgetTable
|
||||
.getByTestId('row')
|
||||
.nth(toIdx)
|
||||
.getByTestId('category-name')
|
||||
.textContent();
|
||||
|
||||
await this.budgetTable
|
||||
.getByTestId('row')
|
||||
.nth(fromIdx)
|
||||
.getByTestId('balance')
|
||||
.getByTestId(/^budget/)
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Transfer to another category' })
|
||||
.click();
|
||||
|
||||
await this.page.getByPlaceholder('(none)').click();
|
||||
|
||||
await this.page.keyboard.type(toName);
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
await this.page.getByRole('button', { name: 'Transfer' }).click();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,64 @@
|
||||
import { BudgetPage } from './budget-page';
|
||||
|
||||
export class ConfigurationPage {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
|
||||
this.heading = page.getByRole('heading');
|
||||
}
|
||||
|
||||
async createTestFile() {
|
||||
await this.page.getByRole('button', { name: 'Create test file' }).click();
|
||||
await this.page.getByRole('button', { name: 'Close' }).click();
|
||||
return new BudgetPage(this.page);
|
||||
}
|
||||
|
||||
async clickOnNoServer() {
|
||||
await this.page.getByRole('button', { name: 'Don’t use a server' }).click();
|
||||
}
|
||||
|
||||
async startFresh() {
|
||||
await this.page.getByRole('button', { name: 'Start fresh' }).click();
|
||||
}
|
||||
|
||||
async importBudget(type, file) {
|
||||
const fileChooserPromise = this.page.waitForEvent('filechooser');
|
||||
await this.page.getByRole('button', { name: 'Import my budget' }).click();
|
||||
|
||||
switch (type) {
|
||||
case 'YNAB4':
|
||||
await this.page
|
||||
.getByRole('button', {
|
||||
name: 'YNAB4 The old unsupported desktop app',
|
||||
})
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Select zip file...' })
|
||||
.click();
|
||||
break;
|
||||
|
||||
case 'nYNAB':
|
||||
await this.page
|
||||
.getByRole('button', { name: 'nYNAB The newer web app' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Select file...' }).click();
|
||||
break;
|
||||
|
||||
case 'Actual':
|
||||
await this.page
|
||||
.getByRole('button', {
|
||||
name: 'Actual Import a file exported from Actual',
|
||||
})
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Select file...' }).click();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unrecognized import type: ${type}`);
|
||||
}
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(file);
|
||||
|
||||
return new BudgetPage(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,8 @@ export class Navigation {
|
||||
await this.page.getByRole('button', { name: 'Create' }).click();
|
||||
return new AccountPage(this.page);
|
||||
}
|
||||
|
||||
async clickOnNoServer() {
|
||||
await this.page.getByRole('button', { name: 'No server' }).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,17 @@ export class RulesPage {
|
||||
}
|
||||
|
||||
async _fillRuleFields(data) {
|
||||
if (data.conditionsOp) {
|
||||
await this.page
|
||||
.getByTestId('conditions-op')
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('option', { exact: true, name: data.conditionsOp })
|
||||
.click();
|
||||
}
|
||||
|
||||
if (data.conditions) {
|
||||
await this._fillEditorFields(
|
||||
data.conditions,
|
||||
|
||||
@@ -65,9 +65,9 @@ test.describe('Schedules', () => {
|
||||
],
|
||||
conditions: [
|
||||
'payee is Home Depot',
|
||||
'account is HSBC',
|
||||
expect.stringMatching(/^date is approx Every month on the/),
|
||||
'amount is approx -25.00',
|
||||
'and account is HSBC',
|
||||
expect.stringMatching(/^and date is approx Every month on the/),
|
||||
'and amount is approx -25.00',
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('Transactions', () => {
|
||||
payee: 'Home Depot',
|
||||
notes: 'Notes field',
|
||||
category: 'Food',
|
||||
debit: '12.34'
|
||||
debit: '12.34',
|
||||
});
|
||||
|
||||
expect(await accountPage.getNthTransaction(0)).toMatchObject({
|
||||
@@ -39,7 +39,7 @@ test.describe('Transactions', () => {
|
||||
notes: 'Notes field',
|
||||
category: 'Food',
|
||||
debit: '12.34',
|
||||
credit: ''
|
||||
credit: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,15 +48,15 @@ test.describe('Transactions', () => {
|
||||
{
|
||||
payee: 'Krogger',
|
||||
notes: 'Notes',
|
||||
debit: '333.33'
|
||||
debit: '333.33',
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
debit: '222.22'
|
||||
debit: '222.22',
|
||||
},
|
||||
{
|
||||
debit: '111.11'
|
||||
}
|
||||
debit: '111.11',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await accountPage.getNthTransaction(0)).toMatchObject({
|
||||
@@ -64,21 +64,21 @@ test.describe('Transactions', () => {
|
||||
notes: 'Notes',
|
||||
category: 'Split',
|
||||
debit: '333.33',
|
||||
credit: ''
|
||||
credit: '',
|
||||
});
|
||||
expect(await accountPage.getNthTransaction(1)).toMatchObject({
|
||||
payee: 'Krogger',
|
||||
notes: '',
|
||||
category: 'General',
|
||||
debit: '222.22',
|
||||
credit: ''
|
||||
credit: '',
|
||||
});
|
||||
expect(await accountPage.getNthTransaction(2)).toMatchObject({
|
||||
payee: 'Krogger',
|
||||
notes: '',
|
||||
category: 'Categorize',
|
||||
debit: '111.11',
|
||||
credit: ''
|
||||
credit: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "23.3.2",
|
||||
"version": "23.4.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@jlongster/lively": "0.0.4",
|
||||
"@juggle/resize-observer": "^3.1.2",
|
||||
"@playwright/test": "^1.29.1",
|
||||
"@reach/listbox": "^0.11.2",
|
||||
"@react-aria/focus": "^3.8.0",
|
||||
@@ -15,35 +16,48 @@
|
||||
"@react-stately/collections": "^3.4.3",
|
||||
"@react-stately/list": "^3.5.3",
|
||||
"@reactions/component": "^2.0.2",
|
||||
"@svgr/cli": "^6.5.1",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"chalk": "2.4.1",
|
||||
"codemirror": "^5.37.0",
|
||||
"chroma-js": "^1.3.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"customize-cra": "^1.0.0",
|
||||
"date-fns": "2.0.0-alpha.27",
|
||||
"date-fns": "^2.29.3",
|
||||
"debounce": "^1.2.0",
|
||||
"eslint": "^8.35.0",
|
||||
"downshift": "1.31.16",
|
||||
"focus-visible": "^4.1.1",
|
||||
"formik": "^0.11.10",
|
||||
"glamor": "^2.20.40",
|
||||
"hotkeys-js": "3.8.2",
|
||||
"identity-obj-proxy": "3.0.0",
|
||||
"inter-ui": "^3.19.3",
|
||||
"jest": "^27.0.0",
|
||||
"jest-watch-typeahead": "^2.2.2",
|
||||
"memoize-one": "^4.0.0",
|
||||
"mitt": "^3.0.0",
|
||||
"perf-deets": "^1.0.15",
|
||||
"node-noop": "1.0.0",
|
||||
"pikaday": "1.8.0",
|
||||
"prop-types": "15.6.0",
|
||||
"react": "16.13.1",
|
||||
"react": "18.2.0",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-dnd": "^10.0.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-dnd-html5-backend": "^10.0.2",
|
||||
"react-dom": "18.2.0",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-modal": "3.16.1",
|
||||
"react-redux": "7.2.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-router-dom-v5-compat": "^6.4.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-select": "^5.7.0",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"victory": "^36.6.8"
|
||||
"victory": "^36.6.8",
|
||||
"wobble": "^1.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env PORT=3001 react-app-rewired start",
|
||||
@@ -53,15 +67,11 @@
|
||||
"build:browser": "cross-env ./bin/build-browser",
|
||||
"test": "react-app-rewired test",
|
||||
"e2e": "npx playwright test e2e --browser=chromium",
|
||||
"lint": "eslint src"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"jest": {
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/src/setupTests.js",
|
||||
"<rootDir>/../loot-design/src/setupTests.js"
|
||||
"<rootDir>/src/setupTests.js"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
"electron 3.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
Content-Security-Policy: default-src 'self' blob:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
|
||||
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
|
||||
|
||||
/kcab/*
|
||||
Content-Security-Policy: default-src 'self' blob:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
|
||||
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
|
||||
|
||||
/*.wasm
|
||||
Content-Type: application/wasm
|
||||
|
||||
@@ -15,3 +15,4 @@ migrations/1615745967948_meta.sql
|
||||
migrations/1616167010796_accounts_order.sql
|
||||
migrations/1618975177358_schedules.sql
|
||||
migrations/1632571489012_remove_cache.js
|
||||
migrations/1679728867040_rules_conditions.sql
|
||||
|
||||
@@ -6,7 +6,7 @@ export default async function runMigration(db, uuid) {
|
||||
db.execQuery(`
|
||||
CREATE TABLE zero_budget_months
|
||||
(id TEXT PRIMARY KEY,
|
||||
buffered INTEGER DEFAULT 0);
|
||||
buffered INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE zero_budgets
|
||||
(id TEXT PRIMARY KEY,
|
||||
@@ -34,12 +34,12 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let budget = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
|
||||
[],
|
||||
true
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
budget.map(monthBudget => {
|
||||
budget.forEach(monthBudget => {
|
||||
let match = monthBudget.name.match(
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/,
|
||||
);
|
||||
if (match == null) {
|
||||
console.log('Warning: invalid budget month name', monthBudget.name);
|
||||
@@ -60,7 +60,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let carryover = db.runQuery(
|
||||
'SELECT * FROM spreadsheet_cells WHERE name = ?',
|
||||
[`${sheetName}!carryover-${cat}`],
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
|
||||
@@ -71,8 +71,8 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
dbmonth,
|
||||
cat,
|
||||
amount,
|
||||
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0
|
||||
]
|
||||
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -81,10 +81,10 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let buffers = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
|
||||
[],
|
||||
true
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
buffers.map(buffer => {
|
||||
buffers.forEach(buffer => {
|
||||
let match = buffer.name.match(/^budget(\d+)!buffered$/);
|
||||
if (match) {
|
||||
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
|
||||
@@ -95,7 +95,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
|
||||
db.runQuery(
|
||||
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
|
||||
[month, amount]
|
||||
[month, amount],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -105,7 +105,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
let notes = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
|
||||
[],
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
let parseNote = str => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE rules ADD COLUMN conditions_op TEXT DEFAULT 'and';
|
||||
|
||||
COMMIT;
|
||||
@@ -27,11 +27,6 @@
|
||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="%PUBLIC_URL%/safari-pinned-tab.svg"
|
||||
color="#5bbad5"
|
||||
/>
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2547 4964 c-1 -1 -47 -4 -102 -7 -582 -32 -1150 -289 -1571 -711
|
||||
-101 -102 -228 -250 -261 -306 -9 -16 -21 -30 -25 -30 -4 0 -8 -4 -8 -9 0 -6
|
||||
-13 -29 -29 -53 -88 -130 -186 -327 -247 -496 -36 -99 -98 -324 -108 -397 -4
|
||||
-23 -8 -50 -10 -62 -3 -12 -8 -53 -11 -90 -4 -37 -9 -81 -11 -98 -6 -46 -5
|
||||
-312 1 -385 16 -176 46 -332 100 -515 75 -253 226 -548 390 -761 91 -118 100
|
||||
-128 220 -249 117 -116 123 -122 225 -200 36 -28 67 -52 70 -55 17 -19 196
|
||||
-129 292 -179 352 -187 740 -282 1143 -281 165 1 255 8 410 35 90 16 247 51
|
||||
275 62 8 4 17 7 20 8 3 1 25 8 50 15 25 7 47 14 50 15 3 1 19 7 35 14 17 6 55
|
||||
22 85 33 92 36 292 142 386 203 365 238 655 555 849 930 118 226 211 501 240
|
||||
710 3 17 7 40 9 52 3 12 7 50 11 85 3 35 8 81 10 103 6 48 5 280 0 350 -7 99
|
||||
-22 207 -41 305 -25 128 -79 314 -114 395 -5 11 -25 58 -45 105 -38 90 -123
|
||||
253 -154 298 -10 15 -36 54 -57 87 -119 184 -335 415 -524 560 -36 28 -67 52
|
||||
-70 55 -16 17 -190 125 -275 171 -240 130 -561 237 -798 265 -29 4 -55 8 -58
|
||||
10 -9 5 -347 23 -352 18z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -8,7 +8,6 @@ const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
|
||||
// everything else.
|
||||
|
||||
let IS_DEV = process.env.NODE_ENV === 'development';
|
||||
let IS_PERF_BUILD = process.env.PERF_BUILD != null;
|
||||
let ACTUAL_VERSION = process.env.REACT_APP_ACTUAL_VERSION;
|
||||
|
||||
// *** Start the backend ***
|
||||
@@ -32,46 +31,10 @@ function createBackendWorker() {
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
|
||||
if (IS_DEV || IS_PERF_BUILD) {
|
||||
worker.onmessage = e => {
|
||||
if (e.data.type === '__actual:backend-running') {
|
||||
let activity = document.querySelector('.debugger .activity');
|
||||
if (activity) {
|
||||
let original = window.getComputedStyle(activity)['background-color'];
|
||||
activity.style.transition = 'none';
|
||||
activity.style.backgroundColor = '#3EBD93';
|
||||
setTimeout(() => {
|
||||
activity.style.transition = 'background-color 1s';
|
||||
activity.style.backgroundColor = original;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
import('perf-deets/frontend').then(({ listenForPerfData }) => {
|
||||
listenForPerfData(worker);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createBackendWorker();
|
||||
|
||||
if (IS_DEV || IS_PERF_BUILD) {
|
||||
import('perf-deets/frontend').then(({ listenForPerfData }) => {
|
||||
listenForPerfData(window);
|
||||
|
||||
global.__startProfile = () => {
|
||||
window.postMessage({ type: '__perf-deets:start-profile' });
|
||||
worker.postMessage({ type: '__perf-deets:start-profile' });
|
||||
};
|
||||
global.__stopProfile = () => {
|
||||
window.postMessage({ type: '__perf-deets:stop-profile' });
|
||||
worker.postMessage({ type: '__perf-deets:stop-profile' });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
ACTUAL_VERSION,
|
||||
@@ -153,22 +116,15 @@ global.Actual = {
|
||||
},
|
||||
};
|
||||
|
||||
if (IS_DEV) {
|
||||
global.Actual.reloadBackend = () => {
|
||||
worker.postMessage({ type: '__actual:shutdown' });
|
||||
createBackendWorker();
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
// Cmd/Ctrl+o
|
||||
if (e.keyCode === 79) {
|
||||
if (e.code === 'KeyO') {
|
||||
e.preventDefault();
|
||||
window.__actionsForMenu.closeBudget();
|
||||
}
|
||||
// Cmd/Ctrl+z
|
||||
else if (e.keyCode === 90) {
|
||||
else if (e.code === 'KeyZ') {
|
||||
if (
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
/* globals importScripts, backend */
|
||||
let hasInitialized = false;
|
||||
|
||||
self.addEventListener('message', e => {
|
||||
/**
|
||||
* Sometimes the frontend build is way faster than backend.
|
||||
* This results in the frontend starting up before backend is
|
||||
* finished and thus the backend script is not available.
|
||||
*
|
||||
* The goal of this function is to retry X amount of times
|
||||
* to retrieve the backend script with a small delay.
|
||||
*/
|
||||
const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => {
|
||||
try {
|
||||
importScripts(script);
|
||||
} catch (e) {
|
||||
// Break if maxRetries has exceeded
|
||||
if (maxRetries <= 0) {
|
||||
throw e;
|
||||
} else {
|
||||
console.groupCollapsed(
|
||||
`Failed to load backend, will retry ${maxRetries} more time(s)`,
|
||||
);
|
||||
console.log(e);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Attempt to retry after a small delay
|
||||
await new Promise(resolve =>
|
||||
setTimeout(async () => {
|
||||
await importScriptsWithRetry(script, {
|
||||
maxRetries: maxRetries - 1,
|
||||
});
|
||||
resolve();
|
||||
}, 5000),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
self.addEventListener('message', async e => {
|
||||
if (!hasInitialized) {
|
||||
let msg = e.data;
|
||||
|
||||
if (msg.type === 'init') {
|
||||
hasInitialized = true;
|
||||
let isDev = !!msg.isDev;
|
||||
let version = msg.version;
|
||||
// let version = msg.version;
|
||||
let hash = msg.hash;
|
||||
|
||||
if (!self.SharedArrayBuffer && !msg.isSharedArrayBufferOverrideEnabled) {
|
||||
@@ -19,26 +54,21 @@ self.addEventListener('message', e => {
|
||||
return;
|
||||
}
|
||||
|
||||
importScripts(`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`);
|
||||
|
||||
backend.initApp(version, isDev, self).then(
|
||||
() => {
|
||||
if (isDev) {
|
||||
console.log('Backend running!');
|
||||
self.postMessage({ type: '__actual:backend-running' });
|
||||
}
|
||||
},
|
||||
err => {
|
||||
console.log(err);
|
||||
let msg = {
|
||||
type: 'app-init-failure',
|
||||
IDBFailure: err.message.includes('indexeddb-failure'),
|
||||
};
|
||||
self.postMessage(msg);
|
||||
|
||||
throw err;
|
||||
},
|
||||
await importScriptsWithRetry(
|
||||
`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`,
|
||||
{ maxRetries: isDev ? 5 : 0 },
|
||||
);
|
||||
|
||||
backend.initApp(isDev, self).catch(err => {
|
||||
console.log(err);
|
||||
let msg = {
|
||||
type: 'app-init-failure',
|
||||
IDBFailure: err.message.includes('indexeddb-failure'),
|
||||
};
|
||||
self.postMessage(msg);
|
||||
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import React from 'react';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import Refresh from 'loot-design/src/svg/v1/Refresh';
|
||||
import Refresh from '../icons/v1/Refresh';
|
||||
|
||||
import View from './View';
|
||||
|
||||
let spin = css.keyframes({
|
||||
'0%': { transform: 'rotateZ(0deg)' },
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
init as initConnection,
|
||||
send,
|
||||
} from 'loot-core/src/platform/client/fetch';
|
||||
import { styles, hasHiddenScrollbars } from 'loot-design/src/style';
|
||||
|
||||
import installPolyfills from '../polyfills';
|
||||
import { styles, hasHiddenScrollbars } from '../style';
|
||||
|
||||
import AppBackground from './AppBackground';
|
||||
import FatalError from './FatalError';
|
||||
@@ -114,13 +114,13 @@ class App extends React.Component {
|
||||
) : budgetId ? (
|
||||
<FinancesApp />
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<AppBackground
|
||||
initializing={initializing}
|
||||
loadingText={loadingText}
|
||||
/>
|
||||
<ManagementApp />
|
||||
</React.Fragment>
|
||||
<ManagementApp isLoading={loadingText != null} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<UpdateNotification />
|
||||
|
||||
@@ -2,11 +2,11 @@ import React from 'react';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { View, Block } from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import AnimatedLoading from 'loot-design/src/svg/AnimatedLoading';
|
||||
import AnimatedLoading from '../icons/AnimatedLoading';
|
||||
import { colors } from '../style';
|
||||
|
||||
import Background from './Background';
|
||||
import { View, Block } from './common';
|
||||
|
||||
function AppBackground({ initializing, loadingText }) {
|
||||
return (
|
||||
|
||||
@@ -3,10 +3,11 @@ import { connect } from 'react-redux';
|
||||
import { useTransition, animated } from 'react-spring';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { View, Text } from 'loot-design/src/components/common';
|
||||
import { colors, styles } from 'loot-design/src/style';
|
||||
|
||||
import { colors, styles } from '../style';
|
||||
|
||||
import AnimatedRefresh from './AnimatedRefresh';
|
||||
import { View, Text } from './common';
|
||||
|
||||
function BankSyncStatus({ accountsSyncing }) {
|
||||
let name = accountsSyncing
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import CodeMirror from 'codemirror';
|
||||
|
||||
import * as spreadsheet from 'loot-core/src/client/sheetql/spreadsheet';
|
||||
import {
|
||||
send,
|
||||
init as initConnection,
|
||||
} from 'loot-core/src/platform/client/fetch';
|
||||
import {
|
||||
View,
|
||||
Button,
|
||||
Input,
|
||||
InlineField,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
|
||||
require('codemirror/lib/codemirror.css');
|
||||
require('codemirror/theme/monokai.css');
|
||||
|
||||
class Debugger extends React.Component {
|
||||
state = {
|
||||
recording: false,
|
||||
selecting: false,
|
||||
name: '__global!tmp',
|
||||
collapsed: true,
|
||||
node: null,
|
||||
};
|
||||
|
||||
toggleRecord = () => {
|
||||
if (this.state.recording) {
|
||||
window.__stopProfile();
|
||||
this.setState({ recording: false });
|
||||
} else {
|
||||
window.__startProfile();
|
||||
this.setState({ recording: true });
|
||||
}
|
||||
};
|
||||
|
||||
reloadBackend = async () => {
|
||||
window.Actual.reloadBackend();
|
||||
initConnection(await global.Actual.getServerSocket());
|
||||
};
|
||||
|
||||
init() {
|
||||
this.mirror = CodeMirror(this.node, {
|
||||
theme: 'monokai',
|
||||
});
|
||||
|
||||
this.mirror.setSize('100%', '100%');
|
||||
|
||||
// this.mirror.on('change', () => {
|
||||
// const val = this.mirror.getValue();
|
||||
// const [sheetName, name] = this.state.name.split('!');
|
||||
|
||||
// spreadsheet.set(sheetName, name, this.mirror.getValue());
|
||||
// });
|
||||
|
||||
const mouseoverHandler = e => {
|
||||
let node = e.target;
|
||||
let cellname = null;
|
||||
|
||||
while (!cellname && node) {
|
||||
cellname = node.dataset && node.dataset.cellname;
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
if (this.state.selecting && cellname) {
|
||||
this.bind(cellname);
|
||||
}
|
||||
};
|
||||
document.body.addEventListener('mouseover', mouseoverHandler, false);
|
||||
|
||||
const clickHandler = e => {
|
||||
if (this.state.selecting) {
|
||||
this.setState({ selecting: false });
|
||||
}
|
||||
};
|
||||
document.body.addEventListener('click', clickHandler, false);
|
||||
|
||||
this.removeListeners = () => {
|
||||
document.body.removeEventListener('mouseover', mouseoverHandler);
|
||||
document.body.removeEventListener('click', clickHandler);
|
||||
};
|
||||
|
||||
this.bind(this.state.name);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
if (this.unbind) {
|
||||
this.unbind();
|
||||
}
|
||||
|
||||
this.removeListeners();
|
||||
this.mirror = null;
|
||||
}
|
||||
|
||||
bind(resolvedName) {
|
||||
if (this.unbind) {
|
||||
this.unbind();
|
||||
}
|
||||
const [sheetName, name] = resolvedName.split('!');
|
||||
let currentReq = Math.random();
|
||||
this.currentReq = currentReq;
|
||||
|
||||
send('debugCell', { sheetName, name }).then(node => {
|
||||
if (currentReq === this.currentReq) {
|
||||
if (node._run) {
|
||||
this.mirror.setValue(node._run);
|
||||
}
|
||||
this.setState({ name: node.name, node });
|
||||
|
||||
this.unbind = spreadsheet.bind(sheetName, { name }, null, node => {
|
||||
if (currentReq !== this.currentReq) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ node: { ...this.state.node, value: node.value } });
|
||||
|
||||
this.valueNode.style.transition = 'none';
|
||||
this.valueNode.style.backgroundColor = colors.y9;
|
||||
setTimeout(() => {
|
||||
this.valueNode.style.transition = 'background-color .8s';
|
||||
this.valueNode.style.backgroundColor = 'rgba(0, 0, 0, 0)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.unbind) {
|
||||
this.unbind();
|
||||
this.unbind = null;
|
||||
}
|
||||
}
|
||||
|
||||
onShow = () => {
|
||||
this.setState({ collapsed: false }, () => {
|
||||
this.init();
|
||||
});
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.setState({ collapsed: true }, () => {
|
||||
this.deinit();
|
||||
});
|
||||
};
|
||||
|
||||
onSelect = () => {
|
||||
this.setState({ selecting: true });
|
||||
};
|
||||
|
||||
onNameChange = e => {
|
||||
const name = e.target.value;
|
||||
this.bind(name);
|
||||
this.setState({ name });
|
||||
};
|
||||
|
||||
unselect() {
|
||||
if (this.unbind) {
|
||||
this.unbind();
|
||||
this.unbind = null;
|
||||
this.setState({ sheetName: null, name: null, node: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { name, node, selecting, collapsed, recording } = this.state;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
'& .CodeMirror': { border: '1px solid ' + colors.b4 },
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>{children}</div>
|
||||
<View
|
||||
className="debugger"
|
||||
style={[
|
||||
{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
margin: 15,
|
||||
padding: 10,
|
||||
backgroundColor: 'rgba(50, 50, 50, .85)',
|
||||
color: 'white',
|
||||
zIndex: 1000,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
!collapsed && {
|
||||
width: 700,
|
||||
height: 200,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{collapsed ? (
|
||||
<React.Fragment>
|
||||
<div
|
||||
className="activity"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: '#303030',
|
||||
marginRight: 10,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
/>
|
||||
<Button onClick={this.toggleRecord} style={{ marginRight: 10 }}>
|
||||
{recording ? 'Stop' : 'Start'} Profile
|
||||
</Button>
|
||||
<Button onClick={this.reloadBackend} style={{ marginRight: 10 }}>
|
||||
Reload backend
|
||||
</Button>
|
||||
<Button onClick={this.onShow}>^</Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: '#303030',
|
||||
color: 'white',
|
||||
padding: '2px 5px',
|
||||
marginRight: 5,
|
||||
}}
|
||||
onClick={this.onClose}
|
||||
>
|
||||
v
|
||||
</Button>
|
||||
<Button
|
||||
style={[
|
||||
{
|
||||
backgroundColor: '#303030',
|
||||
color: 'white',
|
||||
padding: '2px 5px',
|
||||
},
|
||||
selecting && {
|
||||
backgroundColor: colors.p7,
|
||||
},
|
||||
]}
|
||||
onClick={this.onSelect}
|
||||
>
|
||||
Inspect Cell
|
||||
</Button>
|
||||
</View>
|
||||
<InlineField label="Name" style={{ flex: '0 0 auto' }}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={this.onNameChange}
|
||||
style={{
|
||||
backgroundColor: '#303030',
|
||||
color: 'white',
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
label="Expr"
|
||||
style={{ flex: 1, alignItems: 'stretch', overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
style={{ flex: 1, overflow: 'hidden' }}
|
||||
ref={n => (this.node = n)}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
label="Dependencies"
|
||||
labelWidth={100}
|
||||
style={{ flex: '0 0 auto' }}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
height: 30,
|
||||
overflow: 'scroll',
|
||||
}}
|
||||
>
|
||||
{node && JSON.stringify(node._dependencies, null, 2)}
|
||||
</pre>
|
||||
</InlineField>
|
||||
<InlineField label="Value" style={{ flex: '0 0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
transition: 'background-color .5s',
|
||||
height: 30,
|
||||
overflow: 'scroll',
|
||||
}}
|
||||
ref={n => (this.valueNode = n)}
|
||||
>
|
||||
{node && JSON.stringify(node.value)}
|
||||
</div>
|
||||
</InlineField>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Debugger;
|
||||
@@ -1,17 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
View,
|
||||
Stack,
|
||||
Text,
|
||||
Block,
|
||||
Modal,
|
||||
P,
|
||||
Link,
|
||||
Button,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { Checkbox } from 'loot-design/src/components/forms';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import { colors } from '../style';
|
||||
|
||||
import { View, Stack, Text, Block, Modal, P, Link, Button } from './common';
|
||||
import { Checkbox } from './forms';
|
||||
|
||||
class FatalError extends React.Component {
|
||||
state = { showError: false };
|
||||
@@ -22,10 +14,10 @@ class FatalError extends React.Component {
|
||||
// IndexedDB wasn't able to open the database
|
||||
msg = (
|
||||
<Text>
|
||||
Your browser doesn{"'"}t support IndexedDB in this environment, a
|
||||
feature that Actual requires to run. This might happen if you are in
|
||||
private browsing mode. Please try a different browser or turn off
|
||||
private browsing.
|
||||
Your browser doesn’t support IndexedDB in this environment, a feature
|
||||
that Actual requires to run. This might happen if you are in private
|
||||
browsing mode. Please try a different browser or turn off private
|
||||
browsing.
|
||||
</Text>
|
||||
);
|
||||
} else if (error.SharedArrayBufferMissing) {
|
||||
|
||||
@@ -22,13 +22,11 @@ import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import checkForUpdateNotification from 'loot-core/src/client/update-notification';
|
||||
import checkForUpgradeNotifications from 'loot-core/src/client/upgrade-notifications';
|
||||
import * as undo from 'loot-core/src/platform/client/undo';
|
||||
import { BudgetMonthCountProvider } from 'loot-design/src/components/budget/BudgetMonthCountContext';
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import { colors, styles } from 'loot-design/src/style';
|
||||
import Cog from 'loot-design/src/svg/v1/Cog';
|
||||
import PiggyBank from 'loot-design/src/svg/v1/PiggyBank';
|
||||
import Wallet from 'loot-design/src/svg/v1/Wallet';
|
||||
|
||||
import Cog from '../icons/v1/Cog';
|
||||
import PiggyBank from '../icons/v1/PiggyBank';
|
||||
import Wallet from '../icons/v1/Wallet';
|
||||
import { colors, styles } from '../style';
|
||||
import { isMobile } from '../util';
|
||||
import { getLocationState, makeLocationState } from '../util/location-state';
|
||||
import { getIsOutdated, getLatestVersion } from '../util/versions';
|
||||
@@ -39,7 +37,9 @@ import { default as MobileAccounts } from './accounts/MobileAccounts';
|
||||
import { ActiveLocationProvider } from './ActiveLocation';
|
||||
import BankSyncStatus from './BankSyncStatus';
|
||||
import Budget from './budget';
|
||||
import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
|
||||
import { default as MobileBudget } from './budget/MobileBudget';
|
||||
import { View } from './common';
|
||||
import FloatableSidebar, { SidebarProvider } from './FloatableSidebar';
|
||||
import GlobalKeys from './GlobalKeys';
|
||||
import { ManageRulesPage } from './ManageRulesPage';
|
||||
@@ -56,7 +56,6 @@ import LinkSchedule from './schedules/LinkSchedule';
|
||||
import PostsOfflineNotification from './schedules/PostsOfflineNotification';
|
||||
import Settings from './settings';
|
||||
import Titlebar, { TitlebarProvider } from './Titlebar';
|
||||
// import Debugger from './Debugger';
|
||||
|
||||
function PageRoute({ path, component: Component }) {
|
||||
return (
|
||||
@@ -336,7 +335,6 @@ class FinancesApp extends React.Component {
|
||||
<Notifications />
|
||||
<BankSyncStatus />
|
||||
<StackedRoutes isMobile={this.state.isMobile} />
|
||||
{/*window.Actual.IS_DEV && <Debugger />*/}
|
||||
<Modals history={this.history} />
|
||||
</div>
|
||||
{this.state.isMobile && (
|
||||
|
||||
@@ -2,8 +2,9 @@ import React from 'react';
|
||||
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import useResizeObserver from '../hooks/useResizeObserver';
|
||||
|
||||
import { View } from './common';
|
||||
import useResizeObserver from './useResizeObserver';
|
||||
|
||||
const IS_SCROLLING_DEBOUNCE_INTERVAL = 150;
|
||||
|
||||
@@ -2,13 +2,16 @@ import React, { useState, useEffect, useContext } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { useViewportSize } from '@react-aria/utils';
|
||||
import mitt from 'mitt';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import { SIDEBAR_WIDTH } from 'loot-design/src/components/sidebar';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
|
||||
import { colors } from '../style';
|
||||
import { breakpoints } from '../tokens';
|
||||
|
||||
import { View } from './common';
|
||||
import { SIDEBAR_WIDTH } from './sidebar';
|
||||
import SidebarWithData from './SidebarWithData';
|
||||
|
||||
const SidebarContext = React.createContext(null);
|
||||
@@ -20,6 +23,7 @@ export function SidebarProvider({ children }) {
|
||||
value={{
|
||||
show: () => emitter.emit('show'),
|
||||
hide: () => emitter.emit('hide'),
|
||||
toggle: () => emitter.emit('toggle'),
|
||||
on: (name, listener) => {
|
||||
emitter.on(name, listener);
|
||||
return () => emitter.off(name, listener);
|
||||
@@ -39,7 +43,10 @@ function Sidebar({ floatingSidebar }) {
|
||||
let [hidden, setHidden] = useState(true);
|
||||
let sidebar = useSidebar();
|
||||
|
||||
if (!floatingSidebar && hidden) {
|
||||
let windowWidth = useViewportSize().width;
|
||||
let sidebarShouldFloat = floatingSidebar || windowWidth < breakpoints.medium;
|
||||
|
||||
if (!sidebarShouldFloat && hidden) {
|
||||
setHidden(false);
|
||||
}
|
||||
|
||||
@@ -47,6 +54,7 @@ function Sidebar({ floatingSidebar }) {
|
||||
let cleanups = [
|
||||
sidebar.on('show', () => setHidden(false)),
|
||||
sidebar.on('hide', () => setHidden(true)),
|
||||
sidebar.on('toggle', () => setHidden(hidden => !hidden)),
|
||||
];
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn());
|
||||
@@ -55,7 +63,7 @@ function Sidebar({ floatingSidebar }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{floatingSidebar && (
|
||||
{sidebarShouldFloat && (
|
||||
<View
|
||||
onMouseOver={() => setHidden(false)}
|
||||
onMouseLeave={() => setHidden(true)}
|
||||
@@ -72,27 +80,27 @@ function Sidebar({ floatingSidebar }) {
|
||||
|
||||
<View
|
||||
onMouseOver={
|
||||
floatingSidebar
|
||||
sidebarShouldFloat
|
||||
? e => {
|
||||
e.stopPropagation();
|
||||
setHidden(false);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMouseLeave={floatingSidebar ? () => setHidden(true) : null}
|
||||
onMouseLeave={sidebarShouldFloat ? () => setHidden(true) : null}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
// If not floating, the -50 takes into account the transform below
|
||||
bottom: floatingSidebar ? 50 : -50,
|
||||
bottom: sidebarShouldFloat ? 50 : -50,
|
||||
zIndex: 1001,
|
||||
borderRadius: '0 6px 6px 0',
|
||||
overflow: 'hidden',
|
||||
boxShadow:
|
||||
!floatingSidebar || hidden
|
||||
!sidebarShouldFloat || hidden
|
||||
? 'none'
|
||||
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
|
||||
transform: `translateY(${!floatingSidebar ? -50 : 0}px)
|
||||
transform: `translateY(${!sidebarShouldFloat ? -50 : 0}px)
|
||||
translateX(${hidden ? -SIDEBAR_WIDTH : 0}px)`,
|
||||
transition: 'transform .5s, box-shadow .5s',
|
||||
}}
|
||||
@@ -104,12 +112,12 @@ function Sidebar({ floatingSidebar }) {
|
||||
style={[
|
||||
{
|
||||
backgroundColor: colors.n1,
|
||||
opacity: floatingSidebar ? 0 : 1,
|
||||
transform: `translateX(${floatingSidebar ? -50 : 0}px)`,
|
||||
opacity: sidebarShouldFloat ? 0 : 1,
|
||||
transform: `translateX(${sidebarShouldFloat ? -50 : 0}px)`,
|
||||
transition: 'transform .4s, opacity .2s',
|
||||
width: SIDEBAR_WIDTH,
|
||||
},
|
||||
floatingSidebar && {
|
||||
sidebarShouldFloat && {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
|
||||
class GlobalKeys extends React.Component {
|
||||
componentDidMount() {
|
||||
export default function GlobalKeys() {
|
||||
let history = useHistory();
|
||||
useEffect(() => {
|
||||
const handleKeys = e => {
|
||||
if (Platform.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.metaKey) {
|
||||
const { history } = this.props;
|
||||
switch (e.keyCode) {
|
||||
case 49:
|
||||
switch (e.code) {
|
||||
case 'Digit1':
|
||||
history.push('/budget');
|
||||
break;
|
||||
case 50:
|
||||
case 'Digit2':
|
||||
history.push('/reports');
|
||||
break;
|
||||
case 51:
|
||||
case 'Digit3':
|
||||
history.push('/accounts');
|
||||
break;
|
||||
case 188: // ,
|
||||
case 'Comma':
|
||||
if (Platform.OS === 'mac') {
|
||||
history.push('/settings');
|
||||
}
|
||||
@@ -34,18 +34,8 @@ class GlobalKeys extends React.Component {
|
||||
|
||||
document.addEventListener('keydown', handleKeys);
|
||||
|
||||
this.cleanupListeners = () => {
|
||||
document.removeEventListener('keydown', handleKeys);
|
||||
};
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleKeys);
|
||||
}, []);
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanupListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default withRouter(GlobalKeys);
|
||||
|
||||
@@ -3,15 +3,10 @@ import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Button,
|
||||
Tooltip,
|
||||
Menu,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
|
||||
import { colors } from '../style';
|
||||
|
||||
import { View, Text, Button, Tooltip, Menu } from './common';
|
||||
import { useServerURL } from './ServerContext';
|
||||
|
||||
function LoggedInUser({
|
||||
|
||||
@@ -21,14 +21,16 @@ import { getMonthYearFormat } from 'loot-core/src/shared/months';
|
||||
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
|
||||
import { getRecurringDescription } from 'loot-core/src/shared/schedules';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Button,
|
||||
Stack,
|
||||
ExternalLink,
|
||||
Input,
|
||||
} from 'loot-design/src/components/common';
|
||||
|
||||
import useSelected, {
|
||||
useSelectedDispatch,
|
||||
useSelectedItems,
|
||||
SelectedProvider,
|
||||
} from '../hooks/useSelected';
|
||||
import ArrowRight from '../icons/v0/RightArrow2';
|
||||
import { colors } from '../style';
|
||||
|
||||
import { View, Text, Button, Stack, ExternalLink, Input } from './common';
|
||||
import {
|
||||
SelectCell,
|
||||
Row,
|
||||
@@ -37,14 +39,7 @@ import {
|
||||
CellButton,
|
||||
TableHeader,
|
||||
useTableNavigator,
|
||||
} from 'loot-design/src/components/table';
|
||||
import useSelected, {
|
||||
useSelectedDispatch,
|
||||
useSelectedItems,
|
||||
SelectedProvider,
|
||||
} from 'loot-design/src/components/useSelected';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import ArrowRight from 'loot-design/src/svg/v0/RightArrow2';
|
||||
} from './table';
|
||||
|
||||
let SchedulesQuery = liveQueryContext(q('schedules').select('*'));
|
||||
|
||||
@@ -218,7 +213,7 @@ export function ConditionExpression({
|
||||
op,
|
||||
value,
|
||||
options,
|
||||
stage,
|
||||
prefix,
|
||||
style,
|
||||
}) {
|
||||
return (
|
||||
@@ -237,6 +232,7 @@ export function ConditionExpression({
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{prefix && <Text style={{ color: colors.n3 }}>{prefix} </Text>}
|
||||
<Text style={{ color: colors.p4 }}>{mapField(field, options)}</Text>{' '}
|
||||
<Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '}
|
||||
<Value value={value} field={field} />
|
||||
@@ -368,7 +364,7 @@ let Rule = React.memo(
|
||||
op={cond.op}
|
||||
value={cond.value}
|
||||
options={cond.options}
|
||||
stage={rule.stage}
|
||||
prefix={i > 0 ? friendlyOp(rule.conditionsOp) : null}
|
||||
style={i !== 0 && { marginTop: 3 }}
|
||||
/>
|
||||
))}
|
||||
@@ -692,6 +688,7 @@ function ManageRulesContent({ isModal, payeeId, setLoading }) {
|
||||
function onCreateRule() {
|
||||
let rule = {
|
||||
stage: null,
|
||||
conditionsOp: 'and',
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
|
||||
@@ -2,12 +2,13 @@ import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePrefs } from 'loot-core/src/client/actions';
|
||||
import { View, Text, Button } from 'loot-design/src/components/common';
|
||||
import { Checkbox } from 'loot-design/src/components/forms';
|
||||
import { colors, styles } from 'loot-design/src/style';
|
||||
|
||||
import { colors, styles } from '../style';
|
||||
import { isMobile } from '../util';
|
||||
|
||||
import { View, Text, Button } from './common';
|
||||
import { Checkbox } from './forms';
|
||||
|
||||
let buttonStyle = { border: 0, fontSize: 15, padding: '10px 13px' };
|
||||
|
||||
export default function MobileWebMessage() {
|
||||
@@ -99,7 +100,7 @@ export default function MobileWebMessage() {
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Don't remind me again
|
||||
Don’t remind me again
|
||||
</label>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -8,27 +8,27 @@ import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { send, listen, unlisten } from 'loot-core/src/platform/client/fetch';
|
||||
import BudgetSummary from 'loot-design/src/components/modals/BudgetSummary';
|
||||
import CloseAccount from 'loot-design/src/components/modals/CloseAccount';
|
||||
import ConfigureLinkedAccounts from 'loot-design/src/components/modals/ConfigureLinkedAccounts';
|
||||
import CreateLocalAccount from 'loot-design/src/components/modals/CreateLocalAccount';
|
||||
import EditField from 'loot-design/src/components/modals/EditField';
|
||||
import ImportTransactions from 'loot-design/src/components/modals/ImportTransactions';
|
||||
import LoadBackup from 'loot-design/src/components/modals/LoadBackup';
|
||||
import NordigenExternalMsg from 'loot-design/src/components/modals/NordigenExternalMsg';
|
||||
import PlaidExternalMsg from 'loot-design/src/components/modals/PlaidExternalMsg';
|
||||
import SelectLinkedAccounts from 'loot-design/src/components/modals/SelectLinkedAccounts';
|
||||
|
||||
import useFeatureFlag from '../hooks/useFeatureFlag';
|
||||
import useSyncServerStatus from '../hooks/useSyncServerStatus';
|
||||
|
||||
import BudgetSummary from './modals/BudgetSummary';
|
||||
import CloseAccount from './modals/CloseAccount';
|
||||
import ConfigureLinkedAccounts from './modals/ConfigureLinkedAccounts';
|
||||
import ConfirmCategoryDelete from './modals/ConfirmCategoryDelete';
|
||||
import CreateAccount from './modals/CreateAccount';
|
||||
import CreateEncryptionKey from './modals/CreateEncryptionKey';
|
||||
import CreateLocalAccount from './modals/CreateLocalAccount';
|
||||
import EditField from './modals/EditField';
|
||||
import EditRule from './modals/EditRule';
|
||||
import FixEncryptionKey from './modals/FixEncryptionKey';
|
||||
import ImportTransactions from './modals/ImportTransactions';
|
||||
import LoadBackup from './modals/LoadBackup';
|
||||
import ManageRulesModal from './modals/ManageRulesModal';
|
||||
import MergeUnusedPayees from './modals/MergeUnusedPayees';
|
||||
import WelcomeScreen from './modals/WelcomeScreen';
|
||||
import NordigenExternalMsg from './modals/NordigenExternalMsg';
|
||||
import PlaidExternalMsg from './modals/PlaidExternalMsg';
|
||||
import SelectLinkedAccounts from './modals/SelectLinkedAccounts';
|
||||
|
||||
function Modals({
|
||||
history,
|
||||
@@ -41,6 +41,9 @@ function Modals({
|
||||
budgetId,
|
||||
actions,
|
||||
}) {
|
||||
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
|
||||
return modalStack.map(({ name, options = {} }, idx) => {
|
||||
@@ -91,9 +94,9 @@ function Modals({
|
||||
<Route path="/select-linked-accounts">
|
||||
<SelectLinkedAccounts
|
||||
modalProps={modalProps}
|
||||
accounts={options.accounts}
|
||||
externalAccounts={options.accounts}
|
||||
requisitionId={options.requisitionId}
|
||||
actualAccounts={accounts.filter(acct => acct.closed === 0)}
|
||||
localAccounts={accounts.filter(acct => acct.closed === 0)}
|
||||
upgradingAccountId={options.upgradingAccountId}
|
||||
actions={actions}
|
||||
/>
|
||||
@@ -273,21 +276,20 @@ function Modals({
|
||||
actions={actions}
|
||||
name={options.name}
|
||||
onSubmit={options.onSubmit}
|
||||
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Route path="/welcome-screen">
|
||||
<WelcomeScreen modalProps={modalProps} actions={actions} />
|
||||
</Route>
|
||||
|
||||
<Route path="/budget-summary">
|
||||
<BudgetSummary
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
month={options.month}
|
||||
actions={actions}
|
||||
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
|
||||
isGoalTemplatesEnabled={isGoalTemplatesEnabled}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
@@ -6,8 +6,8 @@ import q from 'loot-core/src/client/query-helpers';
|
||||
import { useLiveQuery } from 'loot-core/src/client/query-hooks';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import CustomNotesPaper from '../icons/v2/CustomNotesPaper';
|
||||
import { colors } from '../style';
|
||||
import CustomNotesPaper from '../svg/v2/CustomNotesPaper';
|
||||
|
||||
import { View, Button, Tooltip, useTooltip, Text } from './common';
|
||||
|
||||
@@ -4,6 +4,11 @@ import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
|
||||
import Loading from '../icons/AnimatedLoading';
|
||||
import Delete from '../icons/v0/Delete';
|
||||
import { styles, colors } from '../style';
|
||||
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -11,10 +16,7 @@ import {
|
||||
ButtonWithLoading,
|
||||
Stack,
|
||||
ExternalLink,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { styles, colors } from 'loot-design/src/style';
|
||||
import Loading from 'loot-design/src/svg/AnimatedLoading';
|
||||
import Delete from 'loot-design/src/svg/v0/Delete';
|
||||
} from './common';
|
||||
|
||||
function compileMessage(message, actions, setLoading, onRemove) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Modal, View, Text } from 'loot-design/src/components/common';
|
||||
import { styles } from 'loot-design/src/style';
|
||||
import { styles } from '../style';
|
||||
|
||||
import { Modal, View, Text } from './common';
|
||||
|
||||
let PageTypeContext = React.createContext({ type: 'page' });
|
||||
|
||||
|
||||
@@ -10,17 +10,13 @@ import { closeBudget } from 'loot-core/src/client/actions/budgets';
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InitialFocus,
|
||||
Text,
|
||||
Tooltip,
|
||||
Menu,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { Sidebar } from 'loot-design/src/components/sidebar';
|
||||
import { styles, colors } from 'loot-design/src/style';
|
||||
import ExpandArrow from 'loot-design/src/svg/v0/ExpandArrow';
|
||||
|
||||
import useFeatureFlag from '../hooks/useFeatureFlag';
|
||||
import ExpandArrow from '../icons/v0/ExpandArrow';
|
||||
import { styles, colors } from '../style';
|
||||
|
||||
import { Button, Input, InitialFocus, Text, Tooltip, Menu } from './common';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
function EditableBudgetName({ prefs, savePrefs }) {
|
||||
let dispatch = useDispatch();
|
||||
@@ -49,9 +45,10 @@ function EditableBudgetName({ prefs, savePrefs }) {
|
||||
}
|
||||
|
||||
let items = [
|
||||
{ name: 'rename', text: 'Rename Budget' },
|
||||
{ name: 'rename', text: 'Rename budget' },
|
||||
{ name: 'settings', text: 'Settings' },
|
||||
...(Platform.isBrowser ? [{ name: 'help', text: 'Help' }] : []),
|
||||
{ name: 'close', text: 'Close File' },
|
||||
{ name: 'close', text: 'Close file' },
|
||||
];
|
||||
|
||||
if (editing) {
|
||||
@@ -123,6 +120,8 @@ function SidebarWithData({
|
||||
saveGlobalPrefs,
|
||||
getAccounts,
|
||||
}) {
|
||||
const syncAccount = useFeatureFlag('syncAccount');
|
||||
|
||||
useEffect(() => void getAccounts(), [getAccounts]);
|
||||
|
||||
async function onReorder(id, dropPos, targetId) {
|
||||
@@ -149,9 +148,7 @@ function SidebarWithData({
|
||||
onFloat={() => saveGlobalPrefs({ floatingSidebar: !floatingSidebar })}
|
||||
onReorder={onReorder}
|
||||
onAddAccount={() =>
|
||||
replaceModal(
|
||||
prefs['flags.syncAccount'] ? 'add-account' : 'add-local-account',
|
||||
)
|
||||
replaceModal(syncAccount ? 'add-account' : 'add-local-account')
|
||||
}
|
||||
showClosedAccounts={prefs['ui.showClosedAccounts']}
|
||||
onToggleClosedAccounts={() =>
|
||||
|
||||
@@ -2,12 +2,25 @@ import React, { useState, useEffect, useRef, useContext } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Switch, Route, withRouter } from 'react-router-dom';
|
||||
|
||||
import { useViewportSize } from '@react-aria/utils';
|
||||
import { css, media } from 'glamor';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { listen } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import useFeatureFlag from '../hooks/useFeatureFlag';
|
||||
import ArrowLeft from '../icons/v1/ArrowLeft';
|
||||
import AlertTriangle from '../icons/v2/AlertTriangle';
|
||||
import ArrowButtonRight1 from '../icons/v2/ArrowButtonRight1';
|
||||
import NavigationMenu from '../icons/v2/NavigationMenu';
|
||||
import { colors } from '../style';
|
||||
import tokens, { breakpoints } from '../tokens';
|
||||
|
||||
import AccountSyncCheck from './accounts/AccountSyncCheck';
|
||||
import AnimatedRefresh from './AnimatedRefresh';
|
||||
import { MonthCountSelector } from './budget/MonthCountSelector';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -16,21 +29,11 @@ import {
|
||||
ButtonWithLoading,
|
||||
Tooltip,
|
||||
P,
|
||||
} from 'loot-design/src/components/common';
|
||||
import SheetValue from 'loot-design/src/components/spreadsheet/SheetValue';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import ArrowLeft from 'loot-design/src/svg/v1/ArrowLeft';
|
||||
import AlertTriangle from 'loot-design/src/svg/v2/AlertTriangle';
|
||||
import ArrowButtonRight1 from 'loot-design/src/svg/v2/ArrowButtonRight1';
|
||||
import NavigationMenu from 'loot-design/src/svg/v2/NavigationMenu';
|
||||
import tokens from 'loot-design/src/tokens';
|
||||
|
||||
import AccountSyncCheck from './accounts/AccountSyncCheck';
|
||||
import AnimatedRefresh from './AnimatedRefresh';
|
||||
import { MonthCountSelector } from './budget/MonthCountSelector';
|
||||
} from './common';
|
||||
import { useSidebar } from './FloatableSidebar';
|
||||
import LoggedInUser from './LoggedInUser';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import SheetValue from './spreadsheet/SheetValue';
|
||||
|
||||
export let TitlebarContext = React.createContext();
|
||||
|
||||
@@ -163,7 +166,7 @@ function BudgetTitlebar({ globalPrefs, saveGlobalPrefs, localPrefs }) {
|
||||
let [loading, setLoading] = useState(false);
|
||||
let [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
let reportBudgetEnabled = localPrefs['flags.reportBudget'];
|
||||
const reportBudgetEnabled = useFeatureFlag('reportBudget');
|
||||
|
||||
function onSwitchType() {
|
||||
setLoading(true);
|
||||
@@ -264,6 +267,9 @@ function Titlebar({
|
||||
let sidebar = useSidebar();
|
||||
const serverURL = useServerURL();
|
||||
|
||||
let windowWidth = useViewportSize().width;
|
||||
let sidebarAlwaysFloats = windowWidth < breakpoints.medium;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -283,20 +289,24 @@ function Titlebar({
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{floatingSidebar && (
|
||||
{(floatingSidebar || sidebarAlwaysFloats) && (
|
||||
<Button
|
||||
bare
|
||||
style={{
|
||||
marginRight: 8,
|
||||
'& .arrow-right': { opacity: 0, transition: 'opacity .3s' },
|
||||
'& .menu': { opacity: 1, transition: 'opacity .3s' },
|
||||
'&:hover .arrow-right': { opacity: 1 },
|
||||
'&:hover .menu': { opacity: 0 },
|
||||
'&:hover .arrow-right': !sidebarAlwaysFloats && { opacity: 1 },
|
||||
'&:hover .menu': !sidebarAlwaysFloats && { opacity: 0 },
|
||||
}}
|
||||
onMouseEnter={() => sidebar.show()}
|
||||
onMouseLeave={() => sidebar.hide()}
|
||||
onClick={() => {
|
||||
saveGlobalPrefs({ floatingSidebar: !floatingSidebar });
|
||||
if (windowWidth >= breakpoints.medium) {
|
||||
saveGlobalPrefs({ floatingSidebar: !floatingSidebar });
|
||||
} else {
|
||||
sidebar.toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 15, height: 15 }}>
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
|
||||
import BudgetCategories from './tutorial/BudgetCategories';
|
||||
import BudgetInitial from './tutorial/BudgetInitial';
|
||||
import BudgetNewIncome from './tutorial/BudgetNewIncome';
|
||||
import BudgetNextMonth from './tutorial/BudgetNextMonth';
|
||||
import BudgetSummary from './tutorial/BudgetSummary';
|
||||
import CategoryBalance from './tutorial/CategoryBalance';
|
||||
import Final from './tutorial/Final';
|
||||
import Intro from './tutorial/Intro';
|
||||
import Overspending from './tutorial/Overspending';
|
||||
import TransactionAdd from './tutorial/TransactionAdd';
|
||||
import TransactionEnter from './tutorial/TransactionEnter';
|
||||
|
||||
function generatePath(innerRect, outerRect) {
|
||||
const i = innerRect;
|
||||
const o = outerRect;
|
||||
// prettier-ignore
|
||||
return `
|
||||
M0,0 ${o.width},0 ${o.width},${o.height} L0,${o.height} L0,0 Z
|
||||
M${i.left},${i.top} L${i.left+i.width},${i.top} L${i.left+i.width},${i.top+i.height} L${i.left},${i.top+i.height} L${i.left},${i.top} Z
|
||||
`;
|
||||
}
|
||||
|
||||
function expandRect({ top, left, width, height }, padding) {
|
||||
if (typeof padding === 'number') {
|
||||
return {
|
||||
top: top - padding,
|
||||
left: left - padding,
|
||||
width: width + padding * 2,
|
||||
height: height + padding * 2,
|
||||
};
|
||||
} else if (padding) {
|
||||
return {
|
||||
top: top - (padding.top || 0),
|
||||
left: left - (padding.left || 0),
|
||||
width: width + (padding.right || 0) + (padding.left || 0),
|
||||
height: height + (padding.bottom || 0) + (padding.top || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return { top, left, width, height };
|
||||
}
|
||||
|
||||
function withinWindow(rect) {
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: Math.min(rect.left + rect.width, window.innerWidth) - rect.left,
|
||||
height: Math.min(rect.top + rect.height, window.innerHeight) - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
class MeasureNodes extends React.Component {
|
||||
state = { measurements: null };
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', () => {
|
||||
setTimeout(() => this.updateMeasurements(true), 0);
|
||||
});
|
||||
this.updateMeasurements();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.nodes !== this.props.nodes) {
|
||||
this.updateMeasurements();
|
||||
}
|
||||
}
|
||||
|
||||
updateMeasurements() {
|
||||
this.setState({
|
||||
measurements: this.props.nodes.map(node => node.getBoundingClientRect()),
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { measurements } = this.state;
|
||||
return measurements ? children(...measurements) : null;
|
||||
}
|
||||
}
|
||||
|
||||
class Tutorial extends React.Component {
|
||||
state = { highlightRect: null, windowRect: null };
|
||||
|
||||
static contextTypes = {
|
||||
getTutorialNode: PropTypes.func,
|
||||
endTutorial: PropTypes.func,
|
||||
};
|
||||
|
||||
onClose = didQuitEarly => {
|
||||
// The difference between these is `endTutorial` permanently
|
||||
// disable the tutorial. If the user walked all the way through
|
||||
// it, never show it to them again. Otherwise they will see if
|
||||
// again if they create a new budget.
|
||||
if (didQuitEarly) {
|
||||
this.props.closeTutorial();
|
||||
} else {
|
||||
this.props.endTutorial();
|
||||
}
|
||||
};
|
||||
|
||||
getContent(stage, targetRect, navigationProps) {
|
||||
switch (stage) {
|
||||
case 'budget-summary':
|
||||
return (
|
||||
<BudgetSummary
|
||||
fromYNAB={this.props.fromYNAB}
|
||||
targetRect={targetRect}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'budget-categories':
|
||||
return (
|
||||
<BudgetCategories
|
||||
targetRect={targetRect}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'transaction-add':
|
||||
return (
|
||||
<TransactionAdd
|
||||
targetRect={targetRect}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'budget-new-income':
|
||||
return (
|
||||
<BudgetNewIncome
|
||||
targetRect={targetRect}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'budget-next-month':
|
||||
return <div>hi</div>;
|
||||
default:
|
||||
throw new Error(
|
||||
`Encountered an unexpected error rendering the tutorial content for ${stage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { stage, fromYNAB, nextTutorialStage, closeTutorial } = this.props;
|
||||
if (stage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigationProps = {
|
||||
nextTutorialStage: this.props.nextTutorialStage,
|
||||
previousTutorialStage: this.props.previousTutorialStage,
|
||||
closeTutorial: () => this.onClose(true),
|
||||
endTutorial: () => this.onClose(false),
|
||||
};
|
||||
|
||||
switch (stage) {
|
||||
case 'intro':
|
||||
return (
|
||||
<Intro
|
||||
nextTutorialStage={nextTutorialStage}
|
||||
closeTutorial={closeTutorial}
|
||||
fromYNAB={fromYNAB}
|
||||
/>
|
||||
);
|
||||
case 'budget-initial':
|
||||
return (
|
||||
<BudgetInitial
|
||||
nextTutorialStage={nextTutorialStage}
|
||||
closeTutorial={closeTutorial}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'budget-next-month':
|
||||
return (
|
||||
<BudgetNextMonth
|
||||
nextTutorialStage={nextTutorialStage}
|
||||
closeTutorial={closeTutorial}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'budget-next-month2':
|
||||
return (
|
||||
<BudgetNextMonth
|
||||
nextTutorialStage={nextTutorialStage}
|
||||
closeTutorial={closeTutorial}
|
||||
navigationProps={navigationProps}
|
||||
stepTwo={true}
|
||||
/>
|
||||
);
|
||||
case 'transaction-enter':
|
||||
return (
|
||||
<TransactionEnter
|
||||
fromYNAB={fromYNAB}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
case 'budget-category-balance':
|
||||
return <CategoryBalance navigationProps={navigationProps} />;
|
||||
case 'budget-overspending':
|
||||
return <Overspending navigationProps={navigationProps} />;
|
||||
case 'budget-overspending2':
|
||||
return (
|
||||
<Overspending navigationProps={navigationProps} stepTwo={true} />
|
||||
);
|
||||
case 'final':
|
||||
return (
|
||||
<Final
|
||||
nextTutorialStage={nextTutorialStage}
|
||||
closeTutorial={closeTutorial}
|
||||
navigationProps={navigationProps}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// Default case defined below (outside the switch statement)
|
||||
}
|
||||
|
||||
const { node: targetNode, expand } = this.context.getTutorialNode(stage);
|
||||
|
||||
return (
|
||||
<MeasureNodes nodes={[targetNode.parentNode, document.body]}>
|
||||
{(targetRect, windowRect) => {
|
||||
targetRect = withinWindow(
|
||||
expandRect(expandRect(targetRect, 5), expand),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ReactDOM.createPortal(
|
||||
<svg
|
||||
width={windowRect.width}
|
||||
height={windowRect.height}
|
||||
viewBox={'0 0 ' + windowRect.width + ' ' + windowRect.height}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
fill="rgba(0, 0, 0, .2)"
|
||||
fill-rule="evenodd"
|
||||
d={generatePath(targetRect, windowRect)}
|
||||
style={{ pointerEvents: 'fill' }}
|
||||
/>
|
||||
</svg>,
|
||||
document.body,
|
||||
)}
|
||||
{this.getContent(stage, targetRect, navigationProps)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</MeasureNodes>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
stage: state.tutorial.stage,
|
||||
fromYNAB: state.tutorial.fromYNAB,
|
||||
}),
|
||||
dispatch => bindActionCreators(actions, dispatch),
|
||||
)(Tutorial);
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Tutorial extends React.Component {
|
||||
static childContextTypes = {
|
||||
setTutorialNode: PropTypes.func,
|
||||
getTutorialNode: PropTypes.func,
|
||||
endTutorial: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.nodes = {};
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
setTutorialNode: this.setTutorialNode,
|
||||
getTutorialNode: this.getTutorialNode,
|
||||
};
|
||||
}
|
||||
|
||||
setTutorialNode = (name, node, expand) => {
|
||||
this.nodes[name] = { node, expand };
|
||||
};
|
||||
|
||||
getTutorialNode = (name, node) => {
|
||||
return this.nodes[name];
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
return React.Children.only(children);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(state => ({ deactivated: state.tutorial.deactivated }))(
|
||||
Tutorial,
|
||||
);
|
||||
@@ -4,9 +4,11 @@ import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { View, Text, Link, Button } from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import Close from 'loot-design/src/svg/v1/Close';
|
||||
|
||||
import Close from '../icons/v1/Close';
|
||||
import { colors } from '../style';
|
||||
|
||||
import { View, Text, Link, Button } from './common';
|
||||
|
||||
function closeNotification(setAppState) {
|
||||
// Set a flag to never show an update notification again for this session
|
||||
|
||||
@@ -26,6 +26,28 @@ import {
|
||||
applyChanges,
|
||||
groupById,
|
||||
} from 'loot-core/src/shared/util';
|
||||
|
||||
import useFeatureFlag from '../../hooks/useFeatureFlag';
|
||||
import {
|
||||
SelectedProviderWithItems,
|
||||
useSelectedItems,
|
||||
} from '../../hooks/useSelected';
|
||||
import useSyncServerStatus from '../../hooks/useSyncServerStatus';
|
||||
import Loading from '../../icons/AnimatedLoading';
|
||||
import Add from '../../icons/v1/Add';
|
||||
import DotsHorizontalTriple from '../../icons/v1/DotsHorizontalTriple';
|
||||
import ArrowButtonRight1 from '../../icons/v2/ArrowButtonRight1';
|
||||
import ArrowsExpand3 from '../../icons/v2/ArrowsExpand3';
|
||||
import ArrowsShrink3 from '../../icons/v2/ArrowsShrink3';
|
||||
import CheckCircle1 from '../../icons/v2/CheckCircle1';
|
||||
import DownloadThickBottom from '../../icons/v2/DownloadThickBottom';
|
||||
import Pencil1 from '../../icons/v2/Pencil1';
|
||||
import SvgRemove from '../../icons/v2/Remove';
|
||||
import SearchAlternate from '../../icons/v2/SearchAlternate';
|
||||
import { authorizeBank } from '../../nordigen';
|
||||
import { styles, colors } from '../../style';
|
||||
import { useActiveLocation } from '../ActiveLocation';
|
||||
import AnimatedRefresh from '../AnimatedRefresh';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -36,34 +58,13 @@ import {
|
||||
Tooltip,
|
||||
Menu,
|
||||
Stack,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { KeyHandlers } from 'loot-design/src/components/KeyHandlers';
|
||||
import NotesButton from 'loot-design/src/components/NotesButton';
|
||||
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
|
||||
import format from 'loot-design/src/components/spreadsheet/format';
|
||||
import useSheetValue from 'loot-design/src/components/spreadsheet/useSheetValue';
|
||||
import { SelectedItemsButton } from 'loot-design/src/components/table';
|
||||
import {
|
||||
SelectedProviderWithItems,
|
||||
useSelectedItems,
|
||||
} from 'loot-design/src/components/useSelected';
|
||||
import { styles, colors } from 'loot-design/src/style';
|
||||
import Loading from 'loot-design/src/svg/AnimatedLoading';
|
||||
import Add from 'loot-design/src/svg/v1/Add';
|
||||
import DotsHorizontalTriple from 'loot-design/src/svg/v1/DotsHorizontalTriple';
|
||||
import ArrowButtonRight1 from 'loot-design/src/svg/v2/ArrowButtonRight1';
|
||||
import ArrowsExpand3 from 'loot-design/src/svg/v2/ArrowsExpand3';
|
||||
import ArrowsShrink3 from 'loot-design/src/svg/v2/ArrowsShrink3';
|
||||
import CheckCircle1 from 'loot-design/src/svg/v2/CheckCircle1';
|
||||
import DownloadThickBottom from 'loot-design/src/svg/v2/DownloadThickBottom';
|
||||
import Pencil1 from 'loot-design/src/svg/v2/Pencil1';
|
||||
import SvgRemove from 'loot-design/src/svg/v2/Remove';
|
||||
import SearchAlternate from 'loot-design/src/svg/v2/SearchAlternate';
|
||||
|
||||
import useSyncServerStatus from '../../hooks/useSyncServerStatus';
|
||||
import { authorizeBank } from '../../nordigen';
|
||||
import { useActiveLocation } from '../ActiveLocation';
|
||||
import AnimatedRefresh from '../AnimatedRefresh';
|
||||
} from '../common';
|
||||
import { KeyHandlers } from '../KeyHandlers';
|
||||
import NotesButton from '../NotesButton';
|
||||
import CellValue from '../spreadsheet/CellValue';
|
||||
import format from '../spreadsheet/format';
|
||||
import useSheetValue from '../spreadsheet/useSheetValue';
|
||||
import { SelectedItemsButton } from '../table';
|
||||
|
||||
import { FilterButton, AppliedFilters } from './Filters';
|
||||
import TransactionList from './TransactionList';
|
||||
@@ -166,7 +167,7 @@ function ReconcilingMessage({
|
||||
{(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')}
|
||||
</strong>{' '}
|
||||
to match
|
||||
<br /> your bank{"'"}s balance of{' '}
|
||||
<br /> your bank’s balance of{' '}
|
||||
<Text style={{ fontWeight: 700 }}>
|
||||
{format(targetBalance, 'financial')}
|
||||
</Text>
|
||||
@@ -285,7 +286,7 @@ function AccountMenu({
|
||||
},
|
||||
{
|
||||
name: 'toggle-cleared',
|
||||
text: (showCleared ? 'Hide' : 'Show') + ' "Cleared" Checkboxes',
|
||||
text: (showCleared ? 'Hide' : 'Show') + ' “Cleared” Checkboxes',
|
||||
},
|
||||
{ name: 'export', text: 'Export' },
|
||||
{ name: 'reconcile', text: 'Reconcile' },
|
||||
@@ -400,6 +401,7 @@ function Balances({ balanceQuery, showExtraBalances, onToggleExtraBalances }) {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
data-testid="account-balance"
|
||||
bare
|
||||
onClick={onToggleExtraBalances}
|
||||
style={{
|
||||
@@ -479,6 +481,7 @@ function SelectedTransactionsButton({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onUnlink,
|
||||
onCreateRule,
|
||||
onScheduleAction,
|
||||
}) {
|
||||
let selectedItems = useSelectedItems();
|
||||
@@ -551,6 +554,10 @@ function SelectedTransactionsButton({
|
||||
name: 'link-schedule',
|
||||
text: 'Link schedule',
|
||||
},
|
||||
{
|
||||
name: 'create-rule',
|
||||
text: 'Create rule',
|
||||
},
|
||||
]),
|
||||
Menu.line,
|
||||
{ type: Menu.label, name: 'Edit field' },
|
||||
@@ -604,6 +611,9 @@ function SelectedTransactionsButton({
|
||||
case 'unlink-schedule':
|
||||
onUnlink([...selectedItems]);
|
||||
break;
|
||||
case 'create-rule':
|
||||
onCreateRule([...selectedItems]);
|
||||
break;
|
||||
default:
|
||||
onEdit(name, [...selectedItems]);
|
||||
}
|
||||
@@ -650,6 +660,7 @@ const AccountHeader = React.memo(
|
||||
onBatchDuplicate,
|
||||
onBatchEdit,
|
||||
onBatchUnlink,
|
||||
onCreateRule,
|
||||
onApplyFilter,
|
||||
onUpdateFilter,
|
||||
onDeleteFilter,
|
||||
@@ -757,6 +768,7 @@ const AccountHeader = React.memo(
|
||||
) : (
|
||||
<View
|
||||
style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }}
|
||||
data-testid="account-name"
|
||||
>
|
||||
{account && account.closed
|
||||
? 'Closed: ' + accountName
|
||||
@@ -883,6 +895,7 @@ const AccountHeader = React.memo(
|
||||
onDelete={onBatchDelete}
|
||||
onEdit={onBatchEdit}
|
||||
onUnlink={onBatchUnlink}
|
||||
onCreateRule={onCreateRule}
|
||||
onScheduleAction={onScheduleAction}
|
||||
/>
|
||||
)}
|
||||
@@ -1663,6 +1676,44 @@ class AccountInternal extends React.PureComponent {
|
||||
await this.refetchTransactions();
|
||||
};
|
||||
|
||||
onCreateRule = async ids => {
|
||||
let { data } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*')
|
||||
.options({ splits: 'grouped' }),
|
||||
);
|
||||
let transactions = ungroupTransactions(data);
|
||||
let payeeCondition = transactions[0].imported_payee
|
||||
? {
|
||||
field: 'imported_payee',
|
||||
op: 'is',
|
||||
value: transactions[0].imported_payee,
|
||||
type: 'string',
|
||||
}
|
||||
: {
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
value: transactions[0].payee,
|
||||
type: 'id',
|
||||
};
|
||||
|
||||
let rule = {
|
||||
stage: null,
|
||||
conditions: [payeeCondition],
|
||||
actions: [
|
||||
{
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: null,
|
||||
type: 'id',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.props.pushModal('edit-rule', { rule });
|
||||
};
|
||||
|
||||
onUpdateFilter = (oldFilter, updatedFilter) => {
|
||||
this.applyFilters(
|
||||
this.state.filters.map(f => (f === oldFilter ? updatedFilter : f)),
|
||||
@@ -1728,6 +1779,7 @@ class AccountInternal extends React.PureComponent {
|
||||
payees,
|
||||
syncEnabled,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
addNotification,
|
||||
accountsSyncing,
|
||||
replaceModal,
|
||||
@@ -1818,6 +1870,7 @@ class AccountInternal extends React.PureComponent {
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onBatchEdit={this.onBatchEdit}
|
||||
onBatchUnlink={this.onBatchUnlink}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onUpdateFilter={this.onUpdateFilter}
|
||||
onDeleteFilter={this.onDeleteFilter}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
@@ -1856,6 +1909,7 @@ class AccountInternal extends React.PureComponent {
|
||||
this.state.search !== '' || this.state.filters.length > 0
|
||||
}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
addNotification={addNotification}
|
||||
renderEmpty={() =>
|
||||
showEmptyMessage ? (
|
||||
@@ -1912,14 +1966,15 @@ function AccountHack(props) {
|
||||
}
|
||||
|
||||
export default function Account(props) {
|
||||
const syncEnabled = useFeatureFlag('syncAccount');
|
||||
let state = useSelector(state => ({
|
||||
newTransactions: state.queries.newTransactions,
|
||||
matchedTransactions: state.queries.matchedTransactions,
|
||||
accounts: state.queries.accounts,
|
||||
failedAccounts: state.account.failedAccounts,
|
||||
categoryGroups: state.queries.categories.grouped,
|
||||
syncEnabled: state.prefs.local['flags.syncAccount'],
|
||||
dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
|
||||
hideFraction: state.prefs.local.hideFraction || false,
|
||||
expandSplits: props.match && state.prefs.local['expand-splits'],
|
||||
showBalances:
|
||||
props.match &&
|
||||
@@ -1972,6 +2027,7 @@ export default function Account(props) {
|
||||
<AccountHack
|
||||
{...state}
|
||||
{...actionCreators}
|
||||
syncEnabled={syncEnabled}
|
||||
modalShowing={
|
||||
state.modalShowing ||
|
||||
!!(activeLocation.state && activeLocation.state.locationPtr)
|
||||
|
||||
@@ -2,11 +2,11 @@ import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { View, Button, Tooltip } from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import ExclamationOutline from 'loot-design/src/svg/v1/ExclamationOutline';
|
||||
|
||||
import ExclamationOutline from '../../icons/v1/ExclamationOutline';
|
||||
import { authorizeBank } from '../../nordigen';
|
||||
import { colors } from '../../style';
|
||||
import { View, Button, Tooltip } from '../common';
|
||||
|
||||
function getErrorMessage(type, code) {
|
||||
switch (type.toUpperCase()) {
|
||||
@@ -28,14 +28,6 @@ function getErrorMessage(type, code) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'API_ERROR':
|
||||
switch (code.toUpperCase()) {
|
||||
case 'PLANNED_MAINTENANCE':
|
||||
return 'Our servers are currently undergoing maintenance and will be available again soon.';
|
||||
default:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'RATE_LIMIT_EXCEEDED':
|
||||
return 'Rate limit exceeded for this item. Please try again later.';
|
||||
|
||||
@@ -118,7 +110,7 @@ function AccountSyncCheck({
|
||||
color: 'currentColor',
|
||||
}}
|
||||
/>{' '}
|
||||
This account is experiencing connection problems. Let{"'"}s fix it.
|
||||
This account is experiencing connection problems. Let’s fix it.
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
TYPE_INFO,
|
||||
} from 'loot-core/src/shared/rules';
|
||||
import { titleFirst } from 'loot-core/src/shared/util';
|
||||
|
||||
import DeleteIcon from '../../icons/v0/Delete';
|
||||
import SettingsSliderAlternate from '../../icons/v2/SettingsSliderAlternate';
|
||||
import { colors } from '../../style';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -29,11 +33,7 @@ import {
|
||||
Button,
|
||||
Menu,
|
||||
CustomSelect,
|
||||
} from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import DeleteIcon from 'loot-design/src/svg/v0/Delete';
|
||||
import SettingsSliderAlternate from 'loot-design/src/svg/v2/SettingsSliderAlternate';
|
||||
|
||||
} from '../common';
|
||||
import { Value } from '../ManageRules';
|
||||
import GenericInput from '../util/GenericInput';
|
||||
|
||||
@@ -166,7 +166,7 @@ function ConfigureField({
|
||||
width={300}
|
||||
onClose={() => dispatch({ type: 'close' })}
|
||||
>
|
||||
<FocusScope contain>
|
||||
<FocusScope>
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
{field === 'amount' || field === 'date' ? (
|
||||
<CustomSelect
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
isPreviewId,
|
||||
ungroupTransactions,
|
||||
} from 'loot-core/src/shared/transactions';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import { withThemeColor } from 'loot-design/src/util/withThemeColor';
|
||||
|
||||
import { colors } from '../../style';
|
||||
import { withThemeColor } from '../../util/withThemeColor';
|
||||
import SyncRefresh from '../SyncRefresh';
|
||||
|
||||
import { default as AccountDetails } from './MobileAccountDetails';
|
||||
@@ -231,6 +231,7 @@ function Account(props) {
|
||||
|
||||
let balance = queries.accountBalance(account);
|
||||
let numberFormat = state.prefs.numberFormat || 'comma-dot';
|
||||
let hideFraction = state.prefs.hideFraction || false;
|
||||
|
||||
return (
|
||||
<SyncRefresh onSync={onRefresh}>
|
||||
@@ -246,7 +247,7 @@ function Account(props) {
|
||||
// format changes
|
||||
{...state}
|
||||
{...actionCreators}
|
||||
key={numberFormat}
|
||||
key={numberFormat + hideFraction}
|
||||
account={account}
|
||||
accounts={props.accounts}
|
||||
categories={state.categories}
|
||||
|
||||
@@ -1,77 +1,62 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
Button,
|
||||
InputWithContent,
|
||||
Label,
|
||||
View,
|
||||
} from 'loot-design/src/components/common';
|
||||
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
|
||||
import Text from 'loot-design/src/components/Text';
|
||||
import { colors, styles } from 'loot-design/src/style';
|
||||
import Add from 'loot-design/src/svg/v1/Add';
|
||||
import CheveronLeft from 'loot-design/src/svg/v1/CheveronLeft';
|
||||
import SearchAlternate from 'loot-design/src/svg/v2/SearchAlternate';
|
||||
import Add from '../../icons/v1/Add';
|
||||
import CheveronLeft from '../../icons/v1/CheveronLeft';
|
||||
import SearchAlternate from '../../icons/v2/SearchAlternate';
|
||||
import { colors, styles } from '../../style';
|
||||
import { Button, InputWithContent, Label, View } from '../common';
|
||||
import CellValue from '../spreadsheet/CellValue';
|
||||
import Text from '../Text';
|
||||
|
||||
import { TransactionList } from './MobileTransaction';
|
||||
|
||||
class TransactionSearchInput extends React.Component {
|
||||
state = { text: '' };
|
||||
function TransactionSearchInput({ accountName, onSearch }) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
performSearch = () => {
|
||||
this.props.onSearch(this.state.text);
|
||||
};
|
||||
|
||||
onChange = text => {
|
||||
this.setState({ text }, this.performSearch);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { accountName } = this.props;
|
||||
const { text } = this.state;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.n11,
|
||||
margin: '11px auto 4px',
|
||||
borderRadius: 4,
|
||||
padding: 10,
|
||||
width: '100%',
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.n11,
|
||||
margin: '11px auto 4px',
|
||||
borderRadius: 4,
|
||||
padding: 10,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<InputWithContent
|
||||
leftContent={
|
||||
<SearchAlternate
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
flexShrink: 0,
|
||||
color: text ? colors.p7 : 'inherit',
|
||||
margin: 5,
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
value={text}
|
||||
onUpdate={text => {
|
||||
setText(text);
|
||||
onSearch(text);
|
||||
}}
|
||||
>
|
||||
<InputWithContent
|
||||
leftContent={
|
||||
<SearchAlternate
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
flexShrink: 0,
|
||||
color: text ? colors.p7 : 'inherit',
|
||||
margin: 5,
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
value={text}
|
||||
onUpdate={this.onChange}
|
||||
placeholder={`Search ${accountName}`}
|
||||
style={{
|
||||
backgroundColor: colors.n11,
|
||||
border: `1px solid ${colors.n9}`,
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
height: 32,
|
||||
marginLeft: 4,
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
placeholder={`Search ${accountName}`}
|
||||
style={{
|
||||
backgroundColor: colors.n11,
|
||||
border: `1px solid ${colors.n9}`,
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
height: 32,
|
||||
marginLeft: 4,
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const LEFT_RIGHT_FLEX_WIDTH = 70;
|
||||
@@ -149,8 +134,8 @@ export default function AccountDetails({
|
||||
>
|
||||
{account.name}
|
||||
</View>
|
||||
{/*
|
||||
TODO: connect to an add transaction modal
|
||||
{/*
|
||||
TODO: connect to an add transaction modal
|
||||
Only left here but hidden for flex centering of the account name.
|
||||
*/}
|
||||
<Link to="transaction/new" style={{ visibility: 'hidden' }}>
|
||||
|
||||
@@ -5,16 +5,12 @@ import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { prettyAccountType } from 'loot-core/src/shared/accounts';
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
TextOneLine,
|
||||
View,
|
||||
} from 'loot-design/src/components/common';
|
||||
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
|
||||
import { colors, styles } from 'loot-design/src/style';
|
||||
import Wallet from 'loot-design/src/svg/v1/Wallet';
|
||||
import { withThemeColor } from 'loot-design/src/util/withThemeColor';
|
||||
|
||||
import Wallet from '../../icons/v1/Wallet';
|
||||
import { colors, styles } from '../../style';
|
||||
import { withThemeColor } from '../../util/withThemeColor';
|
||||
import { Button, Text, TextOneLine, View } from '../common';
|
||||
import CellValue from '../spreadsheet/CellValue';
|
||||
|
||||
export function AccountHeader({ name, amount }) {
|
||||
return (
|
||||
@@ -306,13 +302,14 @@ function Accounts(props) {
|
||||
|
||||
let { accounts, categories, newTransactions, updatedAccounts, prefs } = props;
|
||||
let numberFormat = prefs.numberFormat || 'comma-dot';
|
||||
let hideFraction = prefs.hideFraction || false;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<AccountList
|
||||
// This key forces the whole table rerender when the number
|
||||
// format changes
|
||||
key={numberFormat}
|
||||
key={numberFormat + hideFraction}
|
||||
accounts={accounts.filter(account => !account.closed)}
|
||||
categories={categories}
|
||||
transactions={transactions || []}
|
||||
|
||||
@@ -11,10 +11,11 @@ import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
import { titleFirst } from 'loot-core/src/shared/util';
|
||||
import { integerToCurrency, groupById } from 'loot-core/src/shared/util';
|
||||
import { Text, TextOneLine, View } from 'loot-design/src/components/common';
|
||||
import { styles, colors } from 'loot-design/src/style';
|
||||
import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
|
||||
import CheckCircle1 from 'loot-design/src/svg/v2/CheckCircle1';
|
||||
|
||||
import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
|
||||
import CheckCircle1 from '../../icons/v2/CheckCircle1';
|
||||
import { styles, colors } from '../../style';
|
||||
import { Text, TextOneLine, View } from '../common';
|
||||
|
||||
const zIndices = { SECTION_HEADING: 10 };
|
||||
|
||||
|
||||
@@ -12,20 +12,11 @@ import {
|
||||
getCategoriesById,
|
||||
} from 'loot-core/src/client/reducers/queries';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
import {
|
||||
Table,
|
||||
Row,
|
||||
Field,
|
||||
Cell,
|
||||
SelectCell,
|
||||
} from 'loot-design/src/components/table';
|
||||
import {
|
||||
useSelectedItems,
|
||||
useSelectedDispatch,
|
||||
} from 'loot-design/src/components/useSelected';
|
||||
import { styles } from 'loot-design/src/style';
|
||||
import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
|
||||
|
||||
import { useSelectedItems, useSelectedDispatch } from '../../hooks/useSelected';
|
||||
import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
|
||||
import { styles } from '../../style';
|
||||
import { Table, Row, Field, Cell, SelectCell } from '../table';
|
||||
import DisplayId from '../util/DisplayId';
|
||||
|
||||
function serializeTransaction(transaction, dateFormat) {
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function TransactionList({
|
||||
isMatched,
|
||||
isFiltered,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
addNotification,
|
||||
renderEmpty,
|
||||
onChange,
|
||||
@@ -170,6 +171,7 @@ export default function TransactionList({
|
||||
isMatched={isMatched}
|
||||
isFiltered={isFiltered}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
addNotification={addNotification}
|
||||
headerContent={headerContent}
|
||||
renderEmpty={renderEmpty}
|
||||
|
||||
@@ -36,11 +36,27 @@ import {
|
||||
amountToInteger,
|
||||
titleFirst,
|
||||
} from 'loot-core/src/shared/util';
|
||||
import AccountAutocomplete from 'loot-design/src/components/AccountAutocomplete';
|
||||
import CategoryAutocomplete from 'loot-design/src/components/CategorySelect';
|
||||
import { View, Text, Tooltip, Button } from 'loot-design/src/components/common';
|
||||
import DateSelect from 'loot-design/src/components/DateSelect';
|
||||
import PayeeAutocomplete from 'loot-design/src/components/PayeeAutocomplete';
|
||||
|
||||
import useFeatureFlag from '../../hooks/useFeatureFlag';
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import usePrevious from '../../hooks/usePrevious';
|
||||
import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected';
|
||||
import LeftArrow2 from '../../icons/v0/LeftArrow2';
|
||||
import RightArrow2 from '../../icons/v0/RightArrow2';
|
||||
import CheveronDown from '../../icons/v1/CheveronDown';
|
||||
import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
|
||||
import CalendarIcon from '../../icons/v2/Calendar';
|
||||
import Hyperlink2 from '../../icons/v2/Hyperlink2';
|
||||
import { styles, colors } from '../../style';
|
||||
import LegacyAccountAutocomplete from '../autocomplete/AccountAutocomplete';
|
||||
import NewCategoryAutocomplete from '../autocomplete/CategoryAutocomplete';
|
||||
import LegacyCategoryAutocomplete from '../autocomplete/CategorySelect';
|
||||
import NewAccountAutocomplete from '../autocomplete/NewAccountAutocomplete';
|
||||
import NewPayeeAutocomplete from '../autocomplete/NewPayeeAutocomplete';
|
||||
import LegacyPayeeAutocomplete from '../autocomplete/PayeeAutocomplete';
|
||||
import { View, Text, Tooltip, Button } from '../common';
|
||||
import { getStatusProps } from '../schedules/StatusBadge';
|
||||
import DateSelect from '../select/DateSelect';
|
||||
import {
|
||||
Cell,
|
||||
Field,
|
||||
@@ -52,27 +68,13 @@ import {
|
||||
CellButton,
|
||||
useTableNavigator,
|
||||
Table,
|
||||
} from 'loot-design/src/components/table';
|
||||
import { useMergedRefs } from 'loot-design/src/components/useMergedRefs';
|
||||
import {
|
||||
useSelectedDispatch,
|
||||
useSelectedItems,
|
||||
} from 'loot-design/src/components/useSelected';
|
||||
import { styles, colors } from 'loot-design/src/style';
|
||||
import LeftArrow2 from 'loot-design/src/svg/v0/LeftArrow2';
|
||||
import RightArrow2 from 'loot-design/src/svg/v0/RightArrow2';
|
||||
import CheveronDown from 'loot-design/src/svg/v1/CheveronDown';
|
||||
import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize';
|
||||
import CalendarIcon from 'loot-design/src/svg/v2/Calendar';
|
||||
import Hyperlink2 from 'loot-design/src/svg/v2/Hyperlink2';
|
||||
|
||||
import { getStatusProps } from '../schedules/StatusBadge';
|
||||
} from '../table';
|
||||
|
||||
function getDisplayValue(obj, name) {
|
||||
return obj ? obj[name] : '';
|
||||
}
|
||||
|
||||
function serializeTransaction(transaction, showZeroInDeposit, dateFormat) {
|
||||
function serializeTransaction(transaction, showZeroInDeposit) {
|
||||
let { amount, date } = transaction;
|
||||
|
||||
if (isPreviewId(transaction.id)) {
|
||||
@@ -107,7 +109,7 @@ function serializeTransaction(transaction, showZeroInDeposit, dateFormat) {
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeTransaction(transaction, originalTransaction, dateFormat) {
|
||||
function deserializeTransaction(transaction, originalTransaction) {
|
||||
let { debit, credit, date, ...realTransaction } = transaction;
|
||||
|
||||
let amount;
|
||||
@@ -128,20 +130,6 @@ function deserializeTransaction(transaction, originalTransaction, dateFormat) {
|
||||
return { ...realTransaction, date, amount };
|
||||
}
|
||||
|
||||
function getParentTransaction(transactions, fromIndex) {
|
||||
let trans = transactions[fromIndex];
|
||||
let parentIdx = fromIndex;
|
||||
while (parentIdx >= 0) {
|
||||
if (transactions[parentIdx].id === trans.parent_id) {
|
||||
// Found the parent
|
||||
return transactions[parentIdx];
|
||||
}
|
||||
parentIdx--;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLastChild(transactions, index) {
|
||||
let trans = transactions[index];
|
||||
return (
|
||||
@@ -275,7 +263,7 @@ export const TransactionHeader = React.memo(
|
||||
{showCategory && <Cell value="Category" width="flex" />}
|
||||
<Cell value="Payment" width={80} textAlign="right" />
|
||||
<Cell value="Deposit" width={80} textAlign="right" />
|
||||
{showBalance && <Cell value="Balance" width={85} textAlign="right" />}
|
||||
{showBalance && <Cell value="Balance" width={88} textAlign="right" />}
|
||||
{showCleared && <Field width={21} truncate={false} />}
|
||||
<Cell value="" width={15 + styles.scrollbarWidth} />
|
||||
</Row>
|
||||
@@ -399,13 +387,13 @@ function PayeeCell({
|
||||
transaction,
|
||||
payee,
|
||||
transferAcct,
|
||||
importedPayee,
|
||||
isPreview,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
onCreatePayee,
|
||||
onManagePayees,
|
||||
}) {
|
||||
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
|
||||
let isCreatingPayee = useRef(false);
|
||||
|
||||
return (
|
||||
@@ -414,7 +402,7 @@ function PayeeCell({
|
||||
name="payee"
|
||||
value={payeeId}
|
||||
valueStyle={[valueStyle, inherited && { color: colors.n8 }]}
|
||||
formatter={value => getPayeePretty(transaction, payee, transferAcct)}
|
||||
formatter={() => getPayeePretty(transaction, payee, transferAcct)}
|
||||
exposed={focused}
|
||||
onExpose={!isPreview && (name => onEdit(id, name))}
|
||||
onUpdate={async value => {
|
||||
@@ -436,6 +424,9 @@ function PayeeCell({
|
||||
shouldSaveFromKey,
|
||||
inputStyle,
|
||||
}) => {
|
||||
const PayeeAutocomplete = isNewAutocompleteEnabled
|
||||
? NewPayeeAutocomplete
|
||||
: LegacyPayeeAutocomplete;
|
||||
return (
|
||||
<>
|
||||
<PayeeAutocomplete
|
||||
@@ -455,6 +446,8 @@ function PayeeCell({
|
||||
onUpdate={onUpdate}
|
||||
onSelect={onSave}
|
||||
onManagePayees={() => onManagePayees(payeeId)}
|
||||
isCreatable
|
||||
menuPortalTarget={undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -523,6 +516,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
accounts,
|
||||
balance,
|
||||
dateFormat = 'MM/dd/yyyy',
|
||||
hideFraction,
|
||||
onSave,
|
||||
onEdit,
|
||||
onHover,
|
||||
@@ -533,12 +527,20 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
onToggleSplit,
|
||||
} = props;
|
||||
|
||||
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
|
||||
const AccountAutocomplete = isNewAutocompleteEnabled
|
||||
? NewAccountAutocomplete
|
||||
: LegacyAccountAutocomplete;
|
||||
const CategoryAutocomplete = isNewAutocompleteEnabled
|
||||
? NewCategoryAutocomplete
|
||||
: LegacyCategoryAutocomplete;
|
||||
|
||||
let dispatchSelected = useSelectedDispatch();
|
||||
|
||||
let [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit);
|
||||
let [prevTransaction, setPrevTransaction] = useState(originalTransaction);
|
||||
let [transaction, setTransaction] = useState(
|
||||
serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat),
|
||||
serializeTransaction(originalTransaction, showZeroInDeposit),
|
||||
);
|
||||
let isPreview = isPreviewId(transaction.id);
|
||||
|
||||
@@ -547,7 +549,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
showZeroInDeposit !== prevShowZero
|
||||
) {
|
||||
setTransaction(
|
||||
serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat),
|
||||
serializeTransaction(originalTransaction, showZeroInDeposit),
|
||||
);
|
||||
setPrevTransaction(originalTransaction);
|
||||
setPrevShowZero(showZeroInDeposit);
|
||||
@@ -588,13 +590,10 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
let deserialized = deserializeTransaction(
|
||||
newTransaction,
|
||||
originalTransaction,
|
||||
dateFormat,
|
||||
);
|
||||
// Run the transaction through the formatting so that we know
|
||||
// it's always showing the formatted result
|
||||
setTransaction(
|
||||
serializeTransaction(deserialized, showZeroInDeposit, dateFormat),
|
||||
);
|
||||
setTransaction(serializeTransaction(deserialized, showZeroInDeposit));
|
||||
onSave(deserialized);
|
||||
}
|
||||
}
|
||||
@@ -630,6 +629,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
|
||||
let valueStyle = added ? { fontWeight: 600 } : null;
|
||||
let backgroundFocus = hovered || focusedField === 'select';
|
||||
let amountStyle = hideFraction ? { letterSpacing: -0.5 } : null;
|
||||
|
||||
return (
|
||||
<Row
|
||||
@@ -776,6 +776,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
|
||||
onUpdate={onUpdate}
|
||||
onSelect={onSave}
|
||||
menuPortalTarget={undefined}
|
||||
/>
|
||||
)}
|
||||
</CustomCell>
|
||||
@@ -985,6 +986,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
|
||||
onUpdate={onUpdate}
|
||||
onSelect={onSave}
|
||||
menuPortalTarget={undefined}
|
||||
/>
|
||||
)}
|
||||
</CustomCell>
|
||||
@@ -1001,7 +1003,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
textAlign="right"
|
||||
title={debit}
|
||||
onExpose={!isPreview && (name => onEdit(id, name))}
|
||||
style={[isParent && { fontStyle: 'italic' }, styles.tnum]}
|
||||
style={[isParent && { fontStyle: 'italic' }, styles.tnum, amountStyle]}
|
||||
inputProps={{
|
||||
value: debit,
|
||||
onUpdate: onUpdate.bind(null, 'debit'),
|
||||
@@ -1019,7 +1021,7 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
textAlign="right"
|
||||
title={credit}
|
||||
onExpose={!isPreview && (name => onEdit(id, name))}
|
||||
style={[isParent && { fontStyle: 'italic' }, styles.tnum]}
|
||||
style={[isParent && { fontStyle: 'italic' }, styles.tnum, amountStyle]}
|
||||
inputProps={{
|
||||
value: credit,
|
||||
onUpdate: onUpdate.bind(null, 'credit'),
|
||||
@@ -1035,8 +1037,8 @@ export const Transaction = React.memo(function Transaction(props) {
|
||||
: integerToCurrency(balance)
|
||||
}
|
||||
valueStyle={{ color: balance < 0 ? colors.r4 : colors.g4 }}
|
||||
style={styles.tnum}
|
||||
width={85}
|
||||
style={[styles.tnum, amountStyle]}
|
||||
width={88}
|
||||
textAlign="right"
|
||||
/>
|
||||
)}
|
||||
@@ -1123,7 +1125,6 @@ export function isPreviewId(id) {
|
||||
function NewTransaction({
|
||||
transactions,
|
||||
accounts,
|
||||
currentAccountId,
|
||||
categoryGroups,
|
||||
payees,
|
||||
editingTransaction,
|
||||
@@ -1134,6 +1135,7 @@ function NewTransaction({
|
||||
showBalance,
|
||||
showCleared,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
onHover,
|
||||
onClose,
|
||||
onSplit,
|
||||
@@ -1157,7 +1159,7 @@ function NewTransaction({
|
||||
}}
|
||||
data-testid="new-transaction"
|
||||
onKeyDown={e => {
|
||||
if (e.keyCode === 27) {
|
||||
if (e.code === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
@@ -1179,6 +1181,7 @@ function NewTransaction({
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
expanded={true}
|
||||
onHover={onHover}
|
||||
onEdit={onEdit}
|
||||
@@ -1228,46 +1231,26 @@ function NewTransaction({
|
||||
);
|
||||
}
|
||||
|
||||
class TransactionTable_ extends React.Component {
|
||||
container = React.createRef();
|
||||
state = { highlightedRows: null };
|
||||
function TransactionTableInner({
|
||||
tableNavigator,
|
||||
tableRef,
|
||||
dateFormat = 'MM/dd/yyyy',
|
||||
newNavigator,
|
||||
renderEmpty,
|
||||
onHover,
|
||||
onScroll,
|
||||
...props
|
||||
}) {
|
||||
const containerRef = React.createRef();
|
||||
const isAddingPrev = usePrevious(props.isAdding);
|
||||
|
||||
componentDidMount() {
|
||||
this.highlight = ids => {
|
||||
this.setState({ highlightedRows: new Set(ids) }, () => {
|
||||
this.setState({ highlightedRows: null });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { isAdding } = this.props;
|
||||
if (!isAdding && nextProps.isAdding) {
|
||||
this.props.newNavigator.onEdit('temp', 'date');
|
||||
useEffect(() => {
|
||||
if (!isAddingPrev && props.isAdding) {
|
||||
newNavigator.onEdit('temp', 'date');
|
||||
}
|
||||
}
|
||||
}, [isAddingPrev, props.isAdding, newNavigator]);
|
||||
|
||||
componentDidUpdate() {
|
||||
this._cachedParent = null;
|
||||
}
|
||||
|
||||
getParent(trans, index) {
|
||||
let { transactions } = this.props;
|
||||
|
||||
if (this._cachedParent && this._cachedParent.id === trans.parent_id) {
|
||||
return this._cachedParent;
|
||||
}
|
||||
|
||||
if (trans.parent_id) {
|
||||
this._cachedParent = getParentTransaction(transactions, index);
|
||||
return this._cachedParent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
renderRow = ({ item, index, position, editing, focusedFied, onEdit }) => {
|
||||
const { highlightedRows } = this.state;
|
||||
const renderRow = ({ item, index, position, editing }) => {
|
||||
const {
|
||||
transactions,
|
||||
selectedItems,
|
||||
@@ -1279,20 +1262,17 @@ class TransactionTable_ extends React.Component {
|
||||
showAccount,
|
||||
showCategory,
|
||||
balances,
|
||||
dateFormat = 'MM/dd/yyyy',
|
||||
tableNavigator,
|
||||
hideFraction,
|
||||
isNew,
|
||||
isMatched,
|
||||
isExpanded,
|
||||
} = this.props;
|
||||
} = props;
|
||||
|
||||
let trans = item;
|
||||
let hovered = hoveredTransaction === trans.id;
|
||||
let selected = selectedItems.has(trans.id);
|
||||
let highlighted =
|
||||
!selected && (highlightedRows ? highlightedRows.has(trans.id) : false);
|
||||
|
||||
let parent = this.getParent(trans, index);
|
||||
let parent = props.transactionMap.get(trans.parent_id);
|
||||
let isChildDeposit = parent && parent.amount > 0;
|
||||
let expanded = isExpanded && isExpanded((parent || trans).id);
|
||||
|
||||
@@ -1318,7 +1298,7 @@ class TransactionTable_ extends React.Component {
|
||||
<TransactionError
|
||||
error={error}
|
||||
isDeposit={isChildDeposit}
|
||||
onAddSplit={() => this.props.onAddSplit(trans.id)}
|
||||
onAddSplit={() => props.onAddSplit(trans.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -1331,7 +1311,7 @@ class TransactionTable_ extends React.Component {
|
||||
showCleared={showCleared}
|
||||
hovered={hovered}
|
||||
selected={selected}
|
||||
highlighted={highlighted}
|
||||
highlighted={false}
|
||||
added={isNew && isNew(trans.id)}
|
||||
expanded={isExpanded && isExpanded(trans.id)}
|
||||
matched={isMatched && isMatched(trans.id)}
|
||||
@@ -1347,117 +1327,105 @@ class TransactionTable_ extends React.Component {
|
||||
: new Set()
|
||||
}
|
||||
dateFormat={dateFormat}
|
||||
onHover={this.props.onHover}
|
||||
hideFraction={hideFraction}
|
||||
onHover={props.onHover}
|
||||
onEdit={tableNavigator.onEdit}
|
||||
onSave={this.props.onSave}
|
||||
onDelete={this.props.onDelete}
|
||||
onSplit={this.props.onSplit}
|
||||
onManagePayees={this.props.onManagePayees}
|
||||
onCreatePayee={this.props.onCreatePayee}
|
||||
onToggleSplit={this.props.onToggleSplit}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onSplit={props.onSplit}
|
||||
onManagePayees={props.onManagePayees}
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
onToggleSplit={props.onToggleSplit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
let { props } = this;
|
||||
let {
|
||||
tableNavigator,
|
||||
tableRef,
|
||||
dateFormat = 'MM/dd/yyyy',
|
||||
newNavigator,
|
||||
renderEmpty,
|
||||
onHover,
|
||||
onScroll,
|
||||
} = props;
|
||||
return (
|
||||
<View
|
||||
innerRef={containerRef}
|
||||
style={[{ flex: 1, cursor: 'default' }, props.style]}
|
||||
>
|
||||
<View>
|
||||
<TransactionHeader
|
||||
hasSelected={props.selectedItems.size > 0}
|
||||
showAccount={props.showAccount}
|
||||
showCategory={props.showCategory}
|
||||
showBalance={!!props.balances}
|
||||
showCleared={props.showCleared}
|
||||
/>
|
||||
|
||||
return (
|
||||
<View
|
||||
innerRef={this.container}
|
||||
style={[{ flex: 1, cursor: 'default' }, props.style]}
|
||||
>
|
||||
<View>
|
||||
<TransactionHeader
|
||||
hasSelected={props.selectedItems.size > 0}
|
||||
showAccount={props.showAccount}
|
||||
showCategory={props.showCategory}
|
||||
showBalance={!!props.balances}
|
||||
showCleared={props.showCleared}
|
||||
/>
|
||||
|
||||
{props.isAdding && (
|
||||
<View
|
||||
{...newNavigator.getNavigatorProps({
|
||||
onKeyDown: e => props.onCheckNewEnter(e),
|
||||
})}
|
||||
>
|
||||
<NewTransaction
|
||||
transactions={props.newTransactions}
|
||||
editingTransaction={newNavigator.editingId}
|
||||
hoveredTransaction={props.hoveredTransaction}
|
||||
focusedField={newNavigator.focusedField}
|
||||
accounts={props.accounts}
|
||||
currentAccountId={props.currentAccountId}
|
||||
categoryGroups={props.categoryGroups}
|
||||
payees={this.props.payees || []}
|
||||
showAccount={props.showAccount}
|
||||
showCategory={props.showCategory}
|
||||
showBalance={!!props.balances}
|
||||
showCleared={props.showCleared}
|
||||
dateFormat={dateFormat}
|
||||
onClose={props.onCloseAddTransaction}
|
||||
onAdd={this.props.onAddTemporary}
|
||||
onAddSplit={this.props.onAddSplit}
|
||||
onSplit={this.props.onSplit}
|
||||
onEdit={newNavigator.onEdit}
|
||||
onSave={this.props.onSave}
|
||||
onDelete={this.props.onDelete}
|
||||
onHover={this.props.onHover}
|
||||
onManagePayees={this.props.onManagePayees}
|
||||
onCreatePayee={this.props.onCreatePayee}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/*// * On Windows, makes the scrollbar always appear
|
||||
{props.isAdding && (
|
||||
<View
|
||||
{...newNavigator.getNavigatorProps({
|
||||
onKeyDown: e => props.onCheckNewEnter(e),
|
||||
})}
|
||||
>
|
||||
<NewTransaction
|
||||
transactions={props.newTransactions}
|
||||
editingTransaction={newNavigator.editingId}
|
||||
hoveredTransaction={props.hoveredTransaction}
|
||||
focusedField={newNavigator.focusedField}
|
||||
accounts={props.accounts}
|
||||
categoryGroups={props.categoryGroups}
|
||||
payees={props.payees || []}
|
||||
showAccount={props.showAccount}
|
||||
showCategory={props.showCategory}
|
||||
showBalance={!!props.balances}
|
||||
showCleared={props.showCleared}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={props.hideFraction}
|
||||
onClose={props.onCloseAddTransaction}
|
||||
onAdd={props.onAddTemporary}
|
||||
onAddSplit={props.onAddSplit}
|
||||
onSplit={props.onSplit}
|
||||
onEdit={newNavigator.onEdit}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onHover={onHover}
|
||||
onManagePayees={props.onManagePayees}
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/*// * On Windows, makes the scrollbar always appear
|
||||
// the full height of the container ??? */}
|
||||
|
||||
<View
|
||||
style={[{ flex: 1, overflow: 'hidden' }]}
|
||||
data-testid="transaction-table"
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<Table
|
||||
navigator={tableNavigator}
|
||||
ref={tableRef}
|
||||
items={props.transactions}
|
||||
renderItem={this.renderRow}
|
||||
renderEmpty={renderEmpty}
|
||||
loadMore={props.loadMoreTransactions}
|
||||
isSelected={id => props.selectedItems.has(id)}
|
||||
onKeyDown={e => props.onCheckEnter(e)}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
<View
|
||||
style={[{ flex: 1, overflow: 'hidden' }]}
|
||||
data-testid="transaction-table"
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<Table
|
||||
navigator={tableNavigator}
|
||||
ref={tableRef}
|
||||
items={props.transactions}
|
||||
renderItem={renderRow}
|
||||
renderEmpty={renderEmpty}
|
||||
loadMore={props.loadMoreTransactions}
|
||||
isSelected={id => props.selectedItems.has(id)}
|
||||
onKeyDown={e => props.onCheckEnter(e)}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
|
||||
{props.isAdding && (
|
||||
<div
|
||||
key="shadow"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 20,
|
||||
backgroundColor: 'red',
|
||||
boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{props.isAdding && (
|
||||
<div
|
||||
key="shadow"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 20,
|
||||
backgroundColor: 'red',
|
||||
boxShadow: '0 0 6px rgba(0, 0, 0, .20)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export let TransactionTable = React.forwardRef((props, ref) => {
|
||||
@@ -1509,6 +1477,9 @@ export let TransactionTable = React.forwardRef((props, ref) => {
|
||||
prevSplitsExpanded.current = splitsExpanded;
|
||||
return result;
|
||||
}, [props.transactions, splitsExpanded]);
|
||||
const transactionMap = useMemo(() => {
|
||||
return new Map(transactions.map(trans => [trans.id, trans]));
|
||||
}, [transactions]);
|
||||
|
||||
useEffect(() => {
|
||||
// If it's anchored that means we've also disabled animations. To
|
||||
@@ -1619,9 +1590,7 @@ export let TransactionTable = React.forwardRef((props, ref) => {
|
||||
}
|
||||
|
||||
function onCheckNewEnter(e) {
|
||||
const ENTER = 13;
|
||||
|
||||
if (e.keyCode === ENTER) {
|
||||
if (e.code === 'Enter') {
|
||||
if (e.metaKey) {
|
||||
e.stopPropagation();
|
||||
onAddTemporary();
|
||||
@@ -1665,15 +1634,13 @@ export let TransactionTable = React.forwardRef((props, ref) => {
|
||||
}
|
||||
|
||||
function onCheckEnter(e) {
|
||||
const ENTER = 13;
|
||||
|
||||
if (e.keyCode === ENTER && !e.shiftKey) {
|
||||
if (e.code === 'Enter' && !e.shiftKey) {
|
||||
let { editingId: id, focusedField } = tableNavigator;
|
||||
|
||||
afterSave(props => {
|
||||
afterSave(() => {
|
||||
let transactions = latestState.current.transactions;
|
||||
let idx = transactions.findIndex(t => t.id === id);
|
||||
let parent = getParentTransaction(transactions, idx);
|
||||
let parent = transactionMap.get(transactions[idx]?.parent_id);
|
||||
|
||||
if (
|
||||
isLastChild(transactions, idx) &&
|
||||
@@ -1798,11 +1765,11 @@ export let TransactionTable = React.forwardRef((props, ref) => {
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-pascal-case
|
||||
<TransactionTable_
|
||||
<TransactionTableInner
|
||||
tableRef={mergedRef}
|
||||
{...props}
|
||||
transactions={transactions}
|
||||
transactionMap={transactionMap}
|
||||
selectedItems={selectedItems}
|
||||
hoveredTransaction={hoveredTransaction}
|
||||
isExpanded={splitsExpanded.expanded}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { format as formatDate, parse as parseDate } from 'date-fns';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import {
|
||||
generateTransaction,
|
||||
@@ -18,13 +18,15 @@ import {
|
||||
updateTransaction,
|
||||
} from 'loot-core/src/shared/transactions';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
import { SelectedProviderWithItems } from 'loot-design/src/components/useSelected';
|
||||
|
||||
import { SelectedProviderWithItems } from '../../hooks/useSelected';
|
||||
|
||||
import { SplitsExpandedProvider, TransactionTable } from './TransactionsTable';
|
||||
|
||||
const uuid = require('loot-core/src/platform/uuid');
|
||||
|
||||
jest.mock('loot-core/src/platform/client/fetch');
|
||||
jest.mock('../../hooks/useFeatureFlag', () => jest.fn().mockReturnValue(false));
|
||||
|
||||
const accounts = [generateAccount('Bank of America')];
|
||||
const payees = [
|
||||
@@ -76,99 +78,66 @@ function generateTransactions(count, splitAtIndexes = [], showError = false) {
|
||||
return transactions;
|
||||
}
|
||||
|
||||
class LiveTransactionTable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { transactions: props.transactions };
|
||||
}
|
||||
function LiveTransactionTable(props) {
|
||||
const [transactions, setTransactions] = useState(props.transactions);
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.state.transactions !== nextProps.transactions) {
|
||||
this.setState({ transactions: nextProps.transactions });
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (transactions === props.transactions) return;
|
||||
props.onTransactionsChange && props.onTransactionsChange(transactions);
|
||||
}, [transactions]);
|
||||
|
||||
notifyChange = () => {
|
||||
const { onTransactionsChange } = this.props;
|
||||
onTransactionsChange && onTransactionsChange(this.state.transactions);
|
||||
};
|
||||
|
||||
onSplit = id => {
|
||||
let { state } = this;
|
||||
let { data, diff } = splitTransaction(state.transactions, id);
|
||||
this.setState({ transactions: data }, this.notifyChange);
|
||||
const onSplit = id => {
|
||||
let { data, diff } = splitTransaction(transactions, id);
|
||||
setTransactions(data);
|
||||
return diff.added[0].id;
|
||||
};
|
||||
|
||||
// onDelete = id => {
|
||||
// let { state } = this;
|
||||
// this.setState(
|
||||
// {
|
||||
// transactions: applyChanges(
|
||||
// deleteTransaction(state.transactions, id),
|
||||
// state.transactions
|
||||
// )
|
||||
// },
|
||||
// this.notifyChange
|
||||
// );
|
||||
// };
|
||||
|
||||
onSave = transaction => {
|
||||
let { state } = this;
|
||||
let { data } = updateTransaction(state.transactions, transaction);
|
||||
this.setState({ transactions: data }, this.notifyChange);
|
||||
const onSave = transaction => {
|
||||
let { data } = updateTransaction(transactions, transaction);
|
||||
setTransactions(data);
|
||||
};
|
||||
|
||||
onAdd = newTransactions => {
|
||||
let { state } = this;
|
||||
const onAdd = newTransactions => {
|
||||
newTransactions = realizeTempTransactions(newTransactions);
|
||||
this.setState(
|
||||
{ transactions: [...newTransactions, ...state.transactions] },
|
||||
this.notifyChange,
|
||||
);
|
||||
setTransactions(trans => [...newTransactions, ...trans]);
|
||||
};
|
||||
|
||||
onAddSplit = id => {
|
||||
let { state } = this;
|
||||
let { data, diff } = addSplitTransaction(state.transactions, id);
|
||||
this.setState({ transactions: data }, this.notifyChange);
|
||||
const onAddSplit = id => {
|
||||
let { data, diff } = addSplitTransaction(transactions, id);
|
||||
setTransactions(data);
|
||||
return diff.added[0].id;
|
||||
};
|
||||
|
||||
onCreatePayee = name => 'id';
|
||||
const onCreatePayee = () => 'id';
|
||||
|
||||
render() {
|
||||
const { state } = this;
|
||||
|
||||
// It's important that these functions are they same instances
|
||||
// across renders. Doing so tests that the transaction table
|
||||
// implementation properly uses the right latest state even if the
|
||||
// hook dependencies haven't changed
|
||||
return (
|
||||
<TestProvider>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={state.transactions}
|
||||
fetchAllIds={() => state.transactions.map(t => t.id)}
|
||||
>
|
||||
<SplitsExpandedProvider>
|
||||
<TransactionTable
|
||||
{...this.props}
|
||||
transactions={state.transactions}
|
||||
loadMoreTransactions={() => {}}
|
||||
payees={payees}
|
||||
addNotification={n => console.log(n)}
|
||||
onSave={this.onSave}
|
||||
onSplit={this.onSplit}
|
||||
onAdd={this.onAdd}
|
||||
onAddSplit={this.onAddSplit}
|
||||
onCreatePayee={this.onCreatePayee}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</TestProvider>
|
||||
);
|
||||
}
|
||||
// It's important that these functions are they same instances
|
||||
// across renders. Doing so tests that the transaction table
|
||||
// implementation properly uses the right latest state even if the
|
||||
// hook dependencies haven't changed
|
||||
return (
|
||||
<TestProvider>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={transactions}
|
||||
fetchAllIds={() => transactions.map(t => t.id)}
|
||||
>
|
||||
<SplitsExpandedProvider>
|
||||
<TransactionTable
|
||||
{...props}
|
||||
transactions={transactions}
|
||||
loadMoreTransactions={() => {}}
|
||||
payees={payees}
|
||||
addNotification={n => console.log(n)}
|
||||
onSave={onSave}
|
||||
onSplit={onSplit}
|
||||
onAdd={onAdd}
|
||||
onAddSplit={onAddSplit}
|
||||
onCreatePayee={onCreatePayee}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</TestProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function initBasicServer() {
|
||||
@@ -204,52 +173,10 @@ const categories = categoryGroups.reduce(
|
||||
[],
|
||||
);
|
||||
|
||||
const keys = {
|
||||
ESC: {
|
||||
key: 'Esc',
|
||||
keyCode: 27,
|
||||
which: 27,
|
||||
},
|
||||
ENTER: {
|
||||
key: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
},
|
||||
TAB: {
|
||||
key: 'Tab',
|
||||
keyCode: 9,
|
||||
which: 9,
|
||||
},
|
||||
DOWN: {
|
||||
key: 'Down',
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
},
|
||||
UP: {
|
||||
key: 'Up',
|
||||
keyCode: 38,
|
||||
which: 38,
|
||||
},
|
||||
LEFT: {
|
||||
key: 'Left',
|
||||
keyCode: 37,
|
||||
which: 37,
|
||||
},
|
||||
RIGHT: {
|
||||
key: 'Right',
|
||||
keyCode: 39,
|
||||
which: 39,
|
||||
},
|
||||
};
|
||||
|
||||
function prettyDate(date) {
|
||||
return formatDate(parseDate(date, 'yyyy-MM-dd', new Date()), 'MM/dd/yyyy');
|
||||
}
|
||||
|
||||
function keyWithShift(key) {
|
||||
return { ...key, shiftKey: true };
|
||||
}
|
||||
|
||||
function renderTransactions(extraProps) {
|
||||
let transactions = generateTransactions(5, [6]);
|
||||
// Hardcoding the first value makes it easier for tests to do
|
||||
@@ -305,7 +232,7 @@ function queryField(container, name, subSelector = '', idx) {
|
||||
return field;
|
||||
}
|
||||
|
||||
function _editField(field, container) {
|
||||
async function _editField(field, container) {
|
||||
// We only short-circuit this for inputs
|
||||
let input = field.querySelector(`input`);
|
||||
if (input) {
|
||||
@@ -318,11 +245,11 @@ function _editField(field, container) {
|
||||
|
||||
if (field.querySelector(buttonQuery)) {
|
||||
let btn = field.querySelector(buttonQuery);
|
||||
fireEvent.click(btn);
|
||||
await userEvent.click(btn);
|
||||
element = field.querySelector(':focus');
|
||||
expect(element).toBeTruthy();
|
||||
} else {
|
||||
fireEvent.click(field.querySelector('div'));
|
||||
await userEvent.click(field.querySelector('div'));
|
||||
element = field.querySelector('input');
|
||||
expect(element).toBeTruthy();
|
||||
expect(container.ownerDocument.activeElement).toBe(element);
|
||||
@@ -393,123 +320,123 @@ describe('Transactions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('keybindings enter/tab/alt should move around', () => {
|
||||
test('keybindings enter/tab/alt should move around', async () => {
|
||||
const { container } = renderTransactions();
|
||||
|
||||
// Enter/tab goes down/right
|
||||
let input = editField(container, 'notes', 2);
|
||||
fireEvent.keyDown(input, keys.ENTER);
|
||||
let input = await editField(container, 'notes', 2);
|
||||
await userEvent.type(input, '[Enter]');
|
||||
expectToBeEditingField(container, 'notes', 3);
|
||||
|
||||
input = editField(container, 'payee', 2);
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
input = await editField(container, 'payee', 2);
|
||||
await userEvent.type(input, '[Tab]');
|
||||
expectToBeEditingField(container, 'notes', 2);
|
||||
|
||||
// Shift+enter/tab goes up/left
|
||||
input = editField(container, 'notes', 2);
|
||||
fireEvent.keyDown(input, keyWithShift(keys.ENTER));
|
||||
input = await editField(container, 'notes', 2);
|
||||
await userEvent.type(input, '{Shift>}[Enter]{/Shift}');
|
||||
expectToBeEditingField(container, 'notes', 1);
|
||||
|
||||
input = editField(container, 'payee', 2);
|
||||
fireEvent.keyDown(input, keyWithShift(keys.TAB));
|
||||
input = await editField(container, 'payee', 2);
|
||||
await userEvent.type(input, '{Shift>}[Tab]{/Shift}');
|
||||
expectToBeEditingField(container, 'account', 2);
|
||||
|
||||
// Moving forward on the last cell moves to the next row
|
||||
input = editField(container, 'cleared', 2);
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
input = await editField(container, 'cleared', 2);
|
||||
await userEvent.type(input, '[Tab]');
|
||||
expectToBeEditingField(container, 'select', 3);
|
||||
|
||||
// Moving backward on the first cell moves to the previous row
|
||||
editField(container, 'date', 2);
|
||||
input = editField(container, 'select', 2);
|
||||
fireEvent.keyDown(input, keyWithShift(keys.TAB));
|
||||
await editField(container, 'date', 2);
|
||||
input = await editField(container, 'select', 2);
|
||||
await userEvent.type(input, '{Shift>}[Tab]{/Shift}');
|
||||
expectToBeEditingField(container, 'cleared', 1);
|
||||
|
||||
// Blurring should close the input
|
||||
input = editField(container, 'credit', 1);
|
||||
input = await editField(container, 'credit', 1);
|
||||
fireEvent.blur(input);
|
||||
expect(container.querySelector('input')).toBe(null);
|
||||
|
||||
// When reaching the bottom it shouldn't error
|
||||
input = editField(container, 'notes', 4);
|
||||
fireEvent.keyDown(input, keys.ENTER);
|
||||
input = await editField(container, 'notes', 4);
|
||||
await userEvent.type(input, '[Enter]');
|
||||
|
||||
// TODO: fix flakiness and re-enable
|
||||
// When reaching the top it shouldn't error
|
||||
input = editField(container, 'notes', 0);
|
||||
fireEvent.keyDown(input, keyWithShift(keys.ENTER));
|
||||
// input = await editField(container, 'notes', 0);
|
||||
// await userEvent.type(input, '{Shift>}[Enter]{/Shift}');
|
||||
});
|
||||
|
||||
test('keybinding escape resets the value', () => {
|
||||
test('keybinding escape resets the value', async () => {
|
||||
const { container } = renderTransactions();
|
||||
|
||||
let input = editField(container, 'notes', 2);
|
||||
let input = await editField(container, 'notes', 2);
|
||||
let oldValue = input.value;
|
||||
fireEvent.change(input, { target: { value: 'yo new value' } });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'yo new value');
|
||||
expect(input.value).toEqual('yo new value');
|
||||
fireEvent.keyDown(input, keys.ESC);
|
||||
await userEvent.type(input, '[Escape]');
|
||||
expect(input.value).toEqual(oldValue);
|
||||
|
||||
input = editField(container, 'category', 2);
|
||||
input = await editField(container, 'category', 2);
|
||||
oldValue = input.value;
|
||||
fireEvent.change(input, { target: { value: 'Gener' } });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Gener');
|
||||
expect(input.value).toEqual('Gener');
|
||||
fireEvent.keyDown(input, keys.ESC);
|
||||
await userEvent.type(input, '[Escape]');
|
||||
expect(input.value).toEqual(oldValue);
|
||||
});
|
||||
|
||||
test('text fields save when moved away from', () => {
|
||||
test('text fields save when moved away from', async () => {
|
||||
const { container, getTransactions } = renderTransactions();
|
||||
|
||||
function runWithMovementKeys(func) {
|
||||
// All of these keys move to a different field, and the value in
|
||||
// the previous input should be saved
|
||||
const ks = [
|
||||
keys.TAB,
|
||||
keys.ENTER,
|
||||
keyWithShift(keys.TAB),
|
||||
keyWithShift(keys.ENTER),
|
||||
];
|
||||
// All of these keys move to a different field, and the value in
|
||||
// the previous input should be saved
|
||||
const ks = [
|
||||
'[Tab]',
|
||||
'[Enter]',
|
||||
'{Shift>}[Tab]{/Shift}',
|
||||
'{Shift>}[Enter]{/Shift}',
|
||||
];
|
||||
|
||||
ks.forEach((k, i) => func(k, i));
|
||||
}
|
||||
|
||||
runWithMovementKeys((key, idx) => {
|
||||
let input = editField(container, 'notes', 2);
|
||||
for (let idx in ks) {
|
||||
let input = await editField(container, 'notes', 2);
|
||||
let oldValue = input.value;
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'a happy little note' + idx },
|
||||
});
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'a happy little note' + idx);
|
||||
// It's not saved yet
|
||||
expect(getTransactions()[2].notes).toBe(oldValue);
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
await userEvent.type(input, '[Tab]');
|
||||
// Now it should be saved!
|
||||
expect(getTransactions()[2].notes).toBe('a happy little note' + idx);
|
||||
expect(queryField(container, 'notes', 'div', 2).textContent).toBe(
|
||||
'a happy little note' + idx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let input = editField(container, 'notes', 2);
|
||||
let input = await editField(container, 'notes', 2);
|
||||
let oldValue = input.value;
|
||||
fireEvent.change(input, { target: { value: 'another happy note' } });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'another happy note');
|
||||
// It's not saved yet
|
||||
expect(getTransactions()[2].notes).toBe(oldValue);
|
||||
// Blur the input to make it stop editing
|
||||
fireEvent.blur(input);
|
||||
await userEvent.tab();
|
||||
expect(getTransactions()[2].notes).toBe('another happy note');
|
||||
});
|
||||
|
||||
test('dropdown automatically opens and can be filtered', () => {
|
||||
test('dropdown automatically opens and can be filtered', async () => {
|
||||
const { container } = renderTransactions();
|
||||
|
||||
let input = editField(container, 'category', 2);
|
||||
let input = await editField(container, 'category', 2);
|
||||
let tooltip = container.querySelector('[data-testid="tooltip"]');
|
||||
expect(tooltip).toBeTruthy();
|
||||
expect(
|
||||
[...tooltip.querySelectorAll('[data-testid*="category-item"]')].length,
|
||||
).toBe(9);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Gener' } });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Gener');
|
||||
|
||||
// Make sure the list is filtered, the right items exist, and the
|
||||
// first item is highlighted
|
||||
@@ -520,7 +447,8 @@ describe('Transactions', () => {
|
||||
expect(items[1].dataset['testid']).toBe('category-item-highlighted');
|
||||
|
||||
// It should also allow filtering on group names
|
||||
fireEvent.change(input, { target: { value: 'Usual' } });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Usual');
|
||||
|
||||
items = tooltip.querySelectorAll('[data-testid*="category-item"]');
|
||||
expect(items.length).toBe(4);
|
||||
@@ -534,7 +462,7 @@ describe('Transactions', () => {
|
||||
test('dropdown selects an item with keyboard', async () => {
|
||||
const { container, getTransactions } = renderTransactions();
|
||||
|
||||
let input = editField(container, 'category', 2);
|
||||
let input = await editField(container, 'category', 2);
|
||||
let tooltip = container.querySelector('[data-testid="tooltip"]');
|
||||
|
||||
// No item should be highlighted
|
||||
@@ -543,10 +471,7 @@ describe('Transactions', () => {
|
||||
);
|
||||
expect(highlighted).toBe(null);
|
||||
|
||||
fireEvent.keyDown(input, keys.DOWN);
|
||||
fireEvent.keyDown(input, keys.DOWN);
|
||||
fireEvent.keyDown(input, keys.DOWN);
|
||||
fireEvent.keyDown(input, keys.DOWN);
|
||||
await userEvent.keyboard('[ArrowDown][ArrowDown][ArrowDown][ArrowDown]');
|
||||
|
||||
// The right item should be highlighted
|
||||
highlighted = tooltip.querySelector(
|
||||
@@ -559,7 +484,7 @@ describe('Transactions', () => {
|
||||
categories.find(category => category.name === 'Food').id,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(input, keys.ENTER);
|
||||
await userEvent.type(input, '[Enter]');
|
||||
await waitForAutocomplete();
|
||||
|
||||
// The transactions data should be updated with the right category
|
||||
@@ -573,14 +498,14 @@ describe('Transactions', () => {
|
||||
expect(container.querySelector('[data-testid="tooltip"]')).toBe(null);
|
||||
|
||||
// Pressing enter should now move down
|
||||
fireEvent.keyDown(input, keys.ENTER);
|
||||
await userEvent.type(input, '[Enter]');
|
||||
expectToBeEditingField(container, 'category', 3);
|
||||
});
|
||||
|
||||
test('dropdown selects an item when clicking', async () => {
|
||||
const { container, getTransactions } = renderTransactions();
|
||||
|
||||
editField(container, 'category', 2);
|
||||
await editField(container, 'category', 2);
|
||||
|
||||
let tooltip = container.querySelector('[data-testid="tooltip"]');
|
||||
|
||||
@@ -592,7 +517,7 @@ describe('Transactions', () => {
|
||||
expect(highlighted).toBe(null);
|
||||
|
||||
// Hover over an item
|
||||
fireEvent.mouseMove(items[2]);
|
||||
await userEvent.hover(items[2]);
|
||||
|
||||
// Make sure the expected category is highlighted
|
||||
highlighted = tooltip.querySelector(
|
||||
@@ -605,7 +530,7 @@ describe('Transactions', () => {
|
||||
expect(getTransactions()[2].category).toBe(
|
||||
categories.find(c => c.name === 'Food').id,
|
||||
);
|
||||
fireEvent.click(items[2]);
|
||||
await userEvent.click(items[2]);
|
||||
await waitForAutocomplete();
|
||||
expect(getTransactions()[2].category).toBe(
|
||||
categories.find(c => c.name === 'General').id,
|
||||
@@ -617,18 +542,18 @@ describe('Transactions', () => {
|
||||
expectToBeEditingField(container, 'category', 2);
|
||||
});
|
||||
|
||||
test("dropdown hovers but doesn't change value", () => {
|
||||
test('dropdown hovers but doesn’t change value', async () => {
|
||||
const { container, getTransactions } = renderTransactions();
|
||||
|
||||
let input = editField(container, 'category', 2);
|
||||
let input = await editField(container, 'category', 2);
|
||||
let oldCategory = getTransactions()[2].category;
|
||||
let tooltip = container.querySelector('[data-testid="tooltip"]');
|
||||
|
||||
let items = tooltip.querySelectorAll('[data-testid="category-item"]');
|
||||
|
||||
// Hover over a few of the items to highlight them
|
||||
fireEvent.mouseMove(items[2]);
|
||||
fireEvent.mouseMove(items[3]);
|
||||
await userEvent.hover(items[2]);
|
||||
await userEvent.hover(items[3]);
|
||||
|
||||
// Make sure one of them is highlighted
|
||||
let highlighted = tooltip.querySelector(
|
||||
@@ -637,7 +562,7 @@ describe('Transactions', () => {
|
||||
expect(highlighted).toBeTruthy();
|
||||
|
||||
// Navigate away from the field with the keyboard
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
await userEvent.type(input, '[Tab]');
|
||||
|
||||
// Make sure the category didn't update, and that the highlighted
|
||||
// field was different than the transactions' category
|
||||
@@ -653,8 +578,9 @@ describe('Transactions', () => {
|
||||
const { container, getTransactions } = renderTransactions();
|
||||
|
||||
// Invalid values should be rejected and nullified
|
||||
let input = editField(container, 'category', 2);
|
||||
fireEvent.change(input, { target: { value: 'aaabbbccc' } });
|
||||
let input = await editField(container, 'category', 2);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'aaabbbccc');
|
||||
|
||||
// For this first test case, make sure the tooltip is gone. We
|
||||
// don't need to check this in all the other cases
|
||||
@@ -664,36 +590,35 @@ describe('Transactions', () => {
|
||||
expect(tooltipItems.length).toBe(0);
|
||||
|
||||
expect(getTransactions()[2].category).not.toBe(null);
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
await userEvent.tab();
|
||||
expect(getTransactions()[2].category).toBe(null);
|
||||
|
||||
// Clear out the category value
|
||||
input = editField(container, 'category', 3);
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
input = await editField(container, 'category', 3);
|
||||
await userEvent.clear(input);
|
||||
|
||||
// The category should be null when the value is cleared
|
||||
expect(getTransactions()[3].category).not.toBe(null);
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
await userEvent.tab();
|
||||
expect(getTransactions()[3].category).toBe(null);
|
||||
|
||||
// Clear out the payee value
|
||||
input = editField(container, 'payee', 3);
|
||||
input = await editField(container, 'payee', 3);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
await userEvent.clear(input);
|
||||
|
||||
// The payee should be empty when the value is cleared
|
||||
expect(getTransactions()[3].payee).not.toBe('');
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
await userEvent.tab();
|
||||
expect(getTransactions()[3].payee).toBe(null);
|
||||
});
|
||||
|
||||
test('dropdown escape resets the value ', () => {
|
||||
test('dropdown escape resets the value ', async () => {
|
||||
const { container } = renderTransactions();
|
||||
|
||||
let input = editField(container, 'category', 2);
|
||||
let input = await editField(container, 'category', 2);
|
||||
let oldValue = input.value;
|
||||
fireEvent.change(input, { target: { value: 'aaabbbccc' } });
|
||||
fireEvent.keyDown(input, keys.ESC);
|
||||
await userEvent.type(input, 'aaabbbccc[Escape]');
|
||||
expect(input.value).toBe(oldValue);
|
||||
|
||||
// The tooltip be closed
|
||||
@@ -717,17 +642,15 @@ describe('Transactions', () => {
|
||||
expect(container.ownerDocument.activeElement).toBe(input);
|
||||
expect(input.value).not.toBe('');
|
||||
|
||||
input = editNewField(container, 'notes');
|
||||
fireEvent.change(input, { target: { value: 'a transaction' } });
|
||||
fireEvent.keyDown(input, keys.ENTER);
|
||||
input = await editNewField(container, 'notes');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'a transaction[Enter]');
|
||||
|
||||
input = editNewField(container, 'debit');
|
||||
input = await editNewField(container, 'debit');
|
||||
expect(input.value).toBe('0.00');
|
||||
fireEvent.change(input, { target: { value: '100' } });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '100[Enter]');
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, keys.ENTER);
|
||||
});
|
||||
expect(getTransactions().length).toBe(6);
|
||||
expect(getTransactions()[0].amount).toBe(-10000);
|
||||
expect(getTransactions()[0].notes).toBe('a transaction');
|
||||
@@ -743,33 +666,34 @@ describe('Transactions', () => {
|
||||
const { container, getTransactions, updateProps } = renderTransactions();
|
||||
updateProps({ isAdding: true });
|
||||
|
||||
let input = editNewField(container, 'debit');
|
||||
fireEvent.change(input, { target: { value: '55.00' } });
|
||||
fireEvent.blur(input);
|
||||
let input = await editNewField(container, 'debit');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '55.00');
|
||||
|
||||
editNewField(container, 'category');
|
||||
await editNewField(container, 'category');
|
||||
let splitButton = document.body.querySelector(
|
||||
'[data-testid="tooltip"] [data-testid="split-transaction-button"]',
|
||||
);
|
||||
fireEvent.click(splitButton);
|
||||
await userEvent.click(splitButton);
|
||||
await waitForAutocomplete();
|
||||
await waitForAutocomplete();
|
||||
await waitForAutocomplete();
|
||||
|
||||
fireEvent.click(
|
||||
await userEvent.click(
|
||||
container.querySelector('[data-testid="transaction-error"] button'),
|
||||
);
|
||||
|
||||
input = editNewField(container, 'debit', 1);
|
||||
fireEvent.change(input, { target: { value: '45.00' } });
|
||||
fireEvent.blur(input);
|
||||
input = await editNewField(container, 'debit', 1);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '45.00');
|
||||
expect(
|
||||
container.querySelector('[data-testid="transaction-error"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
input = editNewField(container, 'debit', 2);
|
||||
fireEvent.change(input, { target: { value: '10.00' } });
|
||||
fireEvent.blur(input);
|
||||
input = await editNewField(container, 'debit', 2);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '10.00');
|
||||
await userEvent.tab();
|
||||
expect(container.querySelector('[data-testid="transaction-error"]')).toBe(
|
||||
null,
|
||||
);
|
||||
@@ -777,7 +701,7 @@ describe('Transactions', () => {
|
||||
let addButton = container.querySelector('[data-testid="add-button"]');
|
||||
|
||||
expect(getTransactions().length).toBe(5);
|
||||
fireEvent.click(addButton);
|
||||
await userEvent.click(addButton);
|
||||
expect(getTransactions().length).toBe(8);
|
||||
expect(getTransactions()[0].is_parent).toBe(true);
|
||||
expect(getTransactions()[0].amount).toBe(-5500);
|
||||
@@ -785,10 +709,9 @@ describe('Transactions', () => {
|
||||
expect(getTransactions()[1].amount).toBe(-4500);
|
||||
expect(getTransactions()[2].is_child).toBe(true);
|
||||
expect(getTransactions()[2].amount).toBe(-1000);
|
||||
expect(getTransactions().slice(0, 3)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('escape closes the new transaction rows', () => {
|
||||
test('escape closes the new transaction rows', async () => {
|
||||
const { container, updateProps } = renderTransactions({
|
||||
onCloseAddTransaction: () => {
|
||||
updateProps({ isAdding: false });
|
||||
@@ -799,17 +722,17 @@ describe('Transactions', () => {
|
||||
// While adding a transaction, pressing escape should close the
|
||||
// new transaction form
|
||||
let input = expectToBeEditingField(container, 'date', 0, true);
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
await userEvent.type(input, '[Tab]');
|
||||
input = expectToBeEditingField(container, 'account', 0, true);
|
||||
// The first escape closes the dropdown
|
||||
fireEvent.keyDown(input, keys.ESC);
|
||||
await userEvent.type(input, '[Escape]');
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-transaction"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
// TOOD: Fix this
|
||||
// TODO: Fix this
|
||||
// Now it should close the new transaction form
|
||||
// fireEvent.keyDown(input, keys.ESC);
|
||||
// await userEvent.type(input, '[Escape]');
|
||||
// expect(
|
||||
// container.querySelector('[data-testid="new-transaction"]')
|
||||
// ).toBeNull();
|
||||
@@ -819,16 +742,16 @@ describe('Transactions', () => {
|
||||
let cancelButton = container.querySelectorAll(
|
||||
'[data-testid="new-transaction"] [data-testid="cancel-button"]',
|
||||
)[0];
|
||||
fireEvent.click(cancelButton);
|
||||
await userEvent.click(cancelButton);
|
||||
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('transaction can be selected', () => {
|
||||
test('transaction can be selected', async () => {
|
||||
const { container } = renderTransactions();
|
||||
|
||||
editField(container, 'date', 2);
|
||||
await editField(container, 'date', 2);
|
||||
const selectCell = queryField(
|
||||
container,
|
||||
'select',
|
||||
@@ -836,7 +759,7 @@ describe('Transactions', () => {
|
||||
2,
|
||||
);
|
||||
|
||||
fireEvent.click(selectCell);
|
||||
await userEvent.click(selectCell);
|
||||
// The header is is selected as well as the single transaction
|
||||
expect(container.querySelectorAll('[data-testid=select] svg').length).toBe(
|
||||
2,
|
||||
@@ -869,7 +792,7 @@ describe('Transactions', () => {
|
||||
});
|
||||
}
|
||||
|
||||
let input = editField(container, 'category', 0);
|
||||
let input = await editField(container, 'category', 0);
|
||||
let tooltip = container.querySelector('[data-testid="tooltip"]');
|
||||
let splitButton = tooltip.querySelector(
|
||||
'[data-testid="split-transaction-button"]',
|
||||
@@ -881,7 +804,7 @@ describe('Transactions', () => {
|
||||
|
||||
// Make sure splitting a transaction works
|
||||
expect(getTransactions().length).toBe(5);
|
||||
fireEvent.click(splitButton);
|
||||
await userEvent.click(splitButton);
|
||||
await waitForAutocomplete();
|
||||
expect(getTransactions().length).toBe(6);
|
||||
expect(getTransactions()[0].is_parent).toBe(true);
|
||||
@@ -898,31 +821,71 @@ describe('Transactions', () => {
|
||||
|
||||
// Enter an amount for the new split transaction and make sure the
|
||||
// toolbar updates
|
||||
input = editField(container, 'debit', 1);
|
||||
fireEvent.change(input, { target: { value: '10.00' } });
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
input = await editField(container, 'debit', 1);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '10.00[tab]');
|
||||
expect(toolbar.innerHTML.includes('17.77')).toBeTruthy();
|
||||
|
||||
// Add another split transaction and make sure everything is
|
||||
// updated properly
|
||||
fireEvent.click(toolbar.querySelector('button'));
|
||||
await userEvent.click(toolbar.querySelector('button'));
|
||||
expect(getTransactions().length).toBe(7);
|
||||
expect(getTransactions()[2].amount).toBe(0);
|
||||
expectErrorToExist(getTransactions().slice(0, 3));
|
||||
|
||||
// Change the amount to resolve the whole transaction. The toolbar
|
||||
// should disappear and no error should exist
|
||||
input = editField(container, 'debit', 2);
|
||||
fireEvent.change(input, { target: { value: '17.77' } });
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid="transaction-error"]').length,
|
||||
).toBe(0);
|
||||
input = await editField(container, 'debit', 2);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '17.77[tab]');
|
||||
await userEvent.tab();
|
||||
expect(screen.queryAllByTestId('transaction-error')).toHaveLength(0);
|
||||
expectErrorToNotExist(getTransactions().slice(0, 3));
|
||||
|
||||
// This snapshot makes sure the data is as we expect. It also
|
||||
// shows the sort order and makes sure that is correct
|
||||
expect(getTransactions().slice(0, 3)).toMatchSnapshot();
|
||||
const parentId = getTransactions()[0].id;
|
||||
expect(getTransactions().slice(0, 3)).toEqual([
|
||||
{
|
||||
account: accounts[0].id,
|
||||
amount: -2777,
|
||||
category: null,
|
||||
cleared: false,
|
||||
date: '2017-01-01',
|
||||
error: null,
|
||||
id: expect.any(String),
|
||||
is_parent: true,
|
||||
notes: 'Notes',
|
||||
payee: 'payed-to',
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
account: accounts[0].id,
|
||||
amount: -1000,
|
||||
cleared: false,
|
||||
date: '2017-01-01',
|
||||
error: null,
|
||||
id: expect.any(String),
|
||||
is_child: true,
|
||||
parent_id: parentId,
|
||||
payee: 'payed-to',
|
||||
sort_order: -1,
|
||||
starting_balance_flag: null,
|
||||
},
|
||||
{
|
||||
account: accounts[0].id,
|
||||
amount: -1777,
|
||||
cleared: false,
|
||||
date: '2017-01-01',
|
||||
error: null,
|
||||
id: expect.any(String),
|
||||
is_child: true,
|
||||
parent_id: parentId,
|
||||
payee: 'payed-to',
|
||||
sort_order: -2,
|
||||
starting_balance_flag: null,
|
||||
},
|
||||
]);
|
||||
|
||||
// Make sure deleting a split transaction updates the state again,
|
||||
// and deleting all split transactions turns it into a normal
|
||||
@@ -932,20 +895,20 @@ describe('Transactions', () => {
|
||||
// yet because it doesn't do any batch editing
|
||||
//
|
||||
// const deleteCell = queryField(container, 'delete', '', 2);
|
||||
// fireEvent.click(deleteCell);
|
||||
// await userEvent.click(deleteCell);
|
||||
// expect(getTransactions().length).toBe(6);
|
||||
// toolbar = container.querySelector('[data-testid="transaction-error"]');
|
||||
// expect(toolbar).toBeTruthy();
|
||||
// expect(toolbar.innerHTML.includes('17.77')).toBeTruthy();
|
||||
|
||||
// fireEvent.click(queryField(container, 'delete', '', 1));
|
||||
// await userEvent.click(queryField(container, 'delete', '', 1));
|
||||
// expect(getTransactions()[0].isParent).toBe(false);
|
||||
});
|
||||
|
||||
test('transaction with splits shows 0 in correct column', async () => {
|
||||
const { container, getTransactions } = renderTransactions();
|
||||
|
||||
let input = editField(container, 'category', 0);
|
||||
let input = await editField(container, 'category', 0);
|
||||
let tooltip = container.querySelector('[data-testid="tooltip"]');
|
||||
let splitButton = tooltip.querySelector(
|
||||
'[data-testid="split-transaction-button"',
|
||||
@@ -956,9 +919,9 @@ describe('Transactions', () => {
|
||||
|
||||
// Add two new split transactions
|
||||
expect(getTransactions().length).toBe(5);
|
||||
fireEvent.click(splitButton);
|
||||
await userEvent.click(splitButton);
|
||||
await waitForAutocomplete();
|
||||
fireEvent.click(
|
||||
await userEvent.click(
|
||||
container.querySelector('[data-testid="transaction-error"] button'),
|
||||
);
|
||||
expect(getTransactions().length).toBe(7);
|
||||
@@ -970,9 +933,8 @@ describe('Transactions', () => {
|
||||
expect(queryField(container, 'credit', '', 2).textContent).toBe('');
|
||||
|
||||
// Change it to a credit transaction
|
||||
input = editField(container, 'credit', 0);
|
||||
fireEvent.change(input, { target: { value: '55.00' } });
|
||||
fireEvent.keyDown(input, keys.TAB);
|
||||
input = await editField(container, 'credit', 0);
|
||||
await userEvent.type(input, '55.00{Tab}');
|
||||
|
||||
// The zeros should now display in the credit column
|
||||
expect(queryField(container, 'debit', '', 1).textContent).toBe('');
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Transactions adding a new split transaction works 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"account": "testing-uuid-975046",
|
||||
"amount": -5500,
|
||||
"cleared": false,
|
||||
"date": "2017-01-01",
|
||||
"error": null,
|
||||
"id": "testing-uuid-759284",
|
||||
"is_parent": true,
|
||||
},
|
||||
Object {
|
||||
"account": "testing-uuid-975046",
|
||||
"amount": -4500,
|
||||
"cleared": false,
|
||||
"date": "2017-01-01",
|
||||
"error": null,
|
||||
"id": "testing-uuid-57883",
|
||||
"is_child": true,
|
||||
"parent_id": "testing-uuid-759284",
|
||||
"payee": undefined,
|
||||
"sort_order": -1,
|
||||
"starting_balance_flag": null,
|
||||
},
|
||||
Object {
|
||||
"account": "testing-uuid-975046",
|
||||
"amount": -1000,
|
||||
"cleared": false,
|
||||
"date": "2017-01-01",
|
||||
"error": null,
|
||||
"id": "testing-uuid-661157",
|
||||
"is_child": true,
|
||||
"parent_id": "testing-uuid-759284",
|
||||
"payee": undefined,
|
||||
"sort_order": -2,
|
||||
"starting_balance_flag": null,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`Transactions transaction can be split, updated, and deleted 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"account": "testing-uuid-975046",
|
||||
"amount": -2777,
|
||||
"category": null,
|
||||
"cleared": false,
|
||||
"date": "2017-01-01",
|
||||
"error": null,
|
||||
"id": "testing-uuid-795958",
|
||||
"is_parent": true,
|
||||
"notes": "Notes",
|
||||
"payee": "guy",
|
||||
"sort_order": 0,
|
||||
},
|
||||
Object {
|
||||
"account": "testing-uuid-975046",
|
||||
"amount": -1000,
|
||||
"cleared": false,
|
||||
"date": "2017-01-01",
|
||||
"error": null,
|
||||
"id": "testing-uuid-216379",
|
||||
"is_child": true,
|
||||
"parent_id": "testing-uuid-795958",
|
||||
"payee": "guy",
|
||||
"sort_order": -1,
|
||||
"starting_balance_flag": null,
|
||||
},
|
||||
Object {
|
||||
"account": "testing-uuid-975046",
|
||||
"amount": -1777,
|
||||
"cleared": false,
|
||||
"date": "2017-01-01",
|
||||
"error": null,
|
||||
"id": "testing-uuid-482499",
|
||||
"is_child": true,
|
||||
"parent_id": "testing-uuid-795958",
|
||||
"payee": "guy",
|
||||
"sort_order": -2,
|
||||
"starting_balance_flag": null,
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import ExclamationOutline from '../icons/v1/ExclamationOutline';
|
||||
import InformationOutline from '../icons/v1/InformationOutline';
|
||||
import { styles, colors } from '../style';
|
||||
import ExclamationOutline from '../svg/v1/ExclamationOutline';
|
||||
import InformationOutline from '../svg/v1/InformationOutline';
|
||||
|
||||
import { View, Text } from './common';
|
||||
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
|
||||
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
|
||||
|
||||
import { colors } from '../style';
|
||||
import { colors } from '../../style';
|
||||
import { View } from '../common';
|
||||
|
||||
import Autocomplete from './Autocomplete';
|
||||
import { View } from './common';
|
||||
|
||||
export function AccountList({
|
||||
items,
|
||||
@@ -4,10 +4,9 @@ import lively from '@jlongster/lively';
|
||||
import Downshift from 'downshift';
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { colors } from '../style';
|
||||
import Remove from '../svg/v2/Remove';
|
||||
|
||||
import { View, Input, Tooltip, Button } from './common';
|
||||
import Remove from '../../icons/v2/Remove';
|
||||
import { colors } from '../../style';
|
||||
import { View, Input, Tooltip, Button } from '../common';
|
||||
|
||||
function findItem(strict, suggestions, value) {
|
||||
if (strict) {
|
||||
@@ -328,14 +327,12 @@ function onKeyDown(
|
||||
},
|
||||
e,
|
||||
) {
|
||||
let ENTER = 13;
|
||||
let ESC = 27;
|
||||
let { onKeyDown } = inputProps || {};
|
||||
|
||||
// If the dropdown is open, an item is highlighted, and the user
|
||||
// pressed enter, always capture that and handle it ourselves
|
||||
if (isOpen) {
|
||||
if (e.keyCode === ENTER) {
|
||||
if (e.code === 'Enter') {
|
||||
if (highlightedIndex != null) {
|
||||
if (inst.lastChangeType === Downshift.stateChangeTypes.itemMouseEnter) {
|
||||
// If the last thing the user did was hover an item, intentionally
|
||||
@@ -365,7 +362,7 @@ function onKeyDown(
|
||||
}
|
||||
|
||||
// Handle escape ourselves
|
||||
if (e.keyCode === ESC) {
|
||||
if (e.code === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
||||
if (!embedded) {
|
||||
@@ -414,8 +411,7 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) {
|
||||
}
|
||||
|
||||
function defaultShouldSaveFromKey(e) {
|
||||
// Enter
|
||||
return e.keyCode === 13;
|
||||
return e.code === 'Enter';
|
||||
}
|
||||
|
||||
function onFocus({ inst, props: { inputProps = {}, openOnFocus = true } }, e) {
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { components as SelectComponents } from 'react-select';
|
||||
|
||||
import Split from '../../icons/v0/Split';
|
||||
import { colors } from '../../style';
|
||||
import { View } from '../common';
|
||||
|
||||
import Autocomplete from './NewAutocomplete';
|
||||
|
||||
const SPLIT_TRANSACTION_KEY = 'split';
|
||||
|
||||
export default function CategoryAutocomplete({
|
||||
value,
|
||||
categoryGroups,
|
||||
showSplitOption = false,
|
||||
multi = false,
|
||||
onSplit,
|
||||
...props
|
||||
}) {
|
||||
const options = useMemo(() => {
|
||||
const suggestions = categoryGroups.map(group => ({
|
||||
label: group.name,
|
||||
options: group.categories.map(categ => ({
|
||||
value: categ.id,
|
||||
label: categ.name,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (showSplitOption) {
|
||||
suggestions.unshift({
|
||||
value: SPLIT_TRANSACTION_KEY,
|
||||
label: SPLIT_TRANSACTION_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}, [categoryGroups, showSplitOption]);
|
||||
|
||||
const allOptions = useMemo(
|
||||
() =>
|
||||
options.reduce(
|
||||
(carry, { options }) => [...carry, ...(options || [])],
|
||||
[],
|
||||
),
|
||||
[options],
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
options={options}
|
||||
value={
|
||||
multi
|
||||
? allOptions.filter(item => value.includes(item.value))
|
||||
: allOptions.find(item => item.value === value)
|
||||
}
|
||||
isMulti={multi}
|
||||
components={{
|
||||
Option,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Option(props) {
|
||||
if (props.value === SPLIT_TRANSACTION_KEY) {
|
||||
return (
|
||||
<SelectComponents.Option {...props}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
fontSize: 11,
|
||||
color: colors.g8,
|
||||
marginLeft: -12,
|
||||
padding: '4px 0',
|
||||
}}
|
||||
data-testid="split-transaction-button"
|
||||
>
|
||||
<Split
|
||||
width={10}
|
||||
height={10}
|
||||
style={{ marginRight: 5, color: 'inherit' }}
|
||||
/>
|
||||
Split Transaction
|
||||
</View>
|
||||
</SelectComponents.Option>
|
||||
);
|
||||
}
|
||||
return <SelectComponents.Option {...props} />;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { colors } from '../style';
|
||||
import Split from '../svg/v0/Split';
|
||||
import Split from '../../icons/v0/Split';
|
||||
import { colors } from '../../style';
|
||||
import { View, Text, Select } from '../common';
|
||||
|
||||
import Autocomplete, { defaultFilterSuggestion } from './Autocomplete';
|
||||
import { View, Text, Select } from './common';
|
||||
|
||||
export const NativeCategorySelect = React.forwardRef(
|
||||
({ categoryGroups, emptyLabel, ...nativeProps }, ref) => {
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
|
||||
|
||||
import Autocomplete from './NewAutocomplete';
|
||||
|
||||
export default function AccountAutocomplete({
|
||||
value,
|
||||
includeClosedAccounts = true,
|
||||
multi = false,
|
||||
...props
|
||||
}) {
|
||||
const accounts = useCachedAccounts() || [];
|
||||
|
||||
const availableAccounts = useMemo(
|
||||
() =>
|
||||
includeClosedAccounts ? accounts : accounts.filter(item => !item.closed),
|
||||
[accounts, includeClosedAccounts],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'For Budget',
|
||||
options: availableAccounts
|
||||
.filter(item => !item.offbudget)
|
||||
.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Off Budget',
|
||||
options: availableAccounts
|
||||
.filter(item => item.offbudget)
|
||||
.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
],
|
||||
[availableAccounts],
|
||||
);
|
||||
|
||||
const allOptions = useMemo(
|
||||
() => options.reduce((carry, { options }) => [...carry, ...options], []),
|
||||
[options],
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
options={options}
|
||||
value={
|
||||
multi
|
||||
? allOptions.filter(item => value.includes(item.value))
|
||||
: allOptions.find(item => item.value === value)
|
||||
}
|
||||
isMulti={multi}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import Select from 'react-select';
|
||||
import type {
|
||||
GroupBase,
|
||||
Props as SelectProps,
|
||||
PropsValue,
|
||||
SingleValue,
|
||||
SelectInstance,
|
||||
} from 'react-select';
|
||||
|
||||
import type { CreatableProps } from 'react-select/creatable';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
|
||||
import { NullComponent } from '../common';
|
||||
|
||||
import styles from './autocomplete-styles';
|
||||
|
||||
type OptionValue = {
|
||||
__isNew__?: boolean;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface BaseAutocompleteProps {
|
||||
focused?: boolean;
|
||||
embedded?: boolean;
|
||||
onSelect: (value: string | string[]) => void;
|
||||
onCreateOption?: (value: string) => void;
|
||||
isCreatable?: boolean;
|
||||
}
|
||||
|
||||
type SimpleAutocompleteProps = BaseAutocompleteProps & SelectProps<OptionValue>;
|
||||
type CreatableAutocompleteProps = BaseAutocompleteProps &
|
||||
CreatableProps<OptionValue, true, GroupBase<OptionValue>> & {
|
||||
isCreatable: true;
|
||||
};
|
||||
|
||||
type AutocompleteProps = SimpleAutocompleteProps | CreatableAutocompleteProps;
|
||||
|
||||
const isSingleValue = (
|
||||
value: PropsValue<OptionValue>,
|
||||
): value is SingleValue<OptionValue> => {
|
||||
return !Array.isArray(value);
|
||||
};
|
||||
|
||||
const Autocomplete = React.forwardRef<SelectInstance, AutocompleteProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
options = [],
|
||||
focused = false,
|
||||
embedded = false,
|
||||
onSelect,
|
||||
onCreateOption,
|
||||
isCreatable = false,
|
||||
components = {},
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [initialValue] = useState(value);
|
||||
const [isOpen, setIsOpen] = useState(focused || embedded);
|
||||
|
||||
const [inputValue, setInputValue] = useState<
|
||||
AutocompleteProps['inputValue']
|
||||
>(() => (isSingleValue(value) ? value?.label : undefined));
|
||||
const [isInitialInputValue, setInitialInputValue] = useState(true);
|
||||
|
||||
const onInputChange: AutocompleteProps['onInputChange'] = value => {
|
||||
setInputValue(value);
|
||||
setInitialInputValue(false);
|
||||
};
|
||||
|
||||
const filterOption: AutocompleteProps['filterOption'] = (option, input) => {
|
||||
if (isInitialInputValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
option.data?.__isNew__ ||
|
||||
option.label.toLowerCase().includes(input?.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const onChange: AutocompleteProps['onChange'] = (
|
||||
selected: PropsValue<OptionValue>,
|
||||
) => {
|
||||
// Clear button clicked
|
||||
if (!selected) {
|
||||
onSelect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new option
|
||||
if (isSingleValue(selected) && selected.__isNew__) {
|
||||
onCreateOption(selected.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the menu when making a successful selection
|
||||
if (isSingleValue(selected)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
// Multi-select has multiple selections
|
||||
if (!isSingleValue(selected)) {
|
||||
onSelect(selected.map(option => option.value));
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(selected.value);
|
||||
};
|
||||
|
||||
const onKeyDown: AutocompleteProps['onKeyDown'] = event => {
|
||||
if (event.code === 'Escape') {
|
||||
onSelect(
|
||||
isSingleValue(initialValue)
|
||||
? initialValue?.value
|
||||
: initialValue.map(val => val.value),
|
||||
);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const Component = isCreatable ? CreatableSelect : Select;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
value={value}
|
||||
menuIsOpen={isOpen}
|
||||
autoFocus={embedded}
|
||||
options={options}
|
||||
placeholder="(none)"
|
||||
captureMenuScroll={false}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onCreateOption={onCreateOption}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
isClearable
|
||||
filterOption={filterOption}
|
||||
components={{
|
||||
IndicatorSeparator: NullComponent,
|
||||
DropdownIndicator: NullComponent,
|
||||
...components,
|
||||
}}
|
||||
maxMenuHeight={200}
|
||||
styles={styles}
|
||||
data-embedded={embedded}
|
||||
menuPlacement="auto"
|
||||
menuPortalTarget={embedded ? undefined : document.body}
|
||||
inputValue={inputValue}
|
||||
onInputChange={onInputChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Autocomplete;
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { components as SelectComponents } from 'react-select';
|
||||
|
||||
import { createPayee } from 'loot-core/src/client/actions/queries';
|
||||
import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
|
||||
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
|
||||
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
|
||||
|
||||
import Add from '../../icons/v1/Add';
|
||||
import { colors } from '../../style';
|
||||
import { View } from '../common';
|
||||
|
||||
import { AutocompleteFooter, AutocompleteFooterButton } from './Autocomplete';
|
||||
import Autocomplete from './NewAutocomplete';
|
||||
|
||||
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
|
||||
let activePayees =
|
||||
(accounts ? getActivePayees(payees, accounts) : payees) || [];
|
||||
|
||||
function formatOptions(options) {
|
||||
return options.map(row => ({
|
||||
value: row.id,
|
||||
label: row.name,
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
...(focusTransferPayees
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: 'Payees',
|
||||
options: formatOptions(activePayees.filter(p => !p.transfer_acct)),
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'Transfer To/From',
|
||||
options: formatOptions(activePayees.filter(p => p.transfer_acct)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function MenuListWithFooter(props) {
|
||||
return (
|
||||
<>
|
||||
<SelectComponents.MenuList {...props} />
|
||||
{props.selectProps.footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayeeAutocomplete({
|
||||
value,
|
||||
multi = false,
|
||||
showMakeTransfer = true,
|
||||
showManagePayees = false,
|
||||
defaultFocusTransferPayees = false,
|
||||
onSelect,
|
||||
onManagePayees,
|
||||
...props
|
||||
}) {
|
||||
const payees = useCachedPayees();
|
||||
const accounts = useCachedAccounts();
|
||||
|
||||
const [focusTransferPayees, setFocusTransferPayees] = useState(
|
||||
defaultFocusTransferPayees,
|
||||
);
|
||||
const options = useMemo(
|
||||
() => getPayeeSuggestions(payees, focusTransferPayees, accounts),
|
||||
[payees, focusTransferPayees, accounts],
|
||||
);
|
||||
const allOptions = useMemo(
|
||||
() => options.reduce((carry, { options }) => [...carry, ...options], []),
|
||||
[options],
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
options={options}
|
||||
value={
|
||||
multi
|
||||
? allOptions.filter(item => value.includes(item.value))
|
||||
: allOptions.find(item => item.value === value)
|
||||
}
|
||||
isValidNewOption={input => input && !focusTransferPayees}
|
||||
isMulti={multi}
|
||||
onSelect={onSelect}
|
||||
onCreateOption={async selectedValue => {
|
||||
const existingOption = allOptions.find(option =>
|
||||
option.label.toLowerCase().includes(selectedValue?.toLowerCase()),
|
||||
);
|
||||
|
||||
// Prevent creating duplicates
|
||||
if (existingOption) {
|
||||
onSelect(existingOption.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// This is actually a new option, so create it
|
||||
onSelect(await dispatch(createPayee(selectedValue)));
|
||||
}}
|
||||
createOptionPosition="first"
|
||||
formatCreateLabel={inputValue => (
|
||||
<View
|
||||
style={{
|
||||
display: 'block',
|
||||
color: colors.g8,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
marginLeft: -10,
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<Add
|
||||
width={8}
|
||||
height={8}
|
||||
style={{
|
||||
color: colors.g8,
|
||||
marginRight: 5,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
Create Payee “{inputValue}”
|
||||
</View>
|
||||
)}
|
||||
components={{
|
||||
MenuList: MenuListWithFooter,
|
||||
}}
|
||||
minMenuHeight={300}
|
||||
footer={
|
||||
<AutocompleteFooter show={showMakeTransfer || showManagePayees}>
|
||||
{showMakeTransfer && (
|
||||
<AutocompleteFooterButton
|
||||
title="Make Transfer"
|
||||
style={[
|
||||
showManagePayees && { marginBottom: 5 },
|
||||
focusTransferPayees && {
|
||||
backgroundColor: colors.y8,
|
||||
color: colors.g2,
|
||||
borderColor: colors.y8,
|
||||
},
|
||||
]}
|
||||
hoveredStyle={
|
||||
focusTransferPayees && {
|
||||
backgroundColor: colors.y8,
|
||||
colors: colors.y2,
|
||||
}
|
||||
}
|
||||
onClick={() => {
|
||||
setFocusTransferPayees(!focusTransferPayees);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showManagePayees && (
|
||||
<AutocompleteFooterButton
|
||||
title="Manage Payees"
|
||||
onClick={onManagePayees}
|
||||
/>
|
||||
)}
|
||||
</AutocompleteFooter>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,15 @@ import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
|
||||
import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
|
||||
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
|
||||
|
||||
import { colors } from '../style';
|
||||
import Add from '../svg/v1/Add';
|
||||
import Add from '../../icons/v1/Add';
|
||||
import { colors } from '../../style';
|
||||
import { View } from '../common';
|
||||
|
||||
import Autocomplete, {
|
||||
defaultFilterSuggestion,
|
||||
AutocompleteFooter,
|
||||
AutocompleteFooterButton,
|
||||
} from './Autocomplete';
|
||||
import { View } from './common';
|
||||
|
||||
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
|
||||
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
|
||||
@@ -103,7 +103,7 @@ export function PayeeList({
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
Create Payee "{inputValue}"
|
||||
Create Payee “{inputValue}”
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { styles as actualStyles, colors } from '../../style';
|
||||
|
||||
const colourStyles = {
|
||||
...actualStyles.lightScrollbar,
|
||||
control: styles => ({
|
||||
...styles,
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid rgb(208, 208, 208)',
|
||||
borderRadius: 4,
|
||||
outline: 0,
|
||||
marginLeft: -1,
|
||||
marginRight: 1,
|
||||
padding: '5px 2px',
|
||||
fontSize: '13px',
|
||||
minHeight: 'auto',
|
||||
}),
|
||||
input: styles => ({
|
||||
...styles,
|
||||
padding: '0 2px',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
menuPortal: styles => ({
|
||||
...styles,
|
||||
zIndex: 5000,
|
||||
minWidth: 200,
|
||||
}),
|
||||
menu: (styles, { selectProps }) => ({
|
||||
...styles,
|
||||
minWidth: 200,
|
||||
backgroundColor: colors.n1,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
position: selectProps['data-embedded'] ? 'relative' : styles.position,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
menuList: styles => ({
|
||||
...styles,
|
||||
padding: 0,
|
||||
|
||||
// Custom scrollbar styling
|
||||
...Object.entries(actualStyles.lightScrollbar).reduce(
|
||||
(carry, [key, value]) => ({
|
||||
...carry,
|
||||
[key.replace('& ', '')]: value,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
}),
|
||||
group: styles => ({
|
||||
...styles,
|
||||
padding: '5px 0 0',
|
||||
}),
|
||||
groupHeading: styles => ({
|
||||
...styles,
|
||||
color: colors.y9,
|
||||
textTransform: 'none',
|
||||
paddingLeft: '9px',
|
||||
fontSize: '100%',
|
||||
fontWeight: 'normal',
|
||||
}),
|
||||
option: (styles, { isFocused }) => ({
|
||||
...styles,
|
||||
backgroundColor: isFocused ? colors.n5 : undefined,
|
||||
color: 'white',
|
||||
padding: '3px 20px',
|
||||
fontSize: 13,
|
||||
}),
|
||||
valueContainer: (styles, { isMulti, selectProps }) => ({
|
||||
...styles,
|
||||
padding: 'none',
|
||||
overflow: 'visible',
|
||||
marginTop: isMulti && selectProps.value?.length ? -4 : undefined,
|
||||
marginBottom: isMulti && selectProps.value?.length ? -4 : undefined,
|
||||
}),
|
||||
clearIndicator: styles => ({
|
||||
...styles,
|
||||
padding: 'none',
|
||||
'> svg': { height: 15, width: 15 },
|
||||
}),
|
||||
multiValue: styles => ({
|
||||
...styles,
|
||||
backgroundColor: colors.b9,
|
||||
}),
|
||||
};
|
||||
|
||||
export default colourStyles;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import ArrowThinRight from '../../svg/v1/ArrowThinRight';
|
||||
import ArrowThinRight from '../../icons/v1/ArrowThinRight';
|
||||
import { View } from '../common';
|
||||
import CellValue from '../spreadsheet/CellValue';
|
||||
import useSheetValue from '../spreadsheet/useSheetValue';
|
||||
@@ -11,8 +11,8 @@ import { Spring } from 'wobble';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import useResizeObserver from '../../hooks/useResizeObserver';
|
||||
import { View } from '../common';
|
||||
import useResizeObserver from '../useResizeObserver';
|
||||
|
||||
import { MonthsContext } from './MonthsContext';
|
||||
|
||||
@@ -4,10 +4,9 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { View } from '../common';
|
||||
|
||||
import { useBudgetMonthCount } from './BudgetMonthCountContext';
|
||||
import { BudgetPageHeader, BudgetTable } from './misc';
|
||||
import { CategoryGroupsContext } from './util';
|
||||
|
||||
import { BudgetPageHeader, BudgetTable } from './index';
|
||||
|
||||
function getNumPossibleMonths(width) {
|
||||
let estimatedTableWidth = width - 200;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
import {
|
||||
addCategory,
|
||||
moveCategory,
|
||||
moveCategoryGroup,
|
||||
} from 'loot-core/src/shared/categories.js';
|
||||
} from 'loot-core/src/shared/categories';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import SpreadsheetContext from 'loot-design/src/components/spreadsheet/SpreadsheetContext';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import AnimatedLoading from 'loot-design/src/svg/AnimatedLoading';
|
||||
import { withThemeColor } from 'loot-design/src/util/withThemeColor';
|
||||
|
||||
import AnimatedLoading from '../../icons/AnimatedLoading';
|
||||
import { colors } from '../../style';
|
||||
import { withThemeColor } from '../../util/withThemeColor';
|
||||
import { View } from '../common';
|
||||
import SyncRefresh from '../SyncRefresh';
|
||||
|
||||
import { BudgetTable } from './MobileBudgetTable';
|
||||
@@ -192,7 +192,7 @@ class Budget extends React.Component {
|
||||
|
||||
let options = [
|
||||
'Edit Categories',
|
||||
"Copy last month's budget",
|
||||
'Copy last month’s budget',
|
||||
'Set budgets to zero',
|
||||
'Set budgets to 3 month average',
|
||||
budgetType === 'report' && 'Apply to all future budgets',
|
||||
@@ -241,6 +241,7 @@ class Budget extends React.Component {
|
||||
applyBudgetAction,
|
||||
} = this.props;
|
||||
let numberFormat = prefs.numberFormat || 'comma-dot';
|
||||
let hideFraction = prefs.hideFraction || false;
|
||||
|
||||
if (!categoryGroups || !initialized) {
|
||||
return (
|
||||
@@ -264,7 +265,7 @@ class Budget extends React.Component {
|
||||
<BudgetTable
|
||||
// This key forces the whole table rerender when the number
|
||||
// format changes
|
||||
key={numberFormat}
|
||||
key={numberFormat + hideFraction}
|
||||
categories={categories}
|
||||
categoryGroups={categoryGroups}
|
||||
type={budgetType}
|
||||
@@ -292,7 +293,7 @@ class Budget extends React.Component {
|
||||
}
|
||||
|
||||
function BudgetWrapper(props) {
|
||||
let spreadsheet = useContext(SpreadsheetContext);
|
||||
let spreadsheet = useSpreadsheet();
|
||||
return <Budget {...props} spreadsheet={spreadsheet} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,32 +14,25 @@ import * as actions from 'loot-core/src/client/actions';
|
||||
import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { amountToInteger, integerToAmount } from 'loot-core/src/shared/util';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Label,
|
||||
Text,
|
||||
View,
|
||||
} from 'loot-design/src/components/common';
|
||||
import CellValue from 'loot-design/src/components/spreadsheet/CellValue';
|
||||
import format from 'loot-design/src/components/spreadsheet/format';
|
||||
import NamespaceContext from 'loot-design/src/components/spreadsheet/NamespaceContext';
|
||||
import SheetValue from 'loot-design/src/components/spreadsheet/SheetValue';
|
||||
import useSheetValue from 'loot-design/src/components/spreadsheet/useSheetValue';
|
||||
import { colors, styles } from 'loot-design/src/style';
|
||||
import Add from 'loot-design/src/svg/v1/Add';
|
||||
import ArrowThinLeft from 'loot-design/src/svg/v1/ArrowThinLeft';
|
||||
import ArrowThinRight from 'loot-design/src/svg/v1/ArrowThinRight';
|
||||
|
||||
import Add from '../../icons/v1/Add';
|
||||
import ArrowThinLeft from '../../icons/v1/ArrowThinLeft';
|
||||
import ArrowThinRight from '../../icons/v1/ArrowThinRight';
|
||||
import { colors, styles } from '../../style';
|
||||
import { Button, Card, Label, Text, View } from '../common';
|
||||
import CellValue from '../spreadsheet/CellValue';
|
||||
import format from '../spreadsheet/format';
|
||||
import NamespaceContext from '../spreadsheet/NamespaceContext';
|
||||
import SheetValue from '../spreadsheet/SheetValue';
|
||||
import useSheetValue from '../spreadsheet/useSheetValue';
|
||||
import { SyncButton } from '../Titlebar';
|
||||
import { AmountInput } from '../util/AmountInput';
|
||||
// import {
|
||||
// AmountAccessoryContext,
|
||||
// MathOperations
|
||||
// } from 'loot-design/src/components/mobile/AmountInput';
|
||||
// } from '../mobile/AmountInput';
|
||||
|
||||
// import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop';
|
||||
|
||||
import { SyncButton } from '../Titlebar';
|
||||
import { AmountInput } from '../util/AmountInput';
|
||||
|
||||
import { ListItem, ROW_HEIGHT } from './MobileTable';
|
||||
|
||||
export function ToBudget({ toBudget, onClick }) {
|
||||
@@ -1086,6 +1079,7 @@ function UnconnectedBudgetHeader({
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* eslint-disable-next-line rulesdir/typography */}
|
||||
{monthUtils.format(currentMonth, "MMMM ''yy")}
|
||||
</Text>
|
||||
{editMode ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import { colors } from '../../style';
|
||||
import { View } from '../common';
|
||||
|
||||
export const ROW_HEIGHT = 50;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useBudgetMonthCount } from 'loot-design/src/components/budget/BudgetMonthCountContext';
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import { colors } from 'loot-design/src/style';
|
||||
import CalendarIcon from 'loot-design/src/svg/v2/Calendar';
|
||||
import CalendarIcon from '../../icons/v2/Calendar';
|
||||
import { colors } from '../../style';
|
||||
import { View } from '../common';
|
||||
|
||||
import { useBudgetMonthCount } from './BudgetMonthCountContext';
|
||||
|
||||
function Calendar({ color, onClick }) {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useContext, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
import {
|
||||
addCategory,
|
||||
@@ -12,20 +13,21 @@ import {
|
||||
addGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
} from 'loot-core/src/shared/categories.js';
|
||||
} from 'loot-core/src/shared/categories';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import DynamicBudgetTable from 'loot-design/src/components/budget/DynamicBudgetTable';
|
||||
import { getValidMonthBounds } from 'loot-design/src/components/budget/MonthsContext';
|
||||
import * as report from 'loot-design/src/components/budget/report/components';
|
||||
import { ReportProvider } from 'loot-design/src/components/budget/report/ReportContext';
|
||||
import * as rollover from 'loot-design/src/components/budget/rollover/rollover-components';
|
||||
import { RolloverContext } from 'loot-design/src/components/budget/rollover/RolloverContext';
|
||||
import { View } from 'loot-design/src/components/common';
|
||||
import SpreadsheetContext from 'loot-design/src/components/spreadsheet/SpreadsheetContext';
|
||||
import { styles } from 'loot-design/src/style';
|
||||
|
||||
import useFeatureFlag from '../../hooks/useFeatureFlag';
|
||||
import { styles } from '../../style';
|
||||
import { View } from '../common';
|
||||
import { TitlebarContext } from '../Titlebar';
|
||||
|
||||
import DynamicBudgetTable from './DynamicBudgetTable';
|
||||
import { getValidMonthBounds } from './MonthsContext';
|
||||
import * as report from './report/components';
|
||||
import { ReportProvider } from './report/ReportContext';
|
||||
import * as rollover from './rollover/rollover-components';
|
||||
import { RolloverContext } from './rollover/RolloverContext';
|
||||
|
||||
let _initialBudgetMonth = null;
|
||||
|
||||
class Budget extends React.PureComponent {
|
||||
@@ -495,8 +497,20 @@ class Budget extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const RolloverBudgetSummary = React.memo(props => {
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete');
|
||||
return (
|
||||
<rollover.BudgetSummary
|
||||
{...props}
|
||||
isGoalTemplatesEnabled={isGoalTemplatesEnabled}
|
||||
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function BudgetWrapper(props) {
|
||||
let spreadsheet = useContext(SpreadsheetContext);
|
||||
let spreadsheet = useSpreadsheet();
|
||||
let titlebar = useContext(TitlebarContext);
|
||||
|
||||
let reportComponents = useMemo(
|
||||
@@ -514,7 +528,7 @@ function BudgetWrapper(props) {
|
||||
|
||||
let rolloverComponents = useMemo(
|
||||
() => ({
|
||||
SummaryComponent: rollover.BudgetSummary,
|
||||
SummaryComponent: RolloverBudgetSummary,
|
||||
ExpenseCategoryComponent: rollover.ExpenseCategoryMonth,
|
||||
ExpenseGroupComponent: rollover.ExpenseGroupMonth,
|
||||
IncomeCategoryComponent: rollover.IncomeCategoryMonth,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useContext, useState, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import useResizeObserver from '../../hooks/useResizeObserver';
|
||||
import ExpandArrow from '../../icons/v0/ExpandArrow';
|
||||
import ArrowThinLeft from '../../icons/v1/ArrowThinLeft';
|
||||
import ArrowThinRight from '../../icons/v1/ArrowThinRight';
|
||||
import CheveronDown from '../../icons/v1/CheveronDown';
|
||||
import { styles, colors } from '../../style';
|
||||
import ExpandArrow from '../../svg/v0/ExpandArrow';
|
||||
import ArrowThinLeft from '../../svg/v1/ArrowThinLeft';
|
||||
import ArrowThinRight from '../../svg/v1/ArrowThinRight';
|
||||
import CheveronDown from '../../svg/v1/CheveronDown';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -24,7 +26,6 @@ import {
|
||||
} from '../sort.js';
|
||||
import NamespaceContext from '../spreadsheet/NamespaceContext';
|
||||
import { Row, InputCell, ROW_HEIGHT } from '../table';
|
||||
import useResizeObserver from '../useResizeObserver';
|
||||
|
||||
import BudgetSummaries from './BudgetSummaries';
|
||||
import { INCOME_HEADER_HEIGHT, MONTH_BOX_SHADOW } from './constants';
|
||||
@@ -133,14 +134,11 @@ export class BudgetTable extends React.Component {
|
||||
};
|
||||
|
||||
onKeyDown = e => {
|
||||
const TAB = 9;
|
||||
const ENTER = 13;
|
||||
|
||||
if (!this.state.editing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (e.keyCode === ENTER || e.keyCode === TAB) {
|
||||
if (e.code === 'Enter' || e.code === 'Tab') {
|
||||
e.preventDefault();
|
||||
this.moveVertically(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
@@ -188,6 +186,7 @@ export class BudgetTable extends React.Component {
|
||||
|
||||
return (
|
||||
<View
|
||||
data-testid="budget-table"
|
||||
style={[
|
||||
{ flex: 1 },
|
||||
styles.lightScrollbar && {
|
||||
@@ -311,6 +310,7 @@ export function SidebarCategory({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="category-name"
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -392,8 +392,7 @@ export function SidebarCategory({
|
||||
style,
|
||||
]}
|
||||
onKeyDown={e => {
|
||||
const ENTER = 13;
|
||||
if (e.keyCode === ENTER) {
|
||||
if (e.code === 'Enter') {
|
||||
onEditName(null);
|
||||
e.stopPropagation();
|
||||
}
|
||||
@@ -548,8 +547,7 @@ export function SidebarGroup({
|
||||
},
|
||||
]}
|
||||
onKeyDown={e => {
|
||||
const ENTER = 13;
|
||||
if (e.keyCode === ENTER) {
|
||||
if (e.code === 'Enter') {
|
||||
onEdit(null);
|
||||
e.stopPropagation();
|
||||
}
|
||||
@@ -611,6 +609,7 @@ function RenderMonths({ component: Component, editingIndex, args, style }) {
|
||||
const BudgetTotals = React.memo(function BudgetTotals({ MonthComponent }) {
|
||||
return (
|
||||
<View
|
||||
data-testid="budget-totals"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
flexDirection: 'row',
|
||||
@@ -734,7 +733,11 @@ function ExpenseGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseCategory({
|
||||
const ExpenseCategory = connect(state => ({
|
||||
isNewAutocompleteEnabled: state.prefs.local['flags.newAutocomplete'],
|
||||
}))(ExpenseCategoryInternal);
|
||||
|
||||
function ExpenseCategoryInternal({
|
||||
cat,
|
||||
budgetArray,
|
||||
editingCell,
|
||||
@@ -748,6 +751,7 @@ function ExpenseCategory({
|
||||
onShowActivity,
|
||||
onDragChange,
|
||||
onReorder,
|
||||
isNewAutocompleteEnabled,
|
||||
}) {
|
||||
let dragging = dragState && dragState.item === cat;
|
||||
|
||||
@@ -806,6 +810,7 @@ function ExpenseCategory({
|
||||
onEdit: onEditMonth,
|
||||
onBudgetAction,
|
||||
onShowActivity,
|
||||
isNewAutocompleteEnabled,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -5,10 +5,10 @@ import { css } from 'glamor';
|
||||
import { reportBudget } from 'loot-core/src/client/queries';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import DotsHorizontalTriple from '../../../icons/v1/DotsHorizontalTriple';
|
||||
import ArrowButtonDown1 from '../../../icons/v2/ArrowButtonDown1';
|
||||
import ArrowButtonUp1 from '../../../icons/v2/ArrowButtonUp1';
|
||||
import { colors, styles } from '../../../style';
|
||||
import DotsHorizontalTriple from '../../../svg/v1/DotsHorizontalTriple';
|
||||
import ArrowButtonDown1 from '../../../svg/v2/ArrowButtonDown1';
|
||||
import ArrowButtonUp1 from '../../../svg/v2/ArrowButtonUp1';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -364,7 +364,7 @@ export const BudgetSummary = React.memo(function BudgetSummary({ month }) {
|
||||
onBudgetAction(month, type);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'copy-last', text: "Copy last month's budget" },
|
||||
{ name: 'copy-last', text: 'Copy last month’s budget' },
|
||||
{ name: 'set-zero', text: 'Set budgets to zero' },
|
||||
{
|
||||
name: 'set-3-avg',
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Component from '@reactions/component';
|
||||
import { css } from 'glamor';
|
||||
@@ -7,11 +6,10 @@ import { css } from 'glamor';
|
||||
import { rolloverBudget } from 'loot-core/src/client/queries';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import * as actions from '../../../../../loot-core/src/client/actions';
|
||||
import DotsHorizontalTriple from '../../../icons/v1/DotsHorizontalTriple';
|
||||
import ArrowButtonDown1 from '../../../icons/v2/ArrowButtonDown1';
|
||||
import ArrowButtonUp1 from '../../../icons/v2/ArrowButtonUp1';
|
||||
import { colors, styles } from '../../../style';
|
||||
import DotsHorizontalTriple from '../../../svg/v1/DotsHorizontalTriple';
|
||||
import ArrowButtonDown1 from '../../../svg/v2/ArrowButtonDown1';
|
||||
import ArrowButtonUp1 from '../../../svg/v2/ArrowButtonUp1';
|
||||
import {
|
||||
View,
|
||||
Block,
|
||||
@@ -136,7 +134,13 @@ function TotalsList({ prevMonthName, collapsed }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
|
||||
function ToBudget({
|
||||
month,
|
||||
prevMonthName,
|
||||
collapsed,
|
||||
onBudgetAction,
|
||||
isNewAutocompleteEnabled,
|
||||
}) {
|
||||
return (
|
||||
<SheetValue binding={rolloverBudget.toBudget} initialValue={0}>
|
||||
{node => {
|
||||
@@ -209,7 +213,7 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
|
||||
},
|
||||
{
|
||||
name: 'reset-buffer',
|
||||
text: "Reset next month's buffer",
|
||||
text: 'Reset next month’s buffer',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -233,6 +237,7 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
|
||||
category,
|
||||
});
|
||||
}}
|
||||
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -245,7 +250,11 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetSummaryComponent({ month, localPrefs }) {
|
||||
export function BudgetSummary({
|
||||
month,
|
||||
isGoalTemplatesEnabled,
|
||||
isNewAutocompleteEnabled,
|
||||
}) {
|
||||
let {
|
||||
currentMonth,
|
||||
summaryCollapsed: collapsed,
|
||||
@@ -266,10 +275,9 @@ function BudgetSummaryComponent({ month, localPrefs }) {
|
||||
|
||||
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
|
||||
|
||||
let goalTemplatesEnabled = localPrefs['flags.goalTemplatesEnabled'];
|
||||
|
||||
return (
|
||||
<View
|
||||
data-testid="budget-summary"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
boxShadow: MONTH_BOX_SHADOW,
|
||||
@@ -372,17 +380,17 @@ function BudgetSummaryComponent({ month, localPrefs }) {
|
||||
onBudgetAction(month, type);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'copy-last', text: "Copy last month's budget" },
|
||||
{ name: 'copy-last', text: 'Copy last month’s budget' },
|
||||
{ name: 'set-zero', text: 'Set budgets to zero' },
|
||||
{
|
||||
name: 'set-3-avg',
|
||||
text: 'Set budgets to 3 month avg',
|
||||
},
|
||||
goalTemplatesEnabled && {
|
||||
isGoalTemplatesEnabled && {
|
||||
name: 'apply-goal-template',
|
||||
text: 'Apply budget template',
|
||||
},
|
||||
goalTemplatesEnabled && {
|
||||
isGoalTemplatesEnabled && {
|
||||
name: 'overwrite-goal-template',
|
||||
text: 'Overwrite with budget template',
|
||||
},
|
||||
@@ -409,13 +417,18 @@ function BudgetSummaryComponent({ month, localPrefs }) {
|
||||
prevMonthName={prevMonthName}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TotalsList prevMonthName={prevMonthName} />
|
||||
<View style={{ margin: '23px 0' }}>
|
||||
<ToBudget month={month} onBudgetAction={onBudgetAction} />
|
||||
<ToBudget
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
isNewAutocompleteEnabled={isNewAutocompleteEnabled}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
@@ -423,8 +436,3 @@ function BudgetSummaryComponent({ month, localPrefs }) {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const BudgetSummary = connect(
|
||||
state => ({ localPrefs: state.prefs.local }),
|
||||
actions,
|
||||
)(BudgetSummaryComponent);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user