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
312 changed files with 7372 additions and 9911 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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 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

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

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

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}

View File

@@ -1,7 +1,7 @@
// @ts-strict-ignore
import React, {
useCallback,
type ComponentProps,
type ComponentType,
useEffect,
type CSSProperties,
} from 'react';
import { NavLink } from 'react-router-dom';
@@ -9,6 +9,7 @@ import { useSpring, animated, config } from 'react-spring';
import { useDrag } from '@use-gesture/react';
import { usePrevious } from '../../hooks/usePrevious';
import {
SvgAdd,
SvgCog,
@@ -22,20 +23,16 @@ import { SvgCalendar } from '../../icons/v2';
import { theme, styles } from '../../style';
import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
import { useScrollListener } from '../ScrollProvider';
import { useScroll } from '../ScrollProvider';
const COLUMN_COUNT = 3;
const PILL_HEIGHT = 15;
const ROW_HEIGHT = 70;
const TOTAL_HEIGHT = ROW_HEIGHT * COLUMN_COUNT;
const OPEN_FULL_Y = 1;
const OPEN_DEFAULT_Y = TOTAL_HEIGHT - ROW_HEIGHT;
const HIDDEN_Y = TOTAL_HEIGHT;
export const MOBILE_NAV_HEIGHT = ROW_HEIGHT + PILL_HEIGHT;
export function MobileNavTabs() {
const { isNarrowWidth } = useResponsive();
const { scrollY } = useScroll();
const navTabStyle = {
flex: `1 1 ${100 / COLUMN_COUNT}%`,
@@ -43,43 +40,6 @@ export function MobileNavTabs() {
padding: 10,
};
const [{ y }, api] = useSpring(() => ({ y: OPEN_DEFAULT_Y }));
const openFull = useCallback(
({ canceled }: { canceled?: boolean }) => {
// when cancel is true, it means that the user passed the upwards threshold
// so we change the spring config to create a nice wobbly effect
api.start({
y: OPEN_FULL_Y,
immediate: false,
config: canceled ? config.wobbly : config.stiff,
});
},
[api, OPEN_FULL_Y],
);
const openDefault = useCallback(
(velocity = 0) => {
api.start({
y: OPEN_DEFAULT_Y,
immediate: false,
config: { ...config.stiff, velocity },
});
},
[api, OPEN_DEFAULT_Y],
);
const hide = useCallback(
(velocity = 0) => {
api.start({
y: HIDDEN_Y,
immediate: false,
config: { ...config.stiff, velocity },
});
},
[api, HIDDEN_Y],
);
const navTabs = [
{
name: 'Budget',
@@ -129,22 +89,60 @@ export function MobileNavTabs() {
style: navTabStyle,
Icon: SvgCog,
},
].map(tab => (
<NavTab key={tab.path} onClick={() => openDefault()} {...tab} />
));
].map(tab => <NavTab key={tab.path} {...tab} />);
const bufferTabsCount = COLUMN_COUNT - (navTabs.length % COLUMN_COUNT);
const bufferTabs = Array.from({ length: bufferTabsCount }).map((_, idx) => (
<div key={idx} style={navTabStyle} />
));
useScrollListener(({ isScrolling }) => {
if (isScrolling('down')) {
const totalHeight = ROW_HEIGHT * COLUMN_COUNT;
const openY = 0;
const closeY = totalHeight - ROW_HEIGHT;
const hiddenY = totalHeight;
const [{ y }, api] = useSpring(() => ({ y: totalHeight }));
const open = ({ canceled }) => {
// when cancel is true, it means that the user passed the upwards threshold
// so we change the spring config to create a nice wobbly effect
api.start({
y: openY,
immediate: false,
config: canceled ? config.wobbly : config.stiff,
});
};
const close = (velocity = 0) => {
api.start({
y: closeY,
immediate: false,
config: { ...config.stiff, velocity },
});
};
const hide = (velocity = 0) => {
api.start({
y: hiddenY,
immediate: false,
config: { ...config.stiff, velocity },
});
};
const previousScrollY = usePrevious(scrollY);
useEffect(() => {
if (
scrollY &&
previousScrollY &&
scrollY > previousScrollY &&
previousScrollY !== 0
) {
hide();
} else if (isScrolling('up')) {
openDefault();
} else {
close();
}
});
}, [scrollY]);
const bind = useDrag(
({
@@ -165,9 +163,9 @@ export function MobileNavTabs() {
// the threshold for it to close, or if we reset it to its open position
if (last) {
if (oy > ROW_HEIGHT * 0.5 || (vy > 0.5 && dy > 0)) {
openDefault(vy);
close(vy);
} else {
openFull({ canceled });
open({ canceled });
}
} else {
// when the user keeps dragging, we just move the sheet according to
@@ -178,7 +176,7 @@ export function MobileNavTabs() {
{
from: () => [0, y.get()],
filterTaps: true,
bounds: { top: -TOTAL_HEIGHT, bottom: TOTAL_HEIGHT - ROW_HEIGHT },
bounds: { top: -totalHeight, bottom: totalHeight - ROW_HEIGHT },
axis: 'y',
rubberband: true,
},
@@ -194,7 +192,7 @@ export function MobileNavTabs() {
backgroundColor: theme.mobileNavBackground,
borderTop: `1px solid ${theme.menuBorder}`,
...styles.shadow,
height: TOTAL_HEIGHT + PILL_HEIGHT,
height: totalHeight + PILL_HEIGHT,
width: '100%',
position: 'fixed',
zIndex: 100,
@@ -218,7 +216,7 @@ export function MobileNavTabs() {
style={{
flexDirection: 'row',
flexWrap: 'wrap',
height: TOTAL_HEIGHT,
height: totalHeight,
width: '100%',
}}
>
@@ -239,10 +237,9 @@ type NavTabProps = {
path: string;
Icon: ComponentType<NavTabIconProps>;
style?: CSSProperties;
onClick: ComponentProps<typeof NavLink>['onClick'];
};
function NavTab({ Icon: TabIcon, name, path, style, onClick }: NavTabProps) {
function NavTab({ Icon: TabIcon, name, path, style }: NavTabProps) {
return (
<NavLink
to={path}
@@ -256,7 +253,6 @@ function NavTab({ Icon: TabIcon, name, path, style, onClick }: NavTabProps) {
textAlign: 'center',
...style,
})}
onClick={onClick}
>
<TabIcon width={22} height={22} />
{name}

View File

@@ -3,10 +3,13 @@ import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDebounceCallback } from 'usehooks-ts';
import {
collapseModals,
getPayees,
@@ -18,14 +21,11 @@ import {
updateAccount,
} from 'loot-core/client/actions';
import {
accountSchedulesQuery,
SchedulesProvider,
useDefaultSchedulesQueryTransform,
} from 'loot-core/client/data-hooks/schedules';
import {
useTransactions,
useTransactionsSearch,
} from 'loot-core/client/data-hooks/transactions';
import * as queries from 'loot-core/client/queries';
import { type PagedQuery, pagedQuery } from 'loot-core/client/query-helpers';
import { listen, send } from 'loot-core/platform/client/fetch';
import { type Query } from 'loot-core/shared/query';
import { isPreviewId } from 'loot-core/shared/transactions';
@@ -34,10 +34,10 @@ import {
type TransactionEntity,
} from 'loot-core/types/models';
import { useAccountPreviewTransactions } from '../../../hooks/useAccountPreviewTransactions';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useFailedAccounts } from '../../../hooks/useFailedAccounts';
import { useNavigate } from '../../../hooks/useNavigate';
import { usePreviewTransactions } from '../../../hooks/usePreviewTransactions';
import { styles, theme } from '../../../style';
import { Button } from '../../common/Button2';
import { Text } from '../../common/Text';
@@ -56,11 +56,7 @@ export function AccountTransactions({
readonly accountId?: string;
readonly accountName: string;
}) {
const schedulesQuery = useMemo(
() => accountSchedulesQuery(accountId),
[accountId],
);
const schedulesTransform = useDefaultSchedulesQueryTransform(accountId);
return (
<Page
header={
@@ -78,7 +74,7 @@ export function AccountTransactions({
}
padding={0}
>
<SchedulesProvider query={schedulesQuery}>
<SchedulesProvider transform={schedulesTransform}>
<TransactionListWithPreviews
account={account}
accountName={accountName}
@@ -103,39 +99,33 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
const dispatch = useDispatch();
const onSave = useCallback(
(account: AccountEntity) => {
dispatch(updateAccount(account));
},
[dispatch],
);
const onSave = (account: AccountEntity) => {
dispatch(updateAccount(account));
};
const onSaveNotes = useCallback(async (id: string, notes: string) => {
const onSaveNotes = async (id: string, notes: string) => {
await send('notes-save', { id, note: notes });
}, []);
};
const onEditNotes = useCallback(
(id: string) => {
dispatch(
pushModal('notes', {
id: `account-${id}`,
name: account.name,
onSave: onSaveNotes,
}),
);
},
[account.name, dispatch, onSaveNotes],
);
const onEditNotes = (id: string) => {
dispatch(
pushModal('notes', {
id: `account-${id}`,
name: account.name,
onSave: onSaveNotes,
}),
);
};
const onCloseAccount = useCallback(() => {
const onCloseAccount = () => {
dispatch(openAccountCloseModal(account.id));
}, [account.id, dispatch]);
};
const onReopenAccount = useCallback(() => {
const onReopenAccount = () => {
dispatch(reopenAccount(account.id));
}, [account.id, dispatch]);
};
const onClick = useCallback(() => {
const onClick = () => {
dispatch(
pushModal('account-menu', {
accountId: account.id,
@@ -145,15 +135,7 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
onReopenAccount,
}),
);
}, [
account.id,
dispatch,
onCloseAccount,
onEditNotes,
onReopenAccount,
onSave,
]);
};
return (
<View
style={{
@@ -214,60 +196,69 @@ function TransactionListWithPreviews({
accountName,
}: {
readonly account?: AccountEntity;
readonly accountId?:
| AccountEntity['id']
| 'budgeted'
| 'offbudget'
| 'uncategorized';
readonly accountName: AccountEntity['name'] | string;
readonly accountId?: string;
readonly accountName: string;
}) {
const baseTransactionsQuery = useCallback(
const [currentQuery, setCurrentQuery] = useState<Query>();
const [isSearching, setIsSearching] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [transactions, setTransactions] = useState<
ReadonlyArray<TransactionEntity>
>([]);
const prependTransactions = usePreviewTransactions();
const allTransactions = useMemo(
() =>
queries.transactions(accountId).options({ splits: 'none' }).select('*'),
[accountId],
!isSearching ? prependTransactions.concat(transactions) : transactions,
[isSearching, prependTransactions, transactions],
);
const [transactionsQuery, setTransactionsQuery] = useState<Query>(
baseTransactionsQuery(),
);
const {
transactions,
isLoading,
reload: reloadTransactions,
loadMore: loadMoreTransactions,
} = useTransactions({
query: transactionsQuery,
});
const { previewTransactions } = useAccountPreviewTransactions({
accountId: account?.id || '',
});
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const dispatch = useDispatch();
const navigate = useNavigate();
const onRefresh = useCallback(() => {
if (accountId) {
dispatch(syncAndDownload(accountId));
}
}, [accountId, dispatch]);
const onRefresh = () => {
dispatch(syncAndDownload(accountId));
};
const makeRootQuery = useCallback(
() => queries.makeTransactionsQuery(accountId).options({ splits: 'none' }),
[accountId],
);
const paged = useRef<PagedQuery>();
const updateQuery = useCallback((query: Query) => {
paged.current?.unsubscribe();
setIsLoading(true);
paged.current = pagedQuery(
query.options({ splits: 'none' }).select('*'),
(data: ReadonlyArray<TransactionEntity>) => {
setTransactions(data);
setIsLoading(false);
},
{ pageCount: 50 },
);
}, []);
const fetchTransactions = useCallback(() => {
const query = makeRootQuery();
setCurrentQuery(query);
updateQuery(query);
}, [makeRootQuery, updateQuery]);
const refetchTransactions = () => {
paged.current?.run();
};
useEffect(() => {
if (accountId) {
dispatch(markAccountRead(accountId));
}
}, [accountId, dispatch]);
useEffect(() => {
return listen('sync-event', ({ type, tables }) => {
const unlisten = listen('sync-event', ({ type, tables }) => {
if (type === 'applied') {
if (
tables.includes('transactions') ||
tables.includes('category_mapping') ||
tables.includes('payee_mapping')
) {
reloadTransactions?.();
refetchTransactions();
}
if (tables.includes('payees') || tables.includes('payee_mapping')) {
@@ -275,56 +266,77 @@ function TransactionListWithPreviews({
}
}
});
}, [dispatch, reloadTransactions]);
const { isSearching, search: onSearch } = useTransactionsSearch({
updateQuery: setTransactionsQuery,
resetQuery: () => setTransactionsQuery(baseTransactionsQuery()),
dateFormat,
});
fetchTransactions();
dispatch(markAccountRead(accountId));
return () => unlisten();
}, [accountId, dispatch, fetchTransactions]);
const onOpenTransaction = useCallback(
(transaction: TransactionEntity) => {
if (!isPreviewId(transaction.id)) {
navigate(`/transactions/${transaction.id}`);
} else {
dispatch(
pushModal('scheduled-transaction-menu', {
transactionId: transaction.id,
onPost: async transactionId => {
const parts = transactionId.split('/');
await send('schedule/post-transaction', { id: parts[1] });
dispatch(collapseModals('scheduled-transaction-menu'));
},
onSkip: async transactionId => {
const parts = transactionId.split('/');
await send('schedule/skip-next-date', { id: parts[1] });
dispatch(collapseModals('scheduled-transaction-menu'));
},
}),
);
}
},
[dispatch, navigate],
const updateSearchQuery = useDebounceCallback(
useCallback(
searchText => {
if (searchText === '' && currentQuery) {
updateQuery(currentQuery);
} else if (searchText && currentQuery) {
updateQuery(
queries.makeTransactionSearchQuery(
currentQuery,
searchText,
dateFormat,
),
);
}
setIsSearching(searchText !== '');
},
[currentQuery, dateFormat, updateQuery],
),
150,
);
const onSearch = (text: string) => {
updateSearchQuery(text);
};
const onOpenTransaction = (transaction: TransactionEntity) => {
if (!isPreviewId(transaction.id)) {
navigate(`/transactions/${transaction.id}`);
} else {
dispatch(
pushModal('scheduled-transaction-menu', {
transactionId: transaction.id,
onPost: async transactionId => {
const parts = transactionId.split('/');
await send('schedule/post-transaction', { id: parts[1] });
dispatch(collapseModals('scheduled-transaction-menu'));
},
onSkip: async transactionId => {
const parts = transactionId.split('/');
await send('schedule/skip-next-date', { id: parts[1] });
dispatch(collapseModals('scheduled-transaction-menu'));
},
}),
);
}
};
const onLoadMore = () => {
paged.current?.fetchNext();
};
const balanceQueries = useMemo(
() => queriesFromAccountId(accountId, account),
[accountId, account],
);
const transactionsToDisplay = !isSearching
? previewTransactions.concat(transactions)
: transactions;
return (
<TransactionListWithBalances
isLoading={isLoading}
transactions={transactionsToDisplay}
transactions={allTransactions}
balance={balanceQueries.balance}
balanceCleared={balanceQueries.cleared}
balanceUncleared={balanceQueries.uncleared}
onLoadMore={loadMoreTransactions}
onLoadMore={onLoadMore}
searchPlaceholder={`Search ${accountName}`}
onSearch={onSearch}
onOpenTransaction={onOpenTransaction}

View File

@@ -1,11 +1,8 @@
import React, { type CSSProperties, useCallback } from 'react';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { t } from 'i18next';
import { replaceModal, syncAndDownload } from 'loot-core/src/client/actions';
import * as queries from 'loot-core/src/client/queries';
import { type AccountEntity } from 'loot-core/types/models';
import { useAccounts } from '../../../hooks/useAccounts';
import { useFailedAccounts } from '../../../hooks/useFailedAccounts';
@@ -19,22 +16,11 @@ import { Text } from '../../common/Text';
import { TextOneLine } from '../../common/TextOneLine';
import { View } from '../../common/View';
import { MobilePageHeader, Page } from '../../Page';
import { type Binding, type SheetFields } from '../../spreadsheet';
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
import { PullToRefresh } from '../PullToRefresh';
type AccountHeaderProps<SheetFieldName extends SheetFields<'account'>> = {
name: string;
amount: Binding<'account', SheetFieldName>;
style?: CSSProperties;
};
function AccountHeader<SheetFieldName extends SheetFields<'account'>>({
name,
amount,
style = {},
}: AccountHeaderProps<SheetFieldName>) {
function AccountHeader({ name, amount, style = {} }) {
return (
<View
style={{
@@ -60,26 +46,13 @@ function AccountHeader<SheetFieldName extends SheetFields<'account'>>({
</View>
<CellValue binding={amount} type="financial">
{props => (
<CellValueText<'account', SheetFieldName>
{...props}
style={{ ...styles.text, fontSize: 14 }}
/>
<CellValueText {...props} style={{ ...styles.text, fontSize: 14 }} />
)}
</CellValue>
</View>
);
}
type AccountCardProps = {
account: AccountEntity;
updated: boolean;
connected: boolean;
pending: boolean;
failed: boolean;
getBalanceQuery: (account: AccountEntity) => Binding<'account', 'balance'>;
onSelect: (id: string) => void;
};
function AccountCard({
account,
updated,
@@ -88,7 +61,7 @@ function AccountCard({
failed,
getBalanceQuery,
onSelect,
}: AccountCardProps) {
}) {
return (
<Button
onPress={() => onSelect(account.id)}
@@ -112,26 +85,23 @@ function AccountCard({
alignItems: 'center',
}}
>
{
/* TODO: Should bankId be part of the AccountEntity type? */
'bankId' in account && account.bankId ? (
<View
style={{
backgroundColor: pending
? theme.sidebarItemBackgroundPending
: failed
? theme.sidebarItemBackgroundFailed
: theme.sidebarItemBackgroundPositive,
marginRight: '8px',
width: 8,
flexShrink: 0,
height: 8,
borderRadius: 8,
opacity: connected ? 1 : 0,
}}
/>
) : null
}
{account.bankId && (
<View
style={{
backgroundColor: pending
? theme.sidebarItemBackgroundPending
: failed
? theme.sidebarItemBackgroundFailed
: theme.sidebarItemBackgroundPositive,
marginRight: '8px',
width: 8,
flexShrink: 0,
height: 8,
borderRadius: 8,
opacity: connected ? 1 : 0,
}}
/>
)}
<TextOneLine
style={{
...styles.text,
@@ -148,10 +118,11 @@ function AccountCard({
</View>
<CellValue binding={getBalanceQuery(account)} type="financial">
{props => (
<CellValueText<'account', 'balance'>
<CellValueText
{...props}
style={{
fontSize: 16,
color: 'inherit',
...makeAmountFullStyle(props.value),
}}
data-testid="account-balance"
@@ -166,25 +137,14 @@ function EmptyMessage() {
return (
<View style={{ flex: 1, padding: 30 }}>
<Text style={styles.text}>
{t(
'For Actual to be useful, you need to add an account. You can link an account to automatically download transactions, or manage it locally yourself.',
)}
For Actual to be useful, you need to add an account. You can link an
account to automatically download transactions, or manage it locally
yourself.
</Text>
</View>
);
}
type AccountListProps = {
accounts: AccountEntity[];
updatedAccounts: Array<AccountEntity['id']>;
getBalanceQuery: (account: AccountEntity) => Binding<'account', 'balance'>;
getOnBudgetBalance: () => Binding<'account', 'budgeted-accounts-balance'>;
getOffBudgetBalance: () => Binding<'account', 'offbudget-accounts-balance'>;
onAddAccount: () => void;
onSelectAccount: (id: string) => void;
onSync: () => Promise<void>;
};
function AccountList({
accounts,
updatedAccounts,
@@ -194,7 +154,7 @@ function AccountList({
onAddAccount,
onSelectAccount,
onSync,
}: AccountListProps) {
}) {
const failedAccounts = useFailedAccounts();
const syncingAccountIds = useSelector(state => state.account.accountsSyncing);
const budgetedAccounts = accounts.filter(account => account.offbudget === 0);
@@ -204,11 +164,11 @@ function AccountList({
<Page
header={
<MobilePageHeader
title={t('Accounts')}
title="Accounts"
rightContent={
<Button
variant="bare"
aria-label={t('Add account')}
aria-label="Add account"
style={{ margin: 10 }}
onPress={onAddAccount}
>
@@ -224,7 +184,7 @@ function AccountList({
>
{accounts.length === 0 && <EmptyMessage />}
<PullToRefresh onRefresh={onSync}>
<View aria-label="Account list" style={{ margin: 10 }}>
<View style={{ margin: 10 }}>
{budgetedAccounts.length > 0 && (
<AccountHeader name="For Budget" amount={getOnBudgetBalance()} />
)}
@@ -276,20 +236,17 @@ export function Accounts() {
const navigate = useNavigate();
const onSelectAccount = useCallback(
(id: AccountEntity['id']) => {
navigate(`/accounts/${id}`);
},
[navigate],
);
const onSelectAccount = id => {
navigate(`/accounts/${id}`);
};
const onAddAccount = useCallback(() => {
const onAddAccount = () => {
dispatch(replaceModal('add-account'));
}, [dispatch]);
};
const onSync = useCallback(async () => {
const onSync = () => {
dispatch(syncAndDownload());
}, [dispatch]);
};
return (
<View style={{ flex: 1 }}>

View File

@@ -3,7 +3,6 @@ import { useDispatch } from 'react-redux';
import { css } from '@emotion/css';
import { AutoTextSize } from 'auto-text-size';
import { t } from 'i18next';
import memoizeOne from 'memoize-one';
import { collapseModals, pushModal } from 'loot-core/client/actions';
@@ -83,7 +82,7 @@ function ToBudget({ toBudget, onPress, show3Cols }) {
<Button variant="bare" onPress={onPress}>
<View>
<Label
title={amount < 0 ? t('Overbudgeted') : t('To Budget')}
title={amount < 0 ? 'Overbudgeted' : 'To Budget'}
style={{
...(amount < 0 ? styles.smallText : {}),
color: theme.formInputText,
@@ -166,7 +165,7 @@ function Saved({ projected, onPress, show3Cols }) {
</View>
) : (
<Label
title={isNegative ? t('Overspent') : t('Saved')}
title={isNegative ? 'Overspent' : 'Saved'}
style={{
color: theme.formInputText,
textAlign: 'left',
@@ -288,9 +287,7 @@ function BudgetCell({
<CellValue
binding={binding}
type="financial"
aria-label={t('Budgeted amount for {{categoryName}} category', {
categoryName: category.name,
})}
aria-label={`Budgeted amount for ${category.name} category`}
{...props}
>
{({ type, name, value }) =>
@@ -308,9 +305,7 @@ function BudgetCell({
...makeAmountGrey(value),
}}
onPress={onOpenCategoryBudgetMenu}
aria-label={t('Open budget menu for {{categoryName}} category', {
categoryName: category.name,
})}
aria-label={`Open budget menu for ${category.name} category`}
>
<View>
<PrivacyFilter>
@@ -614,9 +609,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
<CellValue
binding={spent}
type="financial"
aria-label={t('Spent amount for {{categoryName}} category', {
categoryName: category.name,
})} // Translated aria-label
aria-label={`Spent amount for ${category.name} category`}
>
{({ type, value }) => (
<Button
@@ -625,10 +618,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
...PILL_STYLE,
}}
onPress={onShowActivity}
aria-label={t(
'Show transactions for {{categoryName}} category',
{ categoryName: category.name },
)} // Translated aria-label
aria-label={`Show transactions for ${category.name} category`}
>
<PrivacyFilter>
<AutoTextSize
@@ -659,9 +649,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
}}
>
<BalanceWithCarryover
aria-label={t('Balance for {{categoryName}} category', {
categoryName: category.name,
})} // Translated aria-label
aria-label={`Balance for ${category.name} category`}
type="financial"
carryover={carryover}
balance={balance}
@@ -694,10 +682,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
maxWidth: columnWidth,
}}
onPress={onOpenBalanceMenu}
aria-label={t(
'Open balance menu for {{categoryName}} category',
{ categoryName: category.name },
)} // Translated aria-label
aria-label={`Open balance menu for ${category.name} category`}
>
<PrivacyFilter>
<AutoTextSize
@@ -1226,9 +1211,7 @@ const IncomeCategory = memo(function IncomeCategory({
<CellValue
binding={balance}
type="financial"
aria-label={t('Balance for {{categoryName}} category', {
categoryName: category.name,
})} // Translated aria-label
aria-label={`Balance for ${category.name} category`}
>
{({ type, value }) => (
<View>
@@ -1427,9 +1410,9 @@ function IncomeGroup({
}}
>
{type === 'report' && (
<Label title={t('Budgeted')} style={{ width: columnWidth }} />
<Label title="Budgeted" style={{ width: columnWidth }} />
)}
<Label title={t('Received')} style={{ width: columnWidth }} />
<Label title="Received" style={{ width: columnWidth }} />
</View>
<Card style={{ marginTop: 0 }}>
@@ -1669,7 +1652,7 @@ export function BudgetTable({
variant="bare"
style={{ margin: 10 }}
onPress={onOpenBudgetPageMenu}
aria-label={t('Budget page menu')}
aria-label="Budget page menu"
>
<SvgLogo
style={{ color: theme.mobileHeaderText }}
@@ -1830,7 +1813,7 @@ function BudgetTableHeader({
/>
)}
<Label
title={t('Budgeted')}
title="Budgeted"
style={{ color: theme.formInputText, paddingRight: 4 }}
/>
</View>
@@ -1889,7 +1872,7 @@ function BudgetTableHeader({
/>
)}
<Label
title={t('Spent')}
title="Spent"
style={{ color: theme.formInputText, paddingRight: 4 }}
/>
</View>
@@ -1926,10 +1909,7 @@ function BudgetTableHeader({
{({ type, value }) => (
<View style={{ width: columnWidth }}>
<View style={{ flex: 1, alignItems: 'flex-end' }}>
<Label
title={t('Balance')}
style={{ color: theme.formInputText }}
/>
<Label title="Balance" style={{ color: theme.formInputText }} />
<View>
<PrivacyFilter>
<AutoTextSize
@@ -1980,7 +1960,7 @@ function MonthSelector({
}}
>
<Button
aria-label={t('Previous month')}
aria-label="Previous month"
variant="bare"
onPress={() => {
if (prevEnabled) {
@@ -2009,7 +1989,7 @@ function MonthSelector({
</Text>
</Button>
<Button
aria-label={t('Next month')}
aria-label="Next month"
variant="bare"
onPress={() => {
if (nextEnabled) {

View File

@@ -1,12 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDebounceCallback } from 'usehooks-ts';
import { getPayees } from 'loot-core/client/actions';
import {
useTransactions,
useTransactionsSearch,
} from 'loot-core/client/data-hooks/transactions';
import * as queries from 'loot-core/client/queries';
import { pagedQuery } from 'loot-core/client/query-helpers';
import { listen } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
@@ -24,64 +23,99 @@ import { TransactionListWithBalances } from '../transactions/TransactionListWith
export function CategoryTransactions({ category, month }) {
const dispatch = useDispatch();
const navigate = useNavigate();
const baseTransactionsQuery = useCallback(
() =>
q('transactions')
.options({ splits: 'inline' })
.filter(getCategoryMonthFilter(category, month))
.select('*'),
[category, month],
);
const [transactionsQuery, setTransactionsQuery] = useState(
baseTransactionsQuery(),
);
const {
transactions,
isLoading,
loadMore: loadMoreTransactions,
reload: reloadTransactions,
} = useTransactions({
query: transactionsQuery,
});
const [isLoading, setIsLoading] = useState(true);
const [currentQuery, setCurrentQuery] = useState();
const [transactions, setTransactions] = useState([]);
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
useEffect(() => {
return listen('sync-event', ({ type, tables }) => {
if (type === 'applied') {
if (
tables.includes('transactions') ||
tables.includes('category_mapping') ||
tables.includes('payee_mapping')
) {
reloadTransactions?.();
}
if (tables.includes('payees') || tables.includes('payee_mapping')) {
dispatch(getPayees());
}
}
});
}, [dispatch, reloadTransactions]);
const { search: onSearch } = useTransactionsSearch({
updateQuery: setTransactionsQuery,
resetQuery: () => setTransactionsQuery(baseTransactionsQuery()),
dateFormat,
});
const onOpenTransaction = useCallback(
transaction => {
// details of how the native app used to handle preview transactions here can be found at commit 05e58279
if (!isPreviewId(transaction.id)) {
navigate(`/transactions/${transaction.id}`);
}
},
[navigate],
const makeRootQuery = useCallback(
() =>
q('transactions')
.options({ splits: 'inline' })
.filter(getCategoryMonthFilter(category, month)),
[category, month],
);
const paged = useRef(null);
const updateQuery = useCallback(query => {
paged.current?.unsubscribe();
setIsLoading(true);
paged.current = pagedQuery(
query.options({ splits: 'inline' }).select('*'),
data => {
setTransactions(data);
setIsLoading(false);
},
{ pageCount: 50 },
);
}, []);
const fetchTransactions = useCallback(async () => {
const query = makeRootQuery();
setCurrentQuery(query);
updateQuery(query);
}, [makeRootQuery, updateQuery]);
useEffect(() => {
function setup() {
return listen('sync-event', ({ type, tables }) => {
if (type === 'applied') {
if (
tables.includes('transactions') ||
tables.includes('category_mapping') ||
tables.includes('payee_mapping')
) {
paged.current?.run();
}
if (tables.includes('payees') || tables.includes('payee_mapping')) {
dispatch(getPayees());
}
}
});
}
fetchTransactions();
return setup();
}, [dispatch, fetchTransactions]);
const updateSearchQuery = useDebounceCallback(
useCallback(
searchText => {
if (searchText === '' && currentQuery) {
updateQuery(currentQuery);
} else if (searchText && currentQuery) {
updateQuery(
queries.makeTransactionSearchQuery(
currentQuery,
searchText,
dateFormat,
),
);
}
},
[currentQuery, dateFormat, updateQuery],
),
150,
);
const onSearch = text => {
updateSearchQuery(text);
};
const onLoadMore = () => {
paged.current?.fetchNext();
};
const onOpenTranasction = transaction => {
// details of how the native app used to handle preview transactions here can be found at commit 05e58279
if (!isPreviewId(transaction.id)) {
navigate(`/transactions/${transaction.id}`);
}
};
const balance = queries.categoryBalance(category, month);
const balanceCleared = queries.categoryBalanceCleared(category, month);
const balanceUncleared = queries.categoryBalanceUncleared(category, month);
@@ -112,8 +146,8 @@ export function CategoryTransactions({ category, month }) {
balanceUncleared={balanceUncleared}
searchPlaceholder={`Search ${category.name}`}
onSearch={onSearch}
onLoadMore={loadMoreTransactions}
onOpenTransaction={onOpenTransaction}
onLoadMore={onLoadMore}
onOpenTransaction={onOpenTranasction}
/>
</Page>
);

View File

@@ -1,7 +1,5 @@
import React from 'react';
import { t } from 'i18next';
import { useNavigate } from '../../../hooks/useNavigate';
import { SvgAdd } from '../../../icons/v1';
import { Button } from '../../common/Button2';
@@ -21,7 +19,7 @@ export function AddTransactionButton({
return (
<Button
variant="bare"
aria-label={t('Add transaction')}
aria-label="Add transaction"
style={{ margin: 10 }}
onPress={() => {
navigate(to, { state: { accountId, categoryId } });

View File

@@ -0,0 +1,43 @@
import React, { useRef } from 'react';
import { useListBox } from 'react-aria';
import { useListState } from 'react-stately';
import { usePrevious } from '../../../hooks/usePrevious';
import { useScroll } from '../../ScrollProvider';
import { ListBoxSection } from './ListBoxSection';
export function ListBox(props) {
const state = useListState(props);
const listBoxRef = useRef();
const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef);
const { loadMore } = props;
const { hasScrolledToBottom } = useScroll();
const scrolledToBottom = hasScrolledToBottom(5);
const prevScrolledToBottom = usePrevious(scrolledToBottom);
if (!prevScrolledToBottom && scrolledToBottom) {
loadMore?.();
}
return (
<>
<div {...labelProps}>{props.label}</div>
<ul
{...listBoxProps}
ref={listBoxRef}
style={{
padding: 0,
listStyle: 'none',
margin: 0,
width: '100%',
}}
>
{[...state.collection].map(item => (
<ListBoxSection key={item.key} section={item} state={state} />
))}
</ul>
</>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { useListBoxSection } from 'react-aria';
import { css } from '@emotion/css';
import { styles, theme } from '../../../style';
import { Option } from './Option';
const zIndices = { SECTION_HEADING: 10 };
export function ListBoxSection({ section, state }) {
const { itemProps, headingProps, groupProps } = useListBoxSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
// The heading is rendered inside an <li> element, which contains
// a <ul> with the child items.
return (
<li {...itemProps} style={{ width: '100%' }}>
{section.rendered && (
<div
{...headingProps}
className={css([
styles.smallText,
{
backgroundColor: theme.pageBackground,
borderBottom: `1px solid ${theme.tableBorder}`,
borderTop: `1px solid ${theme.tableBorder}`,
color: theme.tableHeaderText,
display: 'flex',
justifyContent: 'center',
paddingBottom: 4,
paddingTop: 4,
position: 'sticky',
top: '0',
width: '100%',
zIndex: zIndices.SECTION_HEADING,
},
])}
>
{section.rendered}
</div>
)}
<ul
{...groupProps}
style={{
padding: 0,
listStyle: 'none',
}}
>
{[...section.childNodes].map((node, index, nodes) => (
<Option
key={node.key}
item={node}
state={state}
isLast={index === nodes.length - 1}
/>
))}
</ul>
</li>
);
}

View File

@@ -0,0 +1,30 @@
import React, { useRef } from 'react';
import { useFocusRing, useOption, mergeProps } from 'react-aria';
import { theme } from '../../../style';
export function Option({ isLast, item, state }) {
// Get props for the option element
const ref = useRef();
const { optionProps, isSelected } = useOption({ key: item.key }, state, ref);
// Determine whether we should show a keyboard
const { isFocusVisible, focusProps } = useFocusRing();
return (
<li
{...mergeProps(optionProps, focusProps)}
ref={ref}
style={{
background: isSelected
? theme.tableRowBackgroundHighlight
: theme.tableBackground,
color: isSelected ? theme.tableText : null,
outline: isFocusVisible ? '2px solid orange' : 'none',
...(!isLast && { borderBottom: `1px solid ${theme.tableBorder}` }),
}}
>
{item.rendered}
</li>
);
}

View File

@@ -0,0 +1,257 @@
import React, { memo } from 'react';
import { mergeProps } from 'react-aria';
import {
PressResponder,
usePress,
useLongPress,
} from '@react-aria/interactions';
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
import { isPreviewId } from 'loot-core/src/shared/transactions';
import { integerToCurrency } from 'loot-core/src/shared/util';
import { useAccount } from '../../../hooks/useAccount';
import { useCategories } from '../../../hooks/useCategories';
import { usePayee } from '../../../hooks/usePayee';
import { SvgSplit } from '../../../icons/v0';
import {
SvgArrowsSynchronize,
SvgCheckCircle1,
SvgLockClosed,
} from '../../../icons/v2';
import { styles, theme } from '../../../style';
import { makeAmountFullStyle } from '../../budget/util';
import { Button } from '../../common/Button2';
import { Text } from '../../common/Text';
import { TextOneLine } from '../../common/TextOneLine';
import { View } from '../../common/View';
import { lookupName, getDescriptionPretty, Status } from './TransactionEdit';
const ROW_HEIGHT = 50;
const ListItem = ({ children, style, ...props }) => {
return (
<View
style={{
height: ROW_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 10,
paddingRight: 10,
...style,
}}
{...props}
>
{children}
</View>
);
};
ListItem.displayName = 'ListItem';
export const Transaction = memo(function Transaction({
transaction,
isAdded,
isSelected,
onPress,
onLongPress,
style,
}) {
const { list: categories } = useCategories();
const {
id,
payee: payeeId,
amount: originalAmount,
category: categoryId,
account: accountId,
cleared,
is_parent: isParent,
is_child: isChild,
schedule,
} = transaction;
const payee = usePayee(payeeId);
const account = useAccount(accountId);
const transferAcct = useAccount(payee?.transfer_acct);
const isPreview = isPreviewId(id);
const { longPressProps } = useLongPress({
accessibilityDescription: 'Long press to select multiple transactions',
onLongPress: () => {
if (isPreview) {
return;
}
onLongPress(transaction);
},
});
const { pressProps } = usePress({
onPress: () => {
onPress(transaction);
},
});
let amount = originalAmount;
if (isPreview) {
amount = getScheduledAmount(amount);
}
const categoryName = lookupName(categories, categoryId);
const prettyDescription = getDescriptionPretty(
transaction,
payee,
transferAcct,
);
const specialCategory = account?.offbudget
? 'Off Budget'
: transferAcct && !transferAcct.offbudget
? 'Transfer'
: isParent
? 'Split'
: null;
const prettyCategory = specialCategory || categoryName;
const isReconciled = transaction.reconciled;
const textStyle = isPreview && {
fontStyle: 'italic',
color: theme.pageTextLight,
};
return (
<PressResponder {...mergeProps(pressProps, longPressProps)}>
<Button
style={{
backgroundColor: theme.tableBackground,
...(isSelected
? {
borderWidth: '0 0 0 4px',
borderColor: theme.mobileTransactionSelected,
borderStyle: 'solid',
}
: {
border: 'none',
}),
userSelect: 'none',
width: '100%',
height: 60,
...(isPreview
? {
backgroundColor: theme.tableRowHeaderBackground,
}
: {}),
}}
>
<ListItem
style={{
flex: 1,
...style,
}}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{schedule && (
<SvgArrowsSynchronize
style={{
width: 12,
height: 12,
marginRight: 5,
color: textStyle.color || theme.menuItemText,
}}
/>
)}
<TextOneLine
style={{
...styles.text,
...textStyle,
fontSize: 14,
fontWeight: isAdded ? '600' : '400',
...(prettyDescription === '' && {
color: theme.tableTextLight,
fontStyle: 'italic',
}),
}}
>
{prettyDescription || 'Empty'}
</TextOneLine>
</View>
{isPreview ? (
<Status status={categoryId} isSplit={isParent || isChild} />
) : (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: 3,
}}
>
{isReconciled ? (
<SvgLockClosed
style={{
width: 11,
height: 11,
color: theme.noticeTextLight,
marginRight: 5,
}}
/>
) : (
<SvgCheckCircle1
style={{
width: 11,
height: 11,
color: cleared
? theme.noticeTextLight
: theme.pageTextSubdued,
marginRight: 5,
}}
/>
)}
{(isParent || isChild) && (
<SvgSplit
style={{
width: 12,
height: 12,
marginRight: 5,
}}
/>
)}
<TextOneLine
style={{
fontSize: 11,
marginTop: 1,
fontWeight: '400',
color: prettyCategory
? theme.tableText
: theme.menuItemTextSelected,
fontStyle:
specialCategory || !prettyCategory ? 'italic' : undefined,
textAlign: 'left',
}}
>
{prettyCategory || 'Uncategorized'}
</TextOneLine>
</View>
)}
</View>
<Text
style={{
...styles.text,
...textStyle,
marginLeft: 25,
marginRight: 5,
fontSize: 14,
...makeAmountFullStyle(amount),
}}
>
{integerToCurrency(amount)}
</Text>
</ListItem>
</Button>
</PressResponder>
);
});

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