Compare commits

..

3 Commits

Author SHA1 Message Date
Matt Fiddaman
380f83f3ee recharts differences 2025-10-22 17:10:42 +01:00
Matt Fiddaman
364110ae65 note 2025-10-22 14:26:52 +01:00
Matt Fiddaman
aa1f59e532 bump dependencies 2025-10-22 14:26:31 +01:00
225 changed files with 724 additions and 3002 deletions

View File

@@ -4,7 +4,6 @@ import {
isValidElement,
type ReactElement,
Ref,
RefObject,
useEffect,
useRef,
} from 'react';
@@ -12,20 +11,15 @@ import {
type InitialFocusProps<T extends HTMLElement> = {
/**
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
children:
| ReactElement<{ ref: Ref<T> }>
| ((ref: RefObject<T | null>) => ReactElement);
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
};
/**
* InitialFocus sets focus on its child element
* when it mounts.
* @param {ReactElement | function} children - A single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
* @param {Object} props - The component props.
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
*/
export function InitialFocus<T extends HTMLElement = HTMLElement>({
children,

View File

@@ -31,6 +31,3 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

View File

@@ -1,64 +0,0 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { type MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
import { MobileNavigation } from './page-models/mobile-navigation';
test.describe('Mobile Bank Sync', () => {
let page: Page;
let navigation: MobileNavigation;
let bankSyncPage: MobileBankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new MobileNavigation(page);
configurationPage = new ConfigurationPage(page);
await page.setViewportSize({
width: 350,
height: 600,
});
await page.goto('/');
await configurationPage.createTestFile();
bankSyncPage = await navigation.goToBankSyncPage();
});
test.afterEach(async () => {
await page.close();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(
page.getByRole('heading', { name: 'Bank Sync' }),
).toBeVisible();
await expect(bankSyncPage.searchBox).toBeVisible();
await expect(bankSyncPage.searchBox).toHaveAttribute(
'placeholder',
'Filter accounts…',
);
await expect(page).toMatchThemeScreenshots();
});
test('searches for accounts', async () => {
await bankSyncPage.searchFor('Checking');
await expect(bankSyncPage.searchBox).toHaveValue('Checking');
await expect(page).toMatchThemeScreenshots();
});
test('page handles empty state gracefully', async () => {
await bankSyncPage.searchFor('NonExistentAccount123456789');
const emptyMessage = page.getByText(/No accounts found/);
await expect(emptyMessage).toBeVisible();
await expect(page).toMatchThemeScreenshots();
});
});

View File

@@ -1,35 +0,0 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { type BankSyncPage } from './page-models/bank-sync-page';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';
test.describe('Bank Sync', () => {
let page: Page;
let navigation: Navigation;
let bankSyncPage: BankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
navigation = new Navigation(page);
configurationPage = new ConfigurationPage(page);
await page.goto('/');
await configurationPage.createTestFile();
});
test.afterAll(async () => {
await page.close();
});
test.beforeEach(async () => {
bankSyncPage = await navigation.goToBankSyncPage();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(page).toMatchThemeScreenshots();
});
});

View File

@@ -1,13 +0,0 @@
import { type Page } from '@playwright/test';
export class BankSyncPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async waitToLoad() {
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
}
}

View File

@@ -1,28 +0,0 @@
import { type Locator, type Page } from '@playwright/test';
export class MobileBankSyncPage {
readonly page: Page;
readonly searchBox: Locator;
readonly accountsList: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder(/Filter accounts/i);
this.accountsList = page.getByRole('main');
}
async waitFor(options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
timeout?: number;
}) {
await this.accountsList.waitFor(options);
}
async waitToLoad() {
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
}
async searchFor(term: string) {
await this.searchBox.fill(term);
}
}

View File

@@ -2,7 +2,6 @@ import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBankSyncPage } from './mobile-bank-sync-page';
import { MobileBudgetPage } from './mobile-budget-page';
import { MobilePayeesPage } from './mobile-payees-page';
import { MobileReportsPage } from './mobile-reports-page';
@@ -16,7 +15,6 @@ const NAV_LINKS_HIDDEN_BY_DEFAULT = [
'Schedules',
'Payees',
'Rules',
'Bank Sync',
'Settings',
];
const ROUTES_BY_PAGE = {
@@ -26,7 +24,6 @@ const ROUTES_BY_PAGE = {
Reports: '/reports',
Payees: '/payees',
Rules: '/rules',
'Bank Sync': '/bank-sync',
Settings: '/settings',
};
@@ -185,13 +182,6 @@ export class MobileNavigation {
);
}
async goToBankSyncPage() {
return await this.navigateToPage(
'Bank Sync',
() => new MobileBankSyncPage(this.page),
);
}
async goToSettingsPage() {
return await this.navigateToPage(
'Settings',

View File

@@ -1,7 +1,6 @@
import { type Page } from '@playwright/test';
import { AccountPage } from './account-page';
import { BankSyncPage } from './bank-sync-page';
import { PayeesPage } from './payees-page';
import { ReportsPage } from './reports-page';
import { RulesPage } from './rules-page';
@@ -67,19 +66,6 @@ export class Navigation {
return new PayeesPage(this.page);
}
async goToBankSyncPage() {
const bankSyncLink = this.page.getByRole('link', { name: 'Bank Sync' });
// Expand the "more" menu only if it is not already expanded
if (!(await bankSyncLink.isVisible())) {
await this.page.getByRole('button', { name: 'More' }).click();
}
await bankSyncLink.click();
return new BankSyncPage(this.page);
}
async goToSettingsPage() {
const settingsLink = this.page.getByRole('link', { name: 'Settings' });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -100,19 +100,6 @@ global.Actual = {
restartElectronServer: () => {},
openFileDialog: async ({ filters = [] }) => {
const FILE_ACCEPT_OVERRIDES = {
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
qfx: [
'application/vnd.intu.qfx',
'application/x-qfx',
'application/qfx',
'application/ofx',
'application/x-ofx',
'application/octet-stream',
'com.intuit.qfx',
],
};
return new Promise(resolve => {
let createdElement = false;
// Attempt to reuse an already-created file input.
@@ -130,15 +117,7 @@ global.Actual = {
const filter = filters.find(filter => filter.extensions);
if (filter) {
input.accept = filter.extensions
.flatMap(ext => {
const normalizedExt = ext.startsWith('.')
? ext.toLowerCase()
: `.${ext.toLowerCase()}`;
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
return [normalizedExt, ...overrides];
})
.join(',');
input.accept = filter.extensions.map(ext => '.' + ext).join(',');
}
input.style.position = 'absolute';

View File

@@ -10,13 +10,12 @@ import { View } from '@actual-app/components/view';
import * as undo from 'loot-core/platform/client/undo';
import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
import { BankSync } from './banksync';
import { BankSyncStatus } from './BankSyncStatus';
import { CommandBar } from './CommandBar';
import { GlobalKeys } from './GlobalKeys';
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
import { MobileNavTabs } from './mobile/MobileNavTabs';
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
import { TransactionFormPage } from './mobile/transactions/TransactionFormPage';
import { Notifications } from './Notifications';
import { Reports } from './reports';
import { LoadingIndicator } from './reports/LoadingIndicator';
@@ -279,18 +278,7 @@ export function FinancesApp() {
path="/rules/:id"
element={<NarrowAlternate name="RuleEdit" />}
/>
<Route
path="/bank-sync"
element={<NarrowAlternate name="BankSync" />}
/>
<Route
path="/bank-sync/account/:accountId/edit"
element={
<WideNotSupported redirectTo="/bank-sync">
<MobileBankSyncAccountEditPage />
</WideNotSupported>
}
/>
<Route path="/bank-sync" element={<BankSync />} />
<Route path="/tags" element={<ManageTagsPage />} />
<Route path="/settings" element={<Settings />} />
@@ -317,8 +305,7 @@ export function FinancesApp() {
path="/transactions/:transactionId"
element={
<WideNotSupported>
{/* <TransactionEdit /> */}
<TransactionFormPage />
<TransactionEdit />
</WideNotSupported>
}
/>
@@ -360,7 +347,6 @@ export function FinancesApp() {
<Route path="/accounts" element={<MobileNavTabs />} />
<Route path="/settings" element={<MobileNavTabs />} />
<Route path="/reports" element={<MobileNavTabs />} />
<Route path="/bank-sync" element={<MobileNavTabs />} />
<Route path="/rules" element={<MobileNavTabs />} />
<Route path="/payees" element={<MobileNavTabs />} />
<Route path="*" element={null} />

View File

@@ -1357,11 +1357,11 @@ class AccountInternal extends PureComponent<
onSetTransfer = async (ids: string[]) => {
this.setState({ workingHard: true });
await this.props.onSetTransfer({
await this.props.onSetTransfer(
ids,
payees: this.props.payees,
onSuccess: this.refetchTransactions,
});
this.props.payees,
this.refetchTransactions,
);
};
onConditionsOpChange = (value: 'and' | 'or') => {

View File

@@ -25,7 +25,6 @@ import { InitialFocus } from '@actual-app/components/initial-focus';
import { Input } from '@actual-app/components/input';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Stack } from '@actual-app/components/stack';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -384,192 +383,192 @@ export function AccountHeader({
<FilterButton onApply={onApplyFilter} />
</View>
<View style={{ flex: 1 }} />
<SpaceBetween gap={10}>
<Search
placeholder={t('Search')}
value={search}
onChange={onSearch}
ref={searchInput}
<Search
placeholder={t('Search')}
value={search}
onChange={onSearch}
inputRef={searchInput}
// Remove marginRight magically being added by Stack...
// We need to refactor the Stack component
style={{ marginRight: 0 }}
/>
{workingHard ? (
<View>
<AnimatedLoading style={{ width: 16, height: 16 }} />
</View>
) : (
<SelectedTransactionsButton
getTransaction={id => transactions.find(t => t.id === id)}
onShow={onShowTransactions}
onDuplicate={onBatchDuplicate}
onDelete={onBatchDelete}
onEdit={onBatchEdit}
onRunRules={onRunRules}
onLinkSchedule={onBatchLinkSchedule}
onUnlinkSchedule={onBatchUnlinkSchedule}
onCreateRule={onCreateRule}
onSetTransfer={onSetTransfer}
onScheduleAction={onScheduleAction}
showMakeTransfer={showMakeTransfer}
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
onMergeTransactions={onMergeTransactions}
/>
{workingHard ? (
<View>
<AnimatedLoading style={{ width: 16, height: 16 }} />
</View>
) : (
<SelectedTransactionsButton
getTransaction={id => transactions.find(t => t.id === id)}
onShow={onShowTransactions}
onDuplicate={onBatchDuplicate}
onDelete={onBatchDelete}
onEdit={onBatchEdit}
onRunRules={onRunRules}
onLinkSchedule={onBatchLinkSchedule}
onUnlinkSchedule={onBatchUnlinkSchedule}
onCreateRule={onCreateRule}
onSetTransfer={onSetTransfer}
onScheduleAction={onScheduleAction}
showMakeTransfer={showMakeTransfer}
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
onMergeTransactions={onMergeTransactions}
/>
)}
<View style={{ flex: '0 0 auto' }}>
{account && (
<Tooltip
style={{
...styles.tooltip,
marginBottom: 10,
}}
content={
account?.last_reconciled
? t(
'Reconciled {{ relativeTimeAgo }} ({{ absoluteDate }})',
{
relativeTimeAgo: tsToRelativeTime(
account.last_reconciled,
locale,
)}
<View style={{ flex: '0 0 auto', marginLeft: 10 }}>
{account && (
<Tooltip
style={{
...styles.tooltip,
marginBottom: 10,
}}
content={
account?.last_reconciled
? t(
'Reconciled {{ relativeTimeAgo }} ({{ absoluteDate }})',
{
relativeTimeAgo: tsToRelativeTime(
account.last_reconciled,
locale,
),
absoluteDate: formatDate(
new Date(
parseInt(account.last_reconciled ?? '0', 10),
),
absoluteDate: formatDate(
new Date(
parseInt(account.last_reconciled ?? '0', 10),
),
dateFormat,
{ locale },
),
},
)
: t('Not yet reconciled')
}
placement="top"
triggerProps={{
isDisabled: reconcileOpen,
dateFormat,
{ locale },
),
},
)
: t('Not yet reconciled')
}
placement="top"
triggerProps={{
isDisabled: reconcileOpen,
}}
>
<Button
ref={reconcileRef}
variant="bare"
aria-label={t('Reconcile')}
style={{ padding: 6 }}
onPress={() => {
setReconcileOpen(true);
}}
>
<Button
ref={reconcileRef}
variant="bare"
aria-label={t('Reconcile')}
style={{ padding: 6 }}
onPress={() => {
setReconcileOpen(true);
}}
>
<View>
<SvgLockClosed width={14} height={14} />
</View>
</Button>
<Popover
placement="bottom"
triggerRef={reconcileRef}
style={{ width: 275 }}
isOpen={reconcileOpen}
onOpenChange={() => setReconcileOpen(false)}
>
<ReconcileMenu
account={account}
onClose={() => setReconcileOpen(false)}
onReconcile={onReconcile}
/>
</Popover>
</Tooltip>
)}
</View>
<Button
variant="bare"
aria-label={
<View>
<SvgLockClosed width={14} height={14} />
</View>
</Button>
<Popover
placement="bottom"
triggerRef={reconcileRef}
style={{ width: 275 }}
isOpen={reconcileOpen}
onOpenChange={() => setReconcileOpen(false)}
>
<ReconcileMenu
account={account}
onClose={() => setReconcileOpen(false)}
onReconcile={onReconcile}
/>
</Popover>
</Tooltip>
)}
</View>
<Button
variant="bare"
aria-label={
splitsExpanded.state.mode === 'collapse'
? t('Collapse split transactions')
: t('Expand split transactions')
}
style={{ padding: 6 }}
onPress={onToggleSplits}
>
<View
title={
splitsExpanded.state.mode === 'collapse'
? t('Collapse split transactions')
: t('Expand split transactions')
}
style={{ padding: 6 }}
onPress={onToggleSplits}
>
<View
title={
splitsExpanded.state.mode === 'collapse'
? t('Collapse split transactions')
: t('Expand split transactions')
}
>
{splitsExpanded.state.mode === 'collapse' ? (
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
) : (
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
)}
</View>
</Button>
{account ? (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
{splitsExpanded.state.mode === 'collapse' ? (
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
) : (
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
)}
</View>
</Button>
{account ? (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover style={{ minWidth: 275 }}>
<Dialog>
<AccountMenu
account={account}
canSync={canSync}
showNetWorthChart={showNetWorthChart}
canShowBalances={
canCalculateBalance ? canCalculateBalance() : false
}
isSorted={isSorted}
showBalances={showBalances}
showCleared={showCleared}
showReconciled={showReconciled}
onMenuSelect={onMenuSelect}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
) : (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
<Popover style={{ minWidth: 275 }}>
<Dialog>
<AccountMenu
account={account}
canSync={canSync}
showNetWorthChart={showNetWorthChart}
canShowBalances={
canCalculateBalance ? canCalculateBalance() : false
}
isSorted={isSorted}
showBalances={showBalances}
showCleared={showCleared}
showReconciled={showReconciled}
onMenuSelect={onMenuSelect}
/>
</Button>
</Dialog>
</Popover>
</DialogTrigger>
</View>
) : (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
slot="close"
onMenuSelect={onMenuSelect}
items={[
...(isSorted
? [
{
name: 'remove-sorting',
text: t('Remove all sorting'),
} as const,
]
: []),
{ name: 'export', text: t('Export') },
{
name: 'toggle-net-worth-chart',
text: showNetWorthChart
? t('Hide balance chart')
: t('Show balance chart'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
)}
</SpaceBetween>
<Popover>
<Dialog>
<Menu
slot="close"
onMenuSelect={onMenuSelect}
items={[
...(isSorted
? [
{
name: 'remove-sorting',
text: t('Remove all sorting'),
} as const,
]
: []),
{ name: 'export', text: t('Export') },
{
name: 'toggle-net-worth-chart',
text: showNetWorthChart
? t('Hide balance chart')
: t('Show balance chart'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
)}
</Stack>
{filterConditions?.length > 0 && (
<FiltersStack

View File

@@ -89,7 +89,6 @@ export const AccountRow = memo(
textDecorationColor: theme.pageTextSubdued,
textUnderlineOffset: '4px',
}}
data-vrt-mask
>
{lastSyncString}
</Cell>

View File

@@ -1,214 +0,0 @@
import React, { useState, type ReactNode } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgQuestion } from '@actual-app/components/icons/v1';
import { Stack } from '@actual-app/components/stack';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
import { ToggleField } from '@desktop-client/components/mobile/MobileForms';
type CheckboxOptionProps = {
id: string;
checked: boolean;
onChange: () => void;
disabled?: boolean;
helpMode?: 'desktop' | 'mobile';
children: ReactNode;
};
function CheckboxOption({
id,
checked,
onChange,
disabled,
helpMode = 'desktop',
children,
}: CheckboxOptionProps) {
if (helpMode === 'mobile') {
return (
<Stack
direction="row"
align="center"
justify="space-between"
style={{ marginBottom: 5 }}
>
<Text>{children}</Text>
<ToggleField
id={id}
isOn={checked}
onToggle={onChange}
isDisabled={disabled}
/>
</Stack>
);
}
return (
<LabeledCheckbox
id={id}
checked={checked}
onChange={onChange}
disabled={disabled}
>
{children}
</LabeledCheckbox>
);
}
type CheckboxOptionWithHelpProps = {
id: string;
checked: boolean;
onChange: () => void;
disabled?: boolean;
helpText: string;
helpMode: 'desktop' | 'mobile';
children: ReactNode;
};
function CheckboxOptionWithHelp({
id,
checked,
onChange,
disabled,
helpText,
helpMode,
children,
}: CheckboxOptionWithHelpProps) {
const { t } = useTranslation();
const [showHelp, setShowHelp] = useState(false);
if (helpMode === 'desktop') {
return (
<LabeledCheckbox
id={id}
checked={checked}
onChange={onChange}
disabled={disabled}
>
<Tooltip content={helpText}>
<Stack direction="row" align="center" spacing={1}>
<Text>{children}</Text>
<SvgQuestion height={12} width={12} cursor="pointer" />
</Stack>
</Tooltip>
</LabeledCheckbox>
);
}
return (
<Stack direction="column" spacing={1} style={{ marginBottom: 5 }}>
<Stack direction="row" align="center" justify="space-between">
<Stack direction="row" align="center" spacing={1}>
<Text>{children}</Text>
<Button
variant="bare"
aria-label={t('Help')}
onPress={() => setShowHelp(!showHelp)}
style={{
padding: 0,
height: 'auto',
minHeight: 'auto',
}}
>
<SvgQuestion height={12} width={12} />
</Button>
</Stack>
<ToggleField
id={id}
isOn={checked}
onToggle={onChange}
isDisabled={disabled}
/>
</Stack>
{showHelp && (
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
}}
>
{helpText}
</Text>
)}
</Stack>
);
}
type BankSyncCheckboxOptionsProps = {
importPending: boolean;
setImportPending: (value: boolean) => void;
importNotes: boolean;
setImportNotes: (value: boolean) => void;
reimportDeleted: boolean;
setReimportDeleted: (value: boolean) => void;
importTransactions: boolean;
setImportTransactions: (value: boolean) => void;
helpMode?: 'desktop' | 'mobile';
};
export function BankSyncCheckboxOptions({
importPending,
setImportPending,
importNotes,
setImportNotes,
reimportDeleted,
setReimportDeleted,
importTransactions,
setImportTransactions,
helpMode = 'desktop',
}: BankSyncCheckboxOptionsProps) {
const { t } = useTranslation();
return (
<>
<CheckboxOption
id="form_pending"
checked={importPending}
onChange={() => setImportPending(!importPending)}
disabled={!importTransactions}
helpMode={helpMode}
>
<Trans>Import pending transactions</Trans>
</CheckboxOption>
<CheckboxOption
id="form_notes"
checked={importNotes}
onChange={() => setImportNotes(!importNotes)}
disabled={!importTransactions}
helpMode={helpMode}
>
<Trans>Import transaction notes</Trans>
</CheckboxOption>
<CheckboxOptionWithHelp
id="form_reimport_deleted"
checked={reimportDeleted}
onChange={() => setReimportDeleted(!reimportDeleted)}
disabled={!importTransactions}
helpText={t(
'By default imported transactions that you delete will be re-imported with the next bank sync operation. To disable this behaviour - untick this box.',
)}
helpMode={helpMode}
>
<Trans>Reimport deleted transactions</Trans>
</CheckboxOptionWithHelp>
<CheckboxOptionWithHelp
id="form_import_transactions"
checked={!importTransactions}
onChange={() => setImportTransactions(!importTransactions)}
helpText={t(
'Selecting this option will disable importing transactions and only import the account balance for use in reconciliation',
)}
helpMode={helpMode}
>
<Trans>Investment Account</Trans>
</CheckboxOptionWithHelp>
</>
);
}

View File

@@ -1,17 +1,27 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgQuestion } from '@actual-app/components/icons/v1';
import { Stack } from '@actual-app/components/stack';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { type AccountEntity } from 'loot-core/types/models';
import {
defaultMappings,
type Mappings,
mappingsFromString,
mappingsToString,
} from 'loot-core/server/util/custom-sync-mapping';
import { q } from 'loot-core/shared/query';
import {
type TransactionEntity,
type AccountEntity,
} from 'loot-core/types/models';
import { BankSyncCheckboxOptions } from './BankSyncCheckboxOptions';
import { FieldMapping } from './FieldMapping';
import { useBankSyncAccountSettings } from './useBankSyncAccountSettings';
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
import {
@@ -19,6 +29,9 @@ import {
ModalCloseButton,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { CheckboxOption } from '@desktop-client/components/modals/ImportTransactionsModal/CheckboxOption';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -26,7 +39,7 @@ export type TransactionDirection = 'payment' | 'deposit';
type MappableActualFields = 'date' | 'payee' | 'notes';
type MappableField = {
export type MappableField = {
actualField: MappableActualFields;
syncFields: string[];
};
@@ -94,39 +107,15 @@ const mappableFields: MappableField[] = [
},
];
function getByPath(obj: unknown, path: string): unknown {
if (obj == null) {
return undefined;
}
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
export const getFields = (
transaction: Record<string, unknown>,
): MappableFieldWithExample[] =>
const getFields = (transaction: TransactionEntity) =>
mappableFields.map(field => ({
actualField: field.actualField,
syncFields: field.syncFields
.map(syncField => {
const value = getByPath(transaction, syncField);
return value !== undefined
? { field: syncField, example: String(value) }
: null;
})
.filter(
(item): item is { field: string; example: string } => item !== null,
),
.filter(syncField => transaction[syncField as keyof TransactionEntity])
.map(syncField => ({
field: syncField,
example: transaction[syncField as keyof TransactionEntity],
})),
}));
export type EditSyncAccountProps = {
@@ -135,30 +124,79 @@ export type EditSyncAccountProps = {
export function EditSyncAccount({ account }: EditSyncAccountProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const {
transactionDirection,
setTransactionDirection,
importPending,
setImportPending,
importNotes,
setImportNotes,
reimportDeleted,
setReimportDeleted,
importTransactions,
setImportTransactions,
mappings,
setMapping,
fields,
saveSettings,
} = useBankSyncAccountSettings(account.id);
const [savedMappings = mappingsToString(defaultMappings), setSavedMappings] =
useSyncedPref(`custom-sync-mappings-${account.id}`);
const [savedImportNotes = true, setSavedImportNotes] = useSyncedPref(
`sync-import-notes-${account.id}`,
);
const [savedImportPending = true, setSavedImportPending] = useSyncedPref(
`sync-import-pending-${account.id}`,
);
const [savedReimportDeleted = true, setSavedReimportDeleted] = useSyncedPref(
`sync-reimport-deleted-${account.id}`,
);
const [savedImportTransactions = true, setSavedImportTransactions] =
useSyncedPref(`sync-import-transactions-${account.id}`);
const [transactionDirection, setTransactionDirection] =
useState<TransactionDirection>('payment');
const [importPending, setImportPending] = useState(
String(savedImportPending) === 'true',
);
const [importNotes, setImportNotes] = useState(
String(savedImportNotes) === 'true',
);
const [reimportDeleted, setReimportDeleted] = useState(
String(savedReimportDeleted) === 'true',
);
const [mappings, setMappings] = useState<Mappings>(
mappingsFromString(savedMappings),
);
const [importTransactions, setImportTransactions] = useState(
String(savedImportTransactions) === 'true',
);
const transactionQuery = useMemo(
() =>
q('transactions')
.filter({
account: account.id,
amount: transactionDirection === 'payment' ? { $lte: 0 } : { $gt: 0 },
raw_synced_data: { $ne: null },
})
.options({ splits: 'none' })
.select('*'),
[account.id, transactionDirection],
);
const { transactions } = useTransactions({
query: transactionQuery,
});
const exampleTransaction = useMemo(() => {
const data = transactions?.[0]?.raw_synced_data;
if (!data) return undefined;
try {
return JSON.parse(data);
} catch (error) {
console.error('Failed to parse transaction data:', error);
return undefined;
}
}, [transactions]);
const onSave = async (close: () => void) => {
saveSettings();
const mappingsStr = mappingsToString(mappings);
setSavedMappings(mappingsStr);
setSavedImportPending(String(importPending));
setSavedImportNotes(String(importNotes));
setSavedReimportDeleted(String(reimportDeleted));
setSavedImportTransactions(String(importTransactions));
close();
};
const dispatch = useDispatch();
const onUnlink = async (close: () => void) => {
dispatch(
pushModal({
@@ -177,11 +215,19 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
);
};
const setMapping = (field: string, value: string) => {
setMappings(prev => {
const updated = new Map(prev);
updated?.get(transactionDirection)?.set(field, value);
return updated;
});
};
const potentiallyTruncatedAccountName =
account.name.length > 30 ? account.name.slice(0, 30) + '...' : account.name;
const mapping =
mappings.get(transactionDirection) ?? new Map<string, string>();
const fields = exampleTransaction ? getFields(exampleTransaction) : [];
const mapping = mappings.get(transactionDirection);
return (
<Modal
@@ -204,8 +250,8 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
<FieldMapping
transactionDirection={transactionDirection}
setTransactionDirection={setTransactionDirection}
fields={fields}
mapping={mapping}
fields={fields as MappableFieldWithExample[]}
mapping={mapping!}
setMapping={setMapping}
/>
@@ -213,17 +259,74 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
<Trans>Options</Trans>
</Text>
<BankSyncCheckboxOptions
importPending={importPending}
setImportPending={setImportPending}
importNotes={importNotes}
setImportNotes={setImportNotes}
reimportDeleted={reimportDeleted}
setReimportDeleted={setReimportDeleted}
importTransactions={importTransactions}
setImportTransactions={setImportTransactions}
helpMode="desktop"
/>
<CheckboxOption
id="form_pending"
checked={importPending && importTransactions}
onChange={() => setImportPending(!importPending)}
disabled={!importTransactions}
>
<Trans>Import pending transactions</Trans>
</CheckboxOption>
<CheckboxOption
id="form_notes"
checked={importNotes && importTransactions}
onChange={() => setImportNotes(!importNotes)}
disabled={!importTransactions}
>
<Trans>Import transaction notes</Trans>
</CheckboxOption>
<CheckboxOption
id="form_reimport_deleted"
checked={reimportDeleted && importTransactions}
onChange={() => setReimportDeleted(!reimportDeleted)}
disabled={!importTransactions}
>
<Tooltip
content={t(
'By default imported transactions that you delete will be re-imported with the next bank sync operation. To disable this behaviour - untick this box.',
)}
>
<View
style={{
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Trans>Reimport deleted transactions</Trans>
<SvgQuestion height={12} width={12} cursor="pointer" />
</View>
</Tooltip>
</CheckboxOption>
<CheckboxOption
id="form_import_transactions"
checked={!importTransactions}
onChange={() => setImportTransactions(!importTransactions)}
>
<Tooltip
content={t(
`Selecting this option will disable importing transactions and only import the account balance for use in reconciliation`,
)}
>
<View
style={{
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Trans>Investment Account</Trans>
<SvgQuestion height={12} width={12} cursor="pointer" />
</View>
</Tooltip>
</CheckboxOption>
<View
style={{

View File

@@ -6,7 +6,6 @@ import { SvgEquals } from '@actual-app/components/icons/v1';
import { Select } from '@actual-app/components/select';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
type MappableFieldWithExample,
@@ -38,7 +37,6 @@ type FieldMappingProps = {
fields: MappableFieldWithExample[];
mapping: Map<string, string>;
setMapping: (field: string, value: string) => void;
isMobile?: boolean;
};
export function FieldMapping({
@@ -47,38 +45,11 @@ export function FieldMapping({
fields,
mapping,
setMapping,
isMobile = false,
}: FieldMappingProps) {
const { t } = useTranslation();
const { transactionDirectionOptions } = useTransactionDirectionOptions();
const iconSpacing = isMobile ? 8 : 20;
const iconPadding = isMobile ? 8 : 6;
const arrowIconWidth = 15;
const equalsIconWidth = 12;
const calculatedSelectWidth = Math.max(
...fields.flatMap(field =>
field.syncFields.map(({ field }) => field.length * 8 + 30),
),
);
const calculatedActualFieldWidth = isMobile
? Math.max(50, ...fields.map(field => field.actualField.length * 8 + 20))
: Math.max(75, ...fields.map(field => field.actualField.length * 8 + 20));
const arrowCellWidth = arrowIconWidth + iconSpacing + iconPadding;
const equalsCellWidth = equalsIconWidth + iconSpacing + iconPadding;
const commonCellStyle = { height: '100%', border: 0 };
const iconCellStyle = { ...commonCellStyle };
const selectStyle = {
minWidth: isMobile ? '10ch' : '30ch',
maxWidth: isMobile ? '15ch' : '50ch',
};
return (
<>
<Select
@@ -91,7 +62,6 @@ export function FieldMapping({
style={{
width: '25%',
margin: '1em 0',
minWidth: '100px',
}}
/>
@@ -106,27 +76,19 @@ export function FieldMapping({
<>
<TableHeader>
<Cell
width={calculatedActualFieldWidth}
value={t('Actual field')}
width={100}
style={{ paddingLeft: '10px' }}
plain
>
<Text
style={{ whiteSpace: 'nowrap', fontSize: 13, fontWeight: 500 }}
>
{calculatedActualFieldWidth > 70 ? t('Actual field') : 'Actual'}
</Text>
</Cell>
<Cell value="" width={arrowCellWidth} style={{ padding: 0 }} />
/>
<Cell
value={t('Bank field')}
width={calculatedSelectWidth}
style={{ paddingLeft: 0, ...selectStyle }}
width={330}
style={{ paddingLeft: '10px' }}
/>
<Cell value="" width={equalsCellWidth} style={{ padding: 0 }} />
<Cell
value={t('Example')}
width="flex"
style={{ paddingLeft: 0 }}
style={{ paddingLeft: '10px' }}
/>
</TableHeader>
@@ -145,73 +107,46 @@ export function FieldMapping({
collapsed={true}
>
<Cell
width={calculatedActualFieldWidth}
style={{ ...commonCellStyle, paddingLeft: '10px' }}
plain
>
<Text style={{ whiteSpace: 'nowrap', fontSize: 13 }}>
{field.actualField}
</Text>
</Cell>
value={field.actualField}
width={75}
style={{ paddingLeft: '10px', height: '100%', border: 0 }}
/>
<Cell width={arrowCellWidth} style={iconCellStyle} plain>
<View
<Text>
<SvgRightArrow2
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<SvgRightArrow2
style={{
width: arrowIconWidth,
height: 15,
color: theme.tableText,
}}
/>
</View>
</Cell>
<Cell
width={calculatedSelectWidth}
style={{ ...iconCellStyle, ...selectStyle }}
plain
>
<Select
aria-label={t('Synced field to map to {{field}}', {
field: field.actualField,
})}
options={field.syncFields.map(({ field }) => [
field,
field,
])}
value={mapping.get(field.actualField)}
style={{
width: '100%',
}}
onChange={newValue => {
if (newValue) setMapping(field.actualField, newValue);
width: 15,
height: 15,
color: theme.tableText,
marginRight: '20px',
}}
/>
</Cell>
</Text>
<Cell width={equalsCellWidth} style={iconCellStyle} plain>
<View
<Select
aria-label={t('Synced field to map to {{field}}', {
field: field.actualField,
})}
options={field.syncFields.map(({ field }) => [field, field])}
value={mapping.get(field.actualField)}
style={{
width: 290,
}}
onChange={newValue => {
if (newValue) setMapping(field.actualField, newValue);
}}
/>
<Text>
<SvgEquals
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
width: 12,
height: 12,
color: theme.tableText,
marginLeft: '20px',
}}
>
<SvgEquals
style={{
width: equalsIconWidth,
height: 12,
color: theme.tableText,
}}
/>
</View>
</Cell>
/>
</Text>
<Cell
value={
@@ -220,7 +155,7 @@ export function FieldMapping({
)?.example
}
width="flex"
style={{ ...commonCellStyle, paddingLeft: 0 }}
style={{ paddingLeft: '10px', height: '100%', border: 0 }}
/>
</Row>
);

View File

@@ -1,119 +0,0 @@
import { useState } from 'react';
import {
defaultMappings,
type Mappings,
mappingsFromString,
mappingsToString,
} from 'loot-core/server/util/custom-sync-mapping';
import { q } from 'loot-core/shared/query';
import {
type TransactionDirection,
type MappableFieldWithExample,
getFields,
} from './EditSyncAccount';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
export function useBankSyncAccountSettings(accountId: string) {
const [savedMappings = mappingsToString(defaultMappings), setSavedMappings] =
useSyncedPref(`custom-sync-mappings-${accountId}`);
const [savedImportNotes = true, setSavedImportNotes] = useSyncedPref(
`sync-import-notes-${accountId}`,
);
const [savedImportPending = true, setSavedImportPending] = useSyncedPref(
`sync-import-pending-${accountId}`,
);
const [savedReimportDeleted = true, setSavedReimportDeleted] = useSyncedPref(
`sync-reimport-deleted-${accountId}`,
);
const [savedImportTransactions = true, setSavedImportTransactions] =
useSyncedPref(`sync-import-transactions-${accountId}`);
const [transactionDirection, setTransactionDirection] =
useState<TransactionDirection>('payment');
const [importPending, setImportPending] = useState(
String(savedImportPending) === 'true',
);
const [importNotes, setImportNotes] = useState(
String(savedImportNotes) === 'true',
);
const [reimportDeleted, setReimportDeleted] = useState(
String(savedReimportDeleted) === 'true',
);
const [mappings, setMappings] = useState<Mappings>(
mappingsFromString(savedMappings),
);
const [importTransactions, setImportTransactions] = useState(
String(savedImportTransactions) === 'true',
);
const transactionQuery = q('transactions')
.filter({
account: accountId,
amount: transactionDirection === 'payment' ? { $lte: 0 } : { $gt: 0 },
raw_synced_data: { $ne: null },
})
.options({ splits: 'none' })
.select('*');
const { transactions } = useTransactions({
query: transactionQuery,
});
const data = transactions?.[0]?.raw_synced_data;
let exampleTransaction;
if (data) {
try {
exampleTransaction = JSON.parse(data);
} catch (error) {
console.error('Failed to parse transaction data:', error);
}
}
const fields: MappableFieldWithExample[] = exampleTransaction
? getFields(exampleTransaction)
: [];
const saveSettings = () => {
const mappingsStr = mappingsToString(mappings);
setSavedMappings(mappingsStr);
setSavedImportPending(String(importPending));
setSavedImportNotes(String(importNotes));
setSavedReimportDeleted(String(reimportDeleted));
setSavedImportTransactions(String(importTransactions));
};
const setMapping = (field: string, value: string) => {
setMappings(prev => {
const updated = new Map(prev);
const directionMap = updated.get(transactionDirection);
if (directionMap) {
const newDirectionMap = new Map(directionMap);
newDirectionMap.set(field, value);
updated.set(transactionDirection, newDirectionMap);
}
return updated;
});
};
return {
transactionDirection,
setTransactionDirection,
importPending,
setImportPending,
importNotes,
setImportNotes,
reimportDeleted,
setReimportDeleted,
importTransactions,
setImportTransactions,
mappings,
setMapping,
exampleTransaction,
fields,
saveSettings,
};
}

View File

@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
type SearchProps = {
ref?: Ref<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
value: string;
onChange: (value: string) => void;
placeholder: string;
@@ -21,7 +21,7 @@ type SearchProps = {
};
export function Search({
ref,
inputRef,
value,
onChange,
placeholder,
@@ -70,7 +70,7 @@ export function Search({
/>
<Input
ref={ref}
ref={inputRef}
value={value}
placeholder={placeholder}
onEscape={() => onChange('')}

View File

@@ -1,6 +1,7 @@
import React, {
type ComponentPropsWithoutRef,
type ComponentPropsWithRef,
forwardRef,
type ReactNode,
type CSSProperties,
} from 'react';
@@ -11,7 +12,7 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
type FieldLabelProps = {
title: string;
@@ -47,32 +48,28 @@ const valueStyle = {
type InputFieldProps = ComponentPropsWithRef<typeof Input>;
export function InputField({
disabled,
style,
onUpdate,
ref,
...props
}: InputFieldProps) {
return (
<Input
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
style={{
...valueStyle,
...style,
color: disabled ? theme.tableTextInactive : theme.tableText,
backgroundColor: disabled
? theme.formInputTextReadOnlySelection
: theme.tableBackground,
}}
{...props}
/>
);
}
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
({ disabled, style, onUpdate, ...props }, ref) => {
return (
<Input
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
style={{
...valueStyle,
...style,
color: disabled ? theme.tableTextInactive : theme.tableText,
backgroundColor: disabled
? theme.formInputTextReadOnlySelection
: theme.tableBackground,
}}
{...props}
/>
);
},
);
InputField.displayName = 'InputField';
@@ -81,63 +78,60 @@ type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
textStyle?: CSSProperties;
};
const defaultTapFieldClassName = () =>
css({
...valueStyle,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.tableBackground,
'&[data-disabled]': {
backgroundColor: theme.formInputTextReadOnlySelection,
},
'&[data-pressed]': {
opacity: 0.5,
boxShadow: 'none',
},
'&[data-hovered]': {
boxShadow: 'none',
},
});
export function TapField({
value,
children,
className,
rightContent,
textStyle,
ref,
...props
}: TapFieldProps) {
return (
<Button
ref={ref}
bounce={false}
className={renderProps =>
cx(
defaultTapFieldClassName(),
typeof className === 'function' ? className(renderProps) : className,
)
const defaultTapFieldStyle: ComponentPropsWithoutRef<
typeof Button
>['style'] = ({ isDisabled, isPressed, isHovered }) => ({
...valueStyle,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.tableBackground,
...(isDisabled && {
backgroundColor: theme.formInputTextReadOnlySelection,
}),
...(isPressed
? {
opacity: 0.5,
boxShadow: 'none',
}
{...props}
>
{children ? (
children
) : (
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
...textStyle,
}}
>
{value}
</Text>
)}
{!props.isDisabled && rightContent}
</Button>
);
}
: {}),
...(isHovered
? {
boxShadow: 'none',
}
: {}),
});
export const TapField = forwardRef<HTMLButtonElement, TapFieldProps>(
({ value, children, rightContent, style, textStyle, ...props }, ref) => {
return (
<Button
ref={ref}
bounce={false}
style={renderProps => ({
...defaultTapFieldStyle(renderProps),
...(typeof style === 'function' ? style(renderProps) : style),
})}
{...props}
>
{children ? (
children
) : (
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
...textStyle,
}}
>
{value}
</Text>
)}
{!props.isDisabled && rightContent}
</Button>
);
},
);
TapField.displayName = 'TapField';

View File

@@ -13,7 +13,6 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import {
SvgAdd,
SvgCog,
SvgCreditCard,
SvgPiggyBank,
SvgReports,
SvgStoreFront,
@@ -26,10 +25,7 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { useDrag } from '@use-gesture/react';
import * as Platform from 'loot-core/shared/platform';
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
const COLUMN_COUNT = 3;
const PILL_HEIGHT = 15;
@@ -44,9 +40,6 @@ export const MOBILE_NAV_HEIGHT = ROW_HEIGHT + PILL_HEIGHT;
export function MobileNavTabs() {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const syncServerStatus = useSyncServerStatus();
const isUsingServer =
syncServerStatus !== 'no-server' || Platform.isPlaywright;
const [navbarState, setNavbarState] = useState<'default' | 'open' | 'hidden'>(
'default',
);
@@ -141,16 +134,6 @@ export function MobileNavTabs() {
style: navTabStyle,
Icon: SvgTuning,
},
...(isUsingServer
? [
{
name: t('Bank Sync'),
path: '/bank-sync',
style: navTabStyle,
Icon: SvgCreditCard,
},
]
: []),
{
name: t('Settings'),
path: '/settings',

View File

@@ -1,98 +0,0 @@
import { Trans } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { type AccountEntity } from 'loot-core/types/models';
import { BankSyncAccountsListItem } from './BankSyncAccountsListItem';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
type SyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai' | 'unlinked';
type BankSyncAccountsListProps = {
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
syncSourceReadable: Record<SyncProviders, string>;
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
};
export function BankSyncAccountsList({
groupedAccounts,
syncSourceReadable,
onAction,
}: BankSyncAccountsListProps) {
const allAccounts = Object.values(groupedAccounts).flat();
if (allAccounts.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>No accounts found matching your search.</Trans>
</Text>
</View>
);
}
const shouldShowProviderHeaders = Object.keys(groupedAccounts).length > 1;
return (
<div
style={{ flex: 1, overflow: 'auto', paddingBottom: MOBILE_NAV_HEIGHT }}
>
{(
Object.entries(groupedAccounts) as [SyncProviders, AccountEntity[]][]
).map(([provider, accounts]) => (
<div key={provider}>
{shouldShowProviderHeaders && (
<div
style={{
backgroundColor: theme.mobilePageBackground,
padding: '12px 16px',
borderBottom: `1px solid ${theme.tableBorder}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 40,
}}
>
<Text
style={{
fontSize: 14,
fontWeight: 600,
color: theme.pageTextLight,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
{syncSourceReadable[provider]}
</Text>
</div>
)}
{accounts.map(account => (
<BankSyncAccountsListItem
key={account.id}
account={account}
onAction={onAction}
isLinked={!!account.account_sync_source}
/>
))}
</div>
))}
</div>
);
}

View File

@@ -1,97 +0,0 @@
import { Trans } from 'react-i18next';
import { Stack } from '@actual-app/components/stack';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { tsToRelativeTime } from 'loot-core/shared/util';
import { type AccountEntity } from 'loot-core/types/models';
import { useLocale } from '@desktop-client/hooks/useLocale';
type BankSyncAccountsListItemProps = {
account: AccountEntity;
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
isLinked: boolean;
};
export function BankSyncAccountsListItem({
account,
onAction,
isLinked,
}: BankSyncAccountsListItemProps) {
const locale = useLocale();
const lastSyncString = isLinked
? tsToRelativeTime(account.last_sync, locale, {
capitalize: true,
})
: null;
return (
<Stack
data-testid="bank-sync-account"
direction="row"
align="center"
spacing={12}
style={{
backgroundColor: theme.tableBackground,
borderBottomWidth: 1,
borderBottomColor: theme.tableBorder,
borderBottomStyle: 'solid',
padding: 16,
width: '100%',
cursor: 'pointer',
}}
onClick={() => onAction(account, isLinked ? 'edit' : 'link')}
>
<Stack spacing={1} style={{ flex: 1 }}>
<Text
style={{
fontSize: 15,
fontWeight: 500,
color: theme.tableText,
}}
>
{account.name}
</Text>
{isLinked && account.bankName && (
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
}}
>
{account.bankName}
</Text>
)}
{isLinked && lastSyncString && (
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
}}
data-vrt-mask
>
<Trans>Last sync: {{ time: lastSyncString }}</Trans>
</Text>
)}
</Stack>
<span
style={{
borderRadius: 4,
padding: '5px 10px',
backgroundColor: theme.noticeBackground,
border: '1px solid ' + theme.noticeBackground,
color: theme.noticeTextDark,
fontSize: 13,
fontWeight: 500,
flexShrink: 0,
}}
>
{isLinked ? <Trans>Edit</Trans> : <Trans>Link account</Trans>}
</span>
</Stack>
);
}

View File

@@ -1,187 +0,0 @@
import React from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useParams } from 'react-router';
import { Button } from '@actual-app/components/button';
import { Stack } from '@actual-app/components/stack';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
import { BankSyncCheckboxOptions } from '@desktop-client/components/banksync/BankSyncCheckboxOptions';
import { FieldMapping } from '@desktop-client/components/banksync/FieldMapping';
import { useBankSyncAccountSettings } from '@desktop-client/components/banksync/useBankSyncAccountSettings';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { useAccount } from '@desktop-client/hooks/useAccount';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
export function MobileBankSyncAccountEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { accountId } = useParams<{ accountId: string }>();
const account = useAccount(accountId!);
const {
transactionDirection,
setTransactionDirection,
importPending,
setImportPending,
importNotes,
setImportNotes,
reimportDeleted,
setReimportDeleted,
importTransactions,
setImportTransactions,
mappings,
setMapping,
fields,
saveSettings,
} = useBankSyncAccountSettings(accountId!);
const handleCancel = () => {
navigate('/bank-sync');
};
const handleSave = async () => {
saveSettings();
navigate('/bank-sync');
};
const handleUnlink = () => {
dispatch(
pushModal({
modal: {
name: 'confirm-unlink-account',
options: {
accountName: account?.name || '',
isViewBankSyncSettings: true,
onUnlink: () => {
if (accountId) {
dispatch(unlinkAccount({ id: accountId }));
navigate('/bank-sync');
}
},
},
},
}),
);
};
if (!account) {
return (
<Page
header={
<MobilePageHeader
title={t('Account not found')}
leftContent={<MobileBackButton onPress={handleCancel} />}
/>
}
padding={0}
>
<View
style={{
flex: 1,
backgroundColor: theme.mobilePageBackground,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text>
<Trans>Account not found</Trans>
</Text>
</View>
</Page>
);
}
const mapping =
mappings.get(transactionDirection) ?? new Map<string, string>();
return (
<Page
header={
<MobilePageHeader
title={account.name}
leftContent={<MobileBackButton onPress={handleCancel} />}
/>
}
padding={0}
>
<View style={{ flex: 1, backgroundColor: theme.mobilePageBackground }}>
<View
style={{
flex: 1,
overflow: 'auto',
}}
>
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 15, marginBottom: 10 }}>
<Trans>Field mapping</Trans>
</Text>
<FieldMapping
transactionDirection={transactionDirection}
setTransactionDirection={setTransactionDirection}
fields={fields}
mapping={mapping}
setMapping={setMapping}
isMobile
/>
<Text style={{ fontSize: 15, marginTop: 20, marginBottom: 10 }}>
<Trans>Options</Trans>
</Text>
<BankSyncCheckboxOptions
importPending={importPending}
setImportPending={setImportPending}
importNotes={importNotes}
setImportNotes={setImportNotes}
reimportDeleted={reimportDeleted}
setReimportDeleted={setReimportDeleted}
importTransactions={importTransactions}
setImportTransactions={setImportTransactions}
helpMode="mobile"
/>
</View>
</View>
<View
style={{
padding: 16,
paddingTop: 12,
borderTop: `1px solid ${theme.tableBorder}`,
backgroundColor: theme.mobilePageBackground,
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Button
style={{
color: theme.errorText,
}}
onPress={handleUnlink}
>
<Trans>Unlink account</Trans>
</Button>
<Stack direction="row" style={{ gap: 10 }}>
<Button onPress={handleCancel}>
<Trans>Cancel</Trans>
</Button>
<Button variant="primary" onPress={handleSave}>
<Trans>Save</Trans>
</Button>
</Stack>
</View>
</View>
</Page>
);
}

View File

@@ -1,185 +0,0 @@
import { useMemo, useState, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
type BankSyncProviders,
type AccountEntity,
} from 'loot-core/types/models';
import { BankSyncAccountsList } from './BankSyncAccountsList';
import { Search } from '@desktop-client/components/common/Search';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
type SyncProviders = BankSyncProviders | 'unlinked';
const useSyncSourceReadable = () => {
const { t } = useTranslation();
const syncSourceReadable: Record<SyncProviders, string> = {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: t('Unlinked'),
};
return { syncSourceReadable };
};
export function MobileBankSyncPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { syncSourceReadable } = useSyncSourceReadable();
const accounts = useAccounts();
const [filter, setFilter] = useState('');
const openAccounts = useMemo(
() => accounts.filter(a => !a.closed),
[accounts],
);
const groupedAccounts = useMemo(() => {
const unsorted = openAccounts.reduce(
(acc, a) => {
const syncSource = a.account_sync_source ?? 'unlinked';
acc[syncSource] = acc[syncSource] || [];
acc[syncSource].push(a);
return acc;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
return sortedKeys.reduce(
(sorted, key) => {
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
return sorted;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
}, [openAccounts]);
const filteredGroupedAccounts = useMemo(() => {
if (!filter) return groupedAccounts;
const filterLower = filter.toLowerCase();
const filtered: Record<SyncProviders, AccountEntity[]> = {} as Record<
SyncProviders,
AccountEntity[]
>;
Object.entries(groupedAccounts).forEach(([provider, accounts]) => {
const filteredAccounts = accounts.filter(
account =>
account.name.toLowerCase().includes(filterLower) ||
account.bankName?.toLowerCase().includes(filterLower),
);
if (filteredAccounts.length > 0) {
filtered[provider as SyncProviders] = filteredAccounts;
}
});
return filtered;
}, [groupedAccounts, filter]);
const onAction = useCallback(
(account: AccountEntity, action: 'link' | 'edit') => {
switch (action) {
case 'edit':
navigate(`/bank-sync/account/${account.id}/edit`);
break;
case 'link':
dispatch(
pushModal({
modal: {
name: 'add-account',
options: { upgradingAccountId: account.id },
},
}),
);
break;
default:
break;
}
},
[navigate, dispatch],
);
const onSearchChange = useCallback((value: string) => {
setFilter(value);
}, []);
return (
<Page header={<MobilePageHeader title={t('Bank Sync')} />} padding={0}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search
placeholder={t('Filter accounts…')}
value={filter}
onChange={onSearchChange}
width="100%"
height={styles.mobileMinHeight}
style={{
backgroundColor: theme.tableBackground,
borderColor: theme.formInputBorder,
}}
/>
</View>
{openAccounts.length === 0 ? (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
paddingTop: 40,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>
To use the bank syncing features, you must first add an account.
</Trans>
</Text>
</View>
) : (
<BankSyncAccountsList
groupedAccounts={filteredGroupedAccounts}
syncSourceReadable={syncSourceReadable}
onAction={onAction}
/>
)}
</Page>
);
}

View File

@@ -1,652 +0,0 @@
import {
useMemo,
useCallback,
useState,
type ComponentProps,
createContext,
type ReactNode,
useReducer,
type Dispatch,
useContext,
useEffect,
} from 'react';
import { Form } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCheveronRight } from '@actual-app/components/icons/v1';
import { Input } from '@actual-app/components/input';
import { Label } from '@actual-app/components/label';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { currentDay } from 'loot-core/shared/months';
import {
appendDecimals,
currencyToInteger,
groupById,
type IntegerAmount,
integerToCurrency,
} from 'loot-core/shared/util';
import { type TransactionEntity } from 'loot-core/types/models';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useTransactionBatchActions } from '@desktop-client/hooks/useTransactionBatchActions';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
type TransactionFormState = {
transactions: Record<
TransactionEntity['id'],
Pick<
TransactionEntity,
| 'id'
| 'amount'
| 'payee'
| 'category'
| 'account'
| 'date'
| 'cleared'
| 'notes'
>
>;
focusedTransaction: TransactionEntity['id'] | null;
isSubmitting: boolean;
};
type TransactionFormActions =
| {
type: 'set-amount';
id: TransactionEntity['id'];
amount: TransactionEntity['amount'];
}
| {
type: 'set-payee';
id: TransactionEntity['id'];
payee: TransactionEntity['payee'] | null;
}
| {
type: 'set-category';
id: TransactionEntity['id'];
category: TransactionEntity['category'] | null;
}
| {
type: 'set-notes';
id: TransactionEntity['id'];
notes: NonNullable<TransactionEntity['notes']>;
}
| {
type: 'set-account';
account: TransactionEntity['account'] | null;
}
| {
type: 'set-date';
date: NonNullable<TransactionEntity['date']>;
}
| {
type: 'set-cleared';
cleared: NonNullable<TransactionEntity['cleared']>;
}
| {
type: 'split';
}
| {
type: 'add-split';
}
| {
type: 'focus';
id: TransactionEntity['id'];
}
| {
type: 'reset';
}
| {
type: 'submit';
};
const TransactionFormStateContext = createContext<TransactionFormState>({
transactions: {},
focusedTransaction: null,
isSubmitting: false,
});
const TransactionFormDispatchContext =
createContext<Dispatch<TransactionFormActions> | null>(null);
type TransactionFormProviderProps = {
children: ReactNode;
transactions: readonly TransactionEntity[];
};
export function TransactionFormProvider({
children,
transactions,
}: TransactionFormProviderProps) {
const unmodifiedTransactions = useMemo(() => {
return transactions.reduce(
(acc, transaction) => {
acc[transaction.id] = {
id: transaction.id,
amount: transaction.amount,
payee: transaction.payee,
category: transaction.category,
account: transaction.account,
date: transaction.date,
cleared: transaction.cleared,
notes: transaction.notes,
};
return acc;
},
{} as TransactionFormState['transactions'],
);
}, [transactions]);
const [state, dispatch] = useReducer(
(state: TransactionFormState, action: TransactionFormActions) => {
switch (action.type) {
case 'set-amount':
return {
...state,
transactions: {
...state.transactions,
[action.id]: {
...state.transactions[action.id],
amount: action.amount,
},
},
};
case 'set-payee':
return {
...state,
transactions: {
...state.transactions,
[action.id]: {
...state.transactions[action.id],
payee: action.payee,
},
},
};
case 'set-category':
return {
...state,
transactions: {
...state.transactions,
[action.id]: {
...state.transactions[action.id],
category: action.category,
},
},
};
case 'set-notes':
return {
...state,
transactions: {
...state.transactions,
[action.id]: {
...state.transactions[action.id],
notes: action.notes,
},
},
};
case 'set-account':
return {
...state,
transactions: Object.keys(state.transactions).reduce(
(acc, id) => ({
...acc,
[id]: {
...state.transactions[id],
account: action.account,
},
}),
{} as TransactionFormState['transactions'],
),
};
case 'set-date':
return {
...state,
transactions: Object.keys(state.transactions).reduce(
(acc, id) => ({
...acc,
[id]: {
...state.transactions[id],
date: action.date,
},
}),
{} as TransactionFormState['transactions'],
),
};
case 'set-cleared':
return {
...state,
transactions: Object.keys(state.transactions).reduce(
(acc, id) => ({
...acc,
[id]: {
...state.transactions[id],
cleared: action.cleared,
},
}),
{} as TransactionFormState['transactions'],
),
};
case 'focus':
return {
...state,
focusedTransaction: action.id,
};
case 'reset':
return {
...state,
transactions: unmodifiedTransactions,
isSubmitting: false,
};
case 'submit':
return {
...state,
isSubmitting: true,
};
default:
return state;
}
},
{
transactions: unmodifiedTransactions,
focusedTransaction: null,
isSubmitting: false,
} as TransactionFormState,
);
useEffect(() => {
dispatch({ type: 'reset' });
}, [unmodifiedTransactions]);
const { onBatchSave } = useTransactionBatchActions();
useEffect(() => {
async function saveTransactions() {
const transactionsToSave = Object.values(state.transactions);
await onBatchSave({
transactions: transactionsToSave,
onSuccess: () => {
dispatch({ type: 'reset' });
},
});
}
if (state.isSubmitting) {
saveTransactions().catch(console.error);
}
}, [state.isSubmitting, state.transactions, onBatchSave]);
return (
<TransactionFormStateContext.Provider value={state}>
<TransactionFormDispatchContext.Provider value={dispatch}>
{children}
</TransactionFormDispatchContext.Provider>
</TransactionFormStateContext.Provider>
);
}
export function useTransactionFormState() {
const context = useContext(TransactionFormStateContext);
if (context === null) {
throw new Error(
'useTransactionFormState must be used within a TransactionFormProvider',
);
}
return context;
}
export function useTransactionFormDispatch() {
const context = useContext(TransactionFormDispatchContext);
if (context === null) {
throw new Error(
'useTransactionFormDispatch must be used within a TransactionFormProvider',
);
}
return context;
}
type TransactionFormProps = {
transactions: ReadonlyArray<TransactionEntity>;
};
export function TransactionForm({ transactions }: TransactionFormProps) {
const [transaction] = transactions;
const { t } = useTranslation();
const dispatch = useDispatch();
const lastTransaction = useSelector(
state => state.transactions.lastTransaction,
);
const payees = usePayees();
const payeesById = useMemo(() => groupById(payees), [payees]);
const getPayeeName = useCallback(
(payeeId: TransactionEntity['payee']) => {
if (!payeeId) {
return null;
}
return payeesById[payeeId]?.name ?? null;
},
[payeesById],
);
const { list: categories } = useCategories();
const categoriesById = useMemo(() => groupById(categories), [categories]);
const getCategoryName = useCallback(
(categoryId: TransactionEntity['category']) => {
if (!categoryId) {
return null;
}
return categoriesById[categoryId]?.name ?? null;
},
[categoriesById],
);
const accounts = useAccounts();
const accountsById = useMemo(() => groupById(accounts), [accounts]);
const getAccountName = useCallback(
(accountId: TransactionEntity['account']) => {
if (!accountId) {
return null;
}
return accountsById[accountId]?.name ?? null;
},
[accountsById],
);
const transactionFormState = useTransactionFormState();
const getTransactionState = useCallback(
(id: TransactionEntity['id']) => {
if (!id) {
return null;
}
return transactionFormState.transactions[id] ?? null;
},
[transactionFormState.transactions],
);
const transactionFormDispatch = useTransactionFormDispatch();
const onSelectPayee = (id: TransactionEntity['id']) => {
dispatch(
pushModal({
modal: {
name: 'payee-autocomplete',
options: {
onSelect: payeeId =>
transactionFormDispatch({
type: 'set-payee',
id,
payee: payeeId,
}),
},
},
}),
);
};
const onSelectCategory = (id: TransactionEntity['id']) => {
dispatch(
pushModal({
modal: {
name: 'category-autocomplete',
options: {
onSelect: categoryId =>
transactionFormDispatch({
type: 'set-category',
id,
category: categoryId,
}),
},
},
}),
);
};
const onChangeNotes = (id: TransactionEntity['id'], notes: string) => {
transactionFormDispatch({ type: 'set-notes', id, notes });
};
const onSelectAccount = () => {
dispatch(
pushModal({
modal: {
name: 'account-autocomplete',
options: {
onSelect: accountId =>
transactionFormDispatch({
type: 'set-account',
account: accountId,
}),
},
},
}),
);
};
const onSelectDate = (date: string) => {
transactionFormDispatch({ type: 'set-date', date });
};
const onUpdateAmount = (
id: TransactionEntity['id'],
amount: IntegerAmount,
) => {
console.log('onUpdateAmount', amount);
transactionFormDispatch({ type: 'set-amount', id, amount });
};
const onToggleCleared = (isCleared: boolean) => {
transactionFormDispatch({
type: 'set-cleared',
cleared: isCleared,
});
};
if (!transaction) {
return null;
}
return (
<Form data-testid="transaction-form">
<View style={{ padding: styles.mobileEditingPadding, gap: 40 }}>
<View>
<TransactionAmount
transaction={transaction}
onUpdate={amount => onUpdateAmount(transaction.id, amount)}
/>
</View>
<View
className={css({
gap: 20,
'& .view': {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
},
'& button,input': {
height: styles.mobileMinHeight,
textAlign: 'center',
...styles.mediumText,
},
})}
>
<View>
<Label title={t('Payee')} />
<Button
variant="bare"
onClick={() => onSelectPayee(transaction.id)}
>
<View>
{getPayeeName(getTransactionState(transaction.id)?.payee)}
<SvgCheveronRight
style={{
flexShrink: 0,
color: theme.mobileHeaderTextSubdued,
}}
width="14"
height="14"
/>
</View>
</Button>
</View>
<View>
<Label title={t('Category')} />
<Button
variant="bare"
onClick={() => onSelectCategory(transaction.id)}
>
<View>
{getCategoryName(getTransactionState(transaction.id)?.category)}
<SvgCheveronRight
style={{
flexShrink: 0,
color: theme.mobileHeaderTextSubdued,
}}
width="14"
height="14"
/>
</View>
</Button>
</View>
<View>
<Label title={t('Account')} />
<Button variant="bare" onClick={onSelectAccount}>
<View>
{getAccountName(getTransactionState(transaction.id)?.account)}
<SvgCheveronRight
style={{
flexShrink: 0,
color: theme.mobileHeaderTextSubdued,
}}
width="14"
height="14"
/>
</View>
</Button>
</View>
<View>
<Label title={t('Date')} />
<Input
type="date"
value={getTransactionState(transaction.id)?.date ?? currentDay()}
onChangeValue={onSelectDate}
/>
</View>
<View>
<Label title={t('Cleared')} />
<FormToggle
id="Cleared"
isOn={getTransactionState(transaction.id)?.cleared ?? false}
onToggle={onToggleCleared}
/>
</View>
<View>
<Label title={t('Notes')} />
<Input
value={getTransactionState(transaction.id)?.notes ?? ''}
onChangeValue={notes => onChangeNotes(transaction.id, notes)}
/>
</View>
</View>
</View>
</Form>
);
}
type TransactionAmountProps = {
transaction: TransactionEntity;
onUpdate: (amount: IntegerAmount) => void;
};
function TransactionAmount({ transaction, onUpdate }: TransactionAmountProps) {
const { t } = useTranslation();
const format = useFormat();
const [value, setValue] = useState(format(transaction.amount, 'financial'));
const onChangeValue = useCallback(
(value: string) => {
setValue(appendDecimals(value));
},
[setValue],
);
const _onUpdate = useCallback(
(value: string) => {
const parsedAmount = currencyToInteger(value) || 0;
setValue(
parsedAmount !== 0
? format(parsedAmount, 'financial')
: format(0, 'financial'),
);
if (parsedAmount !== transaction.amount) {
onUpdate(parsedAmount);
}
},
[format],
);
const amountInteger = value ? (currencyToInteger(value) ?? 0) : 0;
return (
<View style={{ alignItems: 'center', gap: 10 }}>
<Label
style={{ textAlign: 'center', ...styles.mediumText }}
title={t('Amount')}
/>
<Input
type="number"
style={{
height: '15vh',
width: '100vw',
textAlign: 'center',
...styles.veryLargeText,
color: amountInteger > 0 ? theme.noticeText : theme.errorText,
}}
value={value || ''}
onChangeValue={onChangeValue}
onUpdate={_onUpdate}
/>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Text style={styles.largeText}>-</Text>
<FormToggle
id="TransactionAmountSign"
isOn={amountInteger > 0}
isDisabled={amountInteger === 0}
onToggle={() => _onUpdate(integerToCurrency(-amountInteger))}
/>
<Text style={styles.largeText}>+</Text>
</View>
</View>
);
}
type FormToggleProps = ComponentProps<typeof Toggle>;
function FormToggle({ className, ...restProps }: FormToggleProps) {
return (
<Toggle
className={css({
'& [data-toggle-container]': {
width: 50,
height: 24,
},
'& [data-toggle]': {
width: 20,
height: 20,
},
})}
{...restProps}
/>
);
}

View File

@@ -1,315 +0,0 @@
import {
type ReactNode,
type Ref,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SvgSplit } from '@actual-app/components/icons/v0';
import { SvgAdd, SvgPiggyBank } from '@actual-app/components/icons/v1';
import { SvgPencilWriteAlternate } from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { q } from 'loot-core/shared/query';
import { groupById, integerToCurrency } from 'loot-core/shared/util';
import { type TransactionEntity } from 'loot-core/types/models';
import {
TransactionForm,
TransactionFormProvider,
useTransactionFormDispatch,
useTransactionFormState,
} from './TransactionForm';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
export function TransactionFormPage() {
const { t } = useTranslation();
const { transactionId } = useParams();
const accounts = useAccounts();
const accountsById = useMemo(() => groupById(accounts), [accounts]);
const payees = usePayees();
const payeesById = useMemo(() => groupById(payees), [payees]);
// const getAccount = useCallback(
// trans => {
// return trans?.account && accountsById?.[trans.account];
// },
// [accountsById],
// );
const getPayee = useCallback(
(trans: TransactionEntity) => {
return trans?.payee ? payeesById?.[trans.payee] : null;
},
[payeesById],
);
const getTransferAccount = useCallback(
(trans: TransactionEntity) => {
const payee = trans && getPayee(trans);
return payee?.transfer_acct ? accountsById?.[payee.transfer_acct] : null;
},
[accountsById, getPayee],
);
const transactionsQuery = useMemo(
() =>
q('transactions')
.filter({ id: transactionId })
.select('*')
.options({ splits: 'all' }),
[transactionId],
);
const { transactions, isLoading } = useTransactions({
query: transactionsQuery,
});
const [transaction] = transactions;
const title = getPrettyPayee({
t,
transaction,
payee: getPayee(transaction),
transferAccount: getTransferAccount(transaction),
});
return (
<TransactionFormProvider transactions={transactions}>
<Page
header={
<MobilePageHeader
title={
!transaction?.payee
? !transactionId
? t('New Transaction')
: t('Transaction')
: title
}
leftContent={<MobileBackButton />}
/>
}
footer={<Footer transactions={transactions} />}
padding={0}
>
{isLoading ? (
<AnimatedLoading width={15} height={15} />
) : (
<TransactionForm transactions={transactions} />
)}
</Page>
</TransactionFormProvider>
);
}
type FooterProps = {
transactions: ReadonlyArray<TransactionEntity>;
};
function Footer({ transactions }: FooterProps) {
const { transactionId } = useParams();
const isAdding = !transactionId;
const [transaction, ...childTransactions] = transactions;
const emptySplitTransaction = childTransactions.find(t => t.amount === 0);
const transactionFormDispatch = useTransactionFormDispatch();
const onClickRemainingSplit = () => {
if (!transaction) {
return;
}
if (childTransactions.length === 0) {
transactionFormDispatch({ type: 'split' });
} else {
if (!emptySplitTransaction) {
transactionFormDispatch({ type: 'add-split' });
} else {
transactionFormDispatch({
type: 'focus',
id: emptySplitTransaction.id,
});
}
}
};
const dispatch = useDispatch();
const onSelectAccount = () => {
dispatch(
pushModal({
modal: {
name: 'account-autocomplete',
options: {
onSelect: (accountId: string) => {
transactionFormDispatch({
type: 'set-account',
account: accountId,
});
},
},
},
}),
);
};
const navigate = useNavigate();
const onSubmit = () => {
transactionFormDispatch({ type: 'submit' });
navigate(-1);
};
return (
<View
data-testid="transaction-form-footer"
style={{
padding: `10px ${styles.mobileEditingPadding}px`,
backgroundColor: theme.tableHeaderBackground,
borderTopWidth: 1,
borderColor: theme.tableBorder,
}}
>
{transaction?.error?.type === 'SplitTransactionError' ? (
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
onPress={onClickRemainingSplit}
>
<SvgSplit width={17} height={17} />
<Text
style={{
...styles.text,
marginLeft: 6,
}}
>
{!emptySplitTransaction ? (
<Trans>
Add new split -{' '}
{{
amount: integerToCurrency(
transaction.amount > 0
? transaction.error.difference
: -transaction.error.difference,
),
}}{' '}
left
</Trans>
) : (
<Trans>
Amount left:{' '}
{{
amount: integerToCurrency(
transaction.amount > 0
? transaction.error.difference
: -transaction.error.difference,
),
}}
</Trans>
)}
</Text>
</Button>
) : !transaction?.account ? (
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
onPress={onSelectAccount}
>
<SvgPiggyBank width={17} height={17} />
<Text
style={{
...styles.text,
marginLeft: 6,
}}
>
<Trans>Select account</Trans>
</Text>
</Button>
) : isAdding ? (
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
// onPress={onSubmit}
>
<SvgAdd width={17} height={17} />
<Text
style={{
...styles.text,
marginLeft: 5,
}}
>
<Trans>Add transaction</Trans>
</Text>
</Button>
) : (
<Button
variant="primary"
style={{ height: styles.mobileMinHeight }}
onPress={onSubmit}
>
<SvgPencilWriteAlternate width={16} height={16} />
<Text
style={{
...styles.text,
marginLeft: 6,
}}
>
<Trans>Save changes</Trans>
</Text>
</Button>
)}
</View>
);
}
function AutoSizingInput({
children,
}: {
children: ({ ref }: { ref: Ref<HTMLInputElement> }) => ReactNode;
}) {
const textRef = useRef<HTMLSpanElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (textRef.current && inputRef.current) {
const spanWidth = textRef.current.offsetWidth;
inputRef.current.style.width = `${spanWidth + 2}px`; // +2 for caret/padding
}
}, [inputRef.current?.value]);
return (
<>
{children({ ref: inputRef })}
{/* Hidden span for measuring text width */}
<Text
ref={textRef}
style={{
position: 'absolute',
visibility: 'hidden',
...styles.veryLargeText,
padding: '0 5px',
}}
>
{inputRef.current?.value || ''}
</Text>
</>
);
}

View File

@@ -11,8 +11,6 @@ import {
ListBoxSection,
Header,
Collection,
Virtualizer,
ListLayout,
} from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
@@ -160,76 +158,64 @@ export function TransactionList({
aria-label={t('Loading transactions...')}
/>
)}
<View style={{ flex: 1, overflow: 'auto' }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: ROW_HEIGHT,
padding: 0,
}}
>
<ListBox
aria-label={t('Transaction list')}
selectionMode={
selectedTransactions.size > 0 ? 'multiple' : 'single'
}
selectedKeys={selectedTransactions}
dependencies={[selectedTransactions]}
renderEmptyState={() =>
!isLoading && (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text style={{ fontSize: 15 }}>
<Trans>No transactions</Trans>
</Text>
</View>
)
}
items={sections}
>
{section => (
<ListBoxSection>
<Header
style={{
...styles.smallText,
backgroundColor: theme.pageBackground,
color: theme.tableHeaderText,
display: 'flex',
justifyContent: 'center',
paddingBottom: 4,
paddingTop: 4,
position: 'sticky',
top: '0',
width: '100%',
zIndex: 10,
}}
>
{monthUtils.format(section.date, 'MMMM dd, yyyy', locale)}
</Header>
<Collection
items={section.transactions.filter(
t => !isPreviewId(t.id) || !t.is_child,
)}
>
{transaction => (
<TransactionListItem
key={transaction.id}
value={transaction}
onPress={trans => onTransactionPress(trans)}
onLongPress={trans => onTransactionPress(trans, true)}
/>
)}
</Collection>
</ListBoxSection>
)}
</ListBox>
</Virtualizer>
</View>
<ListBox
aria-label={t('Transaction list')}
selectionMode={selectedTransactions.size > 0 ? 'multiple' : 'single'}
selectedKeys={selectedTransactions}
dependencies={[selectedTransactions]}
renderEmptyState={() =>
!isLoading && (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text style={{ fontSize: 15 }}>
<Trans>No transactions</Trans>
</Text>
</View>
)
}
items={sections}
>
{section => (
<ListBoxSection>
<Header
style={{
...styles.smallText,
backgroundColor: theme.pageBackground,
color: theme.tableHeaderText,
display: 'flex',
justifyContent: 'center',
paddingBottom: 4,
paddingTop: 4,
position: 'sticky',
top: '0',
width: '100%',
zIndex: 10,
}}
>
{monthUtils.format(section.date, 'MMMM dd, yyyy', locale)}
</Header>
<Collection
items={section.transactions.filter(
t => !isPreviewId(t.id) || !t.is_child,
)}
>
{transaction => (
<TransactionListItem
key={transaction.id}
value={transaction}
onPress={trans => onTransactionPress(trans)}
onLongPress={trans => onTransactionPress(trans, true)}
/>
)}
</Collection>
</ListBoxSection>
)}
</ListBox>
{isLoadingMore && (
<Loading
@@ -632,27 +618,22 @@ function SelectedTransactionsFloatingActionBar({
},
});
} else if (type === 'transfer') {
onSetTransfer?.({
ids: selectedTransactionsArray,
payees,
onSuccess: ids =>
showUndoNotification({
message: t(
'Successfully marked {{count}} transactions as transfer.',
{
count: ids.length,
},
),
}),
});
onSetTransfer?.(selectedTransactionsArray, payees, ids =>
showUndoNotification({
message: t(
'Successfully marked {{count}} transactions as transfer.',
{
count: ids.length,
},
),
}),
);
} else if (type === 'merge') {
onMerge?.({
ids: selectedTransactionsArray,
onSuccess: () =>
showUndoNotification({
message: t('Successfully merged transactions'),
}),
});
onMerge?.(selectedTransactionsArray, () =>
showUndoNotification({
message: t('Successfully merged transactions'),
}),
);
}
setIsMoreOptionsMenuOpen(false);
}}

View File

@@ -8,9 +8,9 @@ import {
type GetPrettyPayeeProps = {
t: ReturnType<typeof useTranslation>['t'];
transaction?: TransactionEntity | null;
payee?: PayeeEntity | null;
transferAccount?: AccountEntity | null;
transaction?: TransactionEntity;
payee?: PayeeEntity;
transferAccount?: AccountEntity;
};
export function getPrettyPayee({

View File

@@ -1,8 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { styles } from '@actual-app/components/styles';
import { View } from '@actual-app/components/view';
@@ -20,7 +19,6 @@ import {
TapField,
} from '@desktop-client/components/mobile/MobileForms';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import {
type Modal as ModalType,
pushModal,
@@ -56,7 +54,7 @@ export function CoverModal({
const [fromCategoryId, setFromCategoryId] = useState<string | null>(null);
const dispatch = useDispatch();
const openCategoryModal = useCallback(() => {
const onCategoryClick = useCallback(() => {
dispatch(
pushModal({
modal: {
@@ -81,13 +79,6 @@ export function CoverModal({
const fromCategory = categories.find(c => c.id === fromCategoryId);
const isInitialMount = useInitialMount();
useEffect(() => {
if (isInitialMount) {
openCategoryModal();
}
}, [isInitialMount, openCategoryModal]);
return (
<Modal name="cover">
{({ state: { close } }) => (
@@ -98,13 +89,7 @@ export function CoverModal({
/>
<View>
<FieldLabel title={t('Cover from a category:')} />
<InitialFocus>
<TapField
autoFocus
value={fromCategory?.name}
onPress={openCategoryModal}
/>
</InitialFocus>
<TapField value={fromCategory?.name} onPress={onCategoryClick} />
</View>
<View

View File

@@ -7,9 +7,9 @@ import React, {
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { Checkbox } from './index';
import { Checkbox } from '@desktop-client/components/forms';
type LabeledCheckboxProps = {
type CheckboxOptionProps = {
id: string;
checked?: ComponentProps<typeof Checkbox>['checked'];
disabled?: ComponentProps<typeof Checkbox>['disabled'];
@@ -18,14 +18,14 @@ type LabeledCheckboxProps = {
style?: CSSProperties;
};
export function LabeledCheckbox({
export function CheckboxOption({
id,
checked,
disabled,
onChange,
children,
style,
}: LabeledCheckboxProps) {
}: CheckboxOptionProps) {
return (
<View
style={{

View File

@@ -19,6 +19,7 @@ import { send } from 'loot-core/platform/client/fetch';
import { type ParseFileOptions } from 'loot-core/server/transactions/import/parse-file';
import { amountToInteger } from 'loot-core/shared/util';
import { CheckboxOption } from './CheckboxOption';
import { DateFormatSelect } from './DateFormatSelect';
import { FieldMappings } from './FieldMappings';
import { InOutOption } from './InOutOption';
@@ -46,7 +47,6 @@ import {
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { SectionLabel } from '@desktop-client/components/forms';
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
import {
TableHeader,
TableWithNavigator,
@@ -901,7 +901,7 @@ export function ImportTransactionsModal({
)}
{isOfxFile(filetype) && (
<LabeledCheckbox
<CheckboxOption
id="form_fallback_missing_payee"
checked={fallbackMissingPayeeToMemo}
onChange={() => {
@@ -909,11 +909,11 @@ export function ImportTransactionsModal({
}}
>
<Trans>Use Memo as a fallback for empty Payees</Trans>
</LabeledCheckbox>
</CheckboxOption>
)}
{filetype !== 'csv' && (
<LabeledCheckbox
<CheckboxOption
id="import_notes"
checked={importNotes}
onChange={() => {
@@ -921,11 +921,11 @@ export function ImportTransactionsModal({
}}
>
<Trans>Import notes from file</Trans>
</LabeledCheckbox>
</CheckboxOption>
)}
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
<LabeledCheckbox
<CheckboxOption
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
@@ -933,7 +933,7 @@ export function ImportTransactionsModal({
}}
>
<Trans>Merge with existing transactions</Trans>
</LabeledCheckbox>
</CheckboxOption>
)}
{/*Import Options */}
@@ -1005,7 +1005,7 @@ export function ImportTransactionsModal({
style={{ width: 50 }}
/>
</label>
<LabeledCheckbox
<CheckboxOption
id="form_has_header"
checked={hasHeaderRow}
onChange={() => {
@@ -1013,8 +1013,8 @@ export function ImportTransactionsModal({
}}
>
<Trans>File has header row</Trans>
</LabeledCheckbox>
<LabeledCheckbox
</CheckboxOption>
<CheckboxOption
id="clear_on_import"
checked={clearOnImport}
onChange={() => {
@@ -1022,8 +1022,8 @@ export function ImportTransactionsModal({
}}
>
<Trans>Clear transactions on import</Trans>
</LabeledCheckbox>
<LabeledCheckbox
</CheckboxOption>
<CheckboxOption
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
@@ -1031,7 +1031,7 @@ export function ImportTransactionsModal({
}}
>
<Trans>Merge with existing transactions</Trans>
</LabeledCheckbox>
</CheckboxOption>
</View>
)}
@@ -1039,7 +1039,7 @@ export function ImportTransactionsModal({
<View style={{ marginRight: 10, gap: 5 }}>
<SectionLabel title={t('AMOUNT OPTIONS')} />
<LabeledCheckbox
<CheckboxOption
id="form_flip"
checked={flipAmount}
onChange={() => {
@@ -1047,7 +1047,7 @@ export function ImportTransactionsModal({
}}
>
<Trans>Flip amount</Trans>
</LabeledCheckbox>
</CheckboxOption>
<MultiplierOption
multiplierEnabled={multiplierEnabled}
multiplierAmount={multiplierAmount}
@@ -1059,7 +1059,7 @@ export function ImportTransactionsModal({
/>
{filetype === 'csv' && (
<>
<LabeledCheckbox
<CheckboxOption
id="form_split"
checked={splitMode}
onChange={() => {
@@ -1069,7 +1069,7 @@ export function ImportTransactionsModal({
<Trans>
Split amount into separate inflow/outflow columns
</Trans>
</LabeledCheckbox>
</CheckboxOption>
<InOutOption
inOutMode={inOutMode}
outValue={outValue}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { View } from '@actual-app/components/view';
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
import { CheckboxOption } from './CheckboxOption';
type InOutOptionProps = {
inOutMode: boolean;
@@ -25,7 +25,7 @@ export function InOutOption({
return (
<View style={{ flexDirection: 'row', gap: 10, height: 28 }}>
<LabeledCheckbox
<CheckboxOption
id="form_inOut"
checked={inOutMode}
disabled={disabled}
@@ -34,7 +34,7 @@ export function InOutOption({
{inOutMode
? t('In/Out outflow value')
: t('Select column to specify if amount goes in/out')}
</LabeledCheckbox>
</CheckboxOption>
{inOutMode && (
<Input
type="text"

View File

@@ -4,12 +4,12 @@ import { Trans, useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { View } from '@actual-app/components/view';
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
import { CheckboxOption } from './CheckboxOption';
type MultiplierOptionProps = {
multiplierEnabled: boolean;
multiplierAmount: string;
onToggle: ComponentProps<typeof LabeledCheckbox>['onChange'];
onToggle: ComponentProps<typeof CheckboxOption>['onChange'];
onChangeAmount: (newValue: string) => void;
};
@@ -22,13 +22,13 @@ export function MultiplierOption({
const { t } = useTranslation();
return (
<View style={{ flexDirection: 'row', gap: 10, height: 28 }}>
<LabeledCheckbox
<CheckboxOption
id="add_multiplier"
checked={multiplierEnabled}
onChange={onToggle}
>
<Trans>Multiply amount</Trans>
</LabeledCheckbox>
</CheckboxOption>
<Input
type="text"
style={{ display: multiplierEnabled ? 'inherit' : 'none' }}

View File

@@ -446,25 +446,28 @@ export function KeyboardShortcutModal() {
padding: '0 16px 16px 16px',
}}
>
<InitialFocus>
<Search
value={searchText}
isInModal
onChange={text => {
setSearchText(text);
// Clear category selection when searching to search all shortcuts
if (text && selectedCategoryId) {
setSelectedCategoryId(null);
}
}}
placeholder={t('Search shortcuts')}
width="100%"
style={{
backgroundColor: theme.tableBackground,
borderColor: theme.formInputBorder,
marginBottom: 10,
}}
/>
<InitialFocus<HTMLInputElement>>
{ref => (
<Search
inputRef={ref}
value={searchText}
isInModal
onChange={text => {
setSearchText(text);
// Clear category selection when searching to search all shortcuts
if (text && selectedCategoryId) {
setSelectedCategoryId(null);
}
}}
placeholder={t('Search shortcuts')}
width="100%"
style={{
backgroundColor: theme.tableBackground,
borderColor: theme.formInputBorder,
marginBottom: 10,
}}
/>
)}
</InitialFocus>
<View
style={{

View File

@@ -2,7 +2,6 @@ import {
useState,
useRef,
useCallback,
useMemo,
Suspense,
lazy,
type ChangeEvent,
@@ -93,8 +92,12 @@ function FormulaInner({ widget }: FormulaInnerProps) {
error,
} = useFormulaExecution(formula, queriesRef.current, queriesVersion);
const colorVariables = useMemo(
() => ({
// Execute color formula with access to main result via named expression
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
queriesRef.current,
queriesVersion,
{
RESULT: result ?? 0,
...Object.entries(themeColors).reduce(
(acc, [key, value]) => {
@@ -103,14 +106,7 @@ function FormulaInner({ widget }: FormulaInnerProps) {
},
{} as Record<string, string>,
),
}),
[result, themeColors],
);
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
queriesRef.current,
queriesVersion,
colorVariables,
},
);
const handleQueriesChange = useCallback(
@@ -391,7 +387,16 @@ function FormulaInner({ widget }: FormulaInnerProps) {
<Suspense fallback={<div style={{ height: 32 }} />}>
<FormulaEditor
value={colorFormula}
variables={colorVariables}
variables={{
RESULT: result ?? 0,
...Object.entries(themeColors).reduce(
(acc, [key, value]) => {
acc[`theme_${key}`] = value;
return acc;
},
{} as Record<string, string>,
),
}}
onChange={setColorFormula}
mode="query"
queries={queriesRef.current}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { View } from '@actual-app/components/view';
@@ -42,8 +42,12 @@ export function FormulaCard({
meta?.queriesVersion,
);
const colorVariables = useMemo(
() => ({
// Execute color formula with access to main result via named expression
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
meta?.queries || {},
meta?.queriesVersion,
{
RESULT: result ?? 0,
...Object.entries(themeColors).reduce(
(acc, [key, value]) => {
@@ -52,14 +56,7 @@ export function FormulaCard({
},
{} as Record<string, string>,
),
}),
[result, themeColors],
);
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
meta?.queries || {},
meta?.queriesVersion,
colorVariables,
},
);
// Determine the custom color from color formula result

View File

@@ -8,5 +8,3 @@ export { MobileRuleEditPage as RuleEdit } from '../mobile/rules/MobileRuleEditPa
export { CategoryPage as Category } from '../mobile/budget/CategoryPage';
export { MobilePayeesPage as Payees } from '../mobile/payees/MobilePayeesPage';
export { MobileBankSyncPage as BankSync } from '../mobile/banksync/MobileBankSyncPage';
export { MobileBankSyncAccountEditPage as BankSyncAccountEdit } from '../mobile/banksync/MobileBankSyncAccountEditPage';

View File

@@ -10,7 +10,6 @@ export { Account } from '../accounts/Account';
export { ManageRulesPage as Rules } from '../ManageRulesPage';
export { ManageRulesPage as RuleEdit } from '../ManageRulesPage';
export { ManagePayeesPage as Payees } from '../payees/ManagePayeesPage';
export { BankSync } from '../banksync';
export { UserDirectoryPage } from '../admin/UserDirectory/UserDirectoryPage';

View File

@@ -51,7 +51,7 @@ export function ScheduleLink({
statuses,
} = useSchedules({ query: schedulesQuery });
const searchInput = useRef<HTMLInputElement | null>(null);
const searchInput = useRef(null);
async function onSelect(scheduleId: string) {
if (ids?.length > 0) {
@@ -105,20 +105,15 @@ export function ScheduleLink({
{ count: ids?.length ?? 0 },
)}
</Text>
<InitialFocus<HTMLInputElement>>
{node => (
<Search
ref={r => {
node.current = r;
searchInput.current = r;
}}
isInModal
width={300}
placeholder={t('Filter schedules…')}
value={filter}
onChange={setFilter}
/>
)}
<InitialFocus>
<Search
inputRef={searchInput}
isInModal
width={300}
placeholder={t('Filter schedules…')}
value={filter}
onChange={setFilter}
/>
</InitialFocus>
{ids.length === 1 && (
<Button

View File

@@ -1,7 +1,6 @@
// @ts-strict-ignore
import React, {
forwardRef,
type Ref,
useEffect,
useImperativeHandle,
useLayoutEffect,
@@ -10,6 +9,7 @@ import React, {
useState,
type ComponentProps,
type KeyboardEvent,
type RefObject,
} from 'react';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
@@ -45,7 +45,6 @@ import DateSelectRight from './DateSelect.right.png';
import { InputField } from '@desktop-client/components/mobile/MobileForms';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
const pickerStyles: CSSProperties = {
@@ -235,7 +234,7 @@ type DateSelectProps = {
embedded?: boolean;
dateFormat: string;
openOnFocus?: boolean;
ref?: Ref<HTMLInputElement>;
inputRef?: RefObject<HTMLInputElement>;
shouldSaveFromKey?: (e: KeyboardEvent<HTMLInputElement>) => boolean;
clearOnBlur?: boolean;
onUpdate?: (selectedDate: string) => void;
@@ -251,7 +250,7 @@ function DateSelectDesktop({
embedded,
dateFormat = 'yyyy-MM-dd',
openOnFocus = true,
ref,
inputRef: originalInputRef,
shouldSaveFromKey = defaultShouldSaveFromKey,
clearOnBlur = true,
onUpdate,
@@ -270,8 +269,13 @@ function DateSelectDesktop({
const picker = useRef(null);
const [value, setValue] = useState(parsedDefaultValue);
const [open, setOpen] = useState(embedded || isOpen || false);
const innerRef = useRef<HTMLInputElement | null>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(innerRef, ref);
const inputRef = useRef(null);
useLayoutEffect(() => {
if (originalInputRef) {
originalInputRef.current = inputRef.current;
}
}, []);
// This is confusing, so let me explain: `selectedValue` should be
// renamed to `currentValue`. It represents the current highlighted
@@ -362,8 +366,8 @@ function DateSelectDesktop({
onKeyDown?.(e);
} else if (!open) {
setOpen(true);
if (innerRef.current) {
innerRef.current.setSelectionRange(0, 10000);
if (inputRef.current) {
inputRef.current.setSelectionRange(0, 10000);
}
}
}
@@ -379,7 +383,7 @@ function DateSelectDesktop({
return (
<Popover
triggerRef={innerRef}
triggerRef={inputRef}
placement="bottom start"
offset={2}
isOpen={open}
@@ -398,7 +402,7 @@ function DateSelectDesktop({
<Input
id={id}
{...inputProps}
ref={mergedRef}
ref={inputRef}
value={value}
onPointerUp={() => {
if (!embedded) {

View File

@@ -83,7 +83,7 @@ function GlobalFeatureToggle({
error,
children,
}: GlobalFeatureToggleProps) {
const [enabled, setEnabled] = useSyncedPref(prefName);
const [enabled, setEnabled] = useSyncedPref(prefName, { isGlobal: true });
return (
<label style={{ display: 'flex' }}>

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