Compare commits
85 Commits
v24.11.0
...
ts-useSpli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a767d99534 | ||
|
|
636153593e | ||
|
|
298b734539 | ||
|
|
e96b986ad0 | ||
|
|
5104a1a563 | ||
|
|
6ea77324ef | ||
|
|
2b908e9263 | ||
|
|
a2892270d2 | ||
|
|
d649eec4db | ||
|
|
5717d90544 | ||
|
|
a35af73023 | ||
|
|
e4b40fb831 | ||
|
|
fa8ff79208 | ||
|
|
3ce7ae91d9 | ||
|
|
1b25235cc7 | ||
|
|
f207803f7a | ||
|
|
df7bc5d2f0 | ||
|
|
5e7538fde3 | ||
|
|
2c0bd6bafd | ||
|
|
501c8653ef | ||
|
|
22623ce65e | ||
|
|
c25e3d4163 | ||
|
|
339fac2806 | ||
|
|
2ebaa527be | ||
|
|
c5411518c4 | ||
|
|
36839ff153 | ||
|
|
9d6db12921 | ||
|
|
590ac1f95e | ||
|
|
8e76a65e0c | ||
|
|
c3eda4247e | ||
|
|
022b9b76b1 | ||
|
|
19f0037256 | ||
|
|
c626fc2f17 | ||
|
|
f523d25052 | ||
|
|
278ac0c730 | ||
|
|
0696c8113d | ||
|
|
688de5f604 | ||
|
|
881410bc74 | ||
|
|
b4d2d6a884 | ||
|
|
5cf439883e | ||
|
|
23bb89b96e | ||
|
|
7010ab1eb6 | ||
|
|
18f538c54b | ||
|
|
e170c0d274 | ||
|
|
dad702e5c2 | ||
|
|
224d445840 | ||
|
|
670419b087 | ||
|
|
58baf74992 | ||
|
|
d08be58f95 | ||
|
|
db68170cce | ||
|
|
1e1092e472 | ||
|
|
d1324408f4 | ||
|
|
9e478014c5 | ||
|
|
dd69e539d3 | ||
|
|
2cb668a40c | ||
|
|
3cefd98ce9 | ||
|
|
fa2830a1fd | ||
|
|
57ac062edc | ||
|
|
0c94214a8f | ||
|
|
2b72b2f2f2 | ||
|
|
985b653a87 | ||
|
|
f14b160e5c | ||
|
|
8eafa1e741 | ||
|
|
aefd9504bf | ||
|
|
1f6977da81 | ||
|
|
290402ee6a | ||
|
|
c3b95886db | ||
|
|
e53d444c32 | ||
|
|
c0f9073f35 | ||
|
|
19c6f85f5e | ||
|
|
d4f1f703ea | ||
|
|
914f59197f | ||
|
|
7c24c269e2 | ||
|
|
c52e5c856d | ||
|
|
b08756cc39 | ||
|
|
29fc22a171 | ||
|
|
815f69a051 | ||
|
|
83ceea4250 | ||
|
|
59d685fab6 | ||
|
|
a267e3abb5 | ||
|
|
e078ed21ba | ||
|
|
41d5922635 | ||
|
|
6f07894be7 | ||
|
|
871de93f2d | ||
|
|
15b2ef1591 |
@@ -161,7 +161,12 @@ module.exports = {
|
||||
],
|
||||
'no-with': 'warn',
|
||||
'no-whitespace-before-property': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: '(useQuery)',
|
||||
},
|
||||
],
|
||||
'require-yield': 'warn',
|
||||
'rest-spread-spacing': ['warn', 'never'],
|
||||
strict: ['warn', 'never'],
|
||||
|
||||
113
.github/workflows/update-vrt.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: /update-vrt
|
||||
on:
|
||||
issue_comment:
|
||||
types: [ created ]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-vrt:
|
||||
name: Update VRT
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/actions/netlify-wait-for-build
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt --update-snapshots
|
||||
env:
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- name: Create patch
|
||||
run: |
|
||||
git config --system --add safe.directory "*"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git reset
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git format-patch -1 HEAD --stdout > Update-VRT.patch
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: patch
|
||||
path: Update-VRT.patch
|
||||
|
||||
push-patch:
|
||||
runs-on: ubuntu-latest
|
||||
needs: update-vrt
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: patch
|
||||
- name: Apply patch and push
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git apply Update-VRT.patch
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git push origin HEAD:${{ steps.comment-branch.outputs.head_ref }}
|
||||
- name: Add finished reaction
|
||||
uses: dkershner6/reaction-action@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commentId: ${{ github.event.comment.id }}
|
||||
reaction: "rocket"
|
||||
|
||||
add-starting-reaction:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: React to comment
|
||||
uses: dkershner6/reaction-action@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commentId: ${{ github.event.comment.id }}
|
||||
reaction: "+1"
|
||||
@@ -36,7 +36,7 @@ fi
|
||||
|
||||
yarn workspace loot-core build:node
|
||||
|
||||
yarn workspace @actual-app/web build --mode=desktop
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "24.11.0",
|
||||
"version": "24.12.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
|
||||
1
packages/desktop-client/.gitignore
vendored
@@ -10,6 +10,7 @@ playwright-report
|
||||
|
||||
# production
|
||||
build
|
||||
build-electron
|
||||
build-stats
|
||||
stats.json
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ test.describe('Mobile Accounts', () => {
|
||||
|
||||
test('opens the accounts page and asserts on balances', async () => {
|
||||
const accountsPage = await navigation.goToAccountsPage();
|
||||
await accountsPage.waitFor();
|
||||
|
||||
const account = await accountsPage.getNthAccount(1);
|
||||
|
||||
@@ -37,7 +38,10 @@ test.describe('Mobile Accounts', () => {
|
||||
|
||||
test('opens individual account page and checks that filtering is working', async () => {
|
||||
const accountsPage = await navigation.goToAccountsPage();
|
||||
await accountsPage.waitFor();
|
||||
|
||||
const accountPage = await accountsPage.openNthAccount(0);
|
||||
await accountPage.waitFor();
|
||||
|
||||
await expect(accountPage.heading).toHaveText('Bank of America');
|
||||
await expect(accountPage.transactionList).toBeVisible();
|
||||
@@ -50,6 +54,9 @@ test.describe('Mobile Accounts', () => {
|
||||
await expect(accountPage.transactions).toHaveCount(0);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await accountPage.clearSearch();
|
||||
await expect(accountPage.transactions).not.toHaveCount(0);
|
||||
|
||||
await accountPage.searchByText('Kroger');
|
||||
await expect(accountPage.transactions).not.toHaveCount(0);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -62,6 +62,8 @@ test.describe('Accounts', () => {
|
||||
|
||||
test('creates a transfer from two existing transactions', async () => {
|
||||
accountPage = await navigation.goToAccountPage('For budget');
|
||||
await accountPage.waitFor();
|
||||
|
||||
await expect(accountPage.accountName).toHaveText('Budgeted Accounts');
|
||||
|
||||
await accountPage.filterByNote('Test Acc Transfer');
|
||||
@@ -109,6 +111,7 @@ test.describe('Accounts', () => {
|
||||
offBudget: false,
|
||||
balance: 0,
|
||||
});
|
||||
await accountPage.waitFor();
|
||||
});
|
||||
|
||||
async function importCsv(screenshot = false) {
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -30,6 +30,10 @@ export class AccountPage {
|
||||
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
|
||||
}
|
||||
|
||||
async waitFor() {
|
||||
await this.transactionTable.waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter details of a transaction
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,10 @@ export class MobileAccountPage {
|
||||
});
|
||||
}
|
||||
|
||||
async waitFor() {
|
||||
await this.transactionList.waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the balance of the account as a number
|
||||
*/
|
||||
@@ -29,6 +33,10 @@ export class MobileAccountPage {
|
||||
await this.searchBox.fill(term);
|
||||
}
|
||||
|
||||
async clearSearch() {
|
||||
await this.searchBox.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to transaction creation page
|
||||
*/
|
||||
|
||||
@@ -4,9 +4,14 @@ export class MobileAccountsPage {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
|
||||
this.accountList = this.page.getByLabel('Account list');
|
||||
this.accounts = this.page.getByTestId('account');
|
||||
}
|
||||
|
||||
async waitFor() {
|
||||
await this.accountList.waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name and balance of the nth account
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MobileAccountPage } from './mobile-account-page';
|
||||
import { MobileAccountsPage } from './mobile-accounts-page';
|
||||
import { MobileBudgetPage } from './mobile-budget-page';
|
||||
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
|
||||
@@ -22,6 +23,13 @@ export class MobileNavigation {
|
||||
return new MobileAccountsPage(this.page);
|
||||
}
|
||||
|
||||
async goToUncategorizedPage() {
|
||||
const button = this.page.getByRole('button', { name: /uncategorized/ });
|
||||
await button.click();
|
||||
|
||||
return new MobileAccountPage(this.page);
|
||||
}
|
||||
|
||||
async goToTransactionEntryPage() {
|
||||
const link = this.page.getByRole('link', { name: 'Transaction' });
|
||||
await link.click();
|
||||
|
||||
@@ -22,8 +22,9 @@ export class ReportsPage {
|
||||
|
||||
async goToCustomReportPage() {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Create new custom report' })
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'New custom report' }).click();
|
||||
return new CustomReportPage(this.page);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,27 +8,10 @@ export class SettingsPage {
|
||||
}
|
||||
|
||||
async useBudgetType(budgetType) {
|
||||
await this.enableExperimentalFeature('Budget mode toggle');
|
||||
|
||||
const switchBudgetTypeButton = this.page.getByRole('button', {
|
||||
name: `Switch to ${budgetType} budgeting`,
|
||||
});
|
||||
|
||||
await switchBudgetTypeButton.click();
|
||||
}
|
||||
|
||||
async enableExperimentalFeature(featureName) {
|
||||
const advancedSettingsButton = this.page.getByTestId('advanced-settings');
|
||||
await advancedSettingsButton.click();
|
||||
|
||||
const experimentalSettingsButton = this.page.getByTestId(
|
||||
'experimental-settings',
|
||||
);
|
||||
await experimentalSettingsButton.click();
|
||||
|
||||
const featureCheckbox = this.page.getByRole('checkbox', {
|
||||
name: featureName,
|
||||
});
|
||||
await featureCheckbox.click();
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 35 KiB |
@@ -48,11 +48,8 @@ test.describe('Mobile Transactions', () => {
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
const accountPage = await transactionEntryPage.createTransaction();
|
||||
|
||||
await expect(accountPage.transactions.nth(0)).toHaveText(
|
||||
'KrogerClothing-12.34',
|
||||
);
|
||||
await transactionEntryPage.createTransaction();
|
||||
await expect(page.getByLabel('Transaction list')).toHaveCount(0);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
@@ -82,4 +79,74 @@ test.describe('Mobile Transactions', () => {
|
||||
'KrogerClothing-12.34',
|
||||
);
|
||||
});
|
||||
|
||||
test('creates an uncategorized transaction from `/accounts/uncategorized` page', async () => {
|
||||
// Create uncategorized transaction
|
||||
let transactionEntryPage = await navigation.goToTransactionEntryPage();
|
||||
await transactionEntryPage.amountField.fill('12.35');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('account-field'),
|
||||
'Ally Savings',
|
||||
);
|
||||
await transactionEntryPage.createTransaction();
|
||||
|
||||
const uncategorizedPage = await navigation.goToUncategorizedPage();
|
||||
transactionEntryPage = await uncategorizedPage.clickCreateTransaction();
|
||||
|
||||
await expect(transactionEntryPage.header).toHaveText('New Transaction');
|
||||
|
||||
await transactionEntryPage.amountField.fill('12.34');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('payee-field'),
|
||||
'Kroger',
|
||||
);
|
||||
|
||||
await transactionEntryPage.createTransaction();
|
||||
|
||||
await expect(uncategorizedPage.transactions.nth(0)).toHaveText(
|
||||
'KrogerUncategorized-12.34',
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('creates a categorized transaction from `/accounts/uncategorized` page', async () => {
|
||||
// Create uncategorized transaction
|
||||
let transactionEntryPage = await navigation.goToTransactionEntryPage();
|
||||
await transactionEntryPage.amountField.fill('12.35');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('account-field'),
|
||||
'Ally Savings',
|
||||
);
|
||||
await transactionEntryPage.createTransaction();
|
||||
|
||||
const uncategorizedPage = await navigation.goToUncategorizedPage();
|
||||
transactionEntryPage = await uncategorizedPage.clickCreateTransaction();
|
||||
|
||||
await expect(transactionEntryPage.header).toHaveText('New Transaction');
|
||||
|
||||
await transactionEntryPage.amountField.fill('12.34');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('payee-field'),
|
||||
'Kroger',
|
||||
);
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('category-field'),
|
||||
'Clothing',
|
||||
);
|
||||
|
||||
await transactionEntryPage.createTransaction();
|
||||
|
||||
await expect(uncategorizedPage.transactions.nth(0)).toHaveText(
|
||||
'(No payee)Uncategorized-12.35',
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "24.11.0",
|
||||
"version": "24.12.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
@@ -50,8 +50,8 @@
|
||||
"promise-retry": "^2.0.1",
|
||||
"re-resizable": "^6.9.17",
|
||||
"react": "18.2.0",
|
||||
"react-aria": "^3.34.3",
|
||||
"react-aria-components": "^1.3.3",
|
||||
"react-aria": "^3.35.1",
|
||||
"react-aria-components": "^1.4.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-router-dom": "6.21.3",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "^9.7.3",
|
||||
"react-stately": "^3.10.9",
|
||||
"react-stately": "^3.33.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"recharts": "^2.10.4",
|
||||
"redux": "^4.2.1",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
|
||||
@@ -39,6 +40,19 @@ function createBackendWorker() {
|
||||
|
||||
createBackendWorker();
|
||||
|
||||
let isUpdateReadyForDownload = false;
|
||||
let markUpdateReadyForDownload;
|
||||
const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
markUpdateReadyForDownload = () => {
|
||||
isUpdateReadyForDownload = true;
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
ACTUAL_VERSION,
|
||||
@@ -140,7 +154,14 @@ global.Actual = {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
onEventFromMain: () => {},
|
||||
applyAppUpdate: () => {},
|
||||
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
|
||||
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
|
||||
applyAppUpdate: async () => {
|
||||
updateSW();
|
||||
|
||||
// Wait for the app to reload
|
||||
await new Promise(() => {});
|
||||
},
|
||||
updateAppMenu: () => {},
|
||||
|
||||
ipcConnect: () => {},
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
|
||||
import { useMetadataPref } from '../hooks/useMetadataPref';
|
||||
import { installPolyfills } from '../polyfills';
|
||||
import { ResponsiveProvider } from '../ResponsiveProvider';
|
||||
import { styles, hasHiddenScrollbars, ThemeStyle, useTheme } from '../style';
|
||||
import { ExposeNavigate } from '../util/router-tools';
|
||||
|
||||
@@ -40,7 +39,7 @@ import { FatalError } from './FatalError';
|
||||
import { FinancesApp } from './FinancesApp';
|
||||
import { ManagementApp } from './manager/ManagementApp';
|
||||
import { Modals } from './Modals';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { ResponsiveProvider } from './responsive/ResponsiveProvider';
|
||||
import { SidebarProvider } from './sidebar/SidebarProvider';
|
||||
import { UpdateNotification } from './UpdateNotification';
|
||||
|
||||
@@ -51,8 +50,20 @@ function AppInner() {
|
||||
const { showBoundary: showErrorBoundary } = useErrorBoundary();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const maybeUpdate = async <T,>(cb?: () => T): Promise<T> => {
|
||||
if (global.Actual.isUpdateReadyForDownload()) {
|
||||
dispatch(
|
||||
setAppState({
|
||||
loadingText: t('Downloading and applying update...'),
|
||||
}),
|
||||
);
|
||||
await global.Actual.applyAppUpdate();
|
||||
}
|
||||
return cb?.();
|
||||
};
|
||||
|
||||
async function init() {
|
||||
const socketName = await global.Actual.getServerSocket();
|
||||
const socketName = await maybeUpdate(() => global.Actual.getServerSocket());
|
||||
|
||||
dispatch(
|
||||
setAppState({
|
||||
@@ -86,14 +97,16 @@ function AppInner() {
|
||||
loadingText: t('Retrieving remote files...'),
|
||||
}),
|
||||
);
|
||||
send('get-remote-files').then(files => {
|
||||
if (files) {
|
||||
const remoteFile = files.find(f => f.fileId === cloudFileId);
|
||||
if (remoteFile && remoteFile.deleted) {
|
||||
dispatch(closeBudget());
|
||||
}
|
||||
|
||||
const files = await send('get-remote-files');
|
||||
if (files) {
|
||||
const remoteFile = files.find(f => f.fileId === cloudFileId);
|
||||
if (remoteFile && remoteFile.deleted) {
|
||||
dispatch(closeBudget());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await maybeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,36 +179,34 @@ export function App() {
|
||||
<SidebarProvider>
|
||||
<BudgetMonthCountProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ScrollProvider>
|
||||
<View
|
||||
data-theme={theme}
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
data-theme={theme}
|
||||
key={
|
||||
hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'
|
||||
}
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
...styles.lightScrollbar,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
key={
|
||||
hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'
|
||||
}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
...styles.lightScrollbar,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{process.env.REACT_APP_REVIEW_ID &&
|
||||
!Platform.isPlaywright && <DevelopmentTopBar />}
|
||||
<AppInner />
|
||||
</ErrorBoundary>
|
||||
<ThemeStyle />
|
||||
<Modals />
|
||||
<UpdateNotification />
|
||||
</View>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{process.env.REACT_APP_REVIEW_ID &&
|
||||
!Platform.isPlaywright && <DevelopmentTopBar />}
|
||||
<AppInner />
|
||||
</ErrorBoundary>
|
||||
<ThemeStyle />
|
||||
<Modals />
|
||||
<UpdateNotification />
|
||||
</View>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
</DndProvider>
|
||||
</BudgetMonthCountProvider>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ReactElement, useEffect } from 'react';
|
||||
import React, { type ReactElement, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ import { useAccounts } from '../hooks/useAccounts';
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useMetaThemeColor } from '../hooks/useMetaThemeColor';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme } from '../style';
|
||||
import { getIsOutdated, getLatestVersion } from '../util/versions';
|
||||
|
||||
@@ -34,6 +33,8 @@ import { ManagePayeesPage } from './payees/ManagePayeesPage';
|
||||
import { Reports } from './reports';
|
||||
import { LoadingIndicator } from './reports/LoadingIndicator';
|
||||
import { NarrowAlternate, WideComponent } from './responsive';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { Settings } from './settings';
|
||||
import { FloatableSidebar } from './sidebar';
|
||||
import { Titlebar } from './Titlebar';
|
||||
@@ -100,6 +101,29 @@ export function FinancesApp() {
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
await global.Actual.waitForUpdateReadyForDownload();
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'message',
|
||||
title: t('A new version of Actual is available!'),
|
||||
message: t('Click the button below to reload and apply the update.'),
|
||||
sticky: true,
|
||||
id: 'update-reload-notification',
|
||||
button: {
|
||||
title: t('Update now'),
|
||||
action: async () => {
|
||||
await global.Actual.applyAppUpdate();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
run();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
const latestVersion = await getLatestVersion();
|
||||
@@ -133,6 +157,8 @@ export function FinancesApp() {
|
||||
run();
|
||||
}, [lastUsedVersion, setLastUsedVersion]);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<View style={{ height: '100%' }}>
|
||||
<RouterBehaviors />
|
||||
@@ -156,113 +182,119 @@ export function FinancesApp() {
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
<ScrollProvider
|
||||
isDisabled={!isNarrowWidth}
|
||||
scrollableRef={scrollableRef}
|
||||
>
|
||||
<Titlebar
|
||||
<View
|
||||
ref={scrollableRef}
|
||||
style={{
|
||||
WebkitAppRegion: 'drag',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
/>
|
||||
<Notifications />
|
||||
<BankSyncStatus />
|
||||
>
|
||||
<Titlebar
|
||||
style={{
|
||||
WebkitAppRegion: 'drag',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
<Notifications />
|
||||
<BankSyncStatus />
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
accountsLoaded ? (
|
||||
accounts.length > 0 ? (
|
||||
<Navigate to="/budget" replace />
|
||||
) : (
|
||||
// If there are no accounts, we want to redirect the user to
|
||||
// the All Accounts screen which will prompt them to add an account
|
||||
<Navigate to="/accounts" replace />
|
||||
)
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/reports/*" element={<Reports />} />
|
||||
|
||||
<Route
|
||||
path="/budget"
|
||||
element={<NarrowAlternate name="Budget" />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/schedules"
|
||||
element={
|
||||
<NarrowNotSupported>
|
||||
<WideComponent name="Schedules" />
|
||||
</NarrowNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/payees" element={<ManagePayeesPage />} />
|
||||
<Route path="/rules" element={<ManageRulesPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<Route
|
||||
path="/gocardless/link"
|
||||
element={
|
||||
<NarrowNotSupported>
|
||||
<WideComponent name="GoCardlessLink" />
|
||||
</NarrowNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={<NarrowAlternate name="Accounts" />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/accounts/:id"
|
||||
element={<NarrowAlternate name="Account" />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/transactions/:transactionId"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
<TransactionEdit />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/categories/:id"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
<Category />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* redirect all other traffic to the budget page */}
|
||||
<Route path="/*" element={<Navigate to="/budget" replace />} />
|
||||
</Routes>
|
||||
</View>
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
accountsLoaded ? (
|
||||
accounts.length > 0 ? (
|
||||
<Navigate to="/budget" replace />
|
||||
) : (
|
||||
// If there are no accounts, we want to redirect the user to
|
||||
// the All Accounts screen which will prompt them to add an account
|
||||
<Navigate to="/accounts" replace />
|
||||
)
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/reports/*" element={<Reports />} />
|
||||
|
||||
<Route
|
||||
path="/budget"
|
||||
element={<NarrowAlternate name="Budget" />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/schedules"
|
||||
element={
|
||||
<NarrowNotSupported>
|
||||
<WideComponent name="Schedules" />
|
||||
</NarrowNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/payees" element={<ManagePayeesPage />} />
|
||||
<Route path="/rules" element={<ManageRulesPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<Route
|
||||
path="/gocardless/link"
|
||||
element={
|
||||
<NarrowNotSupported>
|
||||
<WideComponent name="GoCardlessLink" />
|
||||
</NarrowNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={<NarrowAlternate name="Accounts" />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/accounts/:id"
|
||||
element={<NarrowAlternate name="Account" />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/transactions/:transactionId"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
<TransactionEdit />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/categories/:id"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
<Category />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* redirect all other traffic to the budget page */}
|
||||
<Route path="/*" element={<Navigate to="/budget" replace />} />
|
||||
<Route path="/budget" element={<MobileNavTabs />} />
|
||||
<Route path="/accounts" element={<MobileNavTabs />} />
|
||||
<Route path="/settings" element={<MobileNavTabs />} />
|
||||
<Route path="/reports" element={<MobileNavTabs />} />
|
||||
<Route path="*" element={null} />
|
||||
</Routes>
|
||||
</View>
|
||||
|
||||
<Routes>
|
||||
<Route path="/budget" element={<MobileNavTabs />} />
|
||||
<Route path="/accounts" element={<MobileNavTabs />} />
|
||||
<Route path="/settings" element={<MobileNavTabs />} />
|
||||
<Route path="/reports" element={<MobileNavTabs />} />
|
||||
<Route path="*" element={null} />
|
||||
</Routes>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSelector } from 'react-redux';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
@@ -38,9 +39,11 @@ export function LoggedInUser({
|
||||
getUserData().then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function onChangePassword() {
|
||||
await closeBudget();
|
||||
window.__navigate('/change-password');
|
||||
navigate('/change-password');
|
||||
}
|
||||
|
||||
async function onMenuSelect(type) {
|
||||
@@ -52,14 +55,14 @@ export function LoggedInUser({
|
||||
break;
|
||||
case 'sign-in':
|
||||
await closeBudget();
|
||||
window.__navigate('/login');
|
||||
navigate('/login');
|
||||
break;
|
||||
case 'sign-out':
|
||||
signOut();
|
||||
break;
|
||||
case 'config-server':
|
||||
await closeBudget();
|
||||
window.__navigate('/config-server');
|
||||
navigate('/config-server');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import React, {
|
||||
type SetStateAction,
|
||||
type Dispatch,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { useSchedules } from 'loot-core/client/data-hooks/schedules';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { pushModal } from 'loot-core/src/client/actions/modals';
|
||||
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
@@ -21,7 +24,6 @@ import { type NewRuleEntity } from 'loot-core/src/types/models';
|
||||
import { useAccounts } from '../hooks/useAccounts';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
import { usePayees } from '../hooks/usePayees';
|
||||
import { useSchedules } from '../hooks/useSchedules';
|
||||
import { useSelected, SelectedProvider } from '../hooks/useSelected';
|
||||
import { theme } from '../style';
|
||||
|
||||
@@ -113,7 +115,9 @@ export function ManageRules({
|
||||
const [filter, setFilter] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: schedules = [] } = useSchedules();
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
const { list: categories } = useCategories();
|
||||
const payees = usePayees();
|
||||
const accounts = useAccounts();
|
||||
@@ -196,7 +200,9 @@ export function ManageRules({
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
alert('Some rules were not deleted because they are linked to schedules');
|
||||
alert(
|
||||
t('Some rules were not deleted because they are linked to schedules'),
|
||||
);
|
||||
}
|
||||
|
||||
await loadRules();
|
||||
@@ -204,6 +210,13 @@ export function ManageRules({
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onDeleteRule(id: string) {
|
||||
setLoading(true);
|
||||
await send('rule-delete', id);
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const onEditRule = useCallback(rule => {
|
||||
dispatch(
|
||||
pushModal('edit-rule', {
|
||||
@@ -252,6 +265,7 @@ export function ManageRules({
|
||||
const onHover = useCallback(id => {
|
||||
setHoveredRule(id);
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
@@ -273,19 +287,19 @@ export function ManageRules({
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
Rules are always run in the order that you see them.{' '}
|
||||
{t('Rules are always run in the order that you see them.')}{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/budgeting/rules/"
|
||||
linkColor="muted"
|
||||
>
|
||||
Learn more
|
||||
{t('Learn more')}
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Search
|
||||
placeholder="Filter rules..."
|
||||
placeholder={t('Filter rules...')}
|
||||
value={filter}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
@@ -298,7 +312,7 @@ export function ManageRules({
|
||||
style={{ marginBottom: -1 }}
|
||||
>
|
||||
{filteredRules.length === 0 ? (
|
||||
<EmptyMessage text="No rules" style={{ marginTop: 15 }} />
|
||||
<EmptyMessage text={t('No rules')} style={{ marginTop: 15 }} />
|
||||
) : (
|
||||
<RulesList
|
||||
rules={filteredRules}
|
||||
@@ -306,6 +320,7 @@ export function ManageRules({
|
||||
hoveredRule={hoveredRule}
|
||||
onHover={onHover}
|
||||
onEditRule={onEditRule}
|
||||
onDeleteRule={rule => onDeleteRule(rule.id)}
|
||||
/>
|
||||
)}
|
||||
</SimpleTable>
|
||||
@@ -325,7 +340,7 @@ export function ManageRules({
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
Create new rule
|
||||
{t('Create new rule')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { ManageRules } from './ManageRules';
|
||||
import { Page } from './Page';
|
||||
|
||||
export function ManageRulesPage() {
|
||||
return (
|
||||
<Page header="Rules">
|
||||
<Page header={t('Rules')}>
|
||||
<ManageRules isModal={false} payeeId={null} />
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -44,6 +45,7 @@ import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
|
||||
import { LoadBackupModal } from './modals/LoadBackupModal';
|
||||
import { ConfirmChangeDocumentDirModal } from './modals/manager/ConfirmChangeDocumentDir';
|
||||
import { DeleteFileModal } from './modals/manager/DeleteFileModal';
|
||||
import { DuplicateFileModal } from './modals/manager/DuplicateFileModal';
|
||||
import { FilesSettingsModal } from './modals/manager/FilesSettingsModal';
|
||||
import { ImportActualModal } from './modals/manager/ImportActualModal';
|
||||
import { ImportModal } from './modals/manager/ImportModal';
|
||||
@@ -81,6 +83,8 @@ export function Modals() {
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modals = modalStack
|
||||
.map(({ name, options }) => {
|
||||
switch (name) {
|
||||
@@ -287,10 +291,12 @@ export function Modals() {
|
||||
Header={props => (
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={<ModalTitle title="New Category" shrinkOnOverflow />}
|
||||
title={
|
||||
<ModalTitle title={t('New Category')} shrinkOnOverflow />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder="Category name"
|
||||
inputPlaceholder={t('Category name')}
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
onSubmit={options.onSubmit}
|
||||
@@ -306,12 +312,15 @@ export function Modals() {
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={
|
||||
<ModalTitle title="New Category Group" shrinkOnOverflow />
|
||||
<ModalTitle
|
||||
title={t('New Category Group')}
|
||||
shrinkOnOverflow
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder="Category group name"
|
||||
buttonText="Add"
|
||||
inputPlaceholder={t('Category group name')}
|
||||
buttonText={t('Add')}
|
||||
onValidate={options.onValidate}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
@@ -578,6 +587,16 @@ export function Modals() {
|
||||
return <BudgetListModal key={name} />;
|
||||
case 'delete-budget':
|
||||
return <DeleteFileModal key={name} file={options.file} />;
|
||||
case 'duplicate-budget':
|
||||
return (
|
||||
<DuplicateFileModal
|
||||
key={name}
|
||||
file={options.file}
|
||||
managePage={options?.managePage}
|
||||
loadBudget={options?.loadBudget}
|
||||
onComplete={options?.onComplete}
|
||||
/>
|
||||
);
|
||||
case 'import':
|
||||
return <ImportModal key={name} />;
|
||||
case 'files-settings':
|
||||
|
||||
@@ -3,13 +3,14 @@ import React, { useEffect, useRef, type CSSProperties } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { t } from 'i18next';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme } from '../style';
|
||||
import { remarkBreaks, sequentialNewlinesPlugin } from '../util/markdown';
|
||||
|
||||
import { Text } from './common/Text';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
const remarkPlugins = [sequentialNewlinesPlugin, remarkGfm, remarkBreaks];
|
||||
|
||||
@@ -122,7 +123,7 @@ export function Notes({
|
||||
value={notes || ''}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
onBlur={e => onBlur?.(e.target.value)}
|
||||
placeholder="Notes (markdown supported)"
|
||||
placeholder={t('Notes (markdown supported)')}
|
||||
/>
|
||||
) : (
|
||||
<Text className={css([markdownStyles, getStyle?.(editable)])}>
|
||||
|
||||
@@ -6,6 +6,8 @@ import React, {
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { useNotes } from '../hooks/useNotes';
|
||||
@@ -59,7 +61,7 @@ export function NotesButton({
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
aria-label="View notes"
|
||||
aria-label={t('View notes')}
|
||||
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
|
||||
style={{
|
||||
color: defaultColor,
|
||||
|
||||
@@ -9,6 +9,7 @@ import React, {
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { removeNotification } from 'loot-core/client/actions';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
@@ -16,7 +17,6 @@ import type { NotificationWithId } from 'loot-core/src/client/state-types/notifi
|
||||
|
||||
import { AnimatedLoading } from '../icons/AnimatedLoading';
|
||||
import { SvgDelete } from '../icons/v0';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { styles, theme } from '../style';
|
||||
|
||||
import { Button, ButtonWithLoading } from './common/Button2';
|
||||
@@ -24,6 +24,7 @@ import { Link } from './common/Link';
|
||||
import { Stack } from './common/Stack';
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
function compileMessage(
|
||||
message: string,
|
||||
@@ -231,7 +232,7 @@ function Notification({
|
||||
</Stack>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label="Close"
|
||||
aria-label={t('Close')}
|
||||
style={{ flexShrink: 0, color: 'currentColor' }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { type ReactNode, type CSSProperties } from 'react';
|
||||
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
const HEADER_HEIGHT = 50;
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import React, {
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { usePrivacyMode } from '../hooks/usePrivacyMode';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
|
||||
import { View } from './common/View';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
type ConditionalPrivacyFilterProps = {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,61 +1,204 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
createContext,
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import debounce from 'debounce';
|
||||
|
||||
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
type ScrollListenerArgs = {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
isScrolling: (direction: ScrollDirection) => boolean;
|
||||
hasScrolledToEnd: (direction: ScrollDirection, tolerance?: number) => boolean;
|
||||
};
|
||||
|
||||
type ScrollListener = (args: ScrollListenerArgs) => void;
|
||||
type UnregisterScrollListener = () => void;
|
||||
type RegisterScrollListener = (
|
||||
listener: ScrollListener,
|
||||
) => UnregisterScrollListener;
|
||||
|
||||
type IScrollContext = {
|
||||
scrollY: number | undefined;
|
||||
hasScrolledToBottom: (tolerance?: number) => boolean;
|
||||
registerScrollListener: RegisterScrollListener;
|
||||
};
|
||||
|
||||
const ScrollContext = createContext<IScrollContext | undefined>(undefined);
|
||||
|
||||
type ScrollProviderProps = {
|
||||
type ScrollProviderProps<T extends Element> = {
|
||||
scrollableRef: RefObject<T>;
|
||||
isDisabled?: boolean;
|
||||
delayMs?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function ScrollProvider({ children }: ScrollProviderProps) {
|
||||
const [scrollY, setScrollY] = useState(undefined);
|
||||
const [scrollHeight, setScrollHeight] = useState(undefined);
|
||||
const [clientHeight, setClientHeight] = useState(undefined);
|
||||
export function ScrollProvider<T extends Element>({
|
||||
scrollableRef,
|
||||
isDisabled,
|
||||
delayMs = 10,
|
||||
children,
|
||||
}: ScrollProviderProps<T>) {
|
||||
const previousScrollX = useRef<number | undefined>(undefined);
|
||||
const scrollX = useRef<number | undefined>(undefined);
|
||||
const previousScrollY = useRef<number | undefined>(undefined);
|
||||
const scrollY = useRef<number | undefined>(undefined);
|
||||
const scrollWidth = useRef<number | undefined>(undefined);
|
||||
const scrollHeight = useRef<number | undefined>(undefined);
|
||||
const clientWidth = useRef<number | undefined>(undefined);
|
||||
const clientHeight = useRef<number | undefined>(undefined);
|
||||
const listeners = useRef<ScrollListener[]>([]);
|
||||
|
||||
const hasScrolledToBottom = useCallback(
|
||||
(tolerance = 1) => scrollHeight - scrollY <= clientHeight + tolerance,
|
||||
[clientHeight, scrollHeight, scrollY],
|
||||
const hasScrolledToEnd = useCallback(
|
||||
(direction: ScrollDirection, tolerance = 1) => {
|
||||
const isAtStart = (currentCoordinate?: number) =>
|
||||
currentCoordinate !== undefined && currentCoordinate <= tolerance;
|
||||
|
||||
const isAtEnd = (
|
||||
totalSize?: number,
|
||||
currentCoordinate?: number,
|
||||
viewportSize?: number,
|
||||
) =>
|
||||
totalSize !== undefined &&
|
||||
currentCoordinate !== undefined &&
|
||||
viewportSize !== undefined &&
|
||||
totalSize - currentCoordinate <= viewportSize + tolerance;
|
||||
|
||||
switch (direction) {
|
||||
case 'up': {
|
||||
return isAtStart(scrollY.current);
|
||||
}
|
||||
case 'down': {
|
||||
return isAtEnd(
|
||||
scrollHeight.current,
|
||||
scrollY.current,
|
||||
clientHeight.current,
|
||||
);
|
||||
}
|
||||
case 'left': {
|
||||
return isAtStart(scrollX.current);
|
||||
}
|
||||
case 'right': {
|
||||
return isAtEnd(
|
||||
scrollWidth.current,
|
||||
scrollX.current,
|
||||
clientWidth.current,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listenToScroll = debounce(e => {
|
||||
const target = e.target;
|
||||
setScrollY(target?.scrollTop || 0);
|
||||
setScrollHeight(target?.scrollHeight || 0);
|
||||
setClientHeight(target?.clientHeight || 0);
|
||||
}, 10);
|
||||
const isScrolling = useCallback((direction: ScrollDirection) => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return (
|
||||
previousScrollY.current !== undefined &&
|
||||
scrollY.current !== undefined &&
|
||||
previousScrollY.current > scrollY.current
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
previousScrollY.current !== undefined &&
|
||||
scrollY.current !== undefined &&
|
||||
previousScrollY.current < scrollY.current
|
||||
);
|
||||
case 'left':
|
||||
return (
|
||||
previousScrollX.current !== undefined &&
|
||||
scrollX.current !== undefined &&
|
||||
previousScrollX.current > scrollX.current
|
||||
);
|
||||
case 'right':
|
||||
return (
|
||||
previousScrollX.current !== undefined &&
|
||||
scrollX.current !== undefined &&
|
||||
previousScrollX.current < scrollX.current
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
window.addEventListener('scroll', listenToScroll, {
|
||||
useEffect(() => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listenToScroll = debounce((e: Event) => {
|
||||
const target = e.target;
|
||||
if (target instanceof Element) {
|
||||
previousScrollX.current = scrollX.current;
|
||||
scrollX.current = target.scrollLeft;
|
||||
scrollHeight.current = target.scrollHeight;
|
||||
|
||||
previousScrollY.current = scrollY.current;
|
||||
scrollY.current = target.scrollTop;
|
||||
clientHeight.current = target.clientHeight;
|
||||
|
||||
const currentScrollX = scrollX.current;
|
||||
const currentScrollY = scrollY.current;
|
||||
|
||||
if (currentScrollX !== undefined && currentScrollY !== undefined) {
|
||||
listeners.current.forEach(listener =>
|
||||
listener({
|
||||
scrollX: currentScrollX,
|
||||
scrollY: currentScrollY,
|
||||
isScrolling,
|
||||
hasScrolledToEnd,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
const ref = scrollableRef.current;
|
||||
|
||||
ref?.addEventListener('scroll', listenToScroll, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
return () =>
|
||||
window.removeEventListener('scroll', listenToScroll, {
|
||||
ref?.removeEventListener('scroll', listenToScroll, {
|
||||
capture: true,
|
||||
});
|
||||
}, []);
|
||||
}, [delayMs, hasScrolledToEnd, isDisabled, isScrolling, scrollableRef]);
|
||||
|
||||
const registerScrollListener: RegisterScrollListener = useCallback(
|
||||
listener => {
|
||||
listeners.current.push(listener);
|
||||
|
||||
return () => {
|
||||
listeners.current = listeners.current.filter(l => l !== listener);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider value={{ scrollY, hasScrolledToBottom }}>
|
||||
<ScrollContext.Provider value={{ registerScrollListener }}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useScroll(): IScrollContext {
|
||||
return useContext(ScrollContext);
|
||||
export function useScrollListener(listener: ScrollListener) {
|
||||
const context = useContext(ScrollContext);
|
||||
if (!context) {
|
||||
throw new Error('useScrollListener must be used within a ScrollProvider');
|
||||
}
|
||||
|
||||
const { registerScrollListener } = context;
|
||||
|
||||
useEffect(() => {
|
||||
return registerScrollListener(listener);
|
||||
}, [listener, registerScrollListener]);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import type { Theme } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { themeOptions, useTheme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { Menu } from './common/Menu';
|
||||
import { Popover } from './common/Popover';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
style?: CSSProperties;
|
||||
@@ -45,7 +47,7 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
aria-label="Switch theme"
|
||||
aria-label={t('Switch theme')}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={style}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
SvgViewHide,
|
||||
SvgViewShow,
|
||||
} from '../icons/v2';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
|
||||
@@ -37,6 +37,7 @@ import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { HelpMenu } from './HelpMenu';
|
||||
import { LoggedInUser } from './LoggedInUser';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import { useSidebar } from './sidebar/SidebarProvider';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
@@ -206,7 +207,7 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label="Sync"
|
||||
aria-label={t('Sync')}
|
||||
className={css({
|
||||
...(isMobile
|
||||
? {
|
||||
@@ -290,7 +291,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
>
|
||||
{(floatingSidebar || sidebar.alwaysFloats) && (
|
||||
<Button
|
||||
aria-label="Sidebar menu"
|
||||
aria-label={t('Sidebar menu')}
|
||||
variant="bare"
|
||||
style={{ marginRight: 8 }}
|
||||
onHoverStart={e => {
|
||||
@@ -322,7 +323,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
height={10}
|
||||
style={{ marginRight: 5, color: 'currentColor' }}
|
||||
/>{' '}
|
||||
Back
|
||||
{t('Back')}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
@@ -13,6 +14,7 @@ import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const { t } = useTranslation();
|
||||
const updateInfo = useSelector((state: State) => state.app.updateInfo);
|
||||
const showUpdateNotification = useSelector(
|
||||
(state: State) => state.app.showUpdateNotification,
|
||||
@@ -40,7 +42,9 @@ export function UpdateNotification() {
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ marginRight: 10, fontWeight: 700 }}>
|
||||
<Text>App updated to {updateInfo.version}</Text>
|
||||
<Text>
|
||||
{t('App updated to {{version}}', { version: updateInfo.version })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
<View style={{ marginTop: -1 }}>
|
||||
@@ -53,7 +57,7 @@ export function UpdateNotification() {
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{t('Restart')}
|
||||
</Link>{' '}
|
||||
(
|
||||
<Link
|
||||
@@ -68,12 +72,12 @@ export function UpdateNotification() {
|
||||
)
|
||||
}
|
||||
>
|
||||
notes
|
||||
{t('notes')}
|
||||
</Link>
|
||||
)
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label="Close"
|
||||
aria-label={t('Close')}
|
||||
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
|
||||
onPress={() => {
|
||||
// Set a flag to never show an update notification again for this session
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
createRef,
|
||||
useMemo,
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -19,14 +20,17 @@ import { type UndoState } from 'loot-core/server/undo';
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import {
|
||||
SchedulesProvider,
|
||||
useDefaultSchedulesQueryTransform,
|
||||
accountSchedulesQuery,
|
||||
} from 'loot-core/src/client/data-hooks/schedules';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
|
||||
import {
|
||||
runQuery,
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
} from 'loot-core/src/client/query-helpers';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
import { currentDay } from 'loot-core/src/shared/months';
|
||||
import { q, type Query } from 'loot-core/src/shared/query';
|
||||
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
import {
|
||||
updateTransaction,
|
||||
realizeTempTransactions,
|
||||
@@ -46,6 +50,7 @@ import {
|
||||
type TransactionFilterEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccountPreviewTransactions } from '../../hooks/useAccountPreviewTransactions';
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
@@ -53,7 +58,6 @@ import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { usePayees } from '../../hooks/usePayees';
|
||||
import { usePreviewTransactions } from '../../hooks/usePreviewTransactions';
|
||||
import {
|
||||
SelectedProviderWithItems,
|
||||
type Actions,
|
||||
@@ -143,7 +147,6 @@ type AllTransactionsProps = {
|
||||
transactions: TransactionEntity[],
|
||||
balances: Record<string, { balance: number }> | null,
|
||||
) => ReactElement;
|
||||
collapseTransactions: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
function AllTransactions({
|
||||
@@ -153,14 +156,24 @@ function AllTransactions({
|
||||
showBalances,
|
||||
filtered,
|
||||
children,
|
||||
collapseTransactions,
|
||||
}: AllTransactionsProps) {
|
||||
const accountId = account?.id;
|
||||
const prependTransactions: (TransactionEntity & { _inverse?: boolean })[] =
|
||||
usePreviewTransactions(collapseTransactions).map(trans => ({
|
||||
...trans,
|
||||
_inverse: accountId ? accountId !== trans.account : false,
|
||||
}));
|
||||
const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
|
||||
const { previewTransactions, isLoading: isPreviewTransactionsLoading } =
|
||||
useAccountPreviewTransactions({ accountId });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewTransactionsLoading) {
|
||||
splitsExpandedDispatch({
|
||||
type: 'close-splits',
|
||||
ids: previewTransactions.filter(t => t.is_parent).map(t => t.id),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isPreviewTransactionsLoading,
|
||||
previewTransactions,
|
||||
splitsExpandedDispatch,
|
||||
]);
|
||||
|
||||
transactions ??= [];
|
||||
|
||||
@@ -180,29 +193,26 @@ function AllTransactions({
|
||||
}
|
||||
|
||||
// Reverse so we can calculate from earliest upcoming schedule.
|
||||
const scheduledBalances = [...prependTransactions]
|
||||
const previewBalances = [...previewTransactions]
|
||||
.reverse()
|
||||
.map(scheduledTransaction => {
|
||||
const amount =
|
||||
(scheduledTransaction._inverse ? -1 : 1) *
|
||||
getScheduledAmount(scheduledTransaction.amount);
|
||||
.map(previewTransaction => {
|
||||
return {
|
||||
// TODO: fix me
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
balance: (runningBalance += amount),
|
||||
id: scheduledTransaction.id,
|
||||
balance: (runningBalance += previewTransaction.amount),
|
||||
id: previewTransaction.id,
|
||||
};
|
||||
});
|
||||
return groupById(scheduledBalances);
|
||||
}, [showBalances, prependTransactions, runningBalance]);
|
||||
return groupById(previewBalances);
|
||||
}, [showBalances, previewTransactions, runningBalance]);
|
||||
|
||||
const allTransactions = useMemo(() => {
|
||||
// Don't prepend scheduled transactions if we are filtering
|
||||
if (!filtered && prependTransactions.length > 0) {
|
||||
return prependTransactions.concat(transactions);
|
||||
if (!filtered && previewTransactions.length > 0) {
|
||||
return previewTransactions.concat(transactions);
|
||||
}
|
||||
return transactions;
|
||||
}, [filtered, prependTransactions, transactions]);
|
||||
}, [filtered, previewTransactions, transactions]);
|
||||
|
||||
const allBalances = useMemo(() => {
|
||||
// Don't prepend scheduled transactions if we are filtering
|
||||
@@ -212,7 +222,7 @@ function AllTransactions({
|
||||
return balances;
|
||||
}, [filtered, prependBalances, balances]);
|
||||
|
||||
if (!prependTransactions) {
|
||||
if (!previewTransactions?.length || filtered) {
|
||||
return children(transactions, balances);
|
||||
}
|
||||
return children(allTransactions, allBalances);
|
||||
@@ -240,7 +250,7 @@ function getField(field?: string) {
|
||||
}
|
||||
|
||||
type AccountInternalProps = {
|
||||
accountId?: string;
|
||||
accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized';
|
||||
filterConditions: RuleConditionEntity[];
|
||||
showBalances?: boolean;
|
||||
setShowBalances: (newValue: boolean) => void;
|
||||
@@ -256,8 +266,8 @@ type AccountInternalProps = {
|
||||
accounts: AccountEntity[];
|
||||
getPayees: () => Promise<PayeeEntity[]>;
|
||||
updateAccount: (newAccount: AccountEntity) => void;
|
||||
newTransactions: string[];
|
||||
matchedTransactions: string[];
|
||||
newTransactions: Array<TransactionEntity['id']>;
|
||||
matchedTransactions: Array<TransactionEntity['id']>;
|
||||
splitsExpandedDispatch: ReturnType<typeof useSplitsExpanded>['dispatch'];
|
||||
expandSplits?: boolean;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
@@ -322,7 +332,7 @@ class AccountInternal extends PureComponent<
|
||||
AccountInternalProps,
|
||||
AccountInternalState
|
||||
> {
|
||||
paged: ReturnType<typeof pagedQuery> | null;
|
||||
paged: PagedQuery<TransactionEntity> | null;
|
||||
rootQuery: Query;
|
||||
currentQuery: Query;
|
||||
table: TableRef;
|
||||
@@ -457,7 +467,7 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
fetchAllIds = async () => {
|
||||
const { data } = await runQuery(this.paged?.getQuery().select('id'));
|
||||
const { data } = await runQuery(this.paged?.query.select('id'));
|
||||
// Remember, this is the `grouped` split type so we need to deal
|
||||
// with the `subtransactions` property
|
||||
return data.reduce((arr: string[], t: TransactionEntity) => {
|
||||
@@ -472,7 +482,7 @@ class AccountInternal extends PureComponent<
|
||||
};
|
||||
|
||||
fetchTransactions = (filterConditions?: ConditionEntity[]) => {
|
||||
const query = this.makeRootQuery();
|
||||
const query = this.makeRootTransactionsQuery();
|
||||
this.rootQuery = this.currentQuery = query;
|
||||
if (filterConditions) this.applyFilters(filterConditions);
|
||||
else this.updateQuery(query);
|
||||
@@ -482,10 +492,10 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
};
|
||||
|
||||
makeRootQuery = () => {
|
||||
makeRootTransactionsQuery = () => {
|
||||
const accountId = this.props.accountId;
|
||||
|
||||
return queries.makeTransactionsQuery(accountId);
|
||||
return queries.transactions(accountId);
|
||||
};
|
||||
|
||||
updateQuery(query: Query, isFiltered: boolean = false) {
|
||||
@@ -502,12 +512,9 @@ class AccountInternal extends PureComponent<
|
||||
query = query.filter({ reconciled: { $eq: false } });
|
||||
}
|
||||
|
||||
this.paged = pagedQuery(
|
||||
query.select('*'),
|
||||
async (
|
||||
data: TransactionEntity[],
|
||||
prevData: TransactionEntity[] | null,
|
||||
) => {
|
||||
this.paged = pagedQuery(query.select('*'), {
|
||||
onData: async (groupedData, prevData) => {
|
||||
const data = ungroupTransactions([...groupedData]);
|
||||
const firstLoad = prevData == null;
|
||||
|
||||
if (firstLoad) {
|
||||
@@ -529,7 +536,7 @@ class AccountInternal extends PureComponent<
|
||||
this.setState(
|
||||
{
|
||||
transactions: data,
|
||||
transactionCount: this.paged?.getTotalCount(),
|
||||
transactionCount: this.paged?.totalCount,
|
||||
transactionsFiltered: isFiltered,
|
||||
loading: false,
|
||||
workingHard: false,
|
||||
@@ -549,12 +556,11 @@ class AccountInternal extends PureComponent<
|
||||
},
|
||||
);
|
||||
},
|
||||
{
|
||||
options: {
|
||||
pageCount: 150,
|
||||
onlySync: true,
|
||||
mapper: ungroupTransactions,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: AccountInternalProps) {
|
||||
@@ -590,7 +596,7 @@ class AccountInternal extends PureComponent<
|
||||
);
|
||||
} else {
|
||||
this.updateQuery(
|
||||
queries.makeTransactionSearchQuery(
|
||||
queries.transactionsSearch(
|
||||
this.currentQuery,
|
||||
this.state.search,
|
||||
this.props.dateFormat,
|
||||
@@ -652,27 +658,19 @@ class AccountInternal extends PureComponent<
|
||||
);
|
||||
};
|
||||
|
||||
onTransactionsChange = (
|
||||
newTransaction: TransactionEntity,
|
||||
data: TransactionEntity[],
|
||||
) => {
|
||||
onTransactionsChange = (updatedTransaction: TransactionEntity) => {
|
||||
// Apply changes to pagedQuery data
|
||||
this.paged?.optimisticUpdate(
|
||||
(data: TransactionEntity[]) => {
|
||||
if (newTransaction._deleted) {
|
||||
return data.filter(t => t.id !== newTransaction.id);
|
||||
} else {
|
||||
return data.map(t => {
|
||||
return t.id === newTransaction.id ? newTransaction : t;
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
return data;
|
||||
},
|
||||
);
|
||||
this.paged?.optimisticUpdate(data => {
|
||||
if (updatedTransaction._deleted) {
|
||||
return data.filter(t => t.id !== updatedTransaction.id);
|
||||
} else {
|
||||
return data.map(t => {
|
||||
return t.id === updatedTransaction.id ? updatedTransaction : t;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.props.updateNewTransactions(newTransaction.id);
|
||||
this.props.updateNewTransactions(updatedTransaction.id);
|
||||
};
|
||||
|
||||
canCalculateBalance = () => {
|
||||
@@ -696,8 +694,7 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
const { data } = await runQuery(
|
||||
this.paged
|
||||
?.getQuery()
|
||||
this.paged?.query
|
||||
.options({ splits: 'none' })
|
||||
.select([{ balance: { $sumOver: '$amount' } }]),
|
||||
);
|
||||
@@ -862,22 +859,22 @@ class AccountInternal extends PureComponent<
|
||||
getBalanceQuery(id?: string) {
|
||||
return {
|
||||
name: `balance-query-${id}`,
|
||||
query: this.makeRootQuery().calculate({ $sum: '$amount' }),
|
||||
query: this.makeRootTransactionsQuery().calculate({ $sum: '$amount' }),
|
||||
} as const;
|
||||
}
|
||||
|
||||
getFilteredAmount = async () => {
|
||||
const { data: amount } = await runQuery(
|
||||
this.paged?.getQuery().calculate({ $sum: '$amount' }),
|
||||
this.paged?.query.calculate({ $sum: '$amount' }),
|
||||
);
|
||||
return amount;
|
||||
};
|
||||
|
||||
isNew = (id: string) => {
|
||||
isNew = (id: TransactionEntity['id']) => {
|
||||
return this.props.newTransactions.includes(id);
|
||||
};
|
||||
|
||||
isMatched = (id: string) => {
|
||||
isMatched = (id: TransactionEntity['id']) => {
|
||||
return this.props.matchedTransactions.includes(id);
|
||||
};
|
||||
|
||||
@@ -1678,9 +1675,6 @@ class AccountInternal extends PureComponent<
|
||||
balances={balances}
|
||||
showBalances={showBalances}
|
||||
filtered={transactionsFiltered}
|
||||
collapseTransactions={ids =>
|
||||
this.props.splitsExpandedDispatch({ type: 'close-splits', ids })
|
||||
}
|
||||
>
|
||||
{(allTransactions, allBalances) => (
|
||||
<SelectedProviderWithItems
|
||||
@@ -1803,6 +1797,15 @@ class AccountInternal extends PureComponent<
|
||||
sortField={this.state.sort?.field}
|
||||
ascDesc={this.state.sort?.ascDesc}
|
||||
onChange={this.onTransactionsChange}
|
||||
onBatchDelete={this.onBatchDelete}
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onMakeAsNonSplitTransactions={
|
||||
this.onMakeAsNonSplitTransactions
|
||||
}
|
||||
onRefetch={this.refetchTransactions}
|
||||
onCloseAddTransaction={() =>
|
||||
this.setState({ isAdding: false })
|
||||
@@ -1887,10 +1890,13 @@ export function Account() {
|
||||
const savedFiters = useFilters();
|
||||
const actionCreators = useActions();
|
||||
|
||||
const transform = useDefaultSchedulesQueryTransform(params.id);
|
||||
const schedulesQuery = useMemo(
|
||||
() => accountSchedulesQuery(params.id),
|
||||
[params.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<SchedulesProvider transform={transform}>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SplitsExpandedProvider
|
||||
initialMode={expandSplits ? 'collapse' : 'expand'}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { unlinkAccount } from 'loot-core/client/actions';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { authorizeBank } from '../../gocardless';
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
@@ -16,7 +17,7 @@ import { Link } from '../common/Link';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
|
||||
function getErrorMessage(type, code) {
|
||||
function getErrorMessage(type: string, code: string) {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'ITEM_ERROR':
|
||||
switch (code.toUpperCase()) {
|
||||
@@ -81,7 +82,29 @@ export function AccountSyncCheck() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
if (!failedAccounts) {
|
||||
const reauth = useCallback(
|
||||
(acc: AccountEntity) => {
|
||||
setOpen(false);
|
||||
|
||||
if (acc.account_id) {
|
||||
authorizeBank(dispatch, { upgradingAccountId: acc.account_id });
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const unlink = useCallback(
|
||||
(acc: AccountEntity) => {
|
||||
if (acc.id) {
|
||||
dispatch(unlinkAccount(acc.id));
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!failedAccounts || !id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,22 +114,15 @@ export function AccountSyncCheck() {
|
||||
}
|
||||
|
||||
const account = accounts.find(account => account.id === id);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, code } = error;
|
||||
const showAuth =
|
||||
(type === 'ITEM_ERROR' && code === 'ITEM_LOGIN_REQUIRED') ||
|
||||
(type === 'INVALID_INPUT' && code === 'INVALID_ACCESS_TOKEN');
|
||||
|
||||
function reauth() {
|
||||
setOpen(false);
|
||||
|
||||
authorizeBank(dispatch, { upgradingAccountId: account.account_id });
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
dispatch(unlinkAccount(account.id));
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button
|
||||
@@ -148,20 +164,20 @@ export function AccountSyncCheck() {
|
||||
<View style={{ justifyContent: 'flex-end', flexDirection: 'row' }}>
|
||||
{showAuth ? (
|
||||
<>
|
||||
<Button onPress={unlink}>
|
||||
<Button onPress={() => unlink(account)}>
|
||||
<Trans>Unlink</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
autoFocus
|
||||
onPress={reauth}
|
||||
onPress={() => reauth(account)}
|
||||
style={{ marginLeft: 5 }}
|
||||
>
|
||||
<Trans>Reauthorize</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onPress={unlink}>
|
||||
<Button onPress={() => unlink(account)}>
|
||||
<Trans>Unlink account</Trans>
|
||||
</Button>
|
||||
)}
|
||||
@@ -68,8 +68,13 @@ function SelectedBalance({ selectedItems, account }) {
|
||||
});
|
||||
|
||||
let scheduleBalance = null;
|
||||
const scheduleData = useCachedSchedules();
|
||||
const schedules = scheduleData ? scheduleData.schedules : [];
|
||||
|
||||
const { isLoading, schedules = [] } = useCachedSchedules();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewIds = [...selectedItems]
|
||||
.filter(id => isPreviewId(id))
|
||||
.map(id => id.slice(8));
|
||||
|
||||
@@ -369,7 +369,7 @@ export function AccountHeader({
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
{account && (
|
||||
<>
|
||||
<Button
|
||||
@@ -427,7 +427,7 @@ export function AccountHeader({
|
||||
</View>
|
||||
</Button>
|
||||
{account ? (
|
||||
<View>
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
<MenuButton
|
||||
aria-label="Account menu"
|
||||
ref={triggerRef}
|
||||
@@ -456,7 +456,7 @@ export function AccountHeader({
|
||||
</Popover>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
<MenuButton
|
||||
aria-label="Account menu"
|
||||
ref={triggerRef}
|
||||
|
||||
@@ -29,11 +29,13 @@ export function ReconcilingMessage({
|
||||
onDone,
|
||||
onCreateTransaction,
|
||||
}: ReconcilingMessageProps) {
|
||||
const cleared = useSheetValue<'balance', `balance-query-${string}-cleared`>({
|
||||
name: (balanceQuery.name + '-cleared') as `balance-query-${string}-cleared`,
|
||||
value: 0,
|
||||
query: balanceQuery.query.filter({ cleared: true }),
|
||||
});
|
||||
const cleared =
|
||||
useSheetValue<'balance', `balance-query-${string}-cleared`>({
|
||||
name: (balanceQuery.name +
|
||||
'-cleared') as `balance-query-${string}-cleared`,
|
||||
value: 0,
|
||||
query: balanceQuery.query.filter({ cleared: true }),
|
||||
}) ?? 0;
|
||||
const format = useFormat();
|
||||
const targetDiff = targetBalance - cleared;
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import { css, cx } from '@emotion/css';
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
@@ -17,12 +17,12 @@ import Downshift, { type StateChangeTypes } from 'downshift';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
|
||||
import { SvgRemove } from '../../icons/v2';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Input } from '../common/Input';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
type CommonAutocompleteProps<T extends Item> = {
|
||||
focused?: boolean;
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { SvgSplit } from '../../icons/v0';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { useEnvelopeSheetValue } from '../budget/envelope/EnvelopeBudgetComponents';
|
||||
import { makeAmountFullStyle } from '../budget/util';
|
||||
import { Text } from '../common/Text';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
import { useSheetValue } from '../spreadsheet/useSheetValue';
|
||||
|
||||
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
|
||||
@@ -393,7 +393,7 @@ function CategoryItem({
|
||||
>(balanceBinding);
|
||||
|
||||
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -429,10 +429,13 @@ function CategoryItem({
|
||||
display: !showBalances ? 'none' : undefined,
|
||||
marginLeft: 5,
|
||||
flexShrink: 0,
|
||||
...makeAmountFullStyle(isToBeBudgetedItem ? toBudget : balance, {
|
||||
positiveColor: theme.noticeTextMenu,
|
||||
negativeColor: theme.errorTextMenu,
|
||||
}),
|
||||
...makeAmountFullStyle(
|
||||
(isToBeBudgetedItem ? toBudget : balance) || 0,
|
||||
{
|
||||
positiveColor: theme.noticeTextMenu,
|
||||
negativeColor: theme.errorTextMenu,
|
||||
},
|
||||
),
|
||||
}}
|
||||
>
|
||||
{isToBeBudgetedItem
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { type CSSProperties } from 'react';
|
||||
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { styles, theme } from '../../style';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
type ItemHeaderProps = {
|
||||
title: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestProvider } from 'loot-core/src/mocks/redux';
|
||||
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useCommonPayees } from '../../hooks/usePayees';
|
||||
import { ResponsiveProvider } from '../../ResponsiveProvider';
|
||||
import { ResponsiveProvider } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import {
|
||||
PayeeAutocomplete,
|
||||
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useCommonPayees, usePayees } from '../../hooks/usePayees';
|
||||
import { SvgAdd, SvgBookmark } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import {
|
||||
Autocomplete,
|
||||
|
||||
@@ -11,10 +11,10 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgArrowThinRight } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Tooltip } from '../common/Tooltip';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../spreadsheet/CellValue';
|
||||
import { useFormat } from '../spreadsheet/useFormat';
|
||||
|
||||
@@ -28,6 +28,7 @@ export const BudgetCategories = memo(
|
||||
onSaveGroup,
|
||||
onDeleteCategory,
|
||||
onDeleteGroup,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
}) => {
|
||||
@@ -245,6 +246,7 @@ export const BudgetCategories = memo(
|
||||
onReorderCategory={onReorderCategory}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function BudgetSummaries({ SummaryComponent }: BudgetSummariesProps) {
|
||||
config: { mass: 3, tension: 600, friction: 80 },
|
||||
}));
|
||||
|
||||
const containerRef = useResizeObserver(
|
||||
const containerRef = useResizeObserver<HTMLDivElement>(
|
||||
useCallback(rect => {
|
||||
setWidthState(rect.width);
|
||||
}, []),
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type KeyboardEvent,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
type CategoryEntity,
|
||||
type CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { theme, styles } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { type DropPosition } from '../sort';
|
||||
|
||||
import { BudgetCategories } from './BudgetCategories';
|
||||
import { BudgetSummaries } from './BudgetSummaries';
|
||||
import { BudgetTotals } from './BudgetTotals';
|
||||
import { MonthsProvider } from './MonthsContext';
|
||||
import { type MonthBounds, MonthsProvider } from './MonthsContext';
|
||||
import {
|
||||
findSortDown,
|
||||
findSortUp,
|
||||
@@ -16,7 +26,39 @@ import {
|
||||
separateGroups,
|
||||
} from './util';
|
||||
|
||||
export function BudgetTable(props) {
|
||||
type BudgetTableProps = {
|
||||
type: string;
|
||||
prewarmStartMonth: string;
|
||||
startMonth: string;
|
||||
numMonths: number;
|
||||
monthBounds: MonthBounds;
|
||||
dataComponents: {
|
||||
SummaryComponent: ComponentPropsWithoutRef<
|
||||
typeof BudgetSummaries
|
||||
>['SummaryComponent'];
|
||||
BudgetTotalsComponent: ComponentPropsWithoutRef<
|
||||
typeof BudgetTotals
|
||||
>['MonthComponent'];
|
||||
};
|
||||
onSaveCategory: (category: CategoryEntity) => void;
|
||||
onDeleteCategory: (id: CategoryEntity['id']) => void;
|
||||
onSaveGroup: (group: CategoryGroupEntity) => void;
|
||||
onDeleteGroup: (id: CategoryGroupEntity['id']) => void;
|
||||
onApplyBudgetTemplatesInGroup: (groupId: CategoryGroupEntity['id']) => void;
|
||||
onReorderCategory: (params: {
|
||||
id: CategoryEntity['id'];
|
||||
groupId?: CategoryGroupEntity['id'];
|
||||
targetId: CategoryEntity['id'] | null;
|
||||
}) => void;
|
||||
onReorderGroup: (params: {
|
||||
id: CategoryGroupEntity['id'];
|
||||
targetId: CategoryEntity['id'] | null;
|
||||
}) => void;
|
||||
onShowActivity: (id: CategoryEntity['id'], month?: string) => void;
|
||||
onBudgetAction: (month: string, type: string, args: unknown) => void;
|
||||
};
|
||||
|
||||
export function BudgetTable(props: BudgetTableProps) {
|
||||
const {
|
||||
type,
|
||||
prewarmStartMonth,
|
||||
@@ -28,29 +70,36 @@ export function BudgetTable(props) {
|
||||
onDeleteCategory,
|
||||
onSaveGroup,
|
||||
onDeleteGroup,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
onShowActivity,
|
||||
onBudgetAction,
|
||||
} = props;
|
||||
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const { grouped: categoryGroups = [] } = useCategories();
|
||||
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
|
||||
useLocalPref('budget.collapsed');
|
||||
const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref(
|
||||
'budget.showHiddenCategories',
|
||||
);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onEditMonth = (id, month) => {
|
||||
const onEditMonth = (id: string, month: string) => {
|
||||
setEditing(id ? { id, cell: month } : null);
|
||||
};
|
||||
|
||||
const onEditName = id => {
|
||||
const onEditName = (id: string) => {
|
||||
setEditing(id ? { id, cell: 'name' } : null);
|
||||
};
|
||||
|
||||
const _onReorderCategory = (id, dropPos, targetId) => {
|
||||
const _onReorderCategory = (
|
||||
id: string,
|
||||
dropPos: DropPosition,
|
||||
targetId: string,
|
||||
) => {
|
||||
const isGroup = !!categoryGroups.find(g => g.id === targetId);
|
||||
|
||||
if (isGroup) {
|
||||
@@ -62,7 +111,7 @@ export function BudgetTable(props) {
|
||||
const group = categoryGroups.find(g => g.id === groupId);
|
||||
|
||||
if (group) {
|
||||
const { categories } = group;
|
||||
const { categories = [] } = group;
|
||||
onReorderCategory({
|
||||
id,
|
||||
groupId: group.id,
|
||||
@@ -76,7 +125,7 @@ export function BudgetTable(props) {
|
||||
let targetGroup;
|
||||
|
||||
for (const group of categoryGroups) {
|
||||
if (group.categories.find(cat => cat.id === targetId)) {
|
||||
if (group.categories?.find(cat => cat.id === targetId)) {
|
||||
targetGroup = group;
|
||||
break;
|
||||
}
|
||||
@@ -84,13 +133,17 @@ export function BudgetTable(props) {
|
||||
|
||||
onReorderCategory({
|
||||
id,
|
||||
groupId: targetGroup.id,
|
||||
...findSortDown(targetGroup.categories, dropPos, targetId),
|
||||
groupId: targetGroup?.id,
|
||||
...findSortDown(targetGroup?.categories || [], dropPos, targetId),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const _onReorderGroup = (id, dropPos, targetId) => {
|
||||
const _onReorderGroup = (
|
||||
id: string,
|
||||
dropPos: DropPosition,
|
||||
targetId: string,
|
||||
) => {
|
||||
const [expenseGroups] = separateGroups(categoryGroups); // exclude Income group from sortable groups to fix off-by-one error
|
||||
onReorderGroup({
|
||||
id,
|
||||
@@ -98,13 +151,21 @@ export function BudgetTable(props) {
|
||||
});
|
||||
};
|
||||
|
||||
const moveVertically = dir => {
|
||||
const flattened = categoryGroups.reduce((all, group) => {
|
||||
if (collapsedGroupIds.includes(group.id)) {
|
||||
return all.concat({ id: group.id, isGroup: true });
|
||||
}
|
||||
return all.concat([{ id: group.id, isGroup: true }, ...group.categories]);
|
||||
}, []);
|
||||
const moveVertically = (dir: 1 | -1) => {
|
||||
const flattened = categoryGroups.reduce(
|
||||
(all, group) => {
|
||||
if (collapsedGroupIds.includes(group.id)) {
|
||||
return all.concat({ id: group.id, isGroup: true });
|
||||
}
|
||||
return all.concat([
|
||||
{ id: group.id, isGroup: true },
|
||||
...(group?.categories || []),
|
||||
]);
|
||||
},
|
||||
[] as Array<
|
||||
{ id: CategoryGroupEntity['id']; isGroup: boolean } | CategoryEntity
|
||||
>,
|
||||
);
|
||||
|
||||
if (editing) {
|
||||
const idx = flattened.findIndex(item => item.id === editing.id);
|
||||
@@ -113,10 +174,13 @@ export function BudgetTable(props) {
|
||||
while (nextIdx >= 0 && nextIdx < flattened.length) {
|
||||
const next = flattened[nextIdx];
|
||||
|
||||
if (next.isGroup) {
|
||||
if ('isGroup' in next && next.isGroup) {
|
||||
nextIdx += dir;
|
||||
continue;
|
||||
} else if (type === 'report' || !next.is_income) {
|
||||
} else if (
|
||||
type === 'report' ||
|
||||
('is_income' in next && !next.is_income)
|
||||
) {
|
||||
onEditMonth(next.id, editing.cell);
|
||||
return;
|
||||
} else {
|
||||
@@ -126,7 +190,7 @@ export function BudgetTable(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = e => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!editing) {
|
||||
return null;
|
||||
}
|
||||
@@ -137,7 +201,7 @@ export function BudgetTable(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const onCollapse = collapsedIds => {
|
||||
const onCollapse = (collapsedIds: string[]) => {
|
||||
setCollapsedGroupIdsPref(collapsedIds);
|
||||
};
|
||||
|
||||
@@ -222,6 +286,7 @@ export function BudgetTable(props) {
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<BudgetCategories
|
||||
// @ts-expect-error Fix when migrating BudgetCategories to ts
|
||||
categoryGroups={categoryGroups}
|
||||
editingCell={editing}
|
||||
dataComponents={dataComponents}
|
||||
@@ -235,6 +300,7 @@ export function BudgetTable(props) {
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -131,6 +131,7 @@ const DynamicBudgetTableInner = ({
|
||||
onMonthSelect={_onMonthSelect}
|
||||
/>
|
||||
<BudgetTable
|
||||
type={type}
|
||||
prewarmStartMonth={prewarmStartMonth}
|
||||
startMonth={startMonth}
|
||||
numMonths={numMonths}
|
||||
@@ -144,7 +145,13 @@ const DynamicBudgetTableInner = ({
|
||||
|
||||
DynamicBudgetTableInner.displayName = 'DynamicBudgetTableInner';
|
||||
|
||||
type DynamicBudgetTableProps = ComponentProps<typeof BudgetTable>;
|
||||
type DynamicBudgetTableProps = Omit<
|
||||
ComponentProps<typeof BudgetTable>,
|
||||
'numMonths'
|
||||
> & {
|
||||
maxMonths: number;
|
||||
onMonthSelect: (month: string, numMonths: number) => void;
|
||||
};
|
||||
|
||||
export const DynamicBudgetTable = (props: DynamicBudgetTableProps) => {
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,9 @@ type ExpenseGroupProps = {
|
||||
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
|
||||
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
|
||||
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
|
||||
onApplyBudgetTemplatesInGroup?: ComponentProps<
|
||||
typeof SidebarGroup
|
||||
>['onApplyBudgetTemplatesInGroup'];
|
||||
onDragChange: OnDragChangeCallback<
|
||||
ComponentProps<typeof SidebarGroup>['group']
|
||||
>;
|
||||
@@ -43,6 +46,7 @@ export function ExpenseGroup({
|
||||
onEditName,
|
||||
onSave,
|
||||
onDelete,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onDragChange,
|
||||
onReorderGroup,
|
||||
onReorderCategory,
|
||||
@@ -125,6 +129,7 @@ export function ExpenseGroup({
|
||||
onEdit={onEditName}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
/>
|
||||
<RenderMonths component={MonthComponent} args={{ group }} />
|
||||
|
||||
@@ -7,12 +7,12 @@ import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { styles, theme } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { type BoundsProps } from './MonthsContext';
|
||||
import { type MonthBounds } from './MonthsContext';
|
||||
|
||||
type MonthPickerProps = {
|
||||
startMonth: string;
|
||||
numDisplayed: number;
|
||||
monthBounds: BoundsProps;
|
||||
monthBounds: MonthBounds;
|
||||
style: CSSProperties;
|
||||
onSelect: (month: string) => void;
|
||||
};
|
||||
|
||||
@@ -3,13 +3,13 @@ import React, { createContext, type ReactNode } from 'react';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
export type BoundsProps = {
|
||||
export type MonthBounds = {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export function getValidMonthBounds(
|
||||
bounds: BoundsProps,
|
||||
bounds: MonthBounds,
|
||||
startMonth: undefined | string,
|
||||
endMonth: string,
|
||||
) {
|
||||
@@ -29,7 +29,7 @@ export const MonthsContext = createContext<MonthsContextProps>(null);
|
||||
type MonthsProviderProps = {
|
||||
startMonth: string | undefined;
|
||||
numMonths: number;
|
||||
monthBounds: BoundsProps;
|
||||
monthBounds: MonthBounds;
|
||||
type: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type CSSProperties, type Ref, useRef, useState } from 'react';
|
||||
import React, { type CSSProperties, type Ref, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CategoryEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useContextMenu } from '../../hooks/useContextMenu';
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
@@ -49,7 +50,8 @@ export function SidebarCategory({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const temporary = category.id === 'new';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
|
||||
useContextMenu();
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const displayed = (
|
||||
@@ -61,7 +63,10 @@ export function SidebarCategory({
|
||||
WebkitUserSelect: 'none',
|
||||
opacity: category.hidden || categoryGroup?.hidden ? 0.33 : undefined,
|
||||
backgroundColor: 'transparent',
|
||||
height: 20,
|
||||
}}
|
||||
ref={triggerRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div
|
||||
data-testid="category-name"
|
||||
@@ -74,12 +79,15 @@ export function SidebarCategory({
|
||||
>
|
||||
{category.name}
|
||||
</div>
|
||||
<View style={{ flexShrink: 0, marginLeft: 5 }} ref={triggerRef}>
|
||||
<View style={{ flexShrink: 0, marginLeft: 5 }}>
|
||||
<Button
|
||||
variant="bare"
|
||||
className="hover-visible"
|
||||
style={{ color: 'currentColor', padding: 3 }}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
onPress={() => {
|
||||
resetPosition();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
@@ -93,7 +101,9 @@ export function SidebarCategory({
|
||||
placement="bottom start"
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
style={{ width: 200, margin: 1 }}
|
||||
isNonModal
|
||||
{...position}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type CSSProperties, useRef, useState } from 'react';
|
||||
import React, { type CSSProperties, useRef } from 'react';
|
||||
import { type ConnectDragSource } from 'react-dnd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useContextMenu } from '../../hooks/useContextMenu';
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
@@ -32,6 +34,7 @@ type SidebarGroupProps = {
|
||||
onEdit?: (id: string) => void;
|
||||
onSave?: (group: object) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
onApplyBudgetTemplatesInGroup?: (categories: object[]) => void;
|
||||
onShowNewCategory?: (groupId: string) => void;
|
||||
onHideNewGroup?: () => void;
|
||||
onToggleCollapse?: (id: string) => void;
|
||||
@@ -47,14 +50,17 @@ export function SidebarGroup({
|
||||
onEdit,
|
||||
onSave,
|
||||
onDelete,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onShowNewCategory,
|
||||
onHideNewGroup,
|
||||
onToggleCollapse,
|
||||
}: SidebarGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
|
||||
const temporary = group.id === 'new';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } =
|
||||
useContextMenu();
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const displayed = (
|
||||
@@ -64,10 +70,13 @@ export function SidebarGroup({
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
height: 20,
|
||||
}}
|
||||
ref={triggerRef}
|
||||
onClick={() => {
|
||||
onToggleCollapse(group.id);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{!dragPreview && (
|
||||
<SvgExpandArrow
|
||||
@@ -95,11 +104,14 @@ export function SidebarGroup({
|
||||
</div>
|
||||
{!dragPreview && (
|
||||
<>
|
||||
<View style={{ marginLeft: 5, flexShrink: 0 }} ref={triggerRef}>
|
||||
<View style={{ marginLeft: 5, flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="bare"
|
||||
className="hover-visible"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
onPress={() => {
|
||||
resetPosition();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
style={{ padding: 3 }}
|
||||
>
|
||||
<SvgCheveronDown width={14} height={14} />
|
||||
@@ -110,7 +122,9 @@ export function SidebarGroup({
|
||||
placement="bottom start"
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
style={{ width: 200, margin: 1 }}
|
||||
isNonModal
|
||||
{...position}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
@@ -122,6 +136,12 @@ export function SidebarGroup({
|
||||
onDelete(group.id);
|
||||
} else if (type === 'toggle-visibility') {
|
||||
onSave({ ...group, hidden: !group.hidden });
|
||||
} else if (type === 'apply-multiple-category-template') {
|
||||
onApplyBudgetTemplatesInGroup?.(
|
||||
group.categories
|
||||
.filter(c => !c['hidden'])
|
||||
.map(c => c['id']),
|
||||
);
|
||||
}
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
@@ -130,9 +150,17 @@ export function SidebarGroup({
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
!group.is_income && {
|
||||
name: 'toggle-visibility',
|
||||
text: group.hidden ? t('Show') : t('Hide'),
|
||||
text: group.hidden ? 'Show' : 'Hide',
|
||||
},
|
||||
onDelete && { name: 'delete', text: t('Delete') },
|
||||
...(isGoalTemplatesEnabled
|
||||
? [
|
||||
{
|
||||
name: 'apply-multiple-category-template',
|
||||
text: t('Apply budget templates'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -29,7 +29,8 @@ export function BalanceMenu({
|
||||
const carryover = useEnvelopeSheetValue(
|
||||
envelopeBudget.catCarryover(categoryId),
|
||||
);
|
||||
const balance = useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId));
|
||||
const balance =
|
||||
useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)) ?? 0;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
|
||||