mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0dfb8afbd | ||
|
|
1d301ac78d | ||
|
|
8875f6d487 | ||
|
|
66bfef28c0 | ||
|
|
f03b8a3a14 | ||
|
|
62ebd0627d | ||
|
|
bb1a4747f5 | ||
|
|
d640859940 | ||
|
|
e660e1e727 | ||
|
|
ad89aea45c | ||
|
|
c73416bdb8 | ||
|
|
6253aaa015 | ||
|
|
0d2d861896 | ||
|
|
0baf4a094a | ||
|
|
353474dacd | ||
|
|
5f38b579fe | ||
|
|
1e2bc29a60 | ||
|
|
fafd162db0 | ||
|
|
ec5e98b934 | ||
|
|
18e3a16299 | ||
|
|
efe4194f9c | ||
|
|
4249a0beb1 | ||
|
|
461132b95e | ||
|
|
19af0b36a2 | ||
|
|
6fc4fc294a | ||
|
|
a6b6295426 | ||
|
|
029dfd4688 | ||
|
|
2587350d1e | ||
|
|
d400ebfda0 | ||
|
|
34c8a73ee5 | ||
|
|
4ecb58cd5c | ||
|
|
1305335f0a | ||
|
|
15b51921b2 | ||
|
|
54f9b712e4 | ||
|
|
5afd76fb45 | ||
|
|
54c8d5b7b8 | ||
|
|
e4e9267c08 | ||
|
|
655b677961 | ||
|
|
8faa7bd68d | ||
|
|
f618055aab | ||
|
|
54fa4bccf6 | ||
|
|
24d4070667 | ||
|
|
7b1c3665d5 | ||
|
|
7d80c3eda6 | ||
|
|
f7aa313aea | ||
|
|
c9576d98e4 | ||
|
|
bcc2abf472 | ||
|
|
933ca3ecca | ||
|
|
acaff825c1 | ||
|
|
f913d99c9f | ||
|
|
539cb0e5cf | ||
|
|
d2185909c3 | ||
|
|
646d0d90a4 | ||
|
|
66f7336be8 | ||
|
|
4cebdb537c | ||
|
|
3d4b0c0e25 | ||
|
|
99cf51c159 | ||
|
|
e6b5782c64 | ||
|
|
977296361c | ||
|
|
2d26cf3ad1 | ||
|
|
759a018346 | ||
|
|
4aab3dec0c | ||
|
|
bd14b51e1c | ||
|
|
2d7e0c3f7a | ||
|
|
c4d3a1ce76 | ||
|
|
db65e83722 | ||
|
|
71908b6fb9 | ||
|
|
851fa8c7f5 | ||
|
|
30684a47d7 | ||
|
|
4b712699a8 | ||
|
|
abc4552a78 | ||
|
|
a69d858328 | ||
|
|
9695043206 | ||
|
|
e036397614 | ||
|
|
43cd6b6347 | ||
|
|
9ee93f74fe | ||
|
|
89c065e401 | ||
|
|
6325a36847 | ||
|
|
76c69a6e70 | ||
|
|
c7f6ca4302 | ||
|
|
36b2d7d090 | ||
|
|
7c80a200d7 | ||
|
|
e8a62f89a1 | ||
|
|
b0c5a9389c | ||
|
|
a1d321d65e | ||
|
|
3ceb2d92ad | ||
|
|
0bcf6ea6f9 | ||
|
|
f8b73355ab | ||
|
|
38d2e69858 | ||
|
|
944c7ff30f | ||
|
|
e9188813fd | ||
|
|
131bb86711 | ||
|
|
eed097d41e | ||
|
|
10559a68b3 | ||
|
|
6e7e98e139 | ||
|
|
b89b74c3fa | ||
|
|
3fb69e1b1c | ||
|
|
19a8f14ad9 | ||
|
|
df63c7e141 | ||
|
|
adb20868cc | ||
|
|
ec3efc7191 | ||
|
|
ba59deae5f | ||
|
|
ad9cd5fc4d | ||
|
|
5dec98e754 | ||
|
|
8597b0a373 | ||
|
|
15e9c0405d | ||
|
|
cd00da76ef | ||
|
|
d7d5820c1c | ||
|
|
82482f4182 | ||
|
|
88fb95b230 | ||
|
|
7e33cda7b2 | ||
|
|
bf05b759aa | ||
|
|
835c1a54f7 | ||
|
|
aa2e837e7e | ||
|
|
98a32432ef | ||
|
|
7abbdcc5bb | ||
|
|
adf205db86 |
14
.devcontainer/devcontainer.json
Normal file
14
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,14 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Actual development",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml",
|
||||
"docker-compose.yml"
|
||||
],
|
||||
// Alternatively:
|
||||
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
|
||||
"service": "actual-development",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"postCreateCommand": "yarn install"
|
||||
}
|
||||
6
.devcontainer/docker-compose.yml
Normal file
6
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
actual-development:
|
||||
volumes:
|
||||
- ..:/workspaces:cached
|
||||
command: /bin/sh -c "while sleep 1000; do :; done"
|
||||
82
.eslintrc.js
82
.eslintrc.js
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable rulesdir/typography */
|
||||
const path = require('path');
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir');
|
||||
@@ -9,13 +10,19 @@ rulesDirPlugin.RULES_DIR = path.join(
|
||||
'rules',
|
||||
);
|
||||
|
||||
const ruleFCMsg =
|
||||
'Type the props argument and let TS infer or use ComponentType for a component prop';
|
||||
|
||||
module.exports = {
|
||||
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
|
||||
extends: ['react-app', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { project: [path.join(__dirname, './tsconfig.json')] },
|
||||
reportUnusedDisableDirectives: true,
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': [
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'none',
|
||||
@@ -24,10 +31,14 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
|
||||
'no-restricted-globals': ['error'].concat(
|
||||
require('confusing-browser-globals').filter(g => g !== 'self'),
|
||||
),
|
||||
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
|
||||
'rulesdir/typography': 'error',
|
||||
|
||||
// https://github.com/eslint/eslint/issues/16954
|
||||
@@ -36,8 +47,15 @@ module.exports = {
|
||||
|
||||
// TODO: re-enable these rules
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
// 'react-hooks/exhaustive-deps': [
|
||||
// 'error',
|
||||
// {
|
||||
// additionalHooks: 'useLiveQuery',
|
||||
// },
|
||||
// ],
|
||||
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
@@ -67,10 +85,70 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
|
||||
selector:
|
||||
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
|
||||
message:
|
||||
'Using default React import is discouraged, please use named exports directly instead.',
|
||||
},
|
||||
],
|
||||
|
||||
// Rules disable during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'off',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['./**/*.js'],
|
||||
parserOptions: { project: null },
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./packages/desktop-client/**/*.{ts,tsx}',
|
||||
'./packages/loot-core/src/client/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
// enforce type over interface
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
// enforce import type
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/ban-types': [
|
||||
'error',
|
||||
{
|
||||
types: {
|
||||
// forbid FC as superflous
|
||||
FunctionComponent: { message: ruleFCMsg },
|
||||
FC: { message: ruleFCMsg },
|
||||
},
|
||||
extendDefaults: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./packages/loot-core/src/**/*'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['loot-core/**'],
|
||||
message:
|
||||
'Please use relative imports in loot-core instead of importing from `loot-core/*`',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -8,6 +8,8 @@
|
||||
|
||||
# Declare files that will always have LF line endings on checkout.
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.sh text eol=lf
|
||||
yarn.lock text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
|
||||
181
.github/actions/handle-feature-requests.js
vendored
Normal file
181
.github/actions/handle-feature-requests.js
vendored
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// overview:
|
||||
// 1. Fetch the issues that are linked to the PR
|
||||
// 2. Filter out the issues that are not feature requests
|
||||
// 3. For each feature request:
|
||||
// 1. Remove the 'help wanted' & 'needs votes' labels
|
||||
// 3. Find the automated comment, hide the comment as 'outdated'
|
||||
// 5. Post a new comment saying that the feature request has been implemented, and will be released in the next version. Link to the PR.
|
||||
|
||||
async function makeAPIRequest(query, variables) {
|
||||
const res = await fetch('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function group(name, body) {
|
||||
console.log(`::group::${name}`);
|
||||
const result = body();
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => console.log(`::endgroup::`));
|
||||
}
|
||||
console.log(`::endgroup::`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const featureRequests = await group('Pull Request API Response', async () => {
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
query FetchLinkedIssues($pr: Int!) {
|
||||
repository(owner: "actualbudget", name: "actual") {
|
||||
pullRequest(number: $pr) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
labels(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ pr: parseInt(process.env.PR_NUMBER) },
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
return res.data.repository.pullRequest.closingIssuesReferences.nodes.filter(
|
||||
issue => issue.labels.nodes.some(label => label.name === 'feature'),
|
||||
);
|
||||
});
|
||||
|
||||
if (featureRequests.length === 0) {
|
||||
console.log('No linked feature requests found');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { id, number, labels } of featureRequests) {
|
||||
await group(`Issue #${number}: Remove labels`, async () => {
|
||||
const toRemove = labels.nodes
|
||||
.filter(
|
||||
label => label.name === 'help wanted' || label.name === 'needs votes',
|
||||
)
|
||||
.map(label => label.id);
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
mutation RemoveLabels($issue: ID!, $labels: [ID!]!) {
|
||||
removeLabelsFromLabelable(
|
||||
input: {
|
||||
clientMutationId: "1"
|
||||
labelIds: $labels
|
||||
labelableId: $issue
|
||||
}
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
issue: id,
|
||||
labels: toRemove,
|
||||
},
|
||||
);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
});
|
||||
|
||||
await group(`Issue #${number}: Collapse automatic comment`, async () => {
|
||||
const commentRes = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
query FetchComments($issue: Int!) {
|
||||
repository(owner: "actualbudget", name: "actual") {
|
||||
issue(number: $issue) {
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ issue: number },
|
||||
);
|
||||
console.log(JSON.stringify(commentRes, null, 2));
|
||||
|
||||
const comments = commentRes.data.repository.issue.comments.nodes.filter(
|
||||
comment => comment.author.login === 'github-actions',
|
||||
);
|
||||
const commentToCollapse =
|
||||
comments.find(comment =>
|
||||
comment.body.includes('<!-- feature-auto-close-comment -->'),
|
||||
) ||
|
||||
comments.find(comment =>
|
||||
comment.body.includes(
|
||||
':sparkles: Thanks for sharing your idea! :sparkles:',
|
||||
),
|
||||
);
|
||||
|
||||
if (!commentToCollapse) {
|
||||
console.log('No comment to collapse found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
mutation CollapseComment($comment: ID!) {
|
||||
minimizeComment(
|
||||
input: { classifier: OUTDATED, subjectId: $comment }
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ comment: commentToCollapse.id },
|
||||
);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
});
|
||||
|
||||
await group(`Issue #${number}: Post comment`, async () => {
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
mutation PostComment($issue: ID!, $body: String!) {
|
||||
addComment(
|
||||
input: { subjectId: $issue, body: $body, clientMutationId: "1" }
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
issue: id,
|
||||
body: `:tada: This feature has been implemented in #${process.env.PR_NUMBER} and will be released in the next version. Thanks for sharing your idea! :tada:\n\n<!-- feature-implemented-comment -->`,
|
||||
},
|
||||
);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
47
.github/workflows/build.yml
vendored
47
.github/workflows/build.yml
vendored
@@ -23,6 +23,13 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build API
|
||||
run: cd packages/api && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -38,20 +45,26 @@ jobs:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
|
||||
# TODO: re-enable after solving https://github.com/actualbudget/actual/issues/468
|
||||
# electron:
|
||||
# # As electron builds take longer, we only run them in master.
|
||||
# if: github.event_name != 'pull_request'
|
||||
# strategy:
|
||||
# matrix:
|
||||
# os:
|
||||
# - ubuntu-latest
|
||||
# - windows-latest
|
||||
# - macos-latest
|
||||
# runs-on: ${{ matrix.os }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - name: Set up environment
|
||||
# uses: ./.github/actions/setup
|
||||
# - name: Build Electron
|
||||
# run: ./bin/package
|
||||
electron:
|
||||
# As electron builds take longer, we only run them in master.
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
|
||||
14
.github/workflows/check-release-notes.yml
vendored
14
.github/workflows/check-release-notes.yml
vendored
@@ -1,14 +0,0 @@
|
||||
name: Check release notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check release notes
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
34
.github/workflows/check.yml
vendored
Normal file
34
.github/workflows/check.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test
|
||||
run: yarn test
|
||||
32
.github/workflows/codeql.yml
vendored
Normal file
32
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '23 11 * * 6'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
17
.github/workflows/generate-release-notes.yml
vendored
17
.github/workflows/generate-release-notes.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Generate release notes
|
||||
uses: actualbudget/actions/release-notes/generate@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
needs-triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-ecosystem/action-add-labels@v1
|
||||
if: github.event.issue.labels == null
|
||||
with:
|
||||
labels: needs triage
|
||||
37
.github/workflows/issues-close-feature-requests.yml
vendored
Normal file
37
.github/workflows/issues-close-feature-requests.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Close feature requests with automated message
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
needs-votes:
|
||||
if: ${{ github.event.label.name == 'feature' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-add-labels@v1
|
||||
with:
|
||||
labels: needs votes
|
||||
- name: Add reactions
|
||||
uses: aidan-mundy/react-to-issue@v1.1.1
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
reactions: '+1'
|
||||
- name: Create comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
:sparkles: Thanks for sharing your idea! :sparkles:
|
||||
|
||||
This repository is now using lodash style issue management for enhancements. This means enhancement issues will now be closed instead of leaving them open. This doesn’t mean we don’t accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).
|
||||
|
||||
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
|
||||
|
||||
Don’t forget to upvote the top comment with 👍!
|
||||
|
||||
<!-- feature-auto-close-comment -->
|
||||
- name: Close Issue
|
||||
run: gh issue close "https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
20
.github/workflows/issues-feature-implemented.yml
vendored
Normal file
20
.github/workflows/issues-feature-implemented.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Handle completed feature requests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
handle-feature-requests:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '19'
|
||||
- name: Handle feature requests
|
||||
run: node .github/actions/handle-feature-requests.js
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
14
.github/workflows/issues-remove-help-wanted.yml
vendored
Normal file
14
.github/workflows/issues-remove-help-wanted.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Remove 'help wanted' label from closed issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
remove-help-wanted:
|
||||
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-remove-labels@v1
|
||||
with:
|
||||
labels: help wanted
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Linter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
18
.github/workflows/release-notes.yml
vendored
Normal file
18
.github/workflows/release-notes.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Release notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check release notes
|
||||
if: startsWith(github.head_ref, 'release/') == false
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
- name: Generate release notes
|
||||
if: startsWith(github.head_ref, 'release/') == true
|
||||
uses: actualbudget/actions/release-notes/generate@main
|
||||
20
.github/workflows/stale.yml
vendored
20
.github/workflows/stale.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: -1
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "🚧🚨 This issue is being marked as stale due to 90 days of inactivity. 🚧🚨"
|
||||
only-labels: 'needs triage'
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test
|
||||
run: yarn test
|
||||
18
.github/workflows/typecheck.yml
vendored
18
.github/workflows/typecheck.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
/data/*
|
||||
!data/.gitkeep
|
||||
/data2
|
||||
packages/api/dist
|
||||
packages/desktop-electron/client-build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
packages/desktop-electron/dist
|
||||
@@ -18,6 +19,7 @@ bundle.mobile.js
|
||||
bundle.mobile.js.map
|
||||
export-2020-01-10.csv
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
**/*.log
|
||||
|
||||
@@ -29,3 +31,6 @@ export-2020-01-10.csv
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# VSCode
|
||||
.vscode
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,4 +6,4 @@ plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.4.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-3.5.1.cjs
|
||||
|
||||
@@ -18,16 +18,12 @@ Here are some initial guidelines for how contributions will be treated:
|
||||
|
||||
(sorted alphabetically)
|
||||
|
||||
- @albertogasparin
|
||||
- @j-f1
|
||||
- @jlongster
|
||||
- @MatissJanis
|
||||
- @trevdor
|
||||
|
||||
## Alumni
|
||||
|
||||
(sorted alphabetically)
|
||||
|
||||
- @rich-howell
|
||||
- @trevdor
|
||||
|
||||
## Project ideas
|
||||
|
||||
@@ -36,3 +32,18 @@ We welcome all contributions from the community. If you have an idea for a featu
|
||||
If you do not have ideas what to build: the issue list is always a good starting point. Look for issues labeled with "[help wanted](https://github.com/actualbudget/actual/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)".
|
||||
|
||||
For first time contributions you can also filter the issues labeled with "[good first issue](https://github.com/actualbudget/actual/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)".
|
||||
|
||||
|
||||
## Development Environment
|
||||
If you would like to contribute you can fork this repository and create a branch specific to the project you are working on.
|
||||
|
||||
There are three options for developing:
|
||||
1. Yarn
|
||||
- This is the traditional way to get an environment stood up. Run `yarn` to install the dependencies followed by `yarn start:browser` to start the development server. You will then be able to access Actual at `localhost:3001`.
|
||||
2. Docker Compose
|
||||
- If you prefer to work with docker containers, a `docker-compose.yml` file is included. Run `docker compose up -d` to start Actual. It will be accessible at `localhost:3001`.
|
||||
3. Dev container
|
||||
- Directly integrated in some IDEs, dependencies will be installed automatically as you enter the container.
|
||||
- Use your preferred method to `npm start` the project, your IDE should expose the project on your `localhost` for you.
|
||||
|
||||
Both options above will dynamically update as you make changes to files. If you are making changes to the front end UI, you may have to reload the page to see any changes you make.
|
||||
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
###################################################
|
||||
# This Dockerfile is used by the docker-compose.yml
|
||||
# file to build the development container.
|
||||
# Do not make any changes here unless you know what
|
||||
# you are doing.
|
||||
###################################################
|
||||
|
||||
FROM node:16-bullseye as dev
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
|
||||
WORKDIR /app
|
||||
CMD ["sh", "./docker-start.sh"]
|
||||
@@ -34,12 +34,17 @@ We have a wide range of documentation on how to use Actual, this is all availabl
|
||||
The Actual app is split up into a few packages:
|
||||
|
||||
- loot-core - The core application that runs on any platform
|
||||
- loot-design - The generic design components that make up the UI
|
||||
- desktop-client - The desktop UI
|
||||
- desktop-electron - The desktop app
|
||||
|
||||
More information on the project structure is available in our [community documentation](https://actualbudget.github.io/docs/Developers/project-layout).
|
||||
|
||||
## Feature Requests
|
||||
Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc).
|
||||
Vote for your favorite requests by reacting :+1: to the top comment of the request.
|
||||
|
||||
To add new feature requests, open a new Issue of the "Feature Request" type.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to our wonderful sponsors who make Actual budget possible!
|
||||
|
||||
@@ -42,7 +42,7 @@ git tag -a "$VERSION" -m "$NOTES"
|
||||
git push origin "$VERSION"
|
||||
|
||||
# Make a macOS version
|
||||
./bin/package --release --version "$VERSION"
|
||||
./bin/package-electron --release --version "$VERSION"
|
||||
|
||||
# TODO: browser version
|
||||
|
||||
|
||||
@@ -74,19 +74,15 @@ if [ "$OSTYPE" == "msys" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# We only need to run linting once (and this doesn't seem to work on
|
||||
# Windows for some reason)
|
||||
if [[ $CI != true && "$OSTYPE" == "darwin"* ]]; then
|
||||
yarn lint
|
||||
fi
|
||||
|
||||
yarn patch-package
|
||||
|
||||
yarn rebuild-electron
|
||||
|
||||
yarn workspace loot-core build:node
|
||||
|
||||
yarn workspace @actual-app/web build
|
||||
|
||||
yarn workspace Actual update-client
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
(
|
||||
cd packages/desktop-electron;
|
||||
@@ -103,7 +99,7 @@ yarn workspace Actual update-client
|
||||
echo "\nCreated release $VERSION with release notes \"$RELEASE_NOTES\""
|
||||
elif [ "$RELEASE" == "beta" ]; then
|
||||
yarn build --publish never --arm64 --x64
|
||||
|
||||
|
||||
echo "\nCreated beta release $VERSION"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build --publish never --x64
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
###################################################
|
||||
# This creates and stands up the development
|
||||
# docker container. Depends on the Dockerfile and
|
||||
# docker-start.sh files.
|
||||
###################################################
|
||||
|
||||
services:
|
||||
actual-development:
|
||||
build: .
|
||||
image: actual-development
|
||||
ports:
|
||||
- '3001:3001'
|
||||
volumes:
|
||||
- '.:/app'
|
||||
restart: 'no'
|
||||
|
||||
13
docker-start.sh
Normal file
13
docker-start.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#####################################################
|
||||
# This startup script is used by the docker container
|
||||
# to check if the node_modules folder is empty and
|
||||
# if so, run yarn to install the dependencies.
|
||||
#####################################################
|
||||
|
||||
#!/bin/sh
|
||||
|
||||
if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
|
||||
yarn
|
||||
fi
|
||||
|
||||
yarn start:browser
|
||||
@@ -18,10 +18,12 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npm-run-all --parallel 'start:desktop-*'",
|
||||
"start": "yarn start:browser",
|
||||
"start:desktop": "npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-electron": "yarn workspace Actual watch",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:electron": "yarn start:desktop",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
@@ -51,7 +53,7 @@
|
||||
"resolutions": {
|
||||
"react-error-overlay": "6.0.9"
|
||||
},
|
||||
"packageManager": "yarn@3.4.1",
|
||||
"packageManager": "yarn@3.5.1",
|
||||
"browserslist": [
|
||||
"electron 12.0",
|
||||
"defaults"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
app/bundle.api.js
|
||||
dist
|
||||
|
||||
2
packages/api/.gitignore
vendored
2
packages/api/.gitignore
vendored
@@ -1 +1,3 @@
|
||||
app/bundle.api.js*
|
||||
migrations
|
||||
default-db.sqlite
|
||||
|
||||
@@ -99,6 +99,6 @@ class Query {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function q(table) {
|
||||
export default function q(table) {
|
||||
return new Query({ table });
|
||||
};
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,34 +1,34 @@
|
||||
let bundle = require('./app/bundle.api.js');
|
||||
let injected = require('./injected');
|
||||
let methods = require('./methods');
|
||||
let utils = require('./utils');
|
||||
let actualApp;
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
async function init(config = {}) {
|
||||
import * as bundle from './app/bundle.api';
|
||||
import * as injected from './injected';
|
||||
|
||||
let actualApp;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
// DEPRECATED: remove the next line in @actual-app/api v7
|
||||
export * as methods from './methods';
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
export async function init(config = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
global.fetch = require('node-fetch');
|
||||
global.fetch = fetch;
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.send = bundle.lib.send;
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
shutdown,
|
||||
utils,
|
||||
internal: bundle.lib,
|
||||
...methods,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// TODO: comment on why it works this way
|
||||
|
||||
let send;
|
||||
export let send;
|
||||
|
||||
module.exports = { send };
|
||||
export function override(sendImplementation) {
|
||||
send = sendImplementation;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const q = require('./app/query');
|
||||
const injected = require('./injected');
|
||||
import * as injected from './injected';
|
||||
|
||||
export { default as q } from './app/query';
|
||||
|
||||
function send(name, args) {
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
async function runImport(name, func) {
|
||||
export async function runImport(name, func) {
|
||||
await send('api/start-import', { budgetName: name });
|
||||
try {
|
||||
await func();
|
||||
@@ -16,15 +17,15 @@ async function runImport(name, func) {
|
||||
await send('api/finish-import');
|
||||
}
|
||||
|
||||
async function loadBudget(budgetId) {
|
||||
export async function loadBudget(budgetId) {
|
||||
return send('api/load-budget', { id: budgetId });
|
||||
}
|
||||
|
||||
async function downloadBudget(syncId, { password } = {}) {
|
||||
export async function downloadBudget(syncId, { password } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
async function batchBudgetUpdates(func) {
|
||||
export async function batchBudgetUpdates(func) {
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
await func();
|
||||
@@ -33,63 +34,63 @@ async function batchBudgetUpdates(func) {
|
||||
}
|
||||
}
|
||||
|
||||
function runQuery(query) {
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
function getBudgetMonths() {
|
||||
export function getBudgetMonths() {
|
||||
return send('api/budget-months');
|
||||
}
|
||||
|
||||
function getBudgetMonth(month) {
|
||||
export function getBudgetMonth(month) {
|
||||
return send('api/budget-month', { month });
|
||||
}
|
||||
|
||||
function setBudgetAmount(month, categoryId, value) {
|
||||
export function setBudgetAmount(month, categoryId, value) {
|
||||
return send('api/budget-set-amount', { month, categoryId, amount: value });
|
||||
}
|
||||
|
||||
function setBudgetCarryover(month, categoryId, flag) {
|
||||
export function setBudgetCarryover(month, categoryId, flag) {
|
||||
return send('api/budget-set-carryover', { month, categoryId, flag });
|
||||
}
|
||||
|
||||
function addTransactions(accountId, transactions) {
|
||||
export function addTransactions(accountId, transactions) {
|
||||
return send('api/transactions-add', { accountId, transactions });
|
||||
}
|
||||
|
||||
function importTransactions(accountId, transactions) {
|
||||
export function importTransactions(accountId, transactions) {
|
||||
return send('api/transactions-import', { accountId, transactions });
|
||||
}
|
||||
|
||||
function getTransactions(accountId, startDate, endDate) {
|
||||
export function getTransactions(accountId, startDate, endDate) {
|
||||
return send('api/transactions-get', { accountId, startDate, endDate });
|
||||
}
|
||||
|
||||
function filterTransactions(accountId, text) {
|
||||
export function filterTransactions(accountId, text) {
|
||||
return send('api/transactions-filter', { accountId, text });
|
||||
}
|
||||
|
||||
function updateTransaction(id, fields) {
|
||||
export function updateTransaction(id, fields) {
|
||||
return send('api/transaction-update', { id, fields });
|
||||
}
|
||||
|
||||
function deleteTransaction(id) {
|
||||
export function deleteTransaction(id) {
|
||||
return send('api/transaction-delete', { id });
|
||||
}
|
||||
|
||||
function getAccounts() {
|
||||
export function getAccounts() {
|
||||
return send('api/accounts-get');
|
||||
}
|
||||
|
||||
function createAccount(account, initialBalance) {
|
||||
export function createAccount(account, initialBalance) {
|
||||
return send('api/account-create', { account, initialBalance });
|
||||
}
|
||||
|
||||
function updateAccount(id, fields) {
|
||||
export function updateAccount(id, fields) {
|
||||
return send('api/account-update', { id, fields });
|
||||
}
|
||||
|
||||
function closeAccount(id, transferAccountId, transferCategoryId) {
|
||||
export function closeAccount(id, transferAccountId, transferCategoryId) {
|
||||
return send('api/account-close', {
|
||||
id,
|
||||
transferAccountId,
|
||||
@@ -97,116 +98,54 @@ function closeAccount(id, transferAccountId, transferCategoryId) {
|
||||
});
|
||||
}
|
||||
|
||||
function reopenAccount(id) {
|
||||
export function reopenAccount(id) {
|
||||
return send('api/account-reopen', { id });
|
||||
}
|
||||
|
||||
function deleteAccount(id) {
|
||||
export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
function createCategoryGroup(group) {
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
function updateCategoryGroup(id, fields) {
|
||||
export function updateCategoryGroup(id, fields) {
|
||||
return send('api/category-group-update', { id, fields });
|
||||
}
|
||||
|
||||
function deleteCategoryGroup(id, transferCategoryId) {
|
||||
export function deleteCategoryGroup(id, transferCategoryId) {
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
function getCategories() {
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
}
|
||||
|
||||
function createCategory(category) {
|
||||
export function createCategory(category) {
|
||||
return send('api/category-create', { category });
|
||||
}
|
||||
|
||||
function updateCategory(id, fields) {
|
||||
export function updateCategory(id, fields) {
|
||||
return send('api/category-update', { id, fields });
|
||||
}
|
||||
|
||||
function deleteCategory(id, transferCategoryId) {
|
||||
export function deleteCategory(id, transferCategoryId) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
function getPayees() {
|
||||
export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
|
||||
function createPayee(payee) {
|
||||
export function createPayee(payee) {
|
||||
return send('api/payee-create', { payee });
|
||||
}
|
||||
|
||||
function updatePayee(id, fields) {
|
||||
export function updatePayee(id, fields) {
|
||||
return send('api/payee-update', { id, fields });
|
||||
}
|
||||
|
||||
function deletePayee(id) {
|
||||
export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
function getPayeeRules(payeeId) {
|
||||
return send('api/payee-rules-get', { payeeId });
|
||||
}
|
||||
|
||||
function createPayeeRule(payeeId, rule) {
|
||||
return send('api/payee-rule-create', { payee_id: payeeId, rule });
|
||||
}
|
||||
|
||||
function updatePayeeRule(id, fields) {
|
||||
return send('api/payee-rule-update', { id, fields });
|
||||
}
|
||||
|
||||
function deletePayeeRule(id) {
|
||||
return send('api/payee-rule-delete', { id });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runImport,
|
||||
|
||||
runQuery,
|
||||
q,
|
||||
|
||||
loadBudget,
|
||||
downloadBudget,
|
||||
batchBudgetUpdates,
|
||||
getBudgetMonths,
|
||||
getBudgetMonth,
|
||||
setBudgetAmount,
|
||||
setBudgetCarryover,
|
||||
|
||||
addTransactions,
|
||||
importTransactions,
|
||||
filterTransactions,
|
||||
getTransactions,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
|
||||
getAccounts,
|
||||
createAccount,
|
||||
updateAccount,
|
||||
closeAccount,
|
||||
reopenAccount,
|
||||
deleteAccount,
|
||||
|
||||
getCategories,
|
||||
createCategoryGroup,
|
||||
updateCategoryGroup,
|
||||
deleteCategoryGroup,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
|
||||
getPayees,
|
||||
createPayee,
|
||||
updatePayee,
|
||||
deletePayee,
|
||||
getPayeeRules,
|
||||
createPayeeRule,
|
||||
deletePayeeRule,
|
||||
updatePayeeRule,
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE payees
|
||||
(id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
category TEXT,
|
||||
tombstone INTEGER DEFAULT 0,
|
||||
transfer_acct TEXT);
|
||||
|
||||
CREATE TABLE payee_rules
|
||||
(id TEXT PRIMARY KEY,
|
||||
payee_id TEXT,
|
||||
type TEXT,
|
||||
value TEXT,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
CREATE INDEX payee_rules_lowercase_index ON payee_rules(LOWER(value));
|
||||
|
||||
CREATE TABLE payee_mapping
|
||||
(id TEXT PRIMARY KEY,
|
||||
targetId TEXT);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,25 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TEMPORARY TABLE category_groups_tmp
|
||||
(id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE,
|
||||
is_income INTEGER DEFAULT 0,
|
||||
sort_order REAL,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
INSERT INTO category_groups_tmp SELECT * FROM category_groups;
|
||||
|
||||
DROP TABLE category_groups;
|
||||
|
||||
CREATE TABLE category_groups
|
||||
(id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
is_income INTEGER DEFAULT 0,
|
||||
sort_order REAL,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
INSERT INTO category_groups SELECT * FROM category_groups_tmp;
|
||||
|
||||
DROP TABLE category_groups_tmp;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE INDEX trans_category_date ON transactions(category, date);
|
||||
CREATE INDEX trans_category ON transactions(category);
|
||||
CREATE INDEX trans_date ON transactions(date);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,38 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DELETE FROM spreadsheet_cells WHERE
|
||||
name NOT LIKE '%!budget\_%' ESCAPE '\' AND
|
||||
name NOT LIKE '%!carryover\_%' ESCAPE '\' AND
|
||||
name NOT LIKE '%!buffered';
|
||||
|
||||
UPDATE OR REPLACE spreadsheet_cells SET name = REPLACE(name, '_', '-');
|
||||
|
||||
UPDATE OR REPLACE spreadsheet_cells SET
|
||||
name =
|
||||
SUBSTR(name, 1, 28) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 29, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 33, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 37, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 41, 12)
|
||||
WHERE name LIKE '%!budget-%' AND LENGTH(name) = 52;
|
||||
|
||||
UPDATE OR REPLACE spreadsheet_cells SET
|
||||
name =
|
||||
SUBSTR(name, 1, 31) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 32, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 36, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 40, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 44, 12)
|
||||
WHERE name LIKE '%!carryover-%' AND LENGTH(name) = 55;
|
||||
|
||||
UPDATE spreadsheet_cells SET expr = SUBSTR(expr, 2) WHERE name LIKE '%!carryover-%';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN cleared INTEGER DEFAULT 1;
|
||||
ALTER TABLE transactions ADD COLUMN pending INTEGER DEFAULT 0;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,10 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE rules
|
||||
(id TEXT PRIMARY KEY,
|
||||
stage TEXT,
|
||||
conditions TEXT,
|
||||
actions TEXT,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN parent_id TEXT;
|
||||
|
||||
UPDATE transactions SET
|
||||
parent_id = CASE
|
||||
WHEN isChild THEN SUBSTR(id, 1, INSTR(id, '/') - 1)
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
CREATE INDEX trans_parent_id ON transactions(parent_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,56 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions_layer2;
|
||||
CREATE VIEW v_transactions_layer2 AS
|
||||
SELECT
|
||||
t.id AS id,
|
||||
t.isParent AS is_parent,
|
||||
t.isChild AS is_child,
|
||||
t.acct AS account,
|
||||
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
|
||||
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
|
||||
pm.targetId AS payee,
|
||||
t.imported_description AS imported_payee,
|
||||
IFNULL(t.amount, 0) AS amount,
|
||||
t.notes AS notes,
|
||||
t.date AS date,
|
||||
t.financial_id AS imported_id,
|
||||
t.error AS error,
|
||||
t.starting_balance_flag AS starting_balance_flag,
|
||||
t.transferred_id AS transfer_id,
|
||||
t.sort_order AS sort_order,
|
||||
t.cleared AS cleared,
|
||||
t.tombstone AS tombstone
|
||||
FROM transactions t
|
||||
LEFT JOIN category_mapping cm ON cm.id = t.category
|
||||
LEFT JOIN payee_mapping pm ON pm.id = t.description
|
||||
WHERE
|
||||
t.date IS NOT NULL AND
|
||||
t.acct IS NOT NULL;
|
||||
|
||||
CREATE INDEX trans_sorted ON transactions(date desc, starting_balance_flag, sort_order desc, id);
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions_layer1;
|
||||
CREATE VIEW v_transactions_layer1 AS
|
||||
SELECT t.* FROM v_transactions_layer2 t
|
||||
LEFT JOIN transactions t2 ON (t.is_child = 1 AND t2.id = t.parent_id)
|
||||
WHERE IFNULL(t.tombstone, 0) = 0 AND IFNULL(t2.tombstone, 0) = 0;
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions;
|
||||
CREATE VIEW v_transactions AS
|
||||
SELECT t.* FROM v_transactions_layer1 t
|
||||
ORDER BY t.date desc, t.starting_balance_flag, t.sort_order desc, t.id;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS v_categories;
|
||||
CREATE VIEW v_categories AS
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
is_income,
|
||||
cat_group AS "group",
|
||||
sort_order,
|
||||
tombstone
|
||||
FROM categories;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE INDEX messages_crdt_search ON messages_crdt(dataset, row, column, timestamp);
|
||||
|
||||
ANALYZE;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,33 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- This adds the isChild/parent_id constraint in `where`
|
||||
DROP VIEW IF EXISTS v_transactions_layer2;
|
||||
CREATE VIEW v_transactions_layer2 AS
|
||||
SELECT
|
||||
t.id AS id,
|
||||
t.isParent AS is_parent,
|
||||
t.isChild AS is_child,
|
||||
t.acct AS account,
|
||||
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
|
||||
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
|
||||
pm.targetId AS payee,
|
||||
t.imported_description AS imported_payee,
|
||||
IFNULL(t.amount, 0) AS amount,
|
||||
t.notes AS notes,
|
||||
t.date AS date,
|
||||
t.financial_id AS imported_id,
|
||||
t.error AS error,
|
||||
t.starting_balance_flag AS starting_balance_flag,
|
||||
t.transferred_id AS transfer_id,
|
||||
t.sort_order AS sort_order,
|
||||
t.cleared AS cleared,
|
||||
t.tombstone AS tombstone
|
||||
FROM transactions t
|
||||
LEFT JOIN category_mapping cm ON cm.id = t.category
|
||||
LEFT JOIN payee_mapping pm ON pm.id = t.description
|
||||
WHERE
|
||||
t.date IS NOT NULL AND
|
||||
t.acct IS NOT NULL AND
|
||||
(t.isChild = 0 OR t.parent_id IS NOT NULL);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,10 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE __meta__ (key TEXT PRIMARY KEY, value TEXT);
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions_layer2;
|
||||
DROP VIEW IF EXISTS v_transactions_layer1;
|
||||
DROP VIEW IF EXISTS v_transactions;
|
||||
DROP VIEW IF EXISTS v_categories;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,5 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN sort_order REAL;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,28 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE schedules
|
||||
(id TEXT PRIMARY KEY,
|
||||
rule TEXT,
|
||||
active INTEGER DEFAULT 0,
|
||||
completed INTEGER DEFAULT 0,
|
||||
posts_transaction INTEGER DEFAULT 0,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE schedules_next_date
|
||||
(id TEXT PRIMARY KEY,
|
||||
schedule_id TEXT,
|
||||
local_next_date INTEGER,
|
||||
local_next_date_ts INTEGER,
|
||||
base_next_date INTEGER,
|
||||
base_next_date_ts INTEGER);
|
||||
|
||||
CREATE TABLE schedules_json_paths
|
||||
(schedule_id TEXT PRIMARY KEY,
|
||||
payee TEXT,
|
||||
account TEXT,
|
||||
amount TEXT,
|
||||
date TEXT);
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN schedule TEXT;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,135 +0,0 @@
|
||||
export default async function runMigration(db, uuid) {
|
||||
function getValue(node) {
|
||||
return node.expr != null ? node.expr : node.cachedValue;
|
||||
}
|
||||
|
||||
db.execQuery(`
|
||||
CREATE TABLE zero_budget_months
|
||||
(id TEXT PRIMARY KEY,
|
||||
buffered INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE zero_budgets
|
||||
(id TEXT PRIMARY KEY,
|
||||
month INTEGER,
|
||||
category TEXT,
|
||||
amount INTEGER DEFAULT 0,
|
||||
carryover INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE reflect_budgets
|
||||
(id TEXT PRIMARY KEY,
|
||||
month INTEGER,
|
||||
category TEXT,
|
||||
amount INTEGER DEFAULT 0,
|
||||
carryover INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE notes
|
||||
(id TEXT PRIMARY KEY,
|
||||
note TEXT);
|
||||
|
||||
CREATE TABLE kvcache (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
`);
|
||||
|
||||
// Migrate budget amounts and carryover
|
||||
let budget = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
budget.forEach(monthBudget => {
|
||||
let match = monthBudget.name.match(
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/,
|
||||
);
|
||||
if (match == null) {
|
||||
console.log('Warning: invalid budget month name', monthBudget.name);
|
||||
return;
|
||||
}
|
||||
|
||||
let type = match[1];
|
||||
let month = match[2].slice(0, 4) + '-' + match[2].slice(4);
|
||||
let dbmonth = parseInt(match[2]);
|
||||
let cat = match[3];
|
||||
|
||||
let amount = parseInt(getValue(monthBudget));
|
||||
if (isNaN(amount)) {
|
||||
amount = 0;
|
||||
}
|
||||
|
||||
let sheetName = monthBudget.name.split('!')[0];
|
||||
let carryover = db.runQuery(
|
||||
'SELECT * FROM spreadsheet_cells WHERE name = ?',
|
||||
[`${sheetName}!carryover-${cat}`],
|
||||
true,
|
||||
);
|
||||
|
||||
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
|
||||
db.runQuery(
|
||||
`INSERT INTO ${table} (id, month, category, amount, carryover) VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`${month}-${cat}`,
|
||||
dbmonth,
|
||||
cat,
|
||||
amount,
|
||||
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Migrate buffers
|
||||
let buffers = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
buffers.forEach(buffer => {
|
||||
let match = buffer.name.match(/^budget(\d+)!buffered$/);
|
||||
if (match) {
|
||||
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
|
||||
let amount = parseInt(getValue(buffer));
|
||||
if (isNaN(amount)) {
|
||||
amount = 0;
|
||||
}
|
||||
|
||||
db.runQuery(
|
||||
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
|
||||
[month, amount],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Migrate notes
|
||||
let notes = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
let parseNote = str => {
|
||||
try {
|
||||
let value = JSON.parse(str);
|
||||
return value && value !== '' ? value : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
db.transaction(() => {
|
||||
notes.forEach(note => {
|
||||
let parsed = parseNote(getValue(note));
|
||||
if (parsed) {
|
||||
let [, id] = note.name.split('!');
|
||||
db.runQuery(`INSERT INTO notes (id, note) VALUES (?, ?)`, [id, parsed]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
db.execQuery(`
|
||||
DROP TABLE spreadsheet_cells;
|
||||
ANALYZE;
|
||||
VACUUM;
|
||||
`);
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "5.1.0",
|
||||
"version": "6.1.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"main": "index.js",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"app",
|
||||
"default-db.sqlite",
|
||||
"index.js",
|
||||
"injected.js",
|
||||
"methods.js",
|
||||
"migrations",
|
||||
"utils.js"
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"build": "yarn workspace loot-core build:api"
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "rm -rf dist && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.2.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
let api = require('./index');
|
||||
import * as api from './index';
|
||||
|
||||
async function run() {
|
||||
let app = await api.init({ config: { dataDir: '/tmp' } });
|
||||
|
||||
14
packages/api/tsconfig.dist.json
Normal file
14
packages/api/tsconfig.dist.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "es2021",
|
||||
"module": "CommonJS",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
function amountToInteger(n) {
|
||||
export function amountToInteger(n) {
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
function integerToAmount(n) {
|
||||
export function integerToAmount(n) {
|
||||
return parseFloat((n / 100).toFixed(2));
|
||||
}
|
||||
|
||||
module.exports = { amountToInteger, integerToAmount };
|
||||
|
||||
2
packages/desktop-client/.gitignore
vendored
2
packages/desktop-client/.gitignore
vendored
@@ -17,3 +17,5 @@ npm-debug.log
|
||||
|
||||
*kcab.*
|
||||
public/kcab
|
||||
public/data
|
||||
public/data-file-index.txt
|
||||
|
||||
@@ -2,16 +2,18 @@ const path = require('path');
|
||||
|
||||
const {
|
||||
addWebpackResolve,
|
||||
disableEsLint,
|
||||
override,
|
||||
overrideDevServer,
|
||||
babelInclude,
|
||||
} = require('customize-cra');
|
||||
|
||||
if (process.env.CI) {
|
||||
process.env.DISABLE_ESLINT_PLUGIN = 'true';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
webpack: override(
|
||||
babelInclude([path.resolve('src'), path.resolve('../loot-core')]),
|
||||
process.env.CI && disableEsLint(),
|
||||
addWebpackResolve({
|
||||
extensions: [
|
||||
...(process.env.IS_GENERIC_BROWSER
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
|
||||
test.describe('Budget', () => {
|
||||
let page;
|
||||
let navigation; // eslint-disable-line no-unused-vars
|
||||
let configurationPage;
|
||||
let budgetPage;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
navigation = new Navigation(page);
|
||||
configurationPage = new ConfigurationPage(page);
|
||||
|
||||
await page.goto('/');
|
||||
@@ -25,7 +22,9 @@ test.describe('Budget', () => {
|
||||
test('renders the summary information: available funds, overspent, budgeted and for next month', async () => {
|
||||
const summary = budgetPage.budgetSummary.first();
|
||||
|
||||
await expect(summary.getByText('Available Funds')).toBeVisible();
|
||||
await expect(summary.getByText('Available Funds')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(summary.getByText(/^Overspent in /)).toBeVisible();
|
||||
await expect(summary.getByText('Budgeted')).toBeVisible();
|
||||
await expect(summary.getByText('For Next Month')).toBeVisible();
|
||||
|
||||
@@ -76,6 +76,6 @@ test.describe('Onboarding', () => {
|
||||
|
||||
await navigation.clickOnNoServer();
|
||||
|
||||
expect(await configurationPage.heading).toHaveText('Where’s the server?');
|
||||
await expect(configurationPage.heading).toHaveText('Where’s the server?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +91,12 @@ export class AccountPage {
|
||||
return new CloseAccountModal(this.page.locator('css=[aria-modal]'));
|
||||
}
|
||||
|
||||
async _clearFocusedField() {
|
||||
let isMac = process.platform === 'darwin';
|
||||
await this.page.keyboard.press(isMac ? 'Meta+A' : 'Control+A');
|
||||
await this.page.keyboard.press('Backspace');
|
||||
}
|
||||
|
||||
async _fillTransactionFields(transactionRow, transaction) {
|
||||
if (transaction.payee) {
|
||||
await transactionRow.getByTestId('payee').click();
|
||||
@@ -117,12 +123,14 @@ export class AccountPage {
|
||||
|
||||
if (transaction.debit) {
|
||||
await transactionRow.getByTestId('debit').click();
|
||||
await this._clearFocusedField();
|
||||
await this.page.keyboard.type(transaction.debit);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
if (transaction.credit) {
|
||||
await transactionRow.getByTestId('credit').click();
|
||||
await this._clearFocusedField();
|
||||
await this.page.keyboard.type(transaction.credit);
|
||||
await this.page.keyboard.press('Tab');
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export class RulesPage {
|
||||
}
|
||||
|
||||
if (value) {
|
||||
await row.getByRole('combobox').fill(value);
|
||||
await row.getByRole('textbox').fill(value);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,14 @@ export class SchedulesPage {
|
||||
|
||||
async _fillScheduleFields(data) {
|
||||
if (data.payee) {
|
||||
await this.page.getByLabel('Payee').fill(data.payee);
|
||||
await this.page.getByRole('textbox', { name: 'Payee' }).fill(data.payee);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
if (data.account) {
|
||||
await this.page.getByLabel('Account').fill(data.account);
|
||||
await this.page
|
||||
.getByRole('textbox', { name: 'Account' })
|
||||
.fill(data.account);
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "23.4.0",
|
||||
"version": "23.6.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@jlongster/lively": "0.0.4",
|
||||
"@juggle/resize-observer": "^3.1.2",
|
||||
"@playwright/test": "^1.29.1",
|
||||
"@reach/listbox": "^0.11.2",
|
||||
@@ -15,17 +14,20 @@
|
||||
"@react-aria/utils": "^3.13.3",
|
||||
"@react-stately/collections": "^3.4.3",
|
||||
"@react-stately/list": "^3.5.3",
|
||||
"@reactions/component": "^2.0.2",
|
||||
"@svgr/cli": "^6.5.1",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.1",
|
||||
"@types/react-modal": "^3.16.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"chalk": "2.4.1",
|
||||
"chroma-js": "^1.3.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"customize-cra": "^1.0.0",
|
||||
"date-fns": "^2.29.3",
|
||||
"debounce": "^1.2.0",
|
||||
"downshift": "1.31.16",
|
||||
"downshift": "7.6.0",
|
||||
"focus-visible": "^4.1.1",
|
||||
"formik": "^0.11.10",
|
||||
"glamor": "^2.20.40",
|
||||
@@ -38,7 +40,6 @@
|
||||
"mitt": "^3.0.0",
|
||||
"node-noop": "1.0.0",
|
||||
"pikaday": "1.8.0",
|
||||
"prop-types": "15.6.0",
|
||||
"react": "18.2.0",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-dnd": "^10.0.2",
|
||||
@@ -51,13 +52,11 @@
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-router-dom-v5-compat": "^6.4.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-select": "^5.7.0",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-spring": "^9.7.1",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"victory": "^36.6.8",
|
||||
"wobble": "^1.5.0"
|
||||
"victory": "^36.6.8"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env PORT=3001 react-app-rewired start",
|
||||
@@ -66,7 +65,7 @@
|
||||
"build": "cross-env INLINE_RUNTIME_CHUNK=false react-app-rewired build",
|
||||
"build:browser": "cross-env ./bin/build-browser",
|
||||
"test": "react-app-rewired test",
|
||||
"e2e": "npx playwright test e2e --browser=chromium",
|
||||
"e2e": "npx playwright test --browser=chromium",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
timeout: 20000, // 20 seconds
|
||||
retries: 1,
|
||||
testDir: 'e2e/',
|
||||
use: {
|
||||
screenshot: 'on',
|
||||
browserName: 'chromium',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
default-db.sqlite
|
||||
migrations/.force-copy-windows
|
||||
migrations/1548957970627_remove-db-version.sql
|
||||
migrations/1550601598648_payees.sql
|
||||
migrations/1555786194328_remove_category_group_unique.sql
|
||||
migrations/1561751833510_indexes.sql
|
||||
migrations/1567699552727_budget.sql
|
||||
migrations/1582384163573_cleared.sql
|
||||
migrations/1597756566448_rules.sql
|
||||
migrations/1608652596043_parent_field.sql
|
||||
migrations/1608652596044_trans_views.sql
|
||||
migrations/1612625548236_optimize.sql
|
||||
migrations/1614782639336_trans_views2.sql
|
||||
migrations/1615745967948_meta.sql
|
||||
migrations/1616167010796_accounts_order.sql
|
||||
migrations/1618975177358_schedules.sql
|
||||
migrations/1632571489012_remove_cache.js
|
||||
migrations/1679728867040_rules_conditions.sql
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DROP TABLE db_version;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,23 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE payees
|
||||
(id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
category TEXT,
|
||||
tombstone INTEGER DEFAULT 0,
|
||||
transfer_acct TEXT);
|
||||
|
||||
CREATE TABLE payee_rules
|
||||
(id TEXT PRIMARY KEY,
|
||||
payee_id TEXT,
|
||||
type TEXT,
|
||||
value TEXT,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
CREATE INDEX payee_rules_lowercase_index ON payee_rules(LOWER(value));
|
||||
|
||||
CREATE TABLE payee_mapping
|
||||
(id TEXT PRIMARY KEY,
|
||||
targetId TEXT);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,25 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TEMPORARY TABLE category_groups_tmp
|
||||
(id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE,
|
||||
is_income INTEGER DEFAULT 0,
|
||||
sort_order REAL,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
INSERT INTO category_groups_tmp SELECT * FROM category_groups;
|
||||
|
||||
DROP TABLE category_groups;
|
||||
|
||||
CREATE TABLE category_groups
|
||||
(id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
is_income INTEGER DEFAULT 0,
|
||||
sort_order REAL,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
INSERT INTO category_groups SELECT * FROM category_groups_tmp;
|
||||
|
||||
DROP TABLE category_groups_tmp;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE INDEX trans_category_date ON transactions(category, date);
|
||||
CREATE INDEX trans_category ON transactions(category);
|
||||
CREATE INDEX trans_date ON transactions(date);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,38 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DELETE FROM spreadsheet_cells WHERE
|
||||
name NOT LIKE '%!budget\_%' ESCAPE '\' AND
|
||||
name NOT LIKE '%!carryover\_%' ESCAPE '\' AND
|
||||
name NOT LIKE '%!buffered';
|
||||
|
||||
UPDATE OR REPLACE spreadsheet_cells SET name = REPLACE(name, '_', '-');
|
||||
|
||||
UPDATE OR REPLACE spreadsheet_cells SET
|
||||
name =
|
||||
SUBSTR(name, 1, 28) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 29, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 33, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 37, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 41, 12)
|
||||
WHERE name LIKE '%!budget-%' AND LENGTH(name) = 52;
|
||||
|
||||
UPDATE OR REPLACE spreadsheet_cells SET
|
||||
name =
|
||||
SUBSTR(name, 1, 31) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 32, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 36, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 40, 4) ||
|
||||
'-' ||
|
||||
SUBSTR(name, 44, 12)
|
||||
WHERE name LIKE '%!carryover-%' AND LENGTH(name) = 55;
|
||||
|
||||
UPDATE spreadsheet_cells SET expr = SUBSTR(expr, 2) WHERE name LIKE '%!carryover-%';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN cleared INTEGER DEFAULT 1;
|
||||
ALTER TABLE transactions ADD COLUMN pending INTEGER DEFAULT 0;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,10 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE rules
|
||||
(id TEXT PRIMARY KEY,
|
||||
stage TEXT,
|
||||
conditions TEXT,
|
||||
actions TEXT,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN parent_id TEXT;
|
||||
|
||||
UPDATE transactions SET
|
||||
parent_id = CASE
|
||||
WHEN isChild THEN SUBSTR(id, 1, INSTR(id, '/') - 1)
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
CREATE INDEX trans_parent_id ON transactions(parent_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,56 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions_layer2;
|
||||
CREATE VIEW v_transactions_layer2 AS
|
||||
SELECT
|
||||
t.id AS id,
|
||||
t.isParent AS is_parent,
|
||||
t.isChild AS is_child,
|
||||
t.acct AS account,
|
||||
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
|
||||
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
|
||||
pm.targetId AS payee,
|
||||
t.imported_description AS imported_payee,
|
||||
IFNULL(t.amount, 0) AS amount,
|
||||
t.notes AS notes,
|
||||
t.date AS date,
|
||||
t.financial_id AS imported_id,
|
||||
t.error AS error,
|
||||
t.starting_balance_flag AS starting_balance_flag,
|
||||
t.transferred_id AS transfer_id,
|
||||
t.sort_order AS sort_order,
|
||||
t.cleared AS cleared,
|
||||
t.tombstone AS tombstone
|
||||
FROM transactions t
|
||||
LEFT JOIN category_mapping cm ON cm.id = t.category
|
||||
LEFT JOIN payee_mapping pm ON pm.id = t.description
|
||||
WHERE
|
||||
t.date IS NOT NULL AND
|
||||
t.acct IS NOT NULL;
|
||||
|
||||
CREATE INDEX trans_sorted ON transactions(date desc, starting_balance_flag, sort_order desc, id);
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions_layer1;
|
||||
CREATE VIEW v_transactions_layer1 AS
|
||||
SELECT t.* FROM v_transactions_layer2 t
|
||||
LEFT JOIN transactions t2 ON (t.is_child = 1 AND t2.id = t.parent_id)
|
||||
WHERE IFNULL(t.tombstone, 0) = 0 AND IFNULL(t2.tombstone, 0) = 0;
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions;
|
||||
CREATE VIEW v_transactions AS
|
||||
SELECT t.* FROM v_transactions_layer1 t
|
||||
ORDER BY t.date desc, t.starting_balance_flag, t.sort_order desc, t.id;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS v_categories;
|
||||
CREATE VIEW v_categories AS
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
is_income,
|
||||
cat_group AS "group",
|
||||
sort_order,
|
||||
tombstone
|
||||
FROM categories;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE INDEX messages_crdt_search ON messages_crdt(dataset, row, column, timestamp);
|
||||
|
||||
ANALYZE;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,33 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- This adds the isChild/parent_id constraint in `where`
|
||||
DROP VIEW IF EXISTS v_transactions_layer2;
|
||||
CREATE VIEW v_transactions_layer2 AS
|
||||
SELECT
|
||||
t.id AS id,
|
||||
t.isParent AS is_parent,
|
||||
t.isChild AS is_child,
|
||||
t.acct AS account,
|
||||
CASE WHEN t.isChild = 0 THEN NULL ELSE t.parent_id END AS parent_id,
|
||||
CASE WHEN t.isParent = 1 THEN NULL ELSE cm.transferId END AS category,
|
||||
pm.targetId AS payee,
|
||||
t.imported_description AS imported_payee,
|
||||
IFNULL(t.amount, 0) AS amount,
|
||||
t.notes AS notes,
|
||||
t.date AS date,
|
||||
t.financial_id AS imported_id,
|
||||
t.error AS error,
|
||||
t.starting_balance_flag AS starting_balance_flag,
|
||||
t.transferred_id AS transfer_id,
|
||||
t.sort_order AS sort_order,
|
||||
t.cleared AS cleared,
|
||||
t.tombstone AS tombstone
|
||||
FROM transactions t
|
||||
LEFT JOIN category_mapping cm ON cm.id = t.category
|
||||
LEFT JOIN payee_mapping pm ON pm.id = t.description
|
||||
WHERE
|
||||
t.date IS NOT NULL AND
|
||||
t.acct IS NOT NULL AND
|
||||
(t.isChild = 0 OR t.parent_id IS NOT NULL);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,10 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE __meta__ (key TEXT PRIMARY KEY, value TEXT);
|
||||
|
||||
DROP VIEW IF EXISTS v_transactions_layer2;
|
||||
DROP VIEW IF EXISTS v_transactions_layer1;
|
||||
DROP VIEW IF EXISTS v_transactions;
|
||||
DROP VIEW IF EXISTS v_categories;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,5 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN sort_order REAL;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,28 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE schedules
|
||||
(id TEXT PRIMARY KEY,
|
||||
rule TEXT,
|
||||
active INTEGER DEFAULT 0,
|
||||
completed INTEGER DEFAULT 0,
|
||||
posts_transaction INTEGER DEFAULT 0,
|
||||
tombstone INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE schedules_next_date
|
||||
(id TEXT PRIMARY KEY,
|
||||
schedule_id TEXT,
|
||||
local_next_date INTEGER,
|
||||
local_next_date_ts INTEGER,
|
||||
base_next_date INTEGER,
|
||||
base_next_date_ts INTEGER);
|
||||
|
||||
CREATE TABLE schedules_json_paths
|
||||
(schedule_id TEXT PRIMARY KEY,
|
||||
payee TEXT,
|
||||
account TEXT,
|
||||
amount TEXT,
|
||||
date TEXT);
|
||||
|
||||
ALTER TABLE transactions ADD COLUMN schedule TEXT;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,135 +0,0 @@
|
||||
export default async function runMigration(db, uuid) {
|
||||
function getValue(node) {
|
||||
return node.expr != null ? node.expr : node.cachedValue;
|
||||
}
|
||||
|
||||
db.execQuery(`
|
||||
CREATE TABLE zero_budget_months
|
||||
(id TEXT PRIMARY KEY,
|
||||
buffered INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE zero_budgets
|
||||
(id TEXT PRIMARY KEY,
|
||||
month INTEGER,
|
||||
category TEXT,
|
||||
amount INTEGER DEFAULT 0,
|
||||
carryover INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE reflect_budgets
|
||||
(id TEXT PRIMARY KEY,
|
||||
month INTEGER,
|
||||
category TEXT,
|
||||
amount INTEGER DEFAULT 0,
|
||||
carryover INTEGER DEFAULT 0);
|
||||
|
||||
CREATE TABLE notes
|
||||
(id TEXT PRIMARY KEY,
|
||||
note TEXT);
|
||||
|
||||
CREATE TABLE kvcache (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
||||
`);
|
||||
|
||||
// Migrate budget amounts and carryover
|
||||
let budget = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
budget.forEach(monthBudget => {
|
||||
let match = monthBudget.name.match(
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/,
|
||||
);
|
||||
if (match == null) {
|
||||
console.log('Warning: invalid budget month name', monthBudget.name);
|
||||
return;
|
||||
}
|
||||
|
||||
let type = match[1];
|
||||
let month = match[2].slice(0, 4) + '-' + match[2].slice(4);
|
||||
let dbmonth = parseInt(match[2]);
|
||||
let cat = match[3];
|
||||
|
||||
let amount = parseInt(getValue(monthBudget));
|
||||
if (isNaN(amount)) {
|
||||
amount = 0;
|
||||
}
|
||||
|
||||
let sheetName = monthBudget.name.split('!')[0];
|
||||
let carryover = db.runQuery(
|
||||
'SELECT * FROM spreadsheet_cells WHERE name = ?',
|
||||
[`${sheetName}!carryover-${cat}`],
|
||||
true,
|
||||
);
|
||||
|
||||
let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets';
|
||||
db.runQuery(
|
||||
`INSERT INTO ${table} (id, month, category, amount, carryover) VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`${month}-${cat}`,
|
||||
dbmonth,
|
||||
cat,
|
||||
amount,
|
||||
carryover.length > 0 && getValue(carryover[0]) === 'true' ? 1 : 0,
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Migrate buffers
|
||||
let buffers = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
db.transaction(() => {
|
||||
buffers.forEach(buffer => {
|
||||
let match = buffer.name.match(/^budget(\d+)!buffered$/);
|
||||
if (match) {
|
||||
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
|
||||
let amount = parseInt(getValue(buffer));
|
||||
if (isNaN(amount)) {
|
||||
amount = 0;
|
||||
}
|
||||
|
||||
db.runQuery(
|
||||
`INSERT INTO zero_budget_months (id, buffered) VALUES (?, ?)`,
|
||||
[month, amount],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Migrate notes
|
||||
let notes = db.runQuery(
|
||||
`SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
let parseNote = str => {
|
||||
try {
|
||||
let value = JSON.parse(str);
|
||||
return value && value !== '' ? value : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
db.transaction(() => {
|
||||
notes.forEach(note => {
|
||||
let parsed = parseNote(getValue(note));
|
||||
if (parsed) {
|
||||
let [, id] = note.name.split('!');
|
||||
db.runQuery(`INSERT INTO notes (id, note) VALUES (?, ?)`, [id, parsed]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
db.execQuery(`
|
||||
DROP TABLE spreadsheet_cells;
|
||||
ANALYZE;
|
||||
VACUUM;
|
||||
`);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE rules ADD COLUMN conditions_op TEXT DEFAULT 'and';
|
||||
|
||||
COMMIT;
|
||||
55
packages/desktop-client/src/ResponsiveProvider.tsx
Normal file
55
packages/desktop-client/src/ResponsiveProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode, createContext, useContext } from 'react';
|
||||
|
||||
import { useViewportSize } from '@react-aria/utils';
|
||||
|
||||
import { breakpoints } from './tokens';
|
||||
|
||||
type TResponsiveContext = {
|
||||
atLeastMediumWidth: boolean;
|
||||
isNarrowWidth: boolean;
|
||||
isSmallWidth: boolean;
|
||||
isMediumWidth: boolean;
|
||||
isWideWidth: boolean;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const ResponsiveContext = createContext<TResponsiveContext>(null);
|
||||
|
||||
export function ResponsiveProvider(props: { children: ReactNode }) {
|
||||
/*
|
||||
* Ensure we render on every viewport size change,
|
||||
* even though we're interested in document.documentElement.client<Width|Height>
|
||||
* clientWidth/Height are the document size, do not change on pinch-zoom,
|
||||
* and are what our `min-width` media queries are reading
|
||||
* Viewport size changes on pinch-zoom, which may be useful later when dealing with on-screen keyboards
|
||||
*/
|
||||
useViewportSize();
|
||||
|
||||
const height = document.documentElement.clientHeight;
|
||||
const width = document.documentElement.clientWidth;
|
||||
|
||||
// Possible view modes: narrow, small, medium, wide
|
||||
// To check if we're at least small width, check !isNarrowWidth
|
||||
const viewportInfo = {
|
||||
// atLeastMediumWidth is provided to avoid checking (isMediumWidth || isWideWidth)
|
||||
atLeastMediumWidth: width >= breakpoints.medium,
|
||||
isNarrowWidth: width < breakpoints.small,
|
||||
isSmallWidth: width >= breakpoints.small && width < breakpoints.medium,
|
||||
isMediumWidth: width >= breakpoints.medium && width < breakpoints.wide,
|
||||
// No atLeastWideWidth because that's identical to isWideWidth
|
||||
isWideWidth: width >= breakpoints.wide,
|
||||
height,
|
||||
width,
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContext.Provider value={viewportInfo}>
|
||||
{props.children}
|
||||
</ResponsiveContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useResponsive() {
|
||||
return useContext(ResponsiveContext);
|
||||
}
|
||||
@@ -110,7 +110,6 @@ global.Actual = {
|
||||
applyAppUpdate: () => {},
|
||||
updateAppMenu: isBudgetOpen => {},
|
||||
|
||||
ipcConnect: () => {},
|
||||
getServerSocket: async () => {
|
||||
return worker;
|
||||
},
|
||||
@@ -119,12 +118,12 @@ global.Actual = {
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
// Cmd/Ctrl+o
|
||||
if (e.code === 'KeyO') {
|
||||
if (e.key === 'o') {
|
||||
e.preventDefault();
|
||||
window.__actionsForMenu.closeBudget();
|
||||
}
|
||||
// Cmd/Ctrl+z
|
||||
else if (e.code === 'KeyZ') {
|
||||
else if (e.key.toLowerCase() === 'z') {
|
||||
if (
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
let ActiveLocationContext = React.createContext(null);
|
||||
let ActiveLocationContext = createContext(null);
|
||||
|
||||
export function ActiveLocationProvider({ location, children }) {
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css } from 'glamor';
|
||||
|
||||
import Refresh from '../icons/v1/Refresh';
|
||||
|
||||
import View from './View';
|
||||
import View from './common/View';
|
||||
|
||||
let spin = css.keyframes({
|
||||
'0%': { transform: 'rotateZ(0deg)' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { css } from 'glamor';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import installPolyfills from '../polyfills';
|
||||
import { ResponsiveProvider } from '../ResponsiveProvider';
|
||||
import { styles, hasHiddenScrollbars } from '../style';
|
||||
|
||||
import AppBackground from './AppBackground';
|
||||
@@ -19,7 +20,7 @@ import ManagementApp from './manager/ManagementApp';
|
||||
import MobileWebMessage from './MobileWebMessage';
|
||||
import UpdateNotification from './UpdateNotification';
|
||||
|
||||
class App extends React.Component {
|
||||
class App extends Component {
|
||||
state = {
|
||||
fatalError: null,
|
||||
initializing: true,
|
||||
@@ -90,42 +91,44 @@ class App extends React.Component {
|
||||
const { fatalError, initializing, hiddenScrollbars } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
|
||||
{...css([
|
||||
{
|
||||
height: '100%',
|
||||
backgroundColor: '#E8ECF0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
styles.lightScrollbar,
|
||||
])}
|
||||
>
|
||||
{fatalError ? (
|
||||
<React.Fragment>
|
||||
<AppBackground />
|
||||
<FatalError error={fatalError} buttonText="Restart app" />
|
||||
</React.Fragment>
|
||||
) : initializing ? (
|
||||
<AppBackground
|
||||
initializing={initializing}
|
||||
loadingText={loadingText}
|
||||
/>
|
||||
) : budgetId ? (
|
||||
<FinancesApp />
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveProvider>
|
||||
<div
|
||||
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
|
||||
{...css([
|
||||
{
|
||||
height: '100%',
|
||||
backgroundColor: '#E8ECF0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
styles.lightScrollbar,
|
||||
])}
|
||||
>
|
||||
{fatalError ? (
|
||||
<>
|
||||
<AppBackground />
|
||||
<FatalError error={fatalError} buttonText="Restart app" />
|
||||
</>
|
||||
) : initializing ? (
|
||||
<AppBackground
|
||||
initializing={initializing}
|
||||
loadingText={loadingText}
|
||||
/>
|
||||
<ManagementApp isLoading={loadingText != null} />
|
||||
</>
|
||||
)}
|
||||
) : budgetId ? (
|
||||
<FinancesApp />
|
||||
) : (
|
||||
<>
|
||||
<AppBackground
|
||||
initializing={initializing}
|
||||
loadingText={loadingText}
|
||||
/>
|
||||
<ManagementApp isLoading={loadingText != null} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<UpdateNotification />
|
||||
<MobileWebMessage />
|
||||
</div>
|
||||
<UpdateNotification />
|
||||
<MobileWebMessage />
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { View, Block } from './common';
|
||||
|
||||
function AppBackground({ initializing, loadingText }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Background />
|
||||
|
||||
{(loadingText != null || initializing) && (
|
||||
@@ -32,7 +32,7 @@ function AppBackground({ initializing, loadingText }) {
|
||||
<AnimatedLoading width={25} color={colors.n1} />
|
||||
</View>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function BankSyncStatus({ accountsSyncing }) {
|
||||
: accountsSyncing
|
||||
: null;
|
||||
|
||||
const transitions = useTransition(name, null, {
|
||||
const transitions = useTransition(name, {
|
||||
from: { opacity: 0, transform: 'translateY(-100px)' },
|
||||
enter: { opacity: 1, transform: 'translateY(0)' },
|
||||
leave: { opacity: 0, transform: 'translateY(-100px)' },
|
||||
@@ -35,10 +35,10 @@ function BankSyncStatus({ accountsSyncing }) {
|
||||
zIndex: 501,
|
||||
}}
|
||||
>
|
||||
{transitions.map(
|
||||
({ item, key, props }) =>
|
||||
{transitions(
|
||||
(style, item) =>
|
||||
item && (
|
||||
<animated.div key={key} style={props}>
|
||||
<animated.div key={item} style={style}>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { Component, useState } from 'react';
|
||||
|
||||
import { colors } from '../style';
|
||||
|
||||
import { View, Stack, Text, Block, Modal, P, Link, Button } from './common';
|
||||
import { Checkbox } from './forms';
|
||||
|
||||
class FatalError extends React.Component {
|
||||
class FatalError extends Component {
|
||||
state = { showError: false };
|
||||
|
||||
renderSimple(error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import Backend from 'react-dnd-html5-backend';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -26,19 +26,19 @@ import * as undo from 'loot-core/src/platform/client/undo';
|
||||
import Cog from '../icons/v1/Cog';
|
||||
import PiggyBank from '../icons/v1/PiggyBank';
|
||||
import Wallet from '../icons/v1/Wallet';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { colors, styles } from '../style';
|
||||
import { isMobile } from '../util';
|
||||
import { getLocationState, makeLocationState } from '../util/location-state';
|
||||
import { getIsOutdated, getLatestVersion } from '../util/versions';
|
||||
|
||||
import Account from './accounts/Account';
|
||||
import { default as MobileAccount } from './accounts/MobileAccount';
|
||||
import { default as MobileAccounts } from './accounts/MobileAccounts';
|
||||
import MobileAccount from './accounts/MobileAccount';
|
||||
import MobileAccounts from './accounts/MobileAccounts';
|
||||
import { ActiveLocationProvider } from './ActiveLocation';
|
||||
import BankSyncStatus from './BankSyncStatus';
|
||||
import Budget from './budget';
|
||||
import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
|
||||
import { default as MobileBudget } from './budget/MobileBudget';
|
||||
import MobileBudget from './budget/MobileBudget';
|
||||
import { View } from './common';
|
||||
import FloatableSidebar, { SidebarProvider } from './FloatableSidebar';
|
||||
import GlobalKeys from './GlobalKeys';
|
||||
@@ -57,74 +57,87 @@ import PostsOfflineNotification from './schedules/PostsOfflineNotification';
|
||||
import Settings from './settings';
|
||||
import Titlebar, { TitlebarProvider } from './Titlebar';
|
||||
|
||||
function PageRoute({ path, component: Component }) {
|
||||
return (
|
||||
<Route
|
||||
path={path}
|
||||
children={props => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
display: props.match ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
<Component {...props} />
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
function NarrowNotSupported({ children, redirectTo = '/budget' }) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
return isNarrowWidth ? <Redirect to={redirectTo} /> : children;
|
||||
}
|
||||
|
||||
function Routes({ isMobile, location }) {
|
||||
function Routes({ location }) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
return (
|
||||
<Switch location={location}>
|
||||
<Route path="/">
|
||||
<Route path="/" exact render={() => <Redirect to="/budget" />} />
|
||||
<Route path="/" exact render={() => <Redirect to="/budget" />} />
|
||||
|
||||
<PageRoute path="/reports" component={Reports} />
|
||||
<PageRoute
|
||||
path="/budget"
|
||||
component={isMobile ? MobileBudget : Budget}
|
||||
/>
|
||||
<Route path="/reports">
|
||||
<NarrowNotSupported>
|
||||
<Reports />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
|
||||
<Route path="/schedules" exact component={Schedules} />
|
||||
<Route path="/schedule/edit" exact component={EditSchedule} />
|
||||
<Route path="/schedule/edit/:id" component={EditSchedule} />
|
||||
<Route path="/schedule/link" component={LinkSchedule} />
|
||||
<Route path="/schedule/discover" component={DiscoverSchedules} />
|
||||
<Route
|
||||
path="/schedule/posts-offline-notification"
|
||||
component={PostsOfflineNotification}
|
||||
/>
|
||||
<Route path="/budget">
|
||||
{isNarrowWidth ? <MobileBudget /> : <Budget />}
|
||||
</Route>
|
||||
|
||||
<Route path="/payees" exact component={ManagePayeesPage} />
|
||||
<Route path="/rules" exact component={ManageRulesPage} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/nordigen/link" exact component={NordigenLink} />
|
||||
<Route path="/schedules" exact>
|
||||
<NarrowNotSupported>
|
||||
<Schedules />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
<Route path="/schedule/edit" exact>
|
||||
<NarrowNotSupported>
|
||||
<EditSchedule />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
<Route path="/schedule/edit/:id">
|
||||
<NarrowNotSupported>
|
||||
<EditSchedule />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
<Route path="/schedule/link">
|
||||
<NarrowNotSupported>
|
||||
<LinkSchedule />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
<Route path="/schedule/discover">
|
||||
<NarrowNotSupported>
|
||||
<DiscoverSchedules />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
<Route path="/schedule/posts-offline-notification">
|
||||
<PostsOfflineNotification />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/accounts/:id"
|
||||
exact
|
||||
children={props => {
|
||||
const AcctCmp = isMobile ? MobileAccount : Account;
|
||||
return (
|
||||
props.match && <AcctCmp key={props.match.params.id} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts"
|
||||
exact
|
||||
component={isMobile ? MobileAccounts : Account}
|
||||
/>
|
||||
<Route path="/payees" exact>
|
||||
<ManagePayeesPage />
|
||||
</Route>
|
||||
<Route path="/rules" exact>
|
||||
<ManageRulesPage />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<Settings />
|
||||
</Route>
|
||||
<Route path="/nordigen/link" exact>
|
||||
<NarrowNotSupported>
|
||||
<NordigenLink />
|
||||
</NarrowNotSupported>
|
||||
</Route>
|
||||
|
||||
<Route path="/accounts/:id" exact>
|
||||
{props => {
|
||||
const AcctCmp = isNarrowWidth ? MobileAccount : Account;
|
||||
return (
|
||||
props.match && <AcctCmp key={props.match.params.id} {...props} />
|
||||
);
|
||||
}}
|
||||
</Route>
|
||||
<Route path="/accounts" exact>
|
||||
{isNarrowWidth ? <MobileAccounts /> : <Account />}
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedRoutes({ isMobile }) {
|
||||
function StackedRoutes() {
|
||||
let location = useLocation();
|
||||
let locationPtr = getLocationState(location, 'locationPtr');
|
||||
|
||||
@@ -139,14 +152,14 @@ function StackedRoutes({ isMobile }) {
|
||||
|
||||
return (
|
||||
<ActiveLocationProvider location={locations[locations.length - 1]}>
|
||||
<Routes location={base} isMobile={isMobile} />
|
||||
<Routes location={base} />
|
||||
{stack.map((location, idx) => (
|
||||
<PageTypeProvider
|
||||
key={location.key}
|
||||
type="modal"
|
||||
current={idx === stack.length - 1}
|
||||
>
|
||||
<Routes location={location} isMobile={isMobile} />
|
||||
<Routes location={location} />
|
||||
</PageTypeProvider>
|
||||
))}
|
||||
</ActiveLocationProvider>
|
||||
@@ -177,6 +190,7 @@ function NavTab({ icon: TabIcon, name, path }) {
|
||||
}
|
||||
|
||||
function MobileNavTabs() {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -184,74 +198,60 @@ function MobileNavTabs() {
|
||||
borderTop: `1px solid ${colors.n10}`,
|
||||
bottom: 0,
|
||||
...styles.shadow,
|
||||
display: 'flex',
|
||||
display: isNarrowWidth ? 'flex' : 'none',
|
||||
height: '80px',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 10,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<NavTab name="Budget" path="/budget" icon={Wallet} isActive={false} />
|
||||
<NavTab
|
||||
name="Accounts"
|
||||
path="/accounts"
|
||||
icon={PiggyBank}
|
||||
isActive={false}
|
||||
/>
|
||||
<NavTab name="Settings" path="/settings" icon={Cog} isActive={false} />
|
||||
<NavTab name="Budget" path="/budget" icon={Wallet} />
|
||||
<NavTab name="Accounts" path="/accounts" icon={PiggyBank} />
|
||||
<NavTab name="Settings" path="/settings" icon={Cog} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class FinancesApp extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isMobile: isMobile() };
|
||||
this.history = createBrowserHistory();
|
||||
function FinancesApp(props) {
|
||||
const [patchedHistory] = useState(() => createBrowserHistory());
|
||||
|
||||
let oldPush = this.history.push;
|
||||
this.history.push = (to, state) => {
|
||||
useEffect(() => {
|
||||
let oldPush = patchedHistory.push;
|
||||
patchedHistory.push = (to, state) => {
|
||||
let newState = makeLocationState(to.state || state);
|
||||
if (typeof to === 'object') {
|
||||
return oldPush.call(this.history, { ...to, state: newState });
|
||||
return oldPush.call(patchedHistory, { ...to, state: newState });
|
||||
} else {
|
||||
return oldPush.call(this.history, to, newState);
|
||||
return oldPush.call(patchedHistory, to, newState);
|
||||
}
|
||||
};
|
||||
|
||||
// I'm not sure if this is the best approach but we need this to
|
||||
// globally. We could instead move various workflows inside global
|
||||
// React components, but that's for another day.
|
||||
window.__history = this.history;
|
||||
window.__history = patchedHistory;
|
||||
|
||||
undo.setUndoState('url', window.location.href);
|
||||
|
||||
this.cleanup = this.history.listen(location => {
|
||||
const cleanup = patchedHistory.listen(location => {
|
||||
undo.setUndoState('url', window.location.href);
|
||||
});
|
||||
|
||||
this.handleWindowResize = this.handleWindowResize.bind(this);
|
||||
}
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
handleWindowResize() {
|
||||
this.setState({
|
||||
isMobile: isMobile(),
|
||||
windowWidth: window.innerWidth,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
useEffect(() => {
|
||||
// TODO: quick hack fix for showing the demo
|
||||
if (this.history.location.pathname === '/subscribe') {
|
||||
this.history.push('/');
|
||||
if (patchedHistory.location.pathname === '/subscribe') {
|
||||
patchedHistory.push('/');
|
||||
}
|
||||
|
||||
// Get the accounts and check if any exist. If there are no
|
||||
// accounts, we want to redirect the user to the All Accounts
|
||||
// screen which will prompt them to add an account
|
||||
this.props.getAccounts().then(accounts => {
|
||||
props.getAccounts().then(accounts => {
|
||||
if (accounts.length === 0) {
|
||||
this.history.push('/accounts');
|
||||
patchedHistory.push('/accounts');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -261,96 +261,84 @@ class FinancesApp extends React.Component {
|
||||
// Wait a little bit to make sure the sync button will get the
|
||||
// sync start event. This can be improved later.
|
||||
setTimeout(async () => {
|
||||
await this.props.sync();
|
||||
await props.sync();
|
||||
|
||||
// Check for upgrade notifications. We do this after syncing
|
||||
// because these states are synced across devices, so they will
|
||||
// only see it once for this file
|
||||
checkForUpgradeNotifications(
|
||||
this.props.addNotification,
|
||||
this.props.resetSync,
|
||||
this.history,
|
||||
props.addNotification,
|
||||
props.resetSync,
|
||||
patchedHistory,
|
||||
);
|
||||
}, 100);
|
||||
|
||||
setTimeout(async () => {
|
||||
await this.props.sync();
|
||||
await checkForUpdateNotification(
|
||||
this.props.addNotification,
|
||||
props.addNotification,
|
||||
getIsOutdated,
|
||||
getLatestVersion,
|
||||
this.props.loadPrefs,
|
||||
this.props.savePrefs,
|
||||
props.loadPrefs,
|
||||
props.savePrefs,
|
||||
);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
return (
|
||||
<Router history={patchedHistory}>
|
||||
<CompatRouter>
|
||||
<View style={{ height: '100%', backgroundColor: colors.n10 }}>
|
||||
<GlobalKeys />
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanup();
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Router history={this.history}>
|
||||
<CompatRouter>
|
||||
<View style={{ height: '100%', backgroundColor: colors.n10 }}>
|
||||
<GlobalKeys />
|
||||
|
||||
<View style={{ flexDirection: 'row', flex: 1 }}>
|
||||
{!this.state.isMobile && <FloatableSidebar />}
|
||||
<View style={{ flexDirection: 'row', flex: 1 }}>
|
||||
<FloatableSidebar />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
style={{
|
||||
WebkitAppRegion: 'drag',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!this.state.isMobile && (
|
||||
<Titlebar
|
||||
style={{
|
||||
WebkitAppRegion: 'drag',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Notifications />
|
||||
<BankSyncStatus />
|
||||
<StackedRoutes isMobile={this.state.isMobile} />
|
||||
<Modals history={this.history} />
|
||||
</div>
|
||||
{this.state.isMobile && (
|
||||
<Switch>
|
||||
<Route path="/budget" component={MobileNavTabs} />
|
||||
<Route path="/accounts" component={MobileNavTabs} />
|
||||
<Route path="/settings" component={MobileNavTabs} />
|
||||
</Switch>
|
||||
)}
|
||||
<Notifications />
|
||||
<BankSyncStatus />
|
||||
<StackedRoutes />
|
||||
<Modals history={patchedHistory} />
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path="/budget">
|
||||
<MobileNavTabs />
|
||||
</Route>
|
||||
<Route path="/accounts">
|
||||
<MobileNavTabs />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<MobileNavTabs />
|
||||
</Route>
|
||||
</Switch>
|
||||
</View>
|
||||
</View>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
</View>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
function FinancesAppWithContext(props) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { createRef, PureComponent } from 'react';
|
||||
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
@@ -15,7 +15,7 @@ function ResizeObserver({ onResize, children }) {
|
||||
return children(ref);
|
||||
}
|
||||
|
||||
export class FixedSizeList extends React.PureComponent {
|
||||
export class FixedSizeList extends PureComponent {
|
||||
_outerRef;
|
||||
_resetIsScrollingTimeoutId = null;
|
||||
|
||||
@@ -31,9 +31,9 @@ export class FixedSizeList extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.lastPositions = React.createRef();
|
||||
this.lastPositions = createRef();
|
||||
this.lastPositions.current = new Map();
|
||||
this.needsAnimationRerender = React.createRef();
|
||||
this.needsAnimationRerender = createRef();
|
||||
this.needsAnimationRerender.current = false;
|
||||
this.animationEnabled = false;
|
||||
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import React, { createContext, useState, useContext, useMemo } from 'react';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { useViewportSize } from '@react-aria/utils';
|
||||
import mitt from 'mitt';
|
||||
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
|
||||
import { colors } from '../style';
|
||||
import { breakpoints } from '../tokens';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
|
||||
import { View } from './common';
|
||||
import { SIDEBAR_WIDTH } from './sidebar';
|
||||
import SidebarWithData from './SidebarWithData';
|
||||
|
||||
const SidebarContext = React.createContext(null);
|
||||
const SidebarContext = createContext(null);
|
||||
|
||||
export function SidebarProvider({ children }) {
|
||||
let emitter = mitt();
|
||||
let floatingSidebar = useSelector(
|
||||
state => state.prefs.global.floatingSidebar,
|
||||
);
|
||||
let [hidden, setHidden] = useState(true);
|
||||
let { width } = useResponsive();
|
||||
let alwaysFloats = width < 668;
|
||||
let floating = floatingSidebar || alwaysFloats;
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
show: () => emitter.emit('show'),
|
||||
hide: () => emitter.emit('hide'),
|
||||
toggle: () => emitter.emit('toggle'),
|
||||
on: (name, listener) => {
|
||||
emitter.on(name, listener);
|
||||
return () => emitter.off(name, listener);
|
||||
},
|
||||
}}
|
||||
value={{ hidden, setHidden, floating, alwaysFloats }}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
@@ -36,96 +31,56 @@ export function SidebarProvider({ children }) {
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
return useContext(SidebarContext);
|
||||
let { hidden, setHidden, floating, alwaysFloats } =
|
||||
useContext(SidebarContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({ hidden, setHidden, floating, alwaysFloats }),
|
||||
[hidden, setHidden, floating, alwaysFloats],
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ floatingSidebar }) {
|
||||
let [hidden, setHidden] = useState(true);
|
||||
let sidebar = useSidebar();
|
||||
let { isNarrowWidth } = useResponsive();
|
||||
|
||||
let windowWidth = useViewportSize().width;
|
||||
let sidebarShouldFloat = floatingSidebar || windowWidth < breakpoints.medium;
|
||||
let sidebarShouldFloat = floatingSidebar || sidebar.alwaysFloats;
|
||||
|
||||
if (!sidebarShouldFloat && hidden) {
|
||||
setHidden(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cleanups = [
|
||||
sidebar.on('show', () => setHidden(false)),
|
||||
sidebar.on('hide', () => setHidden(true)),
|
||||
sidebar.on('toggle', () => setHidden(hidden => !hidden)),
|
||||
];
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn());
|
||||
};
|
||||
}, [sidebar]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sidebarShouldFloat && (
|
||||
<View
|
||||
onMouseOver={() => setHidden(false)}
|
||||
onMouseLeave={() => setHidden(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: hidden ? 0 : 160,
|
||||
zIndex: 999,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
|
||||
<View
|
||||
onMouseOver={
|
||||
sidebarShouldFloat
|
||||
? e => {
|
||||
e.stopPropagation();
|
||||
setHidden(false);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMouseLeave={sidebarShouldFloat ? () => setHidden(true) : null}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
// If not floating, the -50 takes into account the transform below
|
||||
bottom: sidebarShouldFloat ? 50 : -50,
|
||||
zIndex: 1001,
|
||||
borderRadius: '0 6px 6px 0',
|
||||
overflow: 'hidden',
|
||||
boxShadow:
|
||||
!sidebarShouldFloat || hidden
|
||||
? 'none'
|
||||
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
|
||||
transform: `translateY(${!sidebarShouldFloat ? -50 : 0}px)
|
||||
translateX(${hidden ? -SIDEBAR_WIDTH : 0}px)`,
|
||||
transition: 'transform .5s, box-shadow .5s',
|
||||
}}
|
||||
>
|
||||
<SidebarWithData />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: colors.n1,
|
||||
opacity: sidebarShouldFloat ? 0 : 1,
|
||||
transform: `translateX(${sidebarShouldFloat ? -50 : 0}px)`,
|
||||
transition: 'transform .4s, opacity .2s',
|
||||
width: SIDEBAR_WIDTH,
|
||||
},
|
||||
sidebarShouldFloat && {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
]}
|
||||
></View>
|
||||
</>
|
||||
return isNarrowWidth ? null : (
|
||||
<View
|
||||
onMouseOver={
|
||||
sidebarShouldFloat
|
||||
? e => {
|
||||
e.stopPropagation();
|
||||
sidebar.setHidden(false);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMouseLeave={sidebarShouldFloat ? () => sidebar.setHidden(true) : null}
|
||||
style={{
|
||||
position: sidebarShouldFloat ? 'absolute' : null,
|
||||
top: 12,
|
||||
// If not floating, the -50 takes into account the transform below
|
||||
bottom: sidebarShouldFloat ? 12 : -50,
|
||||
zIndex: 1001,
|
||||
borderRadius: sidebarShouldFloat ? '0 6px 6px 0' : 0,
|
||||
overflow: 'hidden',
|
||||
boxShadow:
|
||||
!sidebarShouldFloat || sidebar.hidden
|
||||
? 'none'
|
||||
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
|
||||
transform: `translateY(${!sidebarShouldFloat ? -12 : 0}px)
|
||||
translateX(${
|
||||
sidebarShouldFloat && sidebar.hidden
|
||||
? -SIDEBAR_WIDTH
|
||||
: 0
|
||||
}px)`,
|
||||
transition:
|
||||
'transform .5s, box-shadow .5s, border-radius .5s, bottom .5s',
|
||||
}}
|
||||
>
|
||||
<SidebarWithData />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,17 @@ export default function GlobalKeys() {
|
||||
}
|
||||
|
||||
if (e.metaKey) {
|
||||
switch (e.code) {
|
||||
case 'Digit1':
|
||||
switch (e.key) {
|
||||
case '1':
|
||||
history.push('/budget');
|
||||
break;
|
||||
case 'Digit2':
|
||||
case '2':
|
||||
history.push('/reports');
|
||||
break;
|
||||
case 'Digit3':
|
||||
case '3':
|
||||
history.push('/accounts');
|
||||
break;
|
||||
case 'Comma':
|
||||
case ',':
|
||||
if (Platform.OS === 'mac') {
|
||||
history.push('/settings');
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useEffect, useContext } from 'react';
|
||||
import React, { createContext, useEffect, useContext } from 'react';
|
||||
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import hotkeys, { type KeyHandler as HotKeyHandler } from 'hotkeys-js';
|
||||
|
||||
let KeyScopeContext = React.createContext('app');
|
||||
let KeyScopeContext = createContext('app');
|
||||
|
||||
hotkeys.filter = event => {
|
||||
var target = event.target || event.srcElement;
|
||||
var tagName = target.tagName;
|
||||
let target = (event.target || event.srcElement) as HTMLElement;
|
||||
let tagName = target.tagName;
|
||||
|
||||
// This is the default behavior of hotkeys, except we only suppress
|
||||
// key presses if the meta key is not pressed
|
||||
@@ -16,7 +16,7 @@ hotkeys.filter = event => {
|
||||
((tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
tagName === 'SELECT') &&
|
||||
!target.readOnly))
|
||||
!target['readOnly']))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -24,7 +24,16 @@ hotkeys.filter = event => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export function KeyHandler({ keyName, eventType = 'keydown', handler }) {
|
||||
type KeyHandlerProps = {
|
||||
keyName: string;
|
||||
eventType?: string;
|
||||
handler: HotKeyHandler;
|
||||
};
|
||||
export function KeyHandler({
|
||||
keyName,
|
||||
eventType = 'keydown',
|
||||
handler,
|
||||
}: KeyHandlerProps) {
|
||||
let scope = useContext(KeyScopeContext);
|
||||
|
||||
if (eventType !== 'keyup' && eventType !== 'keydown') {
|
||||
@@ -44,6 +53,7 @@ export function KeyHandler({ keyName, eventType = 'keydown', handler }) {
|
||||
hotkeys(keyName, { scope, keyup: true }, _handler);
|
||||
|
||||
return () => {
|
||||
// @ts-expect-error unbind args typedef does not expect an object
|
||||
hotkeys.unbind({
|
||||
key: keyName,
|
||||
scope,
|
||||
@@ -55,7 +65,11 @@ export function KeyHandler({ keyName, eventType = 'keydown', handler }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function KeyHandlers({ eventType, keys = {} }) {
|
||||
type KeyHandlersProps = {
|
||||
eventType?: string;
|
||||
keys: Record<string, HotKeyHandler>;
|
||||
};
|
||||
export function KeyHandlers({ eventType, keys = {} }: KeyHandlersProps) {
|
||||
let handlers = Object.keys(keys).map(key => {
|
||||
return (
|
||||
<KeyHandler
|
||||
@@ -67,5 +81,6 @@ export function KeyHandlers({ eventType, keys = {} }) {
|
||||
);
|
||||
});
|
||||
|
||||
return handlers;
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{handlers}</>;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -297,7 +299,7 @@ export function ActionExpression({ field, op, value, options, style }) {
|
||||
);
|
||||
}
|
||||
|
||||
let Rule = React.memo(
|
||||
let Rule = memo(
|
||||
({
|
||||
rule,
|
||||
hovered,
|
||||
@@ -327,8 +329,8 @@ let Rule = React.memo(
|
||||
<SelectCell
|
||||
exposed={hovered || selected || editing}
|
||||
focused={focusedField === 'select'}
|
||||
onSelect={() => {
|
||||
dispatchSelected({ type: 'select', id: rule.id });
|
||||
onSelect={e => {
|
||||
dispatchSelected({ type: 'select', id: rule.id, event: e });
|
||||
}}
|
||||
onEdit={() => onEdit(rule.id, 'select')}
|
||||
selected={selected}
|
||||
@@ -411,7 +413,7 @@ let Rule = React.memo(
|
||||
},
|
||||
);
|
||||
|
||||
let SimpleTable = React.forwardRef(
|
||||
let SimpleTable = forwardRef(
|
||||
(
|
||||
{ data, navigator, loadMore, style, onHoverLeave, children, ...props },
|
||||
ref,
|
||||
@@ -476,7 +478,7 @@ function RulesHeader() {
|
||||
exposed={true}
|
||||
focused={false}
|
||||
selected={selectedItems.size > 0}
|
||||
onSelect={() => dispatchSelected({ type: 'select-all' })}
|
||||
onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
|
||||
/>
|
||||
<Cell value="Stage" width={50} />
|
||||
<Cell value="Rule" width="flex" />
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePrefs } from 'loot-core/src/client/actions';
|
||||
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { colors, styles } from '../style';
|
||||
import { isMobile } from '../util';
|
||||
|
||||
import { View, Text, Button } from './common';
|
||||
import { Checkbox } from './forms';
|
||||
@@ -16,8 +16,10 @@ export default function MobileWebMessage() {
|
||||
return (state.prefs.local && state.prefs.local.hideMobileMessage) || true;
|
||||
});
|
||||
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
let [show, setShow] = useState(
|
||||
isMobile() &&
|
||||
isNarrowWidth &&
|
||||
!hideMobileMessagePref &&
|
||||
!document.cookie.match(/hideMobileMessage=true/),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user