Compare commits

..

7 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
d2bf2e9cc9 Fix InputWithContent 2024-11-06 09:19:36 -08:00
Joel Jeremy Marquez
7e1cc49478 Fix import 2024-11-04 15:50:42 -08:00
Joel Jeremy Marquez
e6a49b1d99 Rename className var 2024-11-04 15:48:59 -08:00
Joel Jeremy Marquez
db7d890e79 Fix styling issues 2024-11-04 15:48:32 -08:00
Joel Jeremy Marquez
46977b59ca Delay Input autoSelect a bit 2024-11-04 15:45:28 -08:00
Joel Jeremy Marquez
b3d0348493 Remove FocusScope contain 2024-11-04 15:45:28 -08:00
Joel Jeremy Marquez
b78f1fd575 Use react-aria-component input as base of Actual's Input component 2024-11-04 15:45:28 -08:00
301 changed files with 6832 additions and 9151 deletions

View File

@@ -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'],

View File

@@ -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"

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "24.12.0",
"version": "24.11.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {

View File

@@ -10,7 +10,6 @@ playwright-report
# production
build
build-electron
build-stats
stats.json

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -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",

View File

@@ -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>

View File

@@ -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',
}}
/>
);
}

View File

@@ -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>

View File

@@ -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:
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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}
/>

View File

@@ -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)])}>

View File

@@ -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,

View File

@@ -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}
>

View File

@@ -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);
}

View File

@@ -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}
>

View File

@@ -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
}

View File

@@ -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

View File

@@ -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'}
>

View File

@@ -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));

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -170,7 +170,7 @@ type AccountItemProps = {
function AccountItem({
item,
className,
className = '',
highlighted,
embedded,
...props

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}, []),

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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 = <

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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
? [

View File

@@ -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'>>(

View File

@@ -177,7 +177,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
className={
typeof className === 'function'
? renderProps =>
`${defaultButtonClassName} ${className(renderProps)}`
`${defaultButtonClassName} ${className(renderProps) || ''}`
: `${defaultButtonClassName} ${className || ''}`
}
>

View File

@@ -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 });
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -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';

View File

@@ -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={

View File

@@ -56,7 +56,7 @@ export function NameFilter({
/>
<Input
id="name-field"
inputRef={inputRef}
ref={inputRef}
defaultValue={name || ''}
onChangeValue={setName}
/>

View File

@@ -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>

View File

@@ -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 }}>

View File

@@ -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>
);

View File

@@ -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}

Some files were not shown because too many files have changed in this diff Show More