Compare commits
7 Commits
v24.12.0
...
react-aria
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2bf2e9cc9 | ||
|
|
7e1cc49478 | ||
|
|
e6a49b1d99 | ||
|
|
db7d890e79 | ||
|
|
46977b59ca | ||
|
|
b3d0348493 | ||
|
|
b78f1fd575 |
@@ -161,12 +161,7 @@ module.exports = {
|
||||
],
|
||||
'no-with': 'warn',
|
||||
'no-whitespace-before-property': 'warn',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: '(useQuery)',
|
||||
},
|
||||
],
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'require-yield': 'warn',
|
||||
'rest-spread-spacing': ['warn', 'never'],
|
||||
strict: ['warn', 'never'],
|
||||
|
||||
66
.github/workflows/update-vrt.yml
vendored
@@ -4,11 +4,11 @@ on:
|
||||
types: [ created ]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -41,48 +41,11 @@ jobs:
|
||||
run: yarn vrt --update-snapshots
|
||||
env:
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- name: Create patch
|
||||
- name: Commit and push changes
|
||||
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"
|
||||
@@ -90,24 +53,3 @@ jobs:
|
||||
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 # electron specific build
|
||||
yarn workspace @actual-app/web build --mode=desktop
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "24.12.0",
|
||||
"version": "24.11.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
|
||||
1
packages/desktop-client/.gitignore
vendored
@@ -10,7 +10,6 @@ playwright-report
|
||||
|
||||
# production
|
||||
build
|
||||
build-electron
|
||||
build-stats
|
||||
stats.json
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ 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);
|
||||
|
||||
@@ -38,10 +37,7 @@ 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();
|
||||
@@ -54,9 +50,6 @@ 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: 32 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 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,8 +62,6 @@ 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');
|
||||
@@ -111,7 +109,6 @@ 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,10 +30,6 @@ export class AccountPage {
|
||||
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
|
||||
}
|
||||
|
||||
async waitFor() {
|
||||
await this.transactionTable.waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter details of a transaction
|
||||
*/
|
||||
|
||||
@@ -15,10 +15,6 @@ export class MobileAccountPage {
|
||||
});
|
||||
}
|
||||
|
||||
async waitFor() {
|
||||
await this.transactionList.waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the balance of the account as a number
|
||||
*/
|
||||
@@ -33,10 +29,6 @@ export class MobileAccountPage {
|
||||
await this.searchBox.fill(term);
|
||||
}
|
||||
|
||||
async clearSearch() {
|
||||
await this.searchBox.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to transaction creation page
|
||||
*/
|
||||
|
||||
@@ -4,14 +4,9 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -23,13 +22,6 @@ 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,9 +22,8 @@ export class ReportsPage {
|
||||
|
||||
async goToCustomReportPage() {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
.getByRole('button', { name: 'Create new custom report' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'New custom report' }).click();
|
||||
return new CustomReportPage(this.page);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,27 @@ 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: 71 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 30 KiB |
@@ -48,8 +48,11 @@ test.describe('Mobile Transactions', () => {
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await transactionEntryPage.createTransaction();
|
||||
await expect(page.getByLabel('Transaction list')).toHaveCount(0);
|
||||
const accountPage = await transactionEntryPage.createTransaction();
|
||||
|
||||
await expect(accountPage.transactions.nth(0)).toHaveText(
|
||||
'KrogerClothing-12.34',
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
@@ -79,74 +82,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 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.12.0",
|
||||
"version": "24.11.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.35.1",
|
||||
"react-aria-components": "^1.4.1",
|
||||
"react-aria": "^3.34.3",
|
||||
"react-aria-components": "^1.3.3",
|
||||
"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.33.0",
|
||||
"react-stately": "^3.10.9",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"recharts": "^2.10.4",
|
||||
"redux": "^4.2.1",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { FinancesApp } from './FinancesApp';
|
||||
import { ManagementApp } from './manager/ManagementApp';
|
||||
import { Modals } from './Modals';
|
||||
import { ResponsiveProvider } from './responsive/ResponsiveProvider';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { SidebarProvider } from './sidebar/SidebarProvider';
|
||||
import { UpdateNotification } from './UpdateNotification';
|
||||
|
||||
@@ -179,34 +180,36 @@ export function App() {
|
||||
<SidebarProvider>
|
||||
<BudgetMonthCountProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<View
|
||||
data-theme={theme}
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<ScrollProvider>
|
||||
<View
|
||||
key={
|
||||
hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'
|
||||
}
|
||||
data-theme={theme}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
...styles.lightScrollbar,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{process.env.REACT_APP_REVIEW_ID &&
|
||||
!Platform.isPlaywright && <DevelopmentTopBar />}
|
||||
<AppInner />
|
||||
</ErrorBoundary>
|
||||
<ThemeStyle />
|
||||
<Modals />
|
||||
<UpdateNotification />
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollProvider>
|
||||
</DndProvider>
|
||||
</BudgetMonthCountProvider>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SvgPencil1 } from '../icons/v2';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { InitialFocus } from './common/InitialFocus';
|
||||
import { Input } from './common/Input';
|
||||
import { View } from './common/View';
|
||||
|
||||
@@ -33,24 +32,24 @@ export function EditablePageHeaderTitle({
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={title}
|
||||
onEnter={e => onSaveValue(e.currentTarget.value)}
|
||||
onBlur={e => onSaveValue(e.target.value)}
|
||||
onEscape={() => setIsEditing(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -3,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, title.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue={title}
|
||||
onEnter={e => onSaveValue(e.currentTarget.value)}
|
||||
onBlur={e => onSaveValue(e.target.value)}
|
||||
onEscape={() => setIsEditing(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -3,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, title.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ReactElement, useEffect, useRef } from 'react';
|
||||
import React, { type ReactElement, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
@@ -34,7 +34,6 @@ 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';
|
||||
@@ -157,8 +156,6 @@ export function FinancesApp() {
|
||||
run();
|
||||
}, [lastUsedVersion, setLastUsedVersion]);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<View style={{ height: '100%' }}>
|
||||
<RouterBehaviors />
|
||||
@@ -182,119 +179,113 @@ export function FinancesApp() {
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<ScrollProvider
|
||||
isDisabled={!isNarrowWidth}
|
||||
scrollableRef={scrollableRef}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
ref={scrollableRef}
|
||||
<Titlebar
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
WebkitAppRegion: 'drag',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
/>
|
||||
<Notifications />
|
||||
<BankSyncStatus />
|
||||
|
||||
<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} />
|
||||
<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>
|
||||
</ScrollProvider>
|
||||
</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>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -39,11 +38,9 @@ export function LoggedInUser({
|
||||
getUserData().then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function onChangePassword() {
|
||||
await closeBudget();
|
||||
navigate('/change-password');
|
||||
window.__navigate('/change-password');
|
||||
}
|
||||
|
||||
async function onMenuSelect(type) {
|
||||
@@ -55,14 +52,14 @@ export function LoggedInUser({
|
||||
break;
|
||||
case 'sign-in':
|
||||
await closeBudget();
|
||||
navigate('/login');
|
||||
window.__navigate('/login');
|
||||
break;
|
||||
case 'sign-out':
|
||||
signOut();
|
||||
break;
|
||||
case 'config-server':
|
||||
await closeBudget();
|
||||
navigate('/config-server');
|
||||
window.__navigate('/config-server');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -7,11 +7,8 @@ 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';
|
||||
@@ -24,6 +21,7 @@ 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';
|
||||
|
||||
@@ -115,9 +113,7 @@ export function ManageRules({
|
||||
const [filter, setFilter] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
const { data: schedules = [] } = useSchedules();
|
||||
const { list: categories } = useCategories();
|
||||
const payees = usePayees();
|
||||
const accounts = useAccounts();
|
||||
@@ -200,9 +196,7 @@ export function ManageRules({
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
alert(
|
||||
t('Some rules were not deleted because they are linked to schedules'),
|
||||
);
|
||||
alert('Some rules were not deleted because they are linked to schedules');
|
||||
}
|
||||
|
||||
await loadRules();
|
||||
@@ -265,7 +259,6 @@ export function ManageRules({
|
||||
const onHover = useCallback(id => {
|
||||
setHoveredRule(id);
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
@@ -287,21 +280,21 @@ export function ManageRules({
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{t('Rules are always run in the order that you see them.')}{' '}
|
||||
Rules are always run in the order that you see them.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/budgeting/rules/"
|
||||
linkColor="muted"
|
||||
>
|
||||
{t('Learn more')}
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Search
|
||||
placeholder={t('Filter rules...')}
|
||||
placeholder="Filter rules..."
|
||||
value={filter}
|
||||
onChange={onSearchChange}
|
||||
onChangeValue={onSearchChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -312,7 +305,7 @@ export function ManageRules({
|
||||
style={{ marginBottom: -1 }}
|
||||
>
|
||||
{filteredRules.length === 0 ? (
|
||||
<EmptyMessage text={t('No rules')} style={{ marginTop: 15 }} />
|
||||
<EmptyMessage text="No rules" style={{ marginTop: 15 }} />
|
||||
) : (
|
||||
<RulesList
|
||||
rules={filteredRules}
|
||||
@@ -340,7 +333,7 @@ export function ManageRules({
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
{t('Create new rule')}
|
||||
Create new rule
|
||||
</Button>
|
||||
</Stack>
|
||||
</View>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { ManageRules } from './ManageRules';
|
||||
import { Page } from './Page';
|
||||
|
||||
export function ManageRulesPage() {
|
||||
return (
|
||||
<Page header={t('Rules')}>
|
||||
<Page header="Rules">
|
||||
<ManageRules isModal={false} payeeId={null} />
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -82,8 +81,6 @@ export function Modals() {
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modals = modalStack
|
||||
.map(({ name, options }) => {
|
||||
switch (name) {
|
||||
@@ -290,12 +287,10 @@ export function Modals() {
|
||||
Header={props => (
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={
|
||||
<ModalTitle title={t('New Category')} shrinkOnOverflow />
|
||||
}
|
||||
title={<ModalTitle title="New Category" shrinkOnOverflow />}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder={t('Category name')}
|
||||
inputPlaceholder="Category name"
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
onSubmit={options.onSubmit}
|
||||
@@ -311,15 +306,12 @@ export function Modals() {
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={
|
||||
<ModalTitle
|
||||
title={t('New Category Group')}
|
||||
shrinkOnOverflow
|
||||
/>
|
||||
<ModalTitle title="New Category Group" shrinkOnOverflow />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder={t('Category group name')}
|
||||
buttonText={t('Add')}
|
||||
inputPlaceholder="Category group name"
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { theme } from '../style';
|
||||
@@ -123,7 +122,7 @@ export function Notes({
|
||||
value={notes || ''}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
onBlur={e => onBlur?.(e.target.value)}
|
||||
placeholder={t('Notes (markdown supported)')}
|
||||
placeholder="Notes (markdown supported)"
|
||||
/>
|
||||
) : (
|
||||
<Text className={css([markdownStyles, getStyle?.(editable)])}>
|
||||
|
||||
@@ -6,8 +6,6 @@ import React, {
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { useNotes } from '../hooks/useNotes';
|
||||
@@ -61,7 +59,7 @@ export function NotesButton({
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
aria-label={t('View notes')}
|
||||
aria-label="View notes"
|
||||
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
|
||||
style={{
|
||||
color: defaultColor,
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -232,7 +231,7 @@ function Notification({
|
||||
</Stack>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Close')}
|
||||
aria-label="Close"
|
||||
style={{ flexShrink: 0, color: 'currentColor' }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
|
||||
@@ -1,204 +1,61 @@
|
||||
// @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 = {
|
||||
registerScrollListener: RegisterScrollListener;
|
||||
scrollY: number | undefined;
|
||||
hasScrolledToBottom: (tolerance?: number) => boolean;
|
||||
};
|
||||
|
||||
const ScrollContext = createContext<IScrollContext | undefined>(undefined);
|
||||
|
||||
type ScrollProviderProps<T extends Element> = {
|
||||
scrollableRef: RefObject<T>;
|
||||
isDisabled?: boolean;
|
||||
delayMs?: number;
|
||||
type ScrollProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
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[]>([]);
|
||||
export function ScrollProvider({ children }: ScrollProviderProps) {
|
||||
const [scrollY, setScrollY] = useState(undefined);
|
||||
const [scrollHeight, setScrollHeight] = useState(undefined);
|
||||
const [clientHeight, setClientHeight] = useState(undefined);
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
[],
|
||||
const hasScrolledToBottom = useCallback(
|
||||
(tolerance = 1) => scrollHeight - scrollY <= clientHeight + tolerance,
|
||||
[clientHeight, scrollHeight, scrollY],
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listenToScroll = debounce((e: Event) => {
|
||||
const listenToScroll = debounce(e => {
|
||||
const target = e.target;
|
||||
if (target instanceof Element) {
|
||||
previousScrollX.current = scrollX.current;
|
||||
scrollX.current = target.scrollLeft;
|
||||
scrollHeight.current = target.scrollHeight;
|
||||
setScrollY(target?.scrollTop || 0);
|
||||
setScrollHeight(target?.scrollHeight || 0);
|
||||
setClientHeight(target?.clientHeight || 0);
|
||||
}, 10);
|
||||
|
||||
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, {
|
||||
window.addEventListener('scroll', listenToScroll, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
return () =>
|
||||
ref?.removeEventListener('scroll', listenToScroll, {
|
||||
window.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={{ registerScrollListener }}>
|
||||
<ScrollContext.Provider value={{ scrollY, hasScrolledToBottom }}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
export function useScroll(): IScrollContext {
|
||||
return useContext(ScrollContext);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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';
|
||||
@@ -47,7 +45,7 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
aria-label={t('Switch theme')}
|
||||
aria-label="Switch theme"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={style}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -207,7 +206,7 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Sync')}
|
||||
aria-label="Sync"
|
||||
className={css({
|
||||
...(isMobile
|
||||
? {
|
||||
@@ -291,7 +290,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
>
|
||||
{(floatingSidebar || sidebar.alwaysFloats) && (
|
||||
<Button
|
||||
aria-label={t('Sidebar menu')}
|
||||
aria-label="Sidebar menu"
|
||||
variant="bare"
|
||||
style={{ marginRight: 8 }}
|
||||
onHoverStart={e => {
|
||||
@@ -323,7 +322,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
height={10}
|
||||
style={{ marginRight: 5, color: 'currentColor' }}
|
||||
/>{' '}
|
||||
{t('Back')}
|
||||
Back
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
@@ -14,7 +13,6 @@ 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,
|
||||
@@ -42,9 +40,7 @@ export function UpdateNotification() {
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ marginRight: 10, fontWeight: 700 }}>
|
||||
<Text>
|
||||
{t('App updated to {{version}}', { version: updateInfo.version })}
|
||||
</Text>
|
||||
<Text>App updated to {updateInfo.version}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
<View style={{ marginTop: -1 }}>
|
||||
@@ -57,7 +53,7 @@ export function UpdateNotification() {
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{t('Restart')}
|
||||
Restart
|
||||
</Link>{' '}
|
||||
(
|
||||
<Link
|
||||
@@ -72,12 +68,12 @@ export function UpdateNotification() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('notes')}
|
||||
notes
|
||||
</Link>
|
||||
)
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Close')}
|
||||
aria-label="Close"
|
||||
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
|
||||
onPress={() => {
|
||||
// Set a flag to never show an update notification again for this session
|
||||
|
||||
@@ -5,7 +5,6 @@ import React, {
|
||||
createRef,
|
||||
useMemo,
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -20,17 +19,14 @@ import { type UndoState } from 'loot-core/server/undo';
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import {
|
||||
SchedulesProvider,
|
||||
accountSchedulesQuery,
|
||||
useDefaultSchedulesQueryTransform,
|
||||
} from 'loot-core/src/client/data-hooks/schedules';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import {
|
||||
runQuery,
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
} from 'loot-core/src/client/query-helpers';
|
||||
import { runQuery, 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,
|
||||
@@ -50,7 +46,6 @@ 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';
|
||||
@@ -58,6 +53,7 @@ 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,
|
||||
@@ -147,6 +143,7 @@ type AllTransactionsProps = {
|
||||
transactions: TransactionEntity[],
|
||||
balances: Record<string, { balance: number }> | null,
|
||||
) => ReactElement;
|
||||
collapseTransactions: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
function AllTransactions({
|
||||
@@ -156,24 +153,14 @@ function AllTransactions({
|
||||
showBalances,
|
||||
filtered,
|
||||
children,
|
||||
collapseTransactions,
|
||||
}: AllTransactionsProps) {
|
||||
const accountId = account?.id;
|
||||
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,
|
||||
]);
|
||||
const prependTransactions: (TransactionEntity & { _inverse?: boolean })[] =
|
||||
usePreviewTransactions(collapseTransactions).map(trans => ({
|
||||
...trans,
|
||||
_inverse: accountId ? accountId !== trans.account : false,
|
||||
}));
|
||||
|
||||
transactions ??= [];
|
||||
|
||||
@@ -193,26 +180,29 @@ function AllTransactions({
|
||||
}
|
||||
|
||||
// Reverse so we can calculate from earliest upcoming schedule.
|
||||
const previewBalances = [...previewTransactions]
|
||||
const scheduledBalances = [...prependTransactions]
|
||||
.reverse()
|
||||
.map(previewTransaction => {
|
||||
.map(scheduledTransaction => {
|
||||
const amount =
|
||||
(scheduledTransaction._inverse ? -1 : 1) *
|
||||
getScheduledAmount(scheduledTransaction.amount);
|
||||
return {
|
||||
// TODO: fix me
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
balance: (runningBalance += previewTransaction.amount),
|
||||
id: previewTransaction.id,
|
||||
balance: (runningBalance += amount),
|
||||
id: scheduledTransaction.id,
|
||||
};
|
||||
});
|
||||
return groupById(previewBalances);
|
||||
}, [showBalances, previewTransactions, runningBalance]);
|
||||
return groupById(scheduledBalances);
|
||||
}, [showBalances, prependTransactions, runningBalance]);
|
||||
|
||||
const allTransactions = useMemo(() => {
|
||||
// Don't prepend scheduled transactions if we are filtering
|
||||
if (!filtered && previewTransactions.length > 0) {
|
||||
return previewTransactions.concat(transactions);
|
||||
if (!filtered && prependTransactions.length > 0) {
|
||||
return prependTransactions.concat(transactions);
|
||||
}
|
||||
return transactions;
|
||||
}, [filtered, previewTransactions, transactions]);
|
||||
}, [filtered, prependTransactions, transactions]);
|
||||
|
||||
const allBalances = useMemo(() => {
|
||||
// Don't prepend scheduled transactions if we are filtering
|
||||
@@ -222,7 +212,7 @@ function AllTransactions({
|
||||
return balances;
|
||||
}, [filtered, prependBalances, balances]);
|
||||
|
||||
if (!previewTransactions?.length || filtered) {
|
||||
if (!prependTransactions) {
|
||||
return children(transactions, balances);
|
||||
}
|
||||
return children(allTransactions, allBalances);
|
||||
@@ -250,7 +240,7 @@ function getField(field?: string) {
|
||||
}
|
||||
|
||||
type AccountInternalProps = {
|
||||
accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized';
|
||||
accountId?: string;
|
||||
filterConditions: RuleConditionEntity[];
|
||||
showBalances?: boolean;
|
||||
setShowBalances: (newValue: boolean) => void;
|
||||
@@ -266,8 +256,8 @@ type AccountInternalProps = {
|
||||
accounts: AccountEntity[];
|
||||
getPayees: () => Promise<PayeeEntity[]>;
|
||||
updateAccount: (newAccount: AccountEntity) => void;
|
||||
newTransactions: Array<TransactionEntity['id']>;
|
||||
matchedTransactions: Array<TransactionEntity['id']>;
|
||||
newTransactions: string[];
|
||||
matchedTransactions: string[];
|
||||
splitsExpandedDispatch: ReturnType<typeof useSplitsExpanded>['dispatch'];
|
||||
expandSplits?: boolean;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
@@ -332,7 +322,7 @@ class AccountInternal extends PureComponent<
|
||||
AccountInternalProps,
|
||||
AccountInternalState
|
||||
> {
|
||||
paged: PagedQuery<TransactionEntity> | null;
|
||||
paged: ReturnType<typeof pagedQuery> | null;
|
||||
rootQuery: Query;
|
||||
currentQuery: Query;
|
||||
table: TableRef;
|
||||
@@ -467,7 +457,7 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
fetchAllIds = async () => {
|
||||
const { data } = await runQuery(this.paged?.query.select('id'));
|
||||
const { data } = await runQuery(this.paged?.getQuery().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) => {
|
||||
@@ -482,7 +472,7 @@ class AccountInternal extends PureComponent<
|
||||
};
|
||||
|
||||
fetchTransactions = (filterConditions?: ConditionEntity[]) => {
|
||||
const query = this.makeRootTransactionsQuery();
|
||||
const query = this.makeRootQuery();
|
||||
this.rootQuery = this.currentQuery = query;
|
||||
if (filterConditions) this.applyFilters(filterConditions);
|
||||
else this.updateQuery(query);
|
||||
@@ -492,10 +482,10 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
};
|
||||
|
||||
makeRootTransactionsQuery = () => {
|
||||
makeRootQuery = () => {
|
||||
const accountId = this.props.accountId;
|
||||
|
||||
return queries.transactions(accountId);
|
||||
return queries.makeTransactionsQuery(accountId);
|
||||
};
|
||||
|
||||
updateQuery(query: Query, isFiltered: boolean = false) {
|
||||
@@ -512,9 +502,12 @@ class AccountInternal extends PureComponent<
|
||||
query = query.filter({ reconciled: { $eq: false } });
|
||||
}
|
||||
|
||||
this.paged = pagedQuery(query.select('*'), {
|
||||
onData: async (groupedData, prevData) => {
|
||||
const data = ungroupTransactions([...groupedData]);
|
||||
this.paged = pagedQuery(
|
||||
query.select('*'),
|
||||
async (
|
||||
data: TransactionEntity[],
|
||||
prevData: TransactionEntity[] | null,
|
||||
) => {
|
||||
const firstLoad = prevData == null;
|
||||
|
||||
if (firstLoad) {
|
||||
@@ -536,7 +529,7 @@ class AccountInternal extends PureComponent<
|
||||
this.setState(
|
||||
{
|
||||
transactions: data,
|
||||
transactionCount: this.paged?.totalCount,
|
||||
transactionCount: this.paged?.getTotalCount(),
|
||||
transactionsFiltered: isFiltered,
|
||||
loading: false,
|
||||
workingHard: false,
|
||||
@@ -556,11 +549,12 @@ class AccountInternal extends PureComponent<
|
||||
},
|
||||
);
|
||||
},
|
||||
options: {
|
||||
{
|
||||
pageCount: 150,
|
||||
onlySync: true,
|
||||
mapper: ungroupTransactions,
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: AccountInternalProps) {
|
||||
@@ -596,7 +590,7 @@ class AccountInternal extends PureComponent<
|
||||
);
|
||||
} else {
|
||||
this.updateQuery(
|
||||
queries.transactionsSearch(
|
||||
queries.makeTransactionSearchQuery(
|
||||
this.currentQuery,
|
||||
this.state.search,
|
||||
this.props.dateFormat,
|
||||
@@ -658,19 +652,27 @@ class AccountInternal extends PureComponent<
|
||||
);
|
||||
};
|
||||
|
||||
onTransactionsChange = (updatedTransaction: TransactionEntity) => {
|
||||
onTransactionsChange = (
|
||||
newTransaction: TransactionEntity,
|
||||
data: TransactionEntity[],
|
||||
) => {
|
||||
// Apply changes to pagedQuery 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.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.props.updateNewTransactions(updatedTransaction.id);
|
||||
this.props.updateNewTransactions(newTransaction.id);
|
||||
};
|
||||
|
||||
canCalculateBalance = () => {
|
||||
@@ -694,7 +696,8 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
const { data } = await runQuery(
|
||||
this.paged?.query
|
||||
this.paged
|
||||
?.getQuery()
|
||||
.options({ splits: 'none' })
|
||||
.select([{ balance: { $sumOver: '$amount' } }]),
|
||||
);
|
||||
@@ -859,22 +862,22 @@ class AccountInternal extends PureComponent<
|
||||
getBalanceQuery(id?: string) {
|
||||
return {
|
||||
name: `balance-query-${id}`,
|
||||
query: this.makeRootTransactionsQuery().calculate({ $sum: '$amount' }),
|
||||
query: this.makeRootQuery().calculate({ $sum: '$amount' }),
|
||||
} as const;
|
||||
}
|
||||
|
||||
getFilteredAmount = async () => {
|
||||
const { data: amount } = await runQuery(
|
||||
this.paged?.query.calculate({ $sum: '$amount' }),
|
||||
this.paged?.getQuery().calculate({ $sum: '$amount' }),
|
||||
);
|
||||
return amount;
|
||||
};
|
||||
|
||||
isNew = (id: TransactionEntity['id']) => {
|
||||
isNew = (id: string) => {
|
||||
return this.props.newTransactions.includes(id);
|
||||
};
|
||||
|
||||
isMatched = (id: TransactionEntity['id']) => {
|
||||
isMatched = (id: string) => {
|
||||
return this.props.matchedTransactions.includes(id);
|
||||
};
|
||||
|
||||
@@ -1675,6 +1678,9 @@ class AccountInternal extends PureComponent<
|
||||
balances={balances}
|
||||
showBalances={showBalances}
|
||||
filtered={transactionsFiltered}
|
||||
collapseTransactions={ids =>
|
||||
this.props.splitsExpandedDispatch({ type: 'close-splits', ids })
|
||||
}
|
||||
>
|
||||
{(allTransactions, allBalances) => (
|
||||
<SelectedProviderWithItems
|
||||
@@ -1890,13 +1896,10 @@ export function Account() {
|
||||
const savedFiters = useFilters();
|
||||
const actionCreators = useActions();
|
||||
|
||||
const schedulesQuery = useMemo(
|
||||
() => accountSchedulesQuery(params.id),
|
||||
[params.id],
|
||||
);
|
||||
const transform = useDefaultSchedulesQueryTransform(params.id);
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SchedulesProvider transform={transform}>
|
||||
<SplitsExpandedProvider
|
||||
initialMode={expandSplits ? 'collapse' : 'expand'}
|
||||
>
|
||||
|
||||
@@ -68,13 +68,8 @@ function SelectedBalance({ selectedItems, account }) {
|
||||
});
|
||||
|
||||
let scheduleBalance = null;
|
||||
|
||||
const { isLoading, schedules = [] } = useCachedSchedules();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleData = useCachedSchedules();
|
||||
const schedules = scheduleData ? scheduleData.schedules : [];
|
||||
const previewIds = [...selectedItems]
|
||||
.filter(id => isPreviewId(id))
|
||||
.map(id => id.slice(8));
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { theme, styles } from '../../style';
|
||||
import { AnimatedRefresh } from '../AnimatedRefresh';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { MenuButton } from '../common/MenuButton';
|
||||
@@ -345,8 +344,8 @@ export function AccountHeader({
|
||||
<Search
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={onSearch}
|
||||
inputRef={searchInput}
|
||||
onChangeValue={onSearch}
|
||||
ref={searchInput}
|
||||
/>
|
||||
{workingHard ? (
|
||||
<View>
|
||||
@@ -574,24 +573,24 @@ function AccountNameField({
|
||||
if (editingName) {
|
||||
return (
|
||||
<Fragment>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.currentTarget.value)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -4,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.target.value)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -4,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
{saveNameError && (
|
||||
<View style={{ color: theme.warningText }}>{saveNameError}</View>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useRef, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
@@ -9,7 +10,6 @@ import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { SvgCheckCircle1 } from '../../icons/v2';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
@@ -29,13 +29,11 @@ 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 }),
|
||||
}) ?? 0;
|
||||
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 format = useFormat();
|
||||
const targetDiff = targetBalance - cleared;
|
||||
|
||||
@@ -126,43 +124,49 @@ export function ReconcileMenu({
|
||||
});
|
||||
const format = useFormat();
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function onSubmit() {
|
||||
if (inputValue === '') {
|
||||
setInputFocused(true);
|
||||
return;
|
||||
}
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
if (inputValue === '') {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
}
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
},
|
||||
[clearedBalance, inputValue, onClose, onReconcile],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ padding: '5px 8px' }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<InitialFocus>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<View style={{ padding: '5px 8px' }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
defaultValue={format(clearedBalance, 'financial')}
|
||||
onChangeValue={setInputValue}
|
||||
style={{ margin: '7px 0' }}
|
||||
focused={inputFocused}
|
||||
onEnter={onSubmit}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</InitialFocus>
|
||||
)}
|
||||
<Button variant="primary" onPress={onSubmit}>
|
||||
<Trans>Reconcile</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
<Button variant="primary" type="submit">
|
||||
<Trans>Reconcile</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ type AccountItemProps = {
|
||||
|
||||
function AccountItem({
|
||||
item,
|
||||
className,
|
||||
className = '',
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
|
||||
@@ -4,11 +4,12 @@ import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ComponentProps,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
@@ -25,18 +26,16 @@ import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
type CommonAutocompleteProps<T extends Item> = {
|
||||
focused?: boolean;
|
||||
autoFocus?: boolean;
|
||||
embedded?: boolean;
|
||||
containerProps?: HTMLProps<HTMLDivElement>;
|
||||
labelProps?: { id?: string };
|
||||
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
inputProps?: ComponentPropsWithRef<typeof Input>;
|
||||
suggestions?: T[];
|
||||
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
|
||||
renderInput?: (props: ComponentPropsWithRef<typeof Input>) => ReactNode;
|
||||
renderItems?: (
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
|
||||
idx: number,
|
||||
value?: string,
|
||||
) => ReactNode;
|
||||
@@ -138,14 +137,14 @@ function fireUpdate<T extends Item>(
|
||||
onUpdate?.(selected, value);
|
||||
}
|
||||
|
||||
function defaultRenderInput(props: ComponentProps<typeof Input>) {
|
||||
function defaultRenderInput(props: ComponentPropsWithRef<typeof Input>) {
|
||||
// data-1p-ignore disables 1Password autofill behaviour
|
||||
return <Input data-1p-ignore {...props} />;
|
||||
}
|
||||
|
||||
function defaultRenderItems<T extends Item>(
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
|
||||
highlightedIndex: number,
|
||||
) {
|
||||
return (
|
||||
@@ -210,7 +209,7 @@ type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
|
||||
};
|
||||
|
||||
function SingleAutocomplete<T extends Item>({
|
||||
focused,
|
||||
autoFocus,
|
||||
embedded = false,
|
||||
containerProps,
|
||||
labelProps = {},
|
||||
@@ -450,8 +449,8 @@ function SingleAutocomplete<T extends Item>({
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
getInputProps({
|
||||
focused,
|
||||
...inputProps,
|
||||
autoFocus,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
|
||||
@@ -550,8 +549,9 @@ function SingleAutocomplete<T extends Item>({
|
||||
}
|
||||
},
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { onChange } = inputProps || {};
|
||||
onChange?.(e.target.value);
|
||||
const { onChangeValue, onChange } = inputProps || {};
|
||||
onChangeValue?.(e.target.value);
|
||||
onChange?.(e);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
@@ -641,7 +641,6 @@ function MultiAutocomplete<T extends Item>({
|
||||
clearOnBlur = true,
|
||||
...props
|
||||
}: MultiAutocompleteProps<T>) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const selectedItemIds = selectedItems.map(getItemId);
|
||||
|
||||
function onRemoveItem(id: T['id']) {
|
||||
@@ -658,7 +657,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
|
||||
function onKeyDown(
|
||||
e: KeyboardEvent<HTMLInputElement>,
|
||||
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
|
||||
prevOnKeyDown?: ComponentPropsWithoutRef<typeof Input>['onKeyDown'],
|
||||
) {
|
||||
if (e.key === 'Backspace' && e.currentTarget.value === '') {
|
||||
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
|
||||
@@ -682,7 +681,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
strict={strict}
|
||||
renderInput={inputProps => (
|
||||
<View
|
||||
style={{
|
||||
className={`${css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'row',
|
||||
@@ -690,11 +689,11 @@ function MultiAutocomplete<T extends Item>({
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
...(focused && {
|
||||
'&:focus-within': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
}),
|
||||
}}
|
||||
},
|
||||
})} ${inputProps.className || ''}`}
|
||||
>
|
||||
{selectedItems.map((item, idx) => {
|
||||
item = findItem(strict, suggestions, item);
|
||||
@@ -711,21 +710,14 @@ function MultiAutocomplete<T extends Item>({
|
||||
<Input
|
||||
{...inputProps}
|
||||
onKeyDown={e => onKeyDown(e, inputProps.onKeyDown)}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
inputProps.onFocus(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
inputProps.onBlur(e);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 30,
|
||||
border: 0,
|
||||
':focus': { border: 0, boxShadow: 'none' },
|
||||
...inputProps.style,
|
||||
}}
|
||||
className={String(
|
||||
css({
|
||||
flex: 1,
|
||||
minWidth: 30,
|
||||
border: 0,
|
||||
'&[data-focused]': { border: 0, boxShadow: 'none' },
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -761,8 +753,8 @@ export function AutocompleteFooter({
|
||||
}
|
||||
|
||||
type AutocompleteProps<T extends Item> =
|
||||
| ComponentProps<typeof SingleAutocomplete<T>>
|
||||
| ComponentProps<typeof MultiAutocomplete<T>>;
|
||||
| ComponentPropsWithoutRef<typeof SingleAutocomplete<T>>
|
||||
| ComponentPropsWithoutRef<typeof MultiAutocomplete<T>>;
|
||||
|
||||
export function Autocomplete<T extends Item>({
|
||||
...props
|
||||
|
||||
@@ -365,7 +365,7 @@ type CategoryItemProps = {
|
||||
|
||||
function CategoryItem({
|
||||
item,
|
||||
className,
|
||||
className = '',
|
||||
style,
|
||||
highlighted,
|
||||
embedded,
|
||||
@@ -393,7 +393,7 @@ function CategoryItem({
|
||||
>(balanceBinding);
|
||||
|
||||
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -429,13 +429,10 @@ function CategoryItem({
|
||||
display: !showBalances ? 'none' : undefined,
|
||||
marginLeft: 5,
|
||||
flexShrink: 0,
|
||||
...makeAmountFullStyle(
|
||||
(isToBeBudgetedItem ? toBudget : balance) || 0,
|
||||
{
|
||||
positiveColor: theme.noticeTextMenu,
|
||||
negativeColor: theme.errorTextMenu,
|
||||
},
|
||||
),
|
||||
...makeAmountFullStyle(isToBeBudgetedItem ? toBudget : balance, {
|
||||
positiveColor: theme.noticeTextMenu,
|
||||
negativeColor: theme.errorTextMenu,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{isToBeBudgetedItem
|
||||
|
||||
@@ -341,8 +341,6 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
}
|
||||
|
||||
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
key={focusTransferPayees ? 'transfers' : 'all'}
|
||||
@@ -360,16 +358,11 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
return item.name;
|
||||
}}
|
||||
focused={payeeFieldFocused}
|
||||
inputProps={{
|
||||
...inputProps,
|
||||
autoCapitalize: 'words',
|
||||
onBlur: () => {
|
||||
setRawPayee('');
|
||||
setPayeeFieldFocused(false);
|
||||
},
|
||||
onFocus: () => setPayeeFieldFocused(true),
|
||||
onChange: setRawPayee,
|
||||
onBlur: () => setRawPayee(''),
|
||||
onChangeValue: setRawPayee,
|
||||
}}
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
@@ -556,7 +549,7 @@ type PayeeItemProps = {
|
||||
|
||||
function PayeeItem({
|
||||
item,
|
||||
className,
|
||||
className = '',
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
|
||||
@@ -31,7 +31,7 @@ export function BudgetSummaries({ SummaryComponent }: BudgetSummariesProps) {
|
||||
config: { mass: 3, tension: 600, friction: 80 },
|
||||
}));
|
||||
|
||||
const containerRef = useResizeObserver<HTMLDivElement>(
|
||||
const containerRef = useResizeObserver(
|
||||
useCallback(rect => {
|
||||
setWidthState(rect.width);
|
||||
}, []),
|
||||
|
||||
@@ -149,7 +149,7 @@ export function SidebarGroup({
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
!group.is_income && {
|
||||
name: 'toggle-visibility',
|
||||
text: group.hidden ? 'Show' : 'Hide',
|
||||
text: group.hidden ? t('Show') : t('Hide'),
|
||||
},
|
||||
onDelete && { name: 'delete', text: t('Delete') },
|
||||
...(isGoalTemplatesEnabled
|
||||
|
||||
@@ -29,8 +29,7 @@ export function BalanceMenu({
|
||||
const carryover = useEnvelopeSheetValue(
|
||||
envelopeBudget.catCarryover(categoryId),
|
||||
);
|
||||
const balance =
|
||||
useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)) ?? 0;
|
||||
const balance = useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId));
|
||||
|
||||
return (
|
||||
<Menu
|
||||
|
||||
@@ -20,9 +20,9 @@ export function BalanceMovementMenu({
|
||||
onBudgetAction,
|
||||
onClose = () => {},
|
||||
}: BalanceMovementMenuProps) {
|
||||
const catBalance =
|
||||
useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)) ?? 0;
|
||||
|
||||
const catBalance = useEnvelopeSheetValue(
|
||||
envelopeBudget.catBalance(categoryId),
|
||||
);
|
||||
const [menu, _setMenu] = useState('menu');
|
||||
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
@@ -6,7 +7,6 @@ import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { View } from '../../common/View';
|
||||
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
|
||||
|
||||
@@ -39,52 +39,55 @@ export function CoverMenu({
|
||||
: categoryGroups;
|
||||
}, [categoryId, showToBeBudgeted, originalCategoryGroups]);
|
||||
|
||||
function submit() {
|
||||
if (fromCategoryId) {
|
||||
onSubmit(fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
const onSubmitInner = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (fromCategoryId) {
|
||||
onSubmit(fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[fromCategoryId, onSubmit, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Cover from category:</Trans>
|
||||
</View>
|
||||
<Form onSubmit={onSubmitInner}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Cover from category:</Trans>
|
||||
</View>
|
||||
|
||||
<InitialFocus>
|
||||
{node => (
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
|
||||
inputProps={{
|
||||
inputRef: node,
|
||||
onEnter: event => !event.defaultPrevented && submit(),
|
||||
placeholder: t('(none)'),
|
||||
}}
|
||||
showHiddenCategories={false}
|
||||
/>
|
||||
)}
|
||||
</InitialFocus>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
|
||||
inputProps={{
|
||||
placeholder: t('(none)'),
|
||||
}}
|
||||
showHiddenCategories={false}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
onPress={submit}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import {
|
||||
type SheetResult,
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '../../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
|
||||
import { useSheetName } from '../../spreadsheet/useSheetName';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
@@ -41,8 +45,11 @@ export function useEnvelopeSheetName<
|
||||
|
||||
export function useEnvelopeSheetValue<
|
||||
FieldName extends SheetFields<'envelope-budget'>,
|
||||
>(binding: Binding<'envelope-budget', FieldName>) {
|
||||
return useSheetValue(binding);
|
||||
>(
|
||||
binding: Binding<'envelope-budget', FieldName>,
|
||||
onChange?: (result: SheetResult<'envelope-budget', FieldName>) => void,
|
||||
) {
|
||||
return useSheetValue(binding, onChange);
|
||||
}
|
||||
|
||||
export const EnvelopeCellValue = <
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
import React, { useState, useCallback, type FormEvent, useRef } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { envelopeBudget } from 'loot-core/client/queries';
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { Input } from '../../common/Input';
|
||||
import { View } from '../../common/View';
|
||||
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
|
||||
|
||||
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
|
||||
|
||||
type HoldMenuProps = {
|
||||
onSubmit: (amount: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
|
||||
const spreadsheet = useSpreadsheet();
|
||||
const sheetName = useContext(NamespaceContext);
|
||||
const [amount, setAmount] = useState<string>(
|
||||
integerToCurrency(
|
||||
useEnvelopeSheetValue(envelopeBudget.toBudget, result => {
|
||||
setAmount(integerToCurrency(result?.value ?? 0));
|
||||
}) ?? 0,
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [amount, setAmount] = useState<string | null>(null);
|
||||
const onSubmitInner = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const node = await spreadsheet.get(sheetName, 'to-budget');
|
||||
setAmount(integerToCurrency(Math.max(node.value as number, 0)));
|
||||
})();
|
||||
}, []);
|
||||
if (amount === '') {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
function submit(newAmount: string) {
|
||||
const parsedAmount = evalArithmetic(newAmount);
|
||||
if (parsedAmount) {
|
||||
onSubmit(amountToInteger(parsedAmount));
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
const parsedAmount = evalArithmetic(amount);
|
||||
if (parsedAmount) {
|
||||
onSubmit(amountToInteger(parsedAmount));
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[amount, onSubmit, onClose],
|
||||
);
|
||||
|
||||
if (amount === null) {
|
||||
// See `TransferMenu` for more info about this
|
||||
@@ -47,39 +50,37 @@ export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Hold this amount:</Trans>
|
||||
</View>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
value={amount}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setAmount(e.target.value)
|
||||
}
|
||||
onEnter={() => submit(amount)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
<Form onSubmit={onSubmitInner}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Hold this amount:</Trans>
|
||||
</View>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={amount}
|
||||
onChangeValue={(value: string) => setAmount(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
onPress={() => submit(amount)}
|
||||
>
|
||||
<Trans>Hold</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Hold</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
@@ -8,7 +9,6 @@ import { type CategoryEntity } from 'loot-core/types/models';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { Input } from '../../common/Input';
|
||||
import { View } from '../../common/View';
|
||||
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
|
||||
@@ -42,65 +42,67 @@ export function TransferMenu({
|
||||
}, [originalCategoryGroups, categoryId, showToBeBudgeted]);
|
||||
|
||||
const _initialAmount = integerToCurrency(Math.max(initialAmount, 0));
|
||||
const [amount, setAmount] = useState<string | null>(null);
|
||||
const [amount, setAmount] = useState<string>(_initialAmount);
|
||||
const [toCategoryId, setToCategoryId] = useState<string | null>(null);
|
||||
|
||||
const _onSubmit = (newAmount: string | null, categoryId: string | null) => {
|
||||
const parsedAmount = evalArithmetic(newAmount || '');
|
||||
if (parsedAmount && categoryId) {
|
||||
onSubmit?.(amountToInteger(parsedAmount), categoryId);
|
||||
}
|
||||
const _onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
};
|
||||
const parsedAmount = evalArithmetic(amount || '');
|
||||
if (parsedAmount && toCategoryId) {
|
||||
onSubmit?.(amountToInteger(parsedAmount), toCategoryId);
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[amount, toCategoryId, onSubmit, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Transfer this amount:</Trans>
|
||||
</View>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={_initialAmount}
|
||||
onUpdate={value => setAmount(value)}
|
||||
onEnter={() => _onSubmit(amount, toCategoryId)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
|
||||
<Form onSubmit={_onSubmit}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Transfer this amount:</Trans>
|
||||
</View>
|
||||
<Input
|
||||
value={amount}
|
||||
onChangeValue={value => setAmount(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
|
||||
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
|
||||
inputProps={{
|
||||
onEnter: event =>
|
||||
!event.defaultPrevented && _onSubmit(amount, toCategoryId),
|
||||
placeholder: '(none)',
|
||||
}}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
|
||||
inputProps={{
|
||||
placeholder: '(none)',
|
||||
}}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
onPress={() => _onSubmit(amount, toCategoryId)}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ToBudgetMenu({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
|
||||
const forNextMonth = useEnvelopeSheetValue(envelopeBudget.forNextMonth) ?? 0;
|
||||
const forNextMonth = useEnvelopeSheetValue(envelopeBudget.forNextMonth);
|
||||
const items = [
|
||||
...(toBudget > 0
|
||||
? [
|
||||
|
||||
@@ -22,7 +22,11 @@ import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import {
|
||||
type SheetResult,
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '../../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
import { Field, SheetCell, type SheetCellProps } from '../../table';
|
||||
@@ -36,8 +40,9 @@ export const useTrackingSheetValue = <
|
||||
FieldName extends SheetFields<'tracking-budget'>,
|
||||
>(
|
||||
binding: Binding<'tracking-budget', FieldName>,
|
||||
onChange?: (result: SheetResult<'tracking-budget', FieldName>) => void,
|
||||
) => {
|
||||
return useSheetValue(binding);
|
||||
return useSheetValue(binding, onChange);
|
||||
};
|
||||
|
||||
const TrackingCellValue = <FieldName extends SheetFields<'tracking-budget'>>(
|
||||
|
||||
@@ -177,7 +177,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps =>
|
||||
`${defaultButtonClassName} ${className(renderProps)}`
|
||||
`${defaultButtonClassName} ${className(renderProps) || ''}`
|
||||
: `${defaultButtonClassName} ${className || ''}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
cloneElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
type InitialFocusProps = {
|
||||
children:
|
||||
| ReactElement<{ inputRef: Ref<HTMLInputElement> }>
|
||||
| ((node: Ref<HTMLInputElement>) => ReactElement);
|
||||
};
|
||||
|
||||
export function InitialFocus({ children }: InitialFocusProps) {
|
||||
const node = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.current) {
|
||||
// This is needed to avoid a strange interaction with
|
||||
// `ScopeTab`, which doesn't allow it to be focused at first for
|
||||
// some reason. Need to look into it.
|
||||
setTimeout(() => {
|
||||
if (node.current) {
|
||||
node.current.focus();
|
||||
node.current.setSelectionRange(0, 10000);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (typeof children === 'function') {
|
||||
return children(node);
|
||||
}
|
||||
return cloneElement(children, { inputRef: node });
|
||||
}
|
||||
@@ -1,110 +1,138 @@
|
||||
import React, {
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
type CSSProperties,
|
||||
type ComponentPropsWithRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Input as ReactAriaInput } from 'react-aria-components';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import { useProperFocus } from '../../hooks/useProperFocus';
|
||||
import { styles, theme } from '../../style';
|
||||
|
||||
export const defaultInputStyle = {
|
||||
outline: 0,
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
margin: 0,
|
||||
padding: 5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
};
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
style?: CSSProperties;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
type InputProps = ComponentPropsWithRef<typeof ReactAriaInput> & {
|
||||
autoSelect?: boolean;
|
||||
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onEscape?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onChangeValue?: (newValue: string) => void;
|
||||
onUpdate?: (newValue: string) => void;
|
||||
focused?: boolean;
|
||||
};
|
||||
|
||||
export function Input({
|
||||
style,
|
||||
inputRef,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
focused,
|
||||
className,
|
||||
...nativeProps
|
||||
}: InputProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useProperFocus(ref, focused);
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
autoSelect,
|
||||
className = '',
|
||||
onEnter,
|
||||
onEscape,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(ref, inputRef);
|
||||
useEffect(() => {
|
||||
if (autoSelect) {
|
||||
// Select on mount does not work properly for inputs that are inside a dialog.
|
||||
// See https://github.com/facebook/react/issues/23301#issuecomment-1656908450
|
||||
// for the reason why we need to use setTimeout here.
|
||||
setTimeout(() => inputRef.current?.select());
|
||||
}
|
||||
}, [autoSelect]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={mergedRef}
|
||||
className={cx(
|
||||
const defaultInputClassName = useMemo(
|
||||
() =>
|
||||
css(
|
||||
defaultInputStyle,
|
||||
{
|
||||
outline: 0,
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
margin: 0,
|
||||
padding: 5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
':focus': {
|
||||
'&[data-focused]': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
'::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
'&::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
},
|
||||
styles.smallText,
|
||||
style,
|
||||
),
|
||||
className,
|
||||
)}
|
||||
{...nativeProps}
|
||||
onKeyDown={e => {
|
||||
nativeProps.onKeyDown?.(e);
|
||||
[],
|
||||
);
|
||||
|
||||
if (e.key === 'Enter' && onEnter) {
|
||||
onEnter(e);
|
||||
return (
|
||||
<ReactAriaInput
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps => cx(defaultInputClassName, className(renderProps))
|
||||
: cx(defaultInputClassName, className)
|
||||
}
|
||||
onKeyDown={e => {
|
||||
props.onKeyDown?.(e);
|
||||
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape(e);
|
||||
if (e.key === 'Enter' && onEnter) {
|
||||
onEnter(e);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape(e);
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
onUpdate?.(e.target.value);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
onChangeValue?.(e.target.value);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
type BigInputProps = InputProps;
|
||||
|
||||
export const BigInput = forwardRef<HTMLInputElement, BigInputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const defaultClassName = useMemo(
|
||||
() =>
|
||||
String(
|
||||
css({
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
'&, &[data-focused]': { border: 'none', ...styles.shadow },
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={renderProps =>
|
||||
typeof className === 'function'
|
||||
? cx(defaultClassName, className(renderProps))
|
||||
: cx(defaultClassName, className)
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
onUpdate?.(e.target.value);
|
||||
nativeProps.onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
onChangeValue?.(e.target.value);
|
||||
nativeProps.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function BigInput(props: InputProps) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
style={{
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
border: 'none',
|
||||
...styles.shadow,
|
||||
':focus': { border: 'none', ...styles.shadow },
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
BigInput.displayName = 'BigInput';
|
||||
|
||||
@@ -1,74 +1,48 @@
|
||||
import {
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { type ComponentPropsWithRef, type ReactNode, forwardRef } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { theme } from '../../style';
|
||||
|
||||
import { Input, defaultInputStyle } from './Input';
|
||||
import { Input } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
type InputWithContentProps = ComponentProps<typeof Input> & {
|
||||
type InputWithContentProps = ComponentPropsWithRef<typeof Input> & {
|
||||
leftContent?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
inputStyle?: CSSProperties;
|
||||
focusStyle?: CSSProperties;
|
||||
style?: CSSProperties;
|
||||
getStyle?: (focused: boolean) => CSSProperties;
|
||||
containerClassName?: string;
|
||||
};
|
||||
export function InputWithContent({
|
||||
leftContent,
|
||||
rightContent,
|
||||
inputStyle,
|
||||
focusStyle,
|
||||
style,
|
||||
getStyle,
|
||||
...props
|
||||
}: InputWithContentProps) {
|
||||
const [focused, setFocused] = useState(props.focused ?? false);
|
||||
|
||||
export const InputWithContent = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputWithContentProps
|
||||
>(({ leftContent, rightContent, containerClassName, ...props }, ref) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...defaultInputStyle,
|
||||
padding: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...style,
|
||||
...(focused &&
|
||||
(focusStyle ?? {
|
||||
className={cx(
|
||||
css({
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
'&:focus-within': {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
})),
|
||||
...getStyle?.(focused),
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
<Input
|
||||
{...props}
|
||||
focused={focused}
|
||||
style={{
|
||||
width: '100%',
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
'&, &:focus, &:hover': {
|
||||
},
|
||||
'& input, input[data-focused], input[data-hovered]': {
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
}}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
}),
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{leftContent}
|
||||
<Input ref={ref} {...props} />
|
||||
{rightContent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
InputWithContent.displayName = 'InputWithContent';
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
type ComponentPropsWithRef,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { FocusScope } from 'react-aria';
|
||||
import {
|
||||
ModalOverlay as ReactAriaModalOverlay,
|
||||
Modal as ReactAriaModal,
|
||||
@@ -17,7 +18,6 @@ import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { AutoTextSize } from 'auto-text-size';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { useModalState } from '../../hooks/useModalState';
|
||||
import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
||||
@@ -101,7 +101,7 @@ export const Modal = ({
|
||||
<ReactAriaModal>
|
||||
{modalProps => (
|
||||
<Dialog
|
||||
aria-label={t('Modal dialog')}
|
||||
aria-label="Modal dialog"
|
||||
className={css(styles.lightScrollbar)}
|
||||
style={{
|
||||
outline: 'none', // remove focus outline
|
||||
@@ -133,9 +133,11 @@ export const Modal = ({
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: 0, flex: 1, flexShrink: 0 }}>
|
||||
{typeof children === 'function'
|
||||
? children(modalProps)
|
||||
: children}
|
||||
<FocusScope autoFocus restoreFocus>
|
||||
{typeof children === 'function'
|
||||
? children(modalProps)
|
||||
: children}
|
||||
</FocusScope>
|
||||
</View>
|
||||
{isLoading && (
|
||||
<View
|
||||
@@ -330,7 +332,7 @@ export function ModalHeader({
|
||||
>
|
||||
{showLogo && (
|
||||
<SvgLogo
|
||||
aria-label={t('Modal logo')}
|
||||
aria-label="Modal logo"
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ justifyContent: 'center', alignSelf: 'center' }}
|
||||
@@ -403,14 +405,15 @@ export function ModalTitle({
|
||||
|
||||
return isEditing ? (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
}}
|
||||
focused={isEditing}
|
||||
autoFocus={isEditing}
|
||||
autoSelect={isEditing}
|
||||
defaultValue={title}
|
||||
onUpdate={_onTitleUpdate}
|
||||
onKeyDown={e => {
|
||||
@@ -473,7 +476,7 @@ export function ModalCloseButton({ onPress, style }: ModalCloseButtonProps) {
|
||||
variant="bare"
|
||||
onPress={onPress}
|
||||
style={{ padding: '10px 10px' }}
|
||||
aria-label={t('Close')}
|
||||
aria-label="Close"
|
||||
>
|
||||
<SvgDelete width={10} style={style} />
|
||||
</Button>
|
||||
|
||||
@@ -1,92 +1,89 @@
|
||||
import { type Ref } from 'react';
|
||||
import { type ComponentPropsWithRef, forwardRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { SvgRemove, SvgSearchAlternate } from '../../icons/v2';
|
||||
import { theme } from '../../style';
|
||||
|
||||
import { Button } from './Button2';
|
||||
import { type Input } from './Input';
|
||||
import { InputWithContent } from './InputWithContent';
|
||||
import { View } from './View';
|
||||
|
||||
type SearchProps = {
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type SearchProps = ComponentPropsWithRef<typeof Input> & {
|
||||
isInModal?: boolean;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export function Search({
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
isInModal = false,
|
||||
width = 250,
|
||||
}: SearchProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<InputWithContent
|
||||
inputRef={inputRef}
|
||||
style={{
|
||||
width,
|
||||
flex: '',
|
||||
borderColor: isInModal ? undefined : 'transparent',
|
||||
backgroundColor: isInModal ? undefined : theme.formInputBackground,
|
||||
}}
|
||||
focusStyle={
|
||||
isInModal
|
||||
? undefined
|
||||
: {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
backgroundColor: theme.formInputBackgroundSelected,
|
||||
}
|
||||
}
|
||||
leftContent={
|
||||
<SvgSearchAlternate
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
flexShrink: 0,
|
||||
color: value ? theme.menuItemTextSelected : 'inherit',
|
||||
margin: 5,
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rightContent={
|
||||
value && (
|
||||
<View title={t('Clear search term')}>
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{ padding: 8 }}
|
||||
onPress={() => onChange('')}
|
||||
>
|
||||
<SvgRemove style={{ width: 8, height: 8 }} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
inputStyle={{
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholder,
|
||||
transition: 'color .25s',
|
||||
},
|
||||
':focus': isInModal
|
||||
? {}
|
||||
: {
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholderSelected,
|
||||
export const Search = forwardRef<HTMLInputElement, SearchProps>(
|
||||
({ value, onChangeValue, isInModal = false, width = 250, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultClassName = useMemo(
|
||||
() =>
|
||||
css({
|
||||
width,
|
||||
// flex: '',
|
||||
borderColor: isInModal ? undefined : 'transparent',
|
||||
backgroundColor: isInModal ? undefined : theme.formInputBackground,
|
||||
'&:focus-within': isInModal
|
||||
? {}
|
||||
: {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
backgroundColor: theme.formInputBackgroundSelected,
|
||||
},
|
||||
'& input': {
|
||||
flex: 1,
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholder,
|
||||
transition: 'color .25s',
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onChange('');
|
||||
}}
|
||||
onChangeValue={value => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
'[data-focused]': isInModal
|
||||
? {}
|
||||
: {
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholderSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[isInModal, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputWithContent
|
||||
ref={ref}
|
||||
containerClassName={defaultClassName}
|
||||
leftContent={
|
||||
<SvgSearchAlternate
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
flexShrink: 0,
|
||||
color: value ? theme.menuItemTextSelected : 'inherit',
|
||||
margin: '5px 0 5px 5px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rightContent={
|
||||
value && (
|
||||
<View title={t('Clear search term')}>
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{ padding: 8 }}
|
||||
onPress={() => onChangeValue?.('')}
|
||||
>
|
||||
<SvgRemove style={{ width: 8, height: 8 }} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
value={value}
|
||||
onEscape={() => onChangeValue?.('')}
|
||||
onChangeValue={onChangeValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Search.displayName = 'Search';
|
||||
|
||||
@@ -54,7 +54,6 @@ const filterFields = [
|
||||
'cleared',
|
||||
'reconciled',
|
||||
'saved',
|
||||
'transfer',
|
||||
].map(field => [field, mapField(field)]);
|
||||
|
||||
function ConfigureField({
|
||||
@@ -208,7 +207,7 @@ function ConfigureField({
|
||||
>
|
||||
{type !== 'boolean' && (
|
||||
<GenericInput
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
field={field}
|
||||
subfield={subfield}
|
||||
type={
|
||||
|
||||
@@ -56,7 +56,7 @@ export function NameFilter({
|
||||
/>
|
||||
<Input
|
||||
id="name-field"
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
defaultValue={name || ''}
|
||||
onChangeValue={setName}
|
||||
/>
|
||||
|
||||
@@ -63,16 +63,23 @@ export function ConfigServer() {
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { error } = await setServerUrl(url);
|
||||
|
||||
let httpUrl = url;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
httpUrl = 'https://' + url;
|
||||
}
|
||||
|
||||
const { error } = await setServerUrl(httpUrl);
|
||||
setUrl(httpUrl);
|
||||
|
||||
if (error) {
|
||||
if (
|
||||
['network-failure', 'get-server-failure'].includes(error) &&
|
||||
!url.startsWith('http://') &&
|
||||
!url.startsWith('https://')
|
||||
) {
|
||||
const { error } = await setServerUrl('https://' + url);
|
||||
if (error) {
|
||||
setUrl('https://' + url);
|
||||
setError(error);
|
||||
} else {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (error) {
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
} else {
|
||||
@@ -111,6 +118,7 @@ export function ConfigServer() {
|
||||
async function onCreateTestFile() {
|
||||
await setServerUrl(null);
|
||||
await createBudget({ testMode: true });
|
||||
window.__navigate('/');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -249,10 +257,7 @@ export function ConfigServer() {
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ marginLeft: 15 }}
|
||||
onPress={async () => {
|
||||
await onCreateTestFile();
|
||||
navigate('/');
|
||||
}}
|
||||
onPress={onCreateTestFile}
|
||||
>
|
||||
{t('Create test file')}
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,11 @@ import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { loggedIn, setAppState } from 'loot-core/client/actions';
|
||||
import {
|
||||
getUserData,
|
||||
loadAllFiles,
|
||||
setAppState,
|
||||
} from 'loot-core/client/actions';
|
||||
|
||||
import { useMetaThemeColor } from '../../hooks/useMetaThemeColor';
|
||||
import { theme } from '../../style';
|
||||
@@ -51,9 +55,7 @@ function Version() {
|
||||
|
||||
export function ManagementApp() {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
useMetaThemeColor(
|
||||
isNarrowWidth ? theme.mobileConfigServerViewTheme : undefined,
|
||||
);
|
||||
useMetaThemeColor(isNarrowWidth ? theme.mobileConfigServerViewTheme : null);
|
||||
|
||||
const files = useSelector(state => state.budgets.allFiles);
|
||||
const isLoading = useSelector(state => state.app.loadingText !== null);
|
||||
@@ -67,12 +69,16 @@ export function ManagementApp() {
|
||||
// runs on mount only
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
await dispatch(loggedIn());
|
||||
const userData = await dispatch(getUserData());
|
||||
if (userData) {
|
||||
await dispatch(loadAllFiles());
|
||||
}
|
||||
|
||||
dispatch(setAppState({ managerHasInitialized: true }));
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [dispatch]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ height: '100%', color: theme.pageText }}>
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { SvgCheveronLeft } from '../../icons/v1';
|
||||
import { styles } from '../../style';
|
||||
@@ -37,7 +35,7 @@ export function MobileBackButton({
|
||||
marginRight: 5,
|
||||
}}
|
||||
>
|
||||
{t('Back')}
|
||||
Back
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
({ disabled, style, onUpdate, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
inputRef={ref}
|
||||
ref={ref}
|
||||
autoCorrect="false"
|
||||
autoCapitalize="none"
|
||||
disabled={disabled}
|
||||
|
||||