Compare commits

...

45 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
58b57aefe1 arial-labels 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
e25683f130 Fix typecheck error 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
496c76c7f9 vrt 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b7d4964539 Add pillBackgroundHover 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
7479df359a Fix OpButton hover 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b1b14d0813 VRT 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b710b9675e Remove title 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
f8fb4a9ba7 Release notes 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
9f738956d7 React Aria Button on autocomplete and filters 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
dc9ce974a5 VRT 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
27974c63fd VRT 2024-07-15 12:56:46 -07:00
Matiss Janis Aboltins
f81c452ba5 ♻️ (tooltip) refactoring to react-aria (vol.10) (#2828) 2024-07-15 18:18:40 +01:00
Robert Dyer
7072674111 Add help modal for keyboard shortcuts. (#3033)
* Add help modal for keyboard shortcuts.

* add release note

* fix linter

* fix typecheck

* fix linter

* use component syntax for GroupHeading

* use component syntax for Shortcut

* fix linter

* use component syntax for KeyIcon

* refactor to support different dialogs

* show different help based on current page

* fix linter

* reword help

* capitalize letters

* show cmd on mac

* stop event propagation

* dont show if a modal is already open

* remove unused import

* rename modal

* move where location check happens

* dont stop event

* allow typing '?' in inputs

* better filter

* extract function

* fix linter

* dont show if filter popover is visible

* fix linter

* fix wrong shortcut, support SHIFT

* fix linter

* fix conditional
2024-07-15 08:06:09 -07:00
Michael Clark
16e887c917 :electron: Build electron for Mac Universal arch (#3015) 2024-07-13 22:03:57 +01:00
Tom Crasset
572033debe migrate BudgetList to typescript (#3026) 2024-07-13 18:02:01 +01:00
Michael Clark
b85f9102ce :electron: Convert window-state.js ➡️ window-state.ts (#3027) 2024-07-13 13:05:42 +01:00
Yusef Ouda
942aebedd0 fixes alignment of notifications on mobile to be centered (#3046) 2024-07-13 12:45:51 +01:00
Chris Tozlowski
32d830440a Fix the position of the separator in the operator menu when editing a rule (#3037)
* Fix line separator position in operations menu

* release note
2024-07-12 08:20:44 -07:00
youngcw
4575616961 [Goals]: don't reset goals when using "apply template" (#3011)
* fix apply budget

* un change

* note

* lint
2024-07-12 07:05:05 -07:00
DJ Mountney
4a0e2ea306 Remove Trafico workflow in favour of our new GitHub bot (#3023)
* Remove Trafico workflow in favour of our new GitHub bot

* Add release note
2024-07-11 07:30:12 -07:00
Robert Dyer
14ec9a9089 Dim hidden income category rows (#3032)
* Dim hidden income category rows

* add release note

* fix linter
2024-07-11 05:47:12 -07:00
Matt Fiddaman
e91b4070aa Add mergePayees method to the API (#3028) 2024-07-11 10:01:41 +01:00
Robert Dyer
6dd34b0c63 Perform bank sync in same order as accounts shown in sidebar. (#3029) 2024-07-11 09:32:57 +01:00
Robert Dyer
ab4639f48f API: add getBudgets() methods to list all budgets in the local cache or remote server. (#2928) 2024-07-11 09:27:32 +01:00
Austin Pearce
aa3cbd881b Fix alignment of reports` (#3007) 2024-07-10 20:17:48 -07:00
Robert Dyer
8681c9c3e6 Fix editing transactions on mobile not going back. (#2968) 2024-07-10 22:00:50 +01:00
Matiss Janis Aboltins
9ec9aef632 (budget-type) moving the selector to settings page (#3017)
*  (budget-type) moving the selector to settings page

* Feedback: move the block down
2024-07-10 21:58:20 +01:00
Robert Dyer
3be7dd753d Add getAccountBalance() API. (#2930) 2024-07-10 21:52:21 +01:00
Robert Dyer
259e84cea5 Expose bank sync account data in AQL. (#3022)
* Expose bank sync account data in AQL.

* add release note
2024-07-10 07:05:20 -07:00
Austin Pearce
f9014f0e19 Fix mobile payee creation (#3019) 2024-07-09 16:06:25 -07:00
Julian Wachholz
e59f5c9af8 Add apostrophe-dot number format (#2982) 2024-07-09 19:14:38 +01:00
Matt Fiddaman
771c01c8b4 Move bank sync payee name normalisation from actual to actual-server (#2721) 2024-07-09 19:05:15 +01:00
Michael Clark
9f72b43826 :electron: Remove unneded files (#3014) 2024-07-09 18:08:23 +01:00
Julian Wachholz
ec3475d834 Fix number formatting with non-breaking space (#2981) 2024-07-09 18:02:40 +01:00
Wizmaster
5ea9c587a8 Explicitly ask when reconciling transactions on manual import (#2717)
- Added import preview in transaction import list
- Added checkboxes to selectively prevent merging transactions

Co-authored-by: youngcw <calebyoung94@gmail.com>
2024-07-09 06:39:08 -07:00
Matiss Janis Aboltins
1e38055376 🐛 (popover) fix date popover closing when editing a filter (#3009) 2024-07-08 19:57:00 +01:00
Julian Dominguez-Schatz
0ee9126820 Disable interactivity on preview status icons (#2924)
* Disable interactivity on preview statuses

These have no click action but have a focus effect of a purple circle
(residual from the "Cleared" checkbox styling) that looks a bit glitchy.

* Add release notes

* Exclude status field from keyboard navigation
2024-07-08 10:39:00 -07:00
Joel Jeremy Marquez
9e455e4c1e Fix cover modal title (#3008)
* Fix cover modal title

* Release notes
2024-07-08 09:59:36 -07:00
Yusef Ouda
d77b54f27b reorders 'Rename' above 'Hide' in menu popovers, adds debounce to sidebar animation (#3001)
* reorders 'Rename' above 'Hide' in menu popovers

* release notes

* adds debounce to sidebar animation

* bump debounce time

* release notes

* release notes

* Update debounce import

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* Update index.tsx

* removes event listener on titlebar, changes margins
2024-07-08 09:51:28 -07:00
Sreetam Das
ff36d1efbe Add computed padding for handling clipped large Net worth amounts (#2818)
* Add computed padding for handling clipped Net worth amounts

* Add comment, early handle 5 character case

* Add release note

* Update packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx

Co-authored-by: Robert Dyer <rdyer@unl.edu>

* Update vrt snapshots

* Fix NetWorthGraph cutoff when `compact` is true

This happens in case of `ReportCard`

* Update VRT snapshots to revert to original

* Revert snapshots to original

* vrt

---------

Co-authored-by: Robert Dyer <rdyer@unl.edu>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2024-07-08 08:13:47 -07:00
youngcw
cbbbaf65cf remove version from electron build names (#3000)
* remove version from electron build names

* note

* fix
2024-07-07 14:54:14 -07:00
Yusef Ouda
f129b07dc9 Adds ability to resize sidebar (#2993)
* Adds ability to resize sidebar

* Adds release notes

* Changes to feature

* lint

* change translateX to use % for both states

* vrt

* set max sidebar width, cleanup

* set min and max widths

* min width to 200px

* changes resizable sidebar to use re-resizable instead off css resize

* vrt

* vrt
2024-07-07 14:10:41 -07:00
Joel Jeremy Marquez
f1caf21deb Assign schedule to both transactions if schedule is a transfer (#2990)
* Assign schedule to both transactions if schedule is a transfer

* Release notes

* Migration for old scheduled transfer transactions
2024-07-07 09:29:27 -07:00
Michael Clark
a28ea6be8f :electron: server.js ➡️ server.ts (#2995) 2024-07-07 13:31:26 +01:00
Michael Clark
f36c5e002b :electron: Remove "About" screen and broken updater (#2983) 2024-07-05 21:35:52 +01:00
141 changed files with 2197 additions and 1982 deletions

View File

@@ -1,37 +0,0 @@
##########################################################################################
# WARNING! This workflow uses the 'pull_request_target' event. That mans that it will #
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
# a fork. This is necessary to get access to a GitHub token that can modify the PR. #
# Be VERY CAREFUL about adding things to this workflow, since forks can inject #
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
# code execution in this workflow could lead to a compromise of the main repo. #
##########################################################################################
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
##########################################################################################
name: Trafico Reviews
on:
pull_request_target:
types:
- opened
- closed
- reopened
- synchronize
- edited
- review_requested
- review_request_removed
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
manage-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actualbudget/trafico@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,27 +0,0 @@
name: Add WIP
on:
pull_request_target:
types:
- opened
jobs:
add_wip_prefix:
if: |
join(github.event.pull_request.requested_reviewers) == ''
&& !contains(github.event.pull_request.title, 'WIP')
&& !contains(github.event.pull_request.labels.*.name, 'WIP')
&& github.event.pull_request.draft != true
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add WIP
env:
TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: |
echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token
gh pr edit ${{ github.event.pull_request.number }} -t "[WIP] ${TITLE}"

View File

@@ -58,6 +58,19 @@ describe('API CRUD operations', () => {
await api.loadBudget(budgetName);
});
// api: getBudgets
test('getBudgets', async () => {
const budgets = await api.getBudgets();
expect(budgets).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'test-budget',
name: 'Default Test Db',
}),
]),
);
});
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
test('CategoryGroups: successfully update category groups', async () => {
const month = '2023-10';
@@ -251,7 +264,7 @@ describe('API CRUD operations', () => {
);
});
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount, getAccountBalance
test('Accounts: successfully complete account operators', async () => {
const accountId1 = await api.createAccount(
{ name: 'test-account1', offbudget: true },
@@ -272,6 +285,9 @@ describe('API CRUD operations', () => {
]),
);
expect(await api.getAccountBalance(accountId1)).toEqual(1000);
expect(await api.getAccountBalance(accountId2)).toEqual(0);
await api.updateAccount(accountId1, { offbudget: false });
await api.closeAccount(accountId1, accountId2, null);
await api.deleteAccount(accountId2);
@@ -569,6 +585,11 @@ describe('API CRUD operations', () => {
});
expect(addResult).toBe('ok');
expect(await api.getAccountBalance(accountId)).toEqual(200);
expect(
await api.getAccountBalance(accountId, new Date(2023, 10, 2)),
).toEqual(0);
// confirm added transactions exist
let transactions = await api.getTransactions(
accountId,

View File

@@ -31,6 +31,10 @@ export async function downloadBudget(syncId, { password }: { password? } = {}) {
return send('api/download-budget', { syncId, password });
}
export async function getBudgets() {
return send('api/get-budgets');
}
export async function sync() {
return send('api/sync');
}
@@ -125,6 +129,10 @@ export function deleteAccount(id) {
return send('api/account-delete', { id });
}
export function getAccountBalance(id, cutoff?) {
return send('api/account-balance', { id, cutoff });
}
export function getCategoryGroups() {
return send('api/category-groups-get');
}
@@ -173,6 +181,10 @@ export function deletePayee(id) {
return send('api/payee-delete', { id });
}
export function mergePayees(targetId, mergeIds) {
return send('api/payees-merge', { targetId, mergeIds });
}
export function getRules() {
return send('api/rules-get');
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

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

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -47,6 +47,7 @@
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"promise-retry": "^2.0.1",
"re-resizable": "^6.9.17",
"react": "18.2.0",
"react-aria-components": "^1.2.1",
"react-dnd": "^16.0.1",

View File

@@ -135,6 +135,14 @@ global.Actual = {
},
};
function inputFocused(e) {
return (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
);
}
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey) {
// Cmd/Ctrl+o
@@ -144,11 +152,7 @@ document.addEventListener('keydown', e => {
}
// Cmd/Ctrl+z
else if (e.key.toLowerCase() === 'z') {
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
) {
if (inputFocused(e)) {
return;
}
e.preventDefault();
@@ -160,5 +164,10 @@ document.addEventListener('keydown', e => {
window.__actionsForMenu.undo();
}
}
} else if (e.key === '?') {
if (inputFocused(e)) {
return;
}
window.__actionsForMenu.pushModal('keyboard-shortcuts');
}
});

View File

@@ -42,7 +42,7 @@ import { ScrollProvider } from './ScrollProvider';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { Titlebar, TitlebarProvider } from './Titlebar';
import { Titlebar } from './Titlebar';
function NarrowNotSupported({
redirectTo = '/budget',
@@ -246,15 +246,13 @@ export function FinancesApp() {
return (
<SpreadsheetProvider>
<TitlebarProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</TitlebarProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</SpreadsheetProvider>
);
}

View File

@@ -35,6 +35,7 @@ import { GoCardlessExternalMsg } from './modals/GoCardlessExternalMsg';
import { GoCardlessInitialise } from './modals/GoCardlessInitialise';
import { HoldBufferModal } from './modals/HoldBufferModal';
import { ImportTransactions } from './modals/ImportTransactions';
import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
import { LoadBackup } from './modals/LoadBackup';
import { ManageRulesModal } from './modals/ManageRulesModal';
import { MergeUnusedPayees } from './modals/MergeUnusedPayees';
@@ -53,7 +54,6 @@ import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenu
import { SelectLinkedAccounts } from './modals/SelectLinkedAccounts';
import { SimpleFinInitialise } from './modals/SimpleFinInitialise';
import { SingleInputModal } from './modals/SingleInputModal';
import { SwitchBudgetTypeModal } from './modals/SwitchBudgetTypeModal';
import { TransferModal } from './modals/TransferModal';
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
@@ -96,6 +96,9 @@ export function Modals() {
};
switch (name) {
case 'keyboard-shortcuts':
return <KeyboardShortcutModal modalProps={modalProps} />;
case 'import-transactions':
return (
<ImportTransactions
@@ -423,15 +426,6 @@ export function Modals() {
/>
);
case 'switch-budget-type':
return (
<SwitchBudgetTypeModal
key={name}
modalProps={modalProps}
onSwitch={options.onSwitch}
/>
);
case 'account-menu':
return (
<AccountMenuModal
@@ -623,7 +617,6 @@ export function Modals() {
onAddCategoryGroup={options.onAddCategoryGroup}
onToggleHiddenCategories={options.onToggleHiddenCategories}
onSwitchBudgetFile={options.onSwitchBudgetFile}
onSwitchBudgetType={options.onSwitchBudgetType}
/>
);

View File

@@ -13,6 +13,7 @@ import type { NotificationWithId } from 'loot-core/src/client/state-types/notifi
import { useActions } from '../hooks/useActions';
import { AnimatedLoading } from '../icons/AnimatedLoading';
import { SvgDelete } from '../icons/v0';
import { useResponsive } from '../ResponsiveProvider';
import { styles, theme, type CSSProperties } from '../style';
import { Button, ButtonWithLoading } from './common/Button';
@@ -245,6 +246,7 @@ function Notification({
export function Notifications({ style }: { style?: CSSProperties }) {
const { removeNotification } = useActions();
const { isNarrowWidth } = useResponsive();
const notifications = useSelector(
(state: State) => state.notifications.notifications,
);
@@ -254,6 +256,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
position: 'fixed',
bottom: 20,
right: 13,
left: isNarrowWidth ? 13 : undefined,
zIndex: 10000,
...style,
}}

View File

@@ -1,11 +1,4 @@
import React, {
createContext,
useState,
useEffect,
useRef,
useContext,
type ReactNode,
} from 'react';
import React, { useState, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Routes, Route, useLocation } from 'react-router-dom';
@@ -13,10 +6,8 @@ import * as Platform from 'loot-core/src/client/platform';
import * as queries from 'loot-core/src/client/queries';
import { listen } from 'loot-core/src/platform/client/fetch';
import { isDevelopmentEnvironment } from 'loot-core/src/shared/environment';
import { type LocalPrefs } from 'loot-core/src/types/prefs';
import { useActions } from '../hooks/useActions';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
import { useGlobalPref } from '../hooks/useGlobalPref';
import { useLocalPref } from '../hooks/useLocalPref';
import { useNavigate } from '../hooks/useNavigate';
@@ -33,10 +24,8 @@ import { theme, type CSSProperties, styles } from '../style';
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
import { AnimatedRefresh } from './AnimatedRefresh';
import { MonthCountSelector } from './budget/MonthCountSelector';
import { Button, ButtonWithLoading } from './common/Button';
import { Button } from './common/Button';
import { Link } from './common/Link';
import { Paragraph } from './common/Paragraph';
import { Popover } from './common/Popover';
import { Text } from './common/Text';
import { View } from './common/View';
import { LoggedInUser } from './LoggedInUser';
@@ -45,55 +34,6 @@ import { useSidebar } from './sidebar/SidebarProvider';
import { useSheetValue } from './spreadsheet/useSheetValue';
import { ThemeSelector } from './ThemeSelector';
export const SWITCH_BUDGET_MESSAGE_TYPE = 'budget/switch-type';
type SwitchBudgetTypeMessage = {
type: typeof SWITCH_BUDGET_MESSAGE_TYPE;
payload: {
newBudgetType: LocalPrefs['budgetType'];
};
};
export type TitlebarMessage = SwitchBudgetTypeMessage;
type Listener = (msg: TitlebarMessage) => void;
export type TitlebarContextValue = {
sendEvent: (msg: TitlebarMessage) => void;
subscribe: (listener: Listener) => () => void;
};
export const TitlebarContext = createContext<TitlebarContextValue>({
sendEvent() {
throw new Error('TitlebarContext not initialized');
},
subscribe() {
throw new Error('TitlebarContext not initialized');
},
});
type TitlebarProviderProps = {
children?: ReactNode;
};
export function TitlebarProvider({ children }: TitlebarProviderProps) {
const listeners = useRef<Listener[]>([]);
function sendEvent(msg: TitlebarMessage) {
listeners.current.forEach(func => func(msg));
}
function subscribe(listener: Listener) {
listeners.current.push(listener);
return () =>
(listeners.current = listeners.current.filter(func => func !== listener));
}
return (
<TitlebarContext.Provider value={{ sendEvent, subscribe }}>
{children}
</TitlebarContext.Provider>
);
}
function UncategorizedButton() {
const count: number | null = useSheetValue(queries.uncategorizedCount());
if (count === null || count <= 0) {
@@ -287,31 +227,6 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
function BudgetTitlebar() {
const [maxMonths, setMaxMonthsPref] = useGlobalPref('maxMonths');
const [budgetType] = useLocalPref('budgetType');
const { sendEvent } = useContext(TitlebarContext);
const [loading, setLoading] = useState(false);
const [showPopover, setShowPopover] = useState(false);
const triggerRef = useRef(null);
const reportBudgetEnabled = useFeatureFlag('reportBudget');
function onSwitchType() {
setLoading(true);
if (!loading) {
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
sendEvent({
type: SWITCH_BUDGET_MESSAGE_TYPE,
payload: {
newBudgetType,
},
});
}
}
useEffect(() => {
setLoading(false);
}, [budgetType]);
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@@ -319,61 +234,6 @@ function BudgetTitlebar() {
maxMonths={maxMonths || 1}
onChange={value => setMaxMonthsPref(value)}
/>
{reportBudgetEnabled && (
<View style={{ marginLeft: -5 }}>
<ButtonWithLoading
ref={triggerRef}
type="bare"
loading={loading}
style={{
alignSelf: 'flex-start',
padding: '4px 7px',
}}
title="Learn more about budgeting"
onClick={() => setShowPopover(true)}
>
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}
</ButtonWithLoading>
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={showPopover}
onOpenChange={() => setShowPopover(false)}
style={{
padding: 10,
maxWidth: 400,
}}
>
<Paragraph>
You are currently using a{' '}
<Text style={{ fontWeight: 600 }}>
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}.
</Text>{' '}
Switching will not lose any data and you can always switch back.
</Paragraph>
<Paragraph>
<ButtonWithLoading
type="primary"
loading={loading}
onClick={onSwitchType}
>
Switch to a{' '}
{budgetType === 'report' ? 'Rollover budget' : 'Report budget'}
</ButtonWithLoading>
</Paragraph>
<Paragraph isLast={true}>
<Link
variant="external"
to="https://actualbudget.org/docs/experimental/report-budget"
linkColor="muted"
>
How do these types of budgeting work?
</Link>
</Paragraph>
</Popover>
</View>
)}
</View>
);
}
@@ -416,11 +276,6 @@ export function Titlebar({ style }: TitlebarProps) {
sidebar.setHidden(false);
}
}}
onPointerLeave={e => {
if (e.pointerType === 'mouse') {
sidebar.setHidden(true);
}
}}
onPointerUp={e => {
if (e.pointerType !== 'mouse') {
sidebar.setHidden(!sidebar.hidden);

View File

@@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid';
import { validForTransfer } from 'loot-core/client/transfer';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules';
import {
SchedulesProvider,
useDefaultSchedulesQueryTransform,
} from 'loot-core/src/client/data-hooks/schedules';
import * as queries from 'loot-core/src/client/queries';
import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
import { send, listen } from 'loot-core/src/platform/client/fetch';
@@ -1837,29 +1840,7 @@ export function Account() {
const savedFiters = useFilters();
const actionCreators = useActions();
const transform = useMemo(() => {
const filterByAccount = queries.getAccountFilter(params.id, '_account');
const filterByPayee = queries.getAccountFilter(
params.id,
'_payee.transfer_acct',
);
return q => {
q = q.filter({
$and: [{ '_account.closed': false }],
});
if (params.id) {
if (params.id === 'uncategorized') {
q = q.filter({ next_date: null });
} else {
q = q.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return q.orderBy({ next_date: 'desc' });
};
}, [params.id]);
const transform = useDefaultSchedulesQueryTransform(params.id);
return (
<SchedulesProvider transform={transform}>

View File

@@ -17,7 +17,7 @@ import { css } from 'glamor';
import { SvgRemove } from '../../icons/v2';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Input } from '../common/Input';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
@@ -621,7 +621,12 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
}}
>
{name}
<Button type="bare" style={{ marginLeft: 1 }} onClick={onRemove}>
<Button
variant="bare"
aria-label="Remove autocomplete item"
style={{ marginLeft: 1 }}
onPress={onRemove}
>
<SvgRemove style={{ width: 8, height: 8 }} />
</Button>
</View>

View File

@@ -26,7 +26,7 @@ import { usePayees } from '../../hooks/usePayees';
import { SvgAdd } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
@@ -366,9 +366,10 @@ export function PayeeAutocomplete({
<AutocompleteFooter embedded={embedded}>
{showMakeTransfer && (
<Button
type={focusTransferPayees ? 'menuSelected' : 'menu'}
variant={focusTransferPayees ? 'menuSelected' : 'menu'}
aria-label="Make transfer"
style={showManagePayees && { marginBottom: 5 }}
onClick={() => {
onPress={() => {
onUpdate?.(null, null);
setFocusTransferPayees(!focusTransferPayees);
}}
@@ -377,7 +378,11 @@ export function PayeeAutocomplete({
</Button>
)}
{showManagePayees && (
<Button type="menu" onClick={() => onManagePayees()}>
<Button
variant="menu"
aria-label="Manage payees"
onPress={() => onManagePayees()}
>
Manage Payees
</Button>
)}
@@ -420,7 +425,7 @@ export function CreatePayeeButton({
data-testid="create-payee-button"
style={{
display: 'block',
flexShrink: 0,
flex: '1 0',
color: highlighted
? theme.menuAutoCompleteTextHover
: theme.noticeTextMenu,

View File

@@ -58,7 +58,13 @@ export function IncomeCategory({
});
return (
<Row innerRef={dropRef} collapsed={true}>
<Row
innerRef={dropRef}
collapsed={true}
style={{
opacity: cat.hidden ? 0.5 : undefined,
}}
>
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
<SidebarCategory

View File

@@ -106,11 +106,11 @@ export function SidebarCategory({
setMenuOpen(false);
}}
items={[
{ name: 'rename', text: 'Rename' },
!categoryGroup?.hidden && {
name: 'toggle-visibility',
text: category.hidden ? 'Show' : 'Hide',
},
{ name: 'rename', text: 'Rename' },
{ name: 'delete', text: 'Delete' },
]}
/>

View File

@@ -127,11 +127,11 @@ export function SidebarGroup({
}}
items={[
{ name: 'add-category', text: 'Add category' },
{ name: 'rename', text: 'Rename' },
!group.is_income && {
name: 'toggle-visibility',
text: group.hidden ? 'Show' : 'Hide',
},
{ name: 'rename', text: 'Rename' },
onDelete && { name: 'delete', text: 'Delete' },
]}
/>

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { memo, useContext, useMemo, useState, useEffect } from 'react';
import React, { memo, useMemo, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import {
@@ -10,7 +10,6 @@ import {
deleteCategory,
deleteGroup,
getCategories,
loadPrefs,
moveCategory,
moveCategoryGroup,
pushModal,
@@ -28,19 +27,13 @@ import { useNavigate } from '../../hooks/useNavigate';
import { styles } from '../../style';
import { View } from '../common/View';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
import {
SWITCH_BUDGET_MESSAGE_TYPE,
TitlebarContext,
type TitlebarContextValue,
type TitlebarMessage,
} from '../Titlebar';
import { DynamicBudgetTable } from './DynamicBudgetTable';
import * as report from './report/ReportComponents';
import { ReportProvider } from './report/ReportContext';
import * as rollover from './rollover/RolloverComponents';
import { RolloverProvider } from './rollover/RolloverContext';
import { prewarmAllMonths, prewarmMonth, switchBudgetType } from './util';
import { prewarmAllMonths, prewarmMonth } from './util';
type ReportComponents = {
SummaryComponent: typeof report.BudgetSummary;
@@ -66,7 +59,6 @@ type BudgetInnerProps = {
accountId?: string;
reportComponents: ReportComponents;
rolloverComponents: RolloverComponents;
titlebar: TitlebarContextValue;
};
function BudgetInner(props: BudgetInnerProps) {
@@ -95,8 +87,6 @@ function BudgetInner(props: BudgetInnerProps) {
}
useEffect(() => {
const { titlebar } = props;
async function run() {
loadCategories();
@@ -132,8 +122,6 @@ function BudgetInner(props: BudgetInnerProps) {
loadCategories();
}
}),
titlebar.subscribe(onTitlebarEvent),
];
return () => {
@@ -323,24 +311,6 @@ function BudgetInner(props: BudgetInnerProps) {
setSummaryCollapsedPref(!summaryCollapsed);
};
const onTitlebarEvent = async ({ type, payload }: TitlebarMessage) => {
switch (type) {
case SWITCH_BUDGET_MESSAGE_TYPE: {
await switchBudgetType(
payload.newBudgetType,
spreadsheet,
bounds,
startMonth,
async () => {
dispatch(loadPrefs());
},
);
break;
}
default:
}
};
const { reportComponents, rolloverComponents } = props;
if (!initialized || !categoryGroups) {
@@ -416,8 +386,6 @@ const RolloverBudgetSummary = memo<{ month: string }>(props => {
RolloverBudgetSummary.displayName = 'RolloverBudgetSummary';
export function Budget() {
const titlebar = useContext(TitlebarContext);
const reportComponents = useMemo<ReportComponents>(
() => ({
SummaryComponent: report.BudgetSummary,
@@ -460,7 +428,6 @@ export function Budget() {
<BudgetInner
reportComponents={reportComponents}
rolloverComponents={rolloverComponents}
titlebar={titlebar}
/>
</View>
);

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { SvgFilter } from '../../icons/v1';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
export function CompactFiltersButton({ onClick }: { onClick: () => void }) {
return (
<Button type="bare" onClick={onClick}>
<Button variant="bare" onPress={onClick}>
<SvgFilter width={15} height={15} />
</Button>
);

View File

@@ -9,7 +9,7 @@ import {
import { SvgDelete } from '../../icons/v0';
import { type CSSProperties, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -18,6 +18,8 @@ import { Value } from '../rules/Value';
import { FilterEditor } from './FiltersMenu';
import { subfieldFromFilter } from './subfieldFromFilter';
let isDatepickerClick = false;
type FilterExpressionProps = {
field: string | undefined;
customName: string | undefined;
@@ -43,6 +45,8 @@ export function FilterExpression({
const triggerRef = useRef(null);
const field = subfieldFromFilter({ field: originalField, value });
const displayField = mapField(field, options);
const displayOp = friendlyOp(op, null);
return (
<View
@@ -58,9 +62,10 @@ export function FilterExpression({
>
<Button
ref={triggerRef}
type="bare"
disabled={customName != null}
onClick={() => setEditing(true)}
variant="bare"
aria-label={`${displayField} ${displayOp} ${value} filter`}
isDisabled={customName != null}
onPress={() => setEditing(true)}
>
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
{customName ? (
@@ -68,9 +73,9 @@ export function FilterExpression({
) : (
<>
<Text style={{ color: theme.pageTextPositive }}>
{mapField(field, options)}
{displayField}
</Text>{' '}
<Text>{friendlyOp(op, null)}</Text>{' '}
<Text>{displayOp}</Text>{' '}
<Value
value={value}
field={field}
@@ -85,7 +90,7 @@ export function FilterExpression({
)}
</div>
</Button>
<Button type="bare" onClick={onDelete} aria-label="Delete filter">
<Button variant="bare" onPress={onDelete} aria-label="Delete filter">
<SvgDelete
style={{
width: 8,
@@ -100,6 +105,21 @@ export function FilterExpression({
placement="bottom start"
isOpen={editing}
onOpenChange={() => setEditing(false)}
shouldCloseOnInteractOutside={element => {
// Datepicker selections for some reason register 2x clicks
// We want to keep the popover open after selecting a date.
// So we ignore the "close" event on selection + the subsequent event.
if (element instanceof HTMLElement && element.dataset.pikaYear) {
isDatepickerClick = true;
return false;
}
if (isDatepickerClick) {
isDatepickerClick = false;
return false;
}
return true;
}}
style={{ width: 275, padding: 15, color: theme.menuItemText }}
data-testid="filters-menu-tooltip"
>

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { SvgFilter } from '../../icons/v1/Filter';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
export function FiltersButton({ onClick }: { onClick: () => void }) {
return (
<Button type="bare" onClick={onClick} title="Filters">
<Button variant="bare" onPress={onClick} aria-label="Filters">
<SvgFilter style={{ width: 12, height: 12, marginRight: 5 }} /> Filter
</Button>
);

View File

@@ -22,7 +22,7 @@ import { titleFirst } from 'loot-core/src/shared/util';
import { useDateFormat } from '../../hooks/useDateFormat';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
import { Select } from '../common/Select';
@@ -219,9 +219,9 @@ function ConfigureField({
>
<View style={{ flex: 1 }} />
<Button
type="primary"
onClick={e => {
e.preventDefault();
variant="primary"
aria-label="Apply"
onPress={() => {
onApply({
field,
op,

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect } from 'react';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Input } from '../common/Input';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
@@ -54,12 +54,10 @@ export function NameFilter({
/>
</FormField>
<Button
type="primary"
variant="primary"
aria-label={adding ? 'Add' : 'Update'}
style={{ marginTop: 18 }}
onClick={e => {
e.preventDefault();
onAddUpdate();
}}
onPress={onAddUpdate}
>
{adding ? 'Add' : 'Update'}
</Button>

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { friendlyOp } from 'loot-core/src/shared/rules';
import { type CSSProperties, theme } from '../../style';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
type OpButtonProps = {
op: string;
@@ -13,24 +13,30 @@ type OpButtonProps = {
};
export function OpButton({ op, selected, style, onClick }: OpButtonProps) {
const displayOp = friendlyOp(op);
return (
<Button
type="bare"
style={{
backgroundColor: theme.pillBackground,
variant="bare"
aria-label={`${displayOp} op`}
style={({ isHovered, isPressed }) => ({
marginBottom: 5,
...style,
...(selected && {
color: theme.buttonNormalSelectedText,
'&,:hover,:active': {
backgroundColor: theme.buttonNormalSelectedBackground,
color: theme.buttonNormalSelectedText,
},
}),
}}
onClick={onClick}
...(selected
? {
color: theme.pillTextSelected,
backgroundColor: theme.pillBackgroundSelected,
}
: isHovered || isPressed
? {
backgroundColor: theme.pillBackgroundHover,
}
: {
backgroundColor: theme.pillBackground,
}),
})}
onPress={onClick}
>
{friendlyOp(op)}
{displayOp}
</Button>
);
}

View File

@@ -5,7 +5,7 @@ import { type TransactionFilterEntity } from 'loot-core/types/models';
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
import { SvgExpandArrow } from '../../icons/v0';
import { Button } from '../common/Button';
import { Button } from '../common/Button2';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -161,9 +161,10 @@ export function SavedFilterMenuButton({
{conditions.length > 0 && (
<Button
ref={triggerRef}
type="bare"
variant="bare"
aria-label="Saved filter menu"
style={{ marginTop: 10 }}
onClick={() => {
onPress={() => {
setMenuOpen(true);
}}
>

View File

@@ -105,6 +105,17 @@ export const Checkbox = (props: CheckboxProps) => {
content: ' ',
},
},
':disabled': {
border: '1px solid ' + theme.buttonNormalDisabledBorder,
backgroundColor: theme.buttonNormalDisabledBorder,
},
':checked:disabled': {
border: '1px solid ' + theme.buttonNormalDisabledBorder,
backgroundColor: theme.buttonNormalDisabledBorder,
'::after': {
backgroundColor: theme.buttonNormalDisabledBorder,
},
},
'&.focus-visible:focus': {
'::before': {
position: 'absolute',

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react';
import type React from 'react';
import { useState, useRef, type CSSProperties } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -12,6 +13,12 @@ import {
pushModal,
} from 'loot-core/client/actions';
import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
import {
type File,
type LocalFile,
type SyncableLocalFile,
type SyncedLocalFile,
} from 'loot-core/types/file';
import { useInitialMount } from '../../hooks/useInitialMount';
import { useLocalPref } from '../../hooks/useLocalPref';
@@ -32,7 +39,7 @@ import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
function getFileDescription(file) {
function getFileDescription(file: File) {
if (file.state === 'unknown') {
return (
'This is a cloud-based file but its state is unknown because you ' +
@@ -50,8 +57,14 @@ function getFileDescription(file) {
return null;
}
function FileMenu({ onDelete, onClose }) {
function onMenuSelect(type) {
function FileMenu({
onDelete,
onClose,
}: {
onDelete: () => void;
onClose: () => void;
}) {
function onMenuSelect(type: string) {
onClose();
switch (type) {
@@ -83,7 +96,7 @@ function FileMenu({ onDelete, onClose }) {
);
}
function FileMenuButton({ state, onDelete }) {
function FileMenuButton({ onDelete }: { onDelete: () => void }) {
const triggerRef = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -106,17 +119,13 @@ function FileMenuButton({ state, onDelete }) {
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
>
<FileMenu
state={state}
onDelete={onDelete}
onClose={() => setMenuOpen(false)}
/>
<FileMenu onDelete={onDelete} onClose={() => setMenuOpen(false)} />
</Popover>
</View>
);
}
function FileState({ file }) {
function FileState({ file }: { file: File }) {
let Icon;
let status;
let color;
@@ -164,10 +173,20 @@ function FileState({ file }) {
);
}
function File({ file, quickSwitchMode, onSelect, onDelete }) {
function FileItem({
file,
quickSwitchMode,
onSelect,
onDelete,
}: {
file: File;
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
}) {
const selecting = useRef(false);
async function _onSelect(file) {
async function _onSelect(file: File) {
// Never allow selecting the file while uploading/downloading, and
// make sure to never allow duplicate clicks
if (!selecting.current) {
@@ -180,7 +199,7 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
return (
<View
onClick={() => _onSelect(file)}
title={getFileDescription(file)}
title={getFileDescription(file) || ''}
style={{
flexDirection: 'row',
justifyContent: 'space-between',
@@ -193,7 +212,7 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
flexShrink: 0,
cursor: 'pointer',
':hover': {
backgroundColor: theme.hover,
backgroundColor: theme.menuItemBackgroundHover,
},
}}
>
@@ -219,15 +238,27 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
/>
)}
{!quickSwitchMode && (
<FileMenuButton state={file.state} onDelete={() => onDelete(file)} />
)}
{!quickSwitchMode && <FileMenuButton onDelete={() => onDelete(file)} />}
</View>
</View>
);
}
function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
function BudgetFiles({
files,
quickSwitchMode,
onSelect,
onDelete,
}: {
files: File[];
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
}) {
function isLocalFile(file: File): file is LocalFile {
return file.state === 'local';
}
return (
<View
style={{
@@ -252,8 +283,8 @@ function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
</Text>
) : (
files.map(file => (
<File
key={file.id || file.cloudFileId}
<FileItem
key={isLocalFile(file) ? file.id : file.cloudFileId}
file={file}
quickSwitchMode={quickSwitchMode}
onSelect={onSelect}
@@ -265,7 +296,13 @@ function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
);
}
function RefreshButton({ style, onRefresh }) {
function RefreshButton({
style,
onRefresh,
}: {
style?: CSSProperties;
onRefresh: () => void;
}) {
const [loading, setLoading] = useState(false);
async function _onRefresh() {
@@ -288,7 +325,13 @@ function RefreshButton({ style, onRefresh }) {
);
}
function BudgetListHeader({ quickSwitchMode, onRefresh }) {
function BudgetListHeader({
quickSwitchMode,
onRefresh,
}: {
quickSwitchMode: boolean;
onRefresh: () => void;
}) {
return (
<View
style={{
@@ -314,7 +357,14 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
const allFiles = useSelector(state => state.budgets.allFiles || []);
const [id] = useLocalPref('id');
const files = id ? allFiles.filter(f => f.id !== id) : allFiles;
// Remote files do not have the 'id' field
function isNonRemoteFile(
file: File,
): file is LocalFile | SyncableLocalFile | SyncedLocalFile {
return file.state !== 'remote';
}
const nonRemoteFiles = allFiles.filter(isNonRemoteFile);
const files = id ? nonRemoteFiles.filter(f => f.id !== id) : allFiles;
const [creating, setCreating] = useState(false);
const { isNarrowWidth } = useResponsive();
@@ -324,7 +374,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
}
: {};
const onCreate = ({ testMode } = {}) => {
const onCreate = ({ testMode = false } = {}) => {
if (!creating) {
setCreating(true);
dispatch(createBudget({ testMode }));
@@ -341,6 +391,22 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
refresh();
}
const onSelect = (file: File): void => {
const isRemoteFile = file.state === 'remote';
if (!id) {
if (isRemoteFile) {
dispatch(downloadBudget(file.cloudFileId));
} else {
dispatch(loadBudget(file.id));
}
} else if (!isRemoteFile && file.id !== id) {
dispatch(closeAndLoadBudget(file.id));
} else if (isRemoteFile) {
dispatch(closeAndDownloadBudget(file.cloudFileId));
}
};
return (
<View
style={{
@@ -365,21 +431,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
<BudgetFiles
files={files}
quickSwitchMode={quickSwitchMode}
onSelect={file => {
if (!id) {
if (file.state === 'remote') {
dispatch(downloadBudget(file.cloudFileId));
} else {
dispatch(loadBudget(file.id));
}
} else if (file.id !== id) {
if (file.state === 'remote') {
dispatch(closeAndDownloadBudget(file.cloudFileId));
} else {
dispatch(closeAndLoadBudget(file.id));
}
}
}}
onSelect={onSelect}
onDelete={file => dispatch(pushModal('delete-budget', { file }))}
/>
{!quickSwitchMode && (
@@ -408,7 +460,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
<Button
type="primary"
onClick={onCreate}
onClick={() => onCreate()}
style={{
...narrowButtonStyle,
marginLeft: 10,

View File

@@ -7,7 +7,6 @@ import React, {
} from 'react';
import { useDispatch } from 'react-redux';
import memoizeOne from 'memoize-one';
import { useDebounceCallback } from 'usehooks-ts';
import {
@@ -20,7 +19,10 @@ import {
syncAndDownload,
updateAccount,
} from 'loot-core/client/actions';
import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules';
import {
SchedulesProvider,
useDefaultSchedulesQueryTransform,
} from 'loot-core/client/data-hooks/schedules';
import * as queries from 'loot-core/client/queries';
import { pagedQuery } from 'loot-core/client/query-helpers';
import { listen, send } from 'loot-core/platform/client/fetch';
@@ -39,6 +41,7 @@ import { AddTransactionButton } from '../transactions/AddTransactionButton';
import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances';
export function AccountTransactions({ account, pending, failed }) {
const schedulesTransform = useDefaultSchedulesQueryTransform(account.id);
return (
<Page
header={
@@ -52,7 +55,7 @@ export function AccountTransactions({ account, pending, failed }) {
}
padding={0}
>
<SchedulesProvider transform={getSchedulesTransform(account.id)}>
<SchedulesProvider transform={schedulesTransform}>
<TransactionListWithPreviews account={account} />
</SchedulesProvider>
</Page>
@@ -132,15 +135,6 @@ function AccountName({ account, pending, failed }) {
);
}
const getSchedulesTransform = memoizeOne(id => {
const filter = queries.getAccountFilter(id, '_account');
return q => {
q = q.filter({ $and: [filter, { '_account.closed': false }] });
return q.orderBy({ next_date: 'desc' });
};
});
function TransactionListWithPreviews({ account }) {
const [currentQuery, setCurrentQuery] = useState();
const [isSearching, setIsSearching] = useState(false);

View File

@@ -378,7 +378,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
const onCover = () => {
dispatch(
pushModal('cover', {
categoryId: category.id,
title: category.name,
month,
onSubmit: fromCategoryId => {
onBudgetAction(month, 'cover-overspending', {

View File

@@ -16,7 +16,6 @@ import {
updateCategory,
updateGroup,
sync,
loadPrefs,
} from 'loot-core/client/actions';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import { send, listen } from 'loot-core/src/platform/client/fetch';
@@ -31,7 +30,7 @@ import { useLocalPref } from '../../../hooks/useLocalPref';
import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
import { AnimatedLoading } from '../../../icons/AnimatedLoading';
import { theme } from '../../../style';
import { prewarmMonth, switchBudgetType } from '../../budget/util';
import { prewarmMonth } from '../../budget/util';
import { View } from '../../common/View';
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
import { SyncRefresh } from '../../SyncRefresh';
@@ -289,23 +288,6 @@ function BudgetInner(props: BudgetInnerProps) {
// );
// };
const onSwitchBudgetType = async () => {
setInitialized(false);
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
await switchBudgetType(
newBudgetType,
spreadsheet,
bounds,
startMonth,
async () => {
dispatch(loadPrefs());
},
);
setInitialized(true);
};
const onSaveNotes = async (id, notes) => {
await send('notes-save', { id, note: notes });
};
@@ -358,17 +340,6 @@ function BudgetInner(props: BudgetInnerProps) {
);
};
const onOpenSwitchBudgetTypeModal = () => {
dispatch(
pushModal('switch-budget-type', {
onSwitch: () => {
onSwitchBudgetType();
dispatch(collapseModals('budget-page-menu'));
},
}),
);
};
const [showHiddenCategories, setShowHiddenCategoriesPref] = useLocalPref(
'budget.showHiddenCategories',
);
@@ -408,7 +379,6 @@ function BudgetInner(props: BudgetInnerProps) {
onAddCategoryGroup: onOpenNewCategoryGroupModal,
onToggleHiddenCategories,
onSwitchBudgetFile,
onSwitchBudgetType: onOpenSwitchBudgetTypeModal,
}),
);
};

View File

@@ -523,16 +523,20 @@ const TransactionEditInner = memo(function TransactionEditInner({
const [unserializedTransaction] = unserializedTransactions;
const onConfirmSave = async () => {
const { account: accountId } = unserializedTransaction;
const account = accountsById[accountId];
let transactionsToSave = unserializedTransactions;
if (adding) {
transactionsToSave = realizeTempTransactions(unserializedTransactions);
}
props.onSave(transactionsToSave);
navigate(`/accounts/${account.id}`, { replace: true });
if (adding) {
const { account: accountId } = unserializedTransaction;
const account = accountsById[accountId];
navigate(`/accounts/${account.id}`, { replace: true });
} else {
navigate(-1);
}
};
if (unserializedTransaction.reconciled) {
@@ -639,12 +643,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
return;
}
const { account: accountId } = unserializedTransaction;
if (accountId) {
navigate(`/accounts/${accountId}`, { replace: true });
} else {
navigate(-1);
}
navigate(-1);
},
}),
);

View File

@@ -1,6 +1,5 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { useLocalPref } from '../../hooks/useLocalPref';
import { type CSSProperties, theme, styles } from '../../style';
import { Menu } from '../common/Menu';
@@ -18,7 +17,6 @@ export function BudgetPageMenuModal({
onAddCategoryGroup,
onToggleHiddenCategories,
onSwitchBudgetFile,
onSwitchBudgetType,
}: BudgetPageMenuModalProps) {
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
@@ -34,7 +32,6 @@ export function BudgetPageMenuModal({
onAddCategoryGroup={onAddCategoryGroup}
onToggleHiddenCategories={onToggleHiddenCategories}
onSwitchBudgetFile={onSwitchBudgetFile}
onSwitchBudgetType={onSwitchBudgetType}
/>
</Modal>
);
@@ -47,17 +44,14 @@ type BudgetPageMenuProps = Omit<
onAddCategoryGroup: () => void;
onToggleHiddenCategories: () => void;
onSwitchBudgetFile: () => void;
onSwitchBudgetType: () => void;
};
function BudgetPageMenu({
onAddCategoryGroup,
onToggleHiddenCategories,
onSwitchBudgetFile,
onSwitchBudgetType,
...props
}: BudgetPageMenuProps) {
const isReportBudgetEnabled = useFeatureFlag('reportBudget');
const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories');
const onMenuSelect = (name: string) => {
@@ -74,9 +68,6 @@ function BudgetPageMenu({
case 'switch-budget-file':
onSwitchBudgetFile?.();
break;
case 'switch-budget-type':
onSwitchBudgetType?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
@@ -99,14 +90,6 @@ function BudgetPageMenu({
name: 'switch-budget-file',
text: 'Switch budget file',
},
...(isReportBudgetEnabled
? [
{
name: 'switch-budget-type',
text: 'Switch budget type',
},
]
: []),
]}
/>
);

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { send } from 'loot-core/src/platform/client/fetch';
@@ -16,10 +16,10 @@ import { Link } from '../common/Link';
import { Menu } from '../common/Menu';
import { Modal } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { type CommonModalProps } from '../Modals';
import { Tooltip } from '../tooltips';
type CreateAccountProps = {
modalProps: CommonModalProps;
@@ -38,6 +38,7 @@ export function CreateAccountModal({
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] =
useState(null);
const [menuGoCardlessOpen, setGoCardlessMenuOpen] = useState<boolean>(false);
const triggerRef = useRef(null);
const [menuSimplefinOpen, setSimplefinMenuOpen] = useState<boolean>(false);
const onConnectGoCardless = () => {
@@ -235,39 +236,40 @@ export function CreateAccountModal({
: 'Set up GoCardless for bank sync'}
</ButtonWithLoading>
{isGoCardlessSetupComplete && (
<Button
type="bare"
onClick={() => setGoCardlessMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
{menuGoCardlessOpen && (
<Tooltip
position="bottom-right"
width={200}
style={{ padding: 0 }}
onClose={() => setGoCardlessMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onGoCardlessReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset GoCardless credentials',
},
]}
/>
</Tooltip>
)}
</Button>
<>
<Button
ref={triggerRef}
type="bare"
onClick={() => setGoCardlessMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuGoCardlessOpen}
onOpenChange={() => setGoCardlessMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onGoCardlessReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset GoCardless credentials',
},
]}
/>
</Popover>
</>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
@@ -303,39 +305,39 @@ export function CreateAccountModal({
: 'Set up SimpleFIN for bank sync'}
</ButtonWithLoading>
{isSimpleFinSetupComplete && (
<Button
type="bare"
onClick={() => setSimplefinMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
{menuSimplefinOpen && (
<Tooltip
position="bottom-right"
width={200}
style={{ padding: 0 }}
onClose={() => setSimplefinMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onSimpleFinReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset SimpleFIN credentials',
},
]}
/>
</Tooltip>
)}
</Button>
<>
<Button
ref={triggerRef}
type="bare"
onClick={() => setSimplefinMenuOpen(true)}
aria-label="Menu"
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuSimplefinOpen}
onOpenChange={() => setSimplefinMenuOpen(false)}
>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onSimpleFinReset();
}
}}
items={[
{
name: 'reconfigure',
text: 'Reset SimpleFIN credentials',
},
]}
/>
</Popover>
</>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>

View File

@@ -122,7 +122,7 @@ export function OpSelect({
.map(op => [op, formatOp(op, type)]);
if (type === 'string' || type === 'id') {
options.splice(options.length / 2, 0, Menu.line);
options.splice(Math.ceil(options.length / 2), 0, Menu.line);
}
return options;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import * as d from 'date-fns';
@@ -12,6 +12,7 @@ import {
import { useActions } from '../../hooks/useActions';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useLocalPrefs } from '../../hooks/useLocalPrefs';
import { SvgDownAndRightArrow } from '../../icons/v2';
import { theme, styles } from '../../style';
import { Button, ButtonWithLoading } from '../common/Button';
import { Input } from '../common/Input';
@@ -19,6 +20,7 @@ import { Modal } from '../common/Modal';
import { Select } from '../common/Select';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
import { Tooltip } from '../common/Tooltip';
import { View } from '../common/View';
import { Checkbox, SectionLabel } from '../forms';
import { TableHeader, TableWithNavigator, Row, Field } from '../table';
@@ -245,6 +247,12 @@ function applyFieldMappings(transaction, mappings) {
result[field] = transaction[target || field];
}
// Keep preview fields on the mapped transactions
result.trx_id = transaction.trx_id;
result.existing = transaction.existing;
result.ignored = transaction.ignored;
result.selected = transaction.selected;
result.selected_merge = transaction.selected_merge;
return result;
}
@@ -335,33 +343,124 @@ function Transaction({
flipAmount,
multiplierAmount,
categories,
onCheckTransaction,
reconcile,
}) {
const categoryList = categories.map(category => category.name);
const transaction = useMemo(
() =>
fieldMappings
fieldMappings && !rawTransaction.isMatchedTransaction
? applyFieldMappings(rawTransaction, fieldMappings)
: rawTransaction,
[rawTransaction, fieldMappings],
);
const { amount, outflow, inflow } = parseAmountFields(
transaction,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
);
let amount, outflow, inflow;
if (rawTransaction.isMatchedTransaction) {
amount = rawTransaction.amount;
if (splitMode) {
outflow = amount < 0 ? -amount : 0;
inflow = amount > 0 ? amount : 0;
}
} else {
({ amount, outflow, inflow } = parseAmountFields(
transaction,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
));
}
return (
<Row
style={{
backgroundColor: theme.tableBackground,
color:
(transaction.isMatchedTransaction && !transaction.selected_merge) ||
!transaction.selected
? theme.tableTextInactive
: theme.tableText,
}}
>
{reconcile && (
<Field width={31}>
{!transaction.isMatchedTransaction && (
<Tooltip
content={
!transaction.existing && !transaction.ignored
? 'New transaction. You can import it, or skip it.'
: transaction.ignored
? 'Already imported transaction. You can skip it, or import it again.'
: transaction.existing
? 'Updated transaction. You can update it, import it again, or skip it.'
: ''
}
placement="right top"
>
<Checkbox
checked={transaction.selected}
onChange={() => onCheckTransaction(transaction.trx_id)}
style={
transaction.selected_merge
? {
':checked': {
'::after': {
background:
theme.checkboxBackgroundSelected +
// update sign from packages/desktop-client/src/icons/v1/layer.svg
// eslint-disable-next-line rulesdir/typography
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="white" d="M10 1l10 6-10 6L0 7l10-6zm6.67 10L20 13l-10 6-10-6 3.33-2L10 15l6.67-4z" /></svg>\') 9px 9px',
},
},
}
: {
'&': {
border:
'1px solid ' + theme.buttonNormalDisabledBorder,
backgroundColor: theme.buttonNormalDisabledBorder,
'::after': {
display: 'block',
background:
theme.buttonNormalDisabledBorder +
// minus sign adapted from packages/desktop-client/src/icons/v1/add.svg
// eslint-disable-next-line rulesdir/typography
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /></svg>\') 9px 9px',
width: 9,
height: 9,
content: ' ',
},
},
':checked': {
border: '1px solid ' + theme.checkboxBorderSelected,
backgroundColor: theme.checkboxBackgroundSelected,
'::after': {
background:
theme.checkboxBackgroundSelected +
// plus sign from packages/desktop-client/src/icons/v1/add.svg
// eslint-disable-next-line rulesdir/typography
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /><path fill="white" className="path" d="M11.5,23 C10.6715729,23 10,22.3284271 10,21.5 L10,1.5 C10,0.671572875 10.6715729,1.52179594e-16 11.5,0 C12.3284271,-1.52179594e-16 13,0.671572875 13,1.5 L13,21.5 C13,22.3284271 12.3284271,23 11.5,23 Z" /></svg>\') 9px 9px',
},
},
}
}
/>
</Tooltip>
)}
</Field>
)}
<Field width={200}>
{showParsed ? (
{transaction.isMatchedTransaction ? (
<View>
<Stack direction="row" align="flex-start">
<View>
<SvgDownAndRightArrow width={16} height={16} />
</View>
<View>{formatDate(transaction.date, dateFormat)}</View>
</Stack>
</View>
) : showParsed ? (
<ParsedDate
parseDateFormat={parseDateFormat}
dateFormat={dateFormat}
@@ -625,7 +724,9 @@ function FieldMappings({
return null;
}
const options = Object.keys(transactions[0]);
const { existing, ignored, selected, selected_merge, trx_id, ...trans } =
transactions[0];
const options = Object.keys(trans);
mappings = mappings || {};
return (
@@ -738,8 +839,13 @@ function FieldMappings({
export function ImportTransactions({ modalProps, options }) {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const prefs = useLocalPrefs();
const { parseTransactions, importTransactions, getPayees, savePrefs } =
useActions();
const {
parseTransactions,
importTransactions,
importPreviewTransactions,
getPayees,
savePrefs,
} = useActions();
const [multiplierAmount, setMultiplierAmount] = useState('');
const [loadingState, setLoadingState] = useState('parsing');
@@ -754,6 +860,7 @@ export function ImportTransactions({ modalProps, options }) {
const [flipAmount, setFlipAmount] = useState(false);
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
const [reconcile, setReconcile] = useState(true);
const [previewTrigger, setPreviewTrigger] = useState(0);
const { accountId, categories, onImported } = options;
// This cannot be set after parsing the file, because changing it
@@ -783,7 +890,18 @@ export function ImportTransactions({ modalProps, options }) {
setFilename(filename);
setFileType(filetype);
const { errors, transactions } = await parseTransactions(filename, options);
const { errors, transactions: parsedTransactions } =
await parseTransactions(filename, options);
let index = 0;
const transactions = parsedTransactions.map(trans => {
// Add a transient transaction id to match preview with imported transactions
trans.trx_id = index++;
// Select all parsed transactions before first preview run
trans.selected = true;
return trans;
});
setLoadingState(null);
setError(null);
@@ -794,8 +912,14 @@ export function ImportTransactions({ modalProps, options }) {
message: errors[0].message || 'Internal error',
});
} else {
let flipAmount = false;
let fieldMappings = null;
let splitMode = false;
let parseDateFormat = null;
if (filetype === 'csv' || filetype === 'qif') {
setFlipAmount(prefs[`flip-amount-${accountId}-${filetype}`] || false);
flipAmount = prefs[`flip-amount-${accountId}-${filetype}`] || false;
setFlipAmount(flipAmount);
}
if (filetype === 'csv') {
@@ -804,21 +928,22 @@ export function ImportTransactions({ modalProps, options }) {
? JSON.parse(mappings)
: getInitialMappings(transactions);
fieldMappings = mappings;
setFieldMappings(mappings);
// Set initial split mode based on any saved mapping
const initialSplitMode = !!(mappings.outflow || mappings.inflow);
setSplitMode(initialSplitMode);
splitMode = !!(mappings.outflow || mappings.inflow);
setSplitMode(splitMode);
setParseDateFormat(
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, mappings),
);
getInitialDateFormat(transactions, mappings);
setParseDateFormat(parseDateFormat);
} else if (filetype === 'qif') {
setParseDateFormat(
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, { date: 'date' }),
);
getInitialDateFormat(transactions, { date: 'date' });
setParseDateFormat(parseDateFormat);
} else {
setFieldMappings(null);
setParseDateFormat(null);
@@ -827,7 +952,18 @@ export function ImportTransactions({ modalProps, options }) {
// Reverse the transactions because it's very common for them to
// be ordered ascending, but we show transactions descending by
// date. This is purely cosmetic.
setTransactions(transactions.reverse());
const transactionPreview = await getImportPreview(
transactions.reverse(),
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
);
setTransactions(transactionPreview);
}
}
@@ -835,6 +971,7 @@ export function ImportTransactions({ modalProps, options }) {
const amt = e;
if (!amt || amt.match(/^\d{1,}(\.\d{0,4})?$/)) {
setMultiplierAmount(amt);
runImportPreview();
}
}
@@ -902,7 +1039,54 @@ export function ImportTransactions({ modalProps, options }) {
}
function onUpdateFields(field, name) {
setFieldMappings({ ...fieldMappings, [field]: name === '' ? null : name });
const newFieldMappings = {
...fieldMappings,
[field]: name === '' ? null : name,
};
setFieldMappings(newFieldMappings);
runImportPreview();
}
function onCheckTransaction(trx_id) {
const newTransactions = transactions.map(trans => {
if (trans.trx_id === trx_id) {
if (trans.existing) {
// 3-states management for transactions with existing (merged transactions)
// flow of states:
// (selected true && selected_merge true)
// => (selected true && selected_merge false)
// => (selected false)
// => back to (selected true && selected_merge true)
if (!trans.selected) {
return {
...trans,
selected: true,
selected_merge: true,
};
} else if (trans.selected_merge) {
return {
...trans,
selected: true,
selected_merge: false,
};
} else {
return {
...trans,
selected: false,
selected_merge: false,
};
}
} else {
return {
...trans,
selected: !trans.selected,
};
}
}
return trans;
});
setTransactions(newTransactions);
}
async function onImport() {
@@ -912,6 +1096,16 @@ export function ImportTransactions({ modalProps, options }) {
let errorMessage;
for (let trans of transactions) {
if (
trans.isMatchedTransaction ||
(reconcile && !trans.selected && !trans.ignored)
) {
// skip transactions that are
// - matched transaction (existing transaction added to show update changes)
// - unselected transactions that are not ignored by the reconcilation algorithm (only when reconcilation is enabled)
continue;
}
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
const date =
@@ -941,7 +1135,29 @@ export function ImportTransactions({ modalProps, options }) {
const category_id = parseCategoryFields(trans, categories.list);
trans.category = category_id;
const { inflow, outflow, inOut, ...finalTransaction } = trans;
const {
inflow,
outflow,
inOut,
existing,
ignored,
selected,
selected_merge,
trx_id,
...finalTransaction
} = trans;
if (
reconcile &&
((trans.ignored && trans.selected) ||
(trans.existing && trans.selected && !trans.selected_merge))
) {
// in reconcile mode, force transaction add for
// - ignored transactions (aleardy existing) that are checked
// - transactions with existing (merged transactions) that are not selected_merge
finalTransaction.forceAddTransaction = true;
}
finalTransactions.push({
...finalTransaction,
date,
@@ -994,6 +1210,156 @@ export function ImportTransactions({ modalProps, options }) {
modalProps.onClose();
}
const runImportPreviewCallback = useCallback(async () => {
const transactionPreview = await getImportPreview(
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
);
setTransactions(transactionPreview);
}, [
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
]);
useEffect(() => {
runImportPreviewCallback();
}, [previewTrigger]);
function runImportPreview() {
setPreviewTrigger(value => value + 1);
}
async function getImportPreview(
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
) {
const previewTransactions = [];
for (let trans of transactions) {
if (trans.isMatchedTransaction) {
// skip transactions that are matched transaction (existing transaction added to show update changes)
continue;
}
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
const date = isOfxFile(filetype)
? trans.date
: parseDate(trans.date, parseDateFormat);
if (date == null) {
console.log(
`Unable to parse date ${
trans.date || '(empty)'
} with given date format`,
);
break;
}
const { amount } = parseAmountFields(
trans,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
);
if (amount == null) {
console.log(`Transaction on ${trans.date} has no amount`);
break;
}
const category_id = parseCategoryFields(trans, categories.list);
if (category_id != null) {
trans.category = category_id;
}
const {
inflow,
outflow,
inOut,
existing,
ignored,
selected,
selected_merge,
...finalTransaction
} = trans;
previewTransactions.push({
...finalTransaction,
date,
amount: amountToInteger(amount),
cleared: clearOnImport,
});
}
// Retreive the transactions that would be updated (along with the existing trx)
const previewTrx = await importPreviewTransactions(
accountId,
previewTransactions,
);
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
map[entry.transaction.trx_id] = entry;
return map;
}, {});
return transactions
.filter(trans => !trans.isMatchedTransaction)
.reduce((previous, current_trx) => {
let next = previous;
const entry = matchedUpdateMap[current_trx.trx_id];
const existing_trx = entry?.existing;
// if the transaction is matched with an existing one for update
current_trx.existing = !!existing_trx;
// if the transaction is an update that will be ignored
// (reconciled transactions or no change detected)
current_trx.ignored = entry?.ignored || false;
current_trx.selected = !current_trx.ignored;
current_trx.selected_merge = current_trx.existing;
next = next.concat({ ...current_trx });
if (existing_trx) {
// add the updated existing transaction in the list, with the
// isMatchedTransaction flag to identify it in display and not send it again
existing_trx.isMatchedTransaction = true;
existing_trx.category = categories.list.find(
cat => cat.id === existing_trx.category,
)?.name;
// add parent transaction attribute to mimic behaviour
existing_trx.trx_id = current_trx.trx_id;
existing_trx.existing = current_trx.existing;
existing_trx.selected = current_trx.selected;
existing_trx.selected_merge = current_trx.selected_merge;
next = next.concat({ ...existing_trx });
}
return next;
}, []);
}
const headers = [
{ name: 'Date', width: 200 },
{ name: 'Payee', width: 'flex' },
@@ -1001,6 +1367,9 @@ export function ImportTransactions({ modalProps, options }) {
{ name: 'Category', width: 'flex' },
];
if (reconcile) {
headers.unshift({ name: ' ', width: 31 });
}
if (inOutMode) {
headers.push({ name: 'In/Out', width: 90, style: { textAlign: 'left' } });
}
@@ -1038,7 +1407,11 @@ export function ImportTransactions({ modalProps, options }) {
<TableHeader headers={headers} />
<TableWithNavigator
items={transactions}
items={transactions.filter(
trans =>
!trans.isMatchedTransaction ||
(trans.isMatchedTransaction && reconcile),
)}
fields={['payee', 'category', 'amount']}
style={{ backgroundColor: theme.tableHeaderBackground }}
getItemKey={index => index}
@@ -1070,6 +1443,8 @@ export function ImportTransactions({ modalProps, options }) {
flipAmount={flipAmount}
multiplierAmount={multiplierAmount}
categories={categories.list}
onCheckTransaction={onCheckTransaction}
reconcile={reconcile}
/>
</View>
)}
@@ -1094,7 +1469,7 @@ export function ImportTransactions({ modalProps, options }) {
)}
{filetype === 'csv' && (
<View style={{ marginTop: 25 }}>
<View style={{ marginTop: 10 }}>
<FieldMappings
transactions={transactions}
onChange={onUpdateFields}
@@ -1128,16 +1503,16 @@ export function ImportTransactions({ modalProps, options }) {
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(state => !state);
setReconcile(!reconcile);
}}
>
Reconcile transactions
Merge with existing transactions
</CheckboxOption>
)}
{/*Import Options */}
{(filetype === 'qif' || filetype === 'csv') && (
<View style={{ marginTop: 25 }}>
<View style={{ marginTop: 10 }}>
<Stack
direction="row"
align="flex-start"
@@ -1151,14 +1526,17 @@ export function ImportTransactions({ modalProps, options }) {
transactions={transactions}
fieldMappings={fieldMappings}
parseDateFormat={parseDateFormat}
onChange={setParseDateFormat}
onChange={value => {
setParseDateFormat(value);
runImportPreview();
}}
/>
)}
</View>
{/* CSV Options */}
{filetype === 'csv' && (
<View style={{ marginLeft: 25, gap: 5 }}>
<View style={{ marginLeft: 10, gap: 5 }}>
<SectionLabel title="CSV OPTIONS" />
<label
style={{
@@ -1219,23 +1597,26 @@ export function ImportTransactions({ modalProps, options }) {
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(state => !state);
setReconcile(!reconcile);
}}
>
Reconcile transactions
Merge with existing transactions
</CheckboxOption>
</View>
)}
<View style={{ flex: 1 }} />
<View style={{ marginRight: 25, gap: 5 }}>
<View style={{ marginRight: 10, gap: 5 }}>
<SectionLabel title="AMOUNT OPTIONS" />
<CheckboxOption
id="form_flip"
checked={flipAmount}
disabled={splitMode || inOutMode}
onChange={() => setFlipAmount(!flipAmount)}
onChange={() => {
setFlipAmount(!flipAmount);
runImportPreview();
}}
>
Flip amount
</CheckboxOption>
@@ -1245,7 +1626,10 @@ export function ImportTransactions({ modalProps, options }) {
id="form_split"
checked={splitMode}
disabled={inOutMode || flipAmount}
onChange={onSplitMode}
onChange={() => {
onSplitMode();
runImportPreview();
}}
>
Split amount into separate inflow/outflow columns
</CheckboxOption>
@@ -1253,7 +1637,10 @@ export function ImportTransactions({ modalProps, options }) {
inOutMode={inOutMode}
outValue={outValue}
disabled={splitMode || flipAmount}
onToggle={() => setInOutMode(!inOutMode)}
onToggle={() => {
setInOutMode(!inOutMode);
runImportPreview();
}}
onChangeText={setOutValue}
/>
</>
@@ -1264,6 +1651,7 @@ export function ImportTransactions({ modalProps, options }) {
onToggle={() => {
setMultiplierEnabled(!multiplierEnabled);
setMultiplierAmount('');
runImportPreview();
}}
onChangeAmount={onMultiplierChange}
/>
@@ -1279,15 +1667,21 @@ export function ImportTransactions({ modalProps, options }) {
alignSelf: 'flex-end',
flexDirection: 'row',
alignItems: 'center',
gap: '1em',
}}
>
<ButtonWithLoading
type="primary"
disabled={transactions.length === 0}
disabled={
transactions?.filter(trans => !trans.isMatchedTransaction)
.length === 0
}
loading={loadingState === 'importing'}
onClick={onImport}
>
Import {transactions.length} transactions
Import{' '}
{transactions?.filter(trans => !trans.isMatchedTransaction).length}{' '}
transactions
</ButtonWithLoading>
</View>
</View>

View File

@@ -0,0 +1,262 @@
import { useLocation } from 'react-router-dom';
import * as Platform from 'loot-core/src/client/platform';
import { Modal, type ModalProps } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
type KeyboardShortcutsModalProps = {
modalProps?: Partial<ModalProps>;
};
type KeyIconProps = {
shortcut: string;
};
type GroupHeadingProps = {
group: string;
};
type ShortcutProps = {
shortcut: string;
description: string;
meta?: string;
shift?: boolean;
};
function KeyIcon({ shortcut }: KeyIconProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
backgroundColor: '#fff',
color: '#000',
border: '1px solid #000',
borderRadius: 8,
minWidth: 35,
minHeight: 35,
filter: 'drop-shadow(1px 1px)',
padding: 5,
}}
>
{shortcut}
</div>
);
}
function GroupHeading({ group }: GroupHeadingProps) {
return (
<Text
style={{
fontWeight: 'bold',
fontSize: 16,
marginTop: 20,
marginBottom: 10,
}}
>
{group}:
</Text>
);
}
function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
return (
<div
style={{
display: 'flex',
marginBottom: 10,
marginLeft: 20,
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
marginRight: 10,
}}
>
{meta && (
<>
<KeyIcon shortcut={meta} />
<Text
style={{
display: 'flex',
alignItems: 'center',
textAlign: 'center',
fontSize: 16,
paddingLeft: 2,
paddingRight: 2,
}}
>
+
</Text>
</>
)}
{shift && (
<>
<KeyIcon shortcut="Shift" />
<Text
style={{
display: 'flex',
alignItems: 'center',
textAlign: 'center',
fontSize: 16,
paddingLeft: 2,
paddingRight: 2,
}}
>
+
</Text>
</>
)}
<KeyIcon shortcut={shortcut} />
</div>
<div
style={{
display: 'flex',
flexDirection: 'row',
flex: 1,
}}
/>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
maxWidth: 300,
}}
>
{description}
</div>
</div>
);
}
export function KeyboardShortcutModal({
modalProps,
}: KeyboardShortcutsModalProps) {
const location = useLocation();
const onAccounts = location.pathname.startsWith('/accounts');
const ctrl = Platform.OS === 'mac' ? '⌘' : 'Ctrl';
return (
<Modal title="Keyboard Shortcuts" {...modalProps}>
<View
style={{
flexDirection: 'row',
}}
>
<View>
<Shortcut
shortcut="O"
description="Close the current budget and open another"
meta={ctrl}
/>
<Shortcut shortcut="?" description="Show this help dialog" />
{onAccounts && (
<>
<Shortcut shortcut="Enter" description="Move down when editing" />
<Shortcut shortcut="Tab" description="Move right when editing" />
<GroupHeading group="Select a transaction, then" />
<Shortcut
shortcut="J"
description="Move to the next transaction down"
/>
<Shortcut
shortcut="K"
description="Move to the next transaction up"
/>
<Shortcut
shortcut="↑"
description="Move to the next transaction down and scroll"
/>
<Shortcut
shortcut="↓"
description="Move to the next transaction up and scroll"
/>
<Shortcut
shortcut="Space"
description="Toggle selection of current transaction"
/>
<Shortcut
shortcut="Space"
description="Toggle all transactions between current and most recently selected transaction"
shift={true}
/>
</>
)}
</View>
<View
style={{
marginLeft: 20,
marginRight: 20,
}}
>
<Shortcut
shortcut="Z"
description="Undo the last change"
meta={ctrl}
/>
<Shortcut
shortcut="Z"
description="Redo the last undone change"
shift={true}
meta={ctrl}
/>
{onAccounts && (
<>
<Shortcut
shortcut="Enter"
description="Move up when editing"
shift={true}
/>
<Shortcut
shortcut="Tab"
description="Move left when editing"
shift={true}
/>
<GroupHeading group="With transaction(s) selected" />
<Shortcut
shortcut="F"
description="Filter to the selected transactions"
/>
<Shortcut
shortcut="D"
description="Delete selected transactions"
/>
<Shortcut
shortcut="A"
description="Set account for selected transactions"
/>
<Shortcut
shortcut="P"
description="Set payee for selected transactions"
/>
<Shortcut
shortcut="N"
description="Set notes for selected transactions"
/>
<Shortcut
shortcut="C"
description="Set category for selected transactions"
/>
<Shortcut
shortcut="L"
description="Toggle cleared for current transaction"
/>
</>
)}
</View>
</View>
</Modal>
);
}

View File

@@ -1,73 +0,0 @@
// @ts-strict-ignore
import React from 'react';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useResponsive } from '../../ResponsiveProvider';
import { styles } from '../../style';
import { Button } from '../common/Button';
import { Link } from '../common/Link';
import { Modal, ModalTitle } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { type CommonModalProps } from '../Modals';
type SwitchBudgetTypeModalProps = {
modalProps: CommonModalProps;
onSwitch: () => void;
};
export function SwitchBudgetTypeModal({
modalProps,
onSwitch,
}: SwitchBudgetTypeModalProps) {
const [budgetType] = useLocalPref('budgetType');
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
height: styles.mobileMinHeight,
}
: {};
return (
<Modal
title={<ModalTitle title="Switch budget type?" shrinkOnOverflow />}
{...modalProps}
>
<>
<Paragraph>
You are currently using a{' '}
<Text style={{ fontWeight: 600 }}>
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}.
</Text>{' '}
Switching will not lose any data and you can always switch back.
</Paragraph>
<Button
type="primary"
style={{
...narrowStyle,
}}
onClick={() => {
onSwitch?.();
modalProps.onClose?.();
}}
>
Switch to a{' '}
{budgetType === 'report' ? 'Rollover budget' : 'Report budget'}
</Button>
<Paragraph
isLast={true}
style={{
marginTop: 10,
}}
>
<Link
variant="external"
to="https://actualbudget.org/docs/experimental/report-budget"
linkColor="muted"
>
How do these types of budgeting work?
</Link>
</Paragraph>
</>
</Modal>
);
}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { css } from 'glamor';
import { useReports } from 'loot-core/src/client/data-hooks/reports';
import { useAccounts } from '../../hooks/useAccounts';
@@ -56,16 +58,21 @@ export function Overview() {
style={{ paddingBottom: MOBILE_NAV_HEIGHT }}
>
<View
style={{
flexDirection: isNarrowWidth ? 'column' : 'row',
className={`${css({
flex: '0 0 auto',
}}
flexDirection: isNarrowWidth ? 'column' : 'row',
flexWrap: isNarrowWidth ? 'nowrap' : 'wrap',
padding: '10',
'> a, > div': {
margin: '10',
},
})}`}
>
<NetWorthCard accounts={accounts} />
<CashFlowCard />
{spendingReportFeatureFlag && <SpendingCard />}
<CustomReportListCards reports={customReports} />
</View>
<CustomReportListCards reports={customReports} />
</Page>
);
}

View File

@@ -2,6 +2,7 @@ import React, { type ReactNode } from 'react';
import { type CustomReportEntity } from 'loot-core/src/types/models';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { Link } from '../common/Link';
import { View } from '../common/View';
@@ -10,7 +11,7 @@ type ReportCardProps = {
to: string;
children: ReactNode;
report?: CustomReportEntity;
flex?: string;
size?: number;
style?: CSSProperties;
};
@@ -18,10 +19,13 @@ export function ReportCard({
to,
report,
children,
flex,
size = 1,
style,
}: ReportCardProps) {
const containerProps = { flex, margin: 15 };
const { isNarrowWidth } = useResponsive();
const containerProps = {
flex: isNarrowWidth ? '1 1' : `0 0 calc(${size * 100}% / 3 - 20px)`,
};
const content = (
<View

View File

@@ -121,7 +121,12 @@ export function NetWorthGraph({
width={width}
height={height}
data={graphData.data}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
margin={{
top: 0,
right: 0,
left: compact ? 0 : computePadding(graphData.data),
bottom: 0,
}}
>
{compact ? null : (
<CartesianGrid strokeDasharray="3 3" vertical={false} />
@@ -180,3 +185,22 @@ export function NetWorthGraph({
</Container>
);
}
/**
* Add left padding for Y-axis for when large amounts get clipped
* @param netWorthData
* @returns left padding for Net worth graph
*/
function computePadding(netWorthData: Array<{ y: number }>) {
/**
* Convert to string notation, get longest string length
*/
const maxLength = Math.max(
...netWorthData.map(({ y }) => {
return Math.round(y).toLocaleString().length;
}),
);
// No additional left padding is required for upto 5 characters
return Math.max(0, (maxLength - 5) * 5);
}

View File

@@ -83,7 +83,7 @@ export function CashFlowCard() {
const income = graphData?.income || 0;
return (
<ReportCard flex={1} to="/reports/cash-flow">
<ReportCard to="/reports/cash-flow">
<View
style={{ flex: 1 }}
onPointerEnter={onCardHover}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { send, sendCatch } from 'loot-core/platform/client/fetch/index';
import * as monthUtils from 'loot-core/src/shared/months';
@@ -47,9 +47,9 @@ export function CustomReportListCards({
const payees = usePayees();
const accounts = useAccounts();
const categories = useCategories();
const { isNarrowWidth } = useResponsive();
const [_firstDayOfWeekIdx] = useLocalPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
const { isNarrowWidth } = useResponsive();
const [isCardHovered, setIsCardHovered] = useState('');
@@ -118,137 +118,90 @@ export function CustomReportListCards({
setNameMenuOpen({ ...nameMenuOpen, [item]: state });
};
const chunkSize = 3;
const groups = useMemo(() => {
return reports
.map((report: CustomReportEntity, i: number) => {
return i % chunkSize === 0 ? reports.slice(i, i + chunkSize) : null;
})
.filter(e => {
return e;
});
}, [reports]);
const remainder = 3 - (reports.length % 3);
if (reports.length === 0) return null;
return (
<View>
{groups.map((group, i) => (
<>
{reports.map((report, id) => (
<View
key={i}
key={id}
style={{
flex: '0 0 auto',
flexDirection: isNarrowWidth ? 'column' : 'row',
flex: isNarrowWidth ? '1 1' : `0 0 calc(100% / 3 - 20px)`,
}}
>
{group &&
group.map((report, id) => (
<ReportCard to="/reports/custom" report={report}>
<View
style={{ flex: 1, padding: 10 }}
onMouseEnter={() =>
setIsCardHovered(report.id === undefined ? '' : report.id)
}
onMouseLeave={() => {
setIsCardHovered('');
onMenuOpen(report.id === undefined ? '' : report.id, false);
}}
>
<View
key={id}
style={
!isNarrowWidth
? {
position: 'relative',
flex: '1',
}
: {
position: 'relative',
}
}
style={{
flexShrink: 0,
paddingBottom: 5,
}}
>
<View style={{ width: '100%', height: '100%' }}>
<ReportCard to="/reports/custom" report={report}>
<View
style={{ flex: 1, padding: 10 }}
onMouseEnter={() =>
setIsCardHovered(
report.id === undefined ? '' : report.id,
)
}
onMouseLeave={() => {
setIsCardHovered('');
onMenuOpen(
report.id === undefined ? '' : report.id,
false,
);
}}
>
<View
style={{
flexShrink: 0,
paddingBottom: 5,
}}
>
<View style={{ flex: 1 }}>
<Block
style={{
...styles.mediumText,
fontWeight: 500,
marginBottom: 5,
}}
role="heading"
>
{report.name}
</Block>
{report.isDateStatic ? (
<DateRange
start={report.startDate}
end={report.endDate}
/>
) : (
<Text style={{ color: theme.pageTextSubdued }}>
{report.dateRange}
</Text>
)}
</View>
</View>
<GetCardData
report={report}
payees={payees}
accounts={accounts}
categories={categories}
earliestTransaction={earliestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
/>
</View>
</ReportCard>
</View>
<View
style={{
textAlign: 'right',
position: 'absolute',
right: 25,
top: 25,
}}
>
<ListCardsPopover
report={report}
onMenuOpen={onMenuOpen}
isCardHovered={isCardHovered}
reportMenu={reportMenu}
onMenuSelect={onMenuSelect}
nameMenuOpen={nameMenuOpen}
name={name}
setName={setName}
onAddUpdate={onAddUpdate}
err={err}
onNameMenuOpen={onNameMenuOpen}
deleteMenuOpen={deleteMenuOpen}
onDeleteMenuOpen={onDeleteMenuOpen}
onDelete={onDelete}
/>
<View style={{ flex: 1 }}>
<Block
style={{
...styles.mediumText,
fontWeight: 500,
marginBottom: 5,
}}
role="heading"
>
{report.name}
</Block>
{report.isDateStatic ? (
<DateRange start={report.startDate} end={report.endDate} />
) : (
<Text style={{ color: theme.pageTextSubdued }}>
{report.dateRange}
</Text>
)}
</View>
</View>
))}
{remainder !== 3 &&
i + 1 === groups.length &&
[...Array(remainder)].map((e, i) => (
<View key={i} style={{ flex: 1 }} />
))}
<GetCardData
report={report}
payees={payees}
accounts={accounts}
categories={categories}
earliestTransaction={earliestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
/>
</View>
</ReportCard>
<View
style={{
textAlign: 'right',
position: 'absolute',
right: 10,
top: 10,
}}
>
<ListCardsPopover
report={report}
onMenuOpen={onMenuOpen}
isCardHovered={isCardHovered}
reportMenu={reportMenu}
onMenuSelect={onMenuSelect}
nameMenuOpen={nameMenuOpen}
name={name}
setName={setName}
onAddUpdate={onAddUpdate}
err={err}
onNameMenuOpen={onNameMenuOpen}
deleteMenuOpen={deleteMenuOpen}
onDeleteMenuOpen={onDeleteMenuOpen}
onDelete={onDelete}
/>
</View>
</View>
))}
</View>
</>
);
}

View File

@@ -29,7 +29,7 @@ export function NetWorthCard({ accounts }) {
const data = useReport('net_worth', params);
return (
<ReportCard flex={2} to="/reports/net-worth">
<ReportCard size={2} to="/reports/net-worth">
<View
style={{ flex: 1 }}
onPointerEnter={onCardHover}

View File

@@ -39,7 +39,7 @@ export function SpendingCard() {
const showLastMonth = data && Math.abs(data.intervalData[27].lastMonth) > 0;
return (
<ReportCard flex="1" to="/reports/spending">
<ReportCard to="/reports/spending">
<View
style={{ flex: 1 }}
onPointerEnter={() => setIsCardHovered(true)}

View File

@@ -363,7 +363,6 @@ export function SchedulesTable({
items={items as ScheduleEntity[]}
renderItem={renderItem}
renderEmpty={filter ? 'No matching schedules' : 'No schedules'}
allowPopupsEscape={items.length < 6}
/>
</View>
);

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { loadPrefs } from 'loot-core/src/client/actions';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import * as monthUtils from 'loot-core/src/shared/months';
import { useLocalPref } from '../../hooks/useLocalPref';
import { switchBudgetType } from '../budget/util';
import { ButtonWithLoading } from '../common/Button2';
import { Link } from '../common/Link';
import { Text } from '../common/Text';
import { Setting } from './UI';
export function BudgetTypeSettings() {
const dispatch = useDispatch();
const [budgetType] = useLocalPref('budgetType');
const [loading, setLoading] = useState(false);
const currentMonth = monthUtils.currentMonth();
const [startMonthPref] = useLocalPref('budget.startMonth');
const startMonth = startMonthPref || currentMonth;
const spreadsheet = useSpreadsheet();
function onSwitchType() {
setLoading(true);
if (!loading) {
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
switchBudgetType(
newBudgetType,
spreadsheet,
{
start: startMonth,
end: startMonth,
},
startMonth,
async () => {
dispatch(loadPrefs());
setLoading(false);
},
);
}
}
return (
<Setting
primaryAction={
<ButtonWithLoading isLoading={loading} onPress={onSwitchType}>
Switch to {budgetType === 'report' ? 'envelope' : 'tracking'}{' '}
budgeting
</ButtonWithLoading>
}
>
<Text>
<strong>Envelope budgeting</strong> (recommended) digitally mimics
physical envelope budgeting system by allocating funds into virtual
envelopes for different expenses. It helps track spending and ensure you
dont overspend in any category.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/envelope-budgeting"
linkColor="purple"
>
Learn more
</Link>
</Text>
<Text>
With <strong>tracking budgeting</strong>, category balances reset each
month, and funds are managed using a Saved metric instead of To Be
Budgeted. Income is forecasted to plan future spending, rather than
relying on current available funds.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/experimental/report-budget"
linkColor="purple"
>
Learn more
</Link>
</Text>
</Setting>
);
}

View File

@@ -6,6 +6,7 @@ import * as Platform from 'loot-core/src/client/platform';
import { listen } from 'loot-core/src/platform/client/fetch';
import { useActions } from '../../hooks/useActions';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { useGlobalPref } from '../../hooks/useGlobalPref';
import { useLatestVersion, useIsOutdated } from '../../hooks/useLatestVersion';
import { useLocalPref } from '../../hooks/useLocalPref';
@@ -23,6 +24,7 @@ import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs';
import { Page } from '../Page';
import { useServerVersion } from '../ServerContext';
import { BudgetTypeSettings } from './BudgetTypeSettings';
import { EncryptionSettings } from './Encryption';
import { ExperimentalFeatures } from './Experimental';
import { ExportBudget } from './Export';
@@ -178,6 +180,7 @@ export function Settings() {
<ThemeSettings />
<FormatSettings />
<EncryptionSettings />
{useFeatureFlag('reportBudget') && <BudgetTypeSettings />}
<ExportBudget />
<AdvancedToggle>

View File

@@ -1,6 +1,8 @@
import React, { useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Resizable } from 're-resizable';
import {
closeBudget,
moveAccount,
@@ -12,9 +14,11 @@ import { useAccounts } from '../../hooks/useAccounts';
import { useGlobalPref } from '../../hooks/useGlobalPref';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useNavigate } from '../../hooks/useNavigate';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { SvgExpandArrow } from '../../icons/v0';
import { SvgReports, SvgWallet } from '../../icons/v1';
import { SvgCalendar } from '../../icons/v2';
import { useResponsive } from '../../ResponsiveProvider';
import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { InitialFocus } from '../common/InitialFocus';
@@ -30,20 +34,28 @@ import { useSidebar } from './SidebarProvider';
import { ToggleButton } from './ToggleButton';
import { Tools } from './Tools';
export const SIDEBAR_WIDTH = 240;
export function Sidebar() {
const hasWindowButtons = !Platform.isBrowser && Platform.OS === 'mac';
const dispatch = useDispatch();
const sidebar = useSidebar();
const accounts = useAccounts();
const { width } = useResponsive();
const [showClosedAccounts, setShowClosedAccountsPref] = useLocalPref(
'ui.showClosedAccounts',
);
const [isFloating = false, setFloatingSidebarPref] =
useGlobalPref('floatingSidebar');
const [_sidebarWidth, setSidebarWidth] = useLocalPref('sidebarWidth');
const DEFAULT_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = width / 3;
const MIN_SIDEBAR_WIDTH = 200;
const sidebarWidth = Math.min(
MAX_SIDEBAR_WIDTH,
Math.max(MIN_SIDEBAR_WIDTH, _sidebarWidth || DEFAULT_SIDEBAR_WIDTH),
);
async function onReorder(
id: string,
dropPos: 'top' | 'bottom',
@@ -70,72 +82,96 @@ export function Sidebar() {
setShowClosedAccountsPref(!showClosedAccounts);
};
const containerRef = useResizeObserver(rect => {
setSidebarWidth(rect.width);
});
return (
<View
style={{
width: SIDEBAR_WIDTH,
color: theme.sidebarItemText,
backgroundColor: theme.sidebarBackground,
'& .float': {
opacity: isFloating ? 1 : 0,
transition: 'opacity .25s, width .25s',
width: hasWindowButtons || isFloating ? null : 0,
},
'&:hover .float': {
opacity: 1,
width: hasWindowButtons ? null : 'auto',
},
flex: 1,
...styles.darkScrollbar,
<Resizable
defaultSize={{
width: sidebarWidth,
height: '100%',
}}
maxWidth={MAX_SIDEBAR_WIDTH}
minWidth={MIN_SIDEBAR_WIDTH}
enable={{
top: false,
right: true,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
>
<View
innerRef={containerRef}
style={{
paddingTop: 35,
height: 30,
flexDirection: 'row',
alignItems: 'center',
margin: '0 8px 23px 20px',
transition: 'padding .4s',
...(hasWindowButtons && {
paddingTop: 20,
justifyContent: 'flex-start',
}),
color: theme.sidebarItemText,
height: '100%',
backgroundColor: theme.sidebarBackground,
'& .float': {
opacity: isFloating ? 1 : 0,
transition: 'opacity .25s, width .25s',
width: hasWindowButtons || isFloating ? null : 0,
},
'&:hover .float': {
opacity: 1,
width: hasWindowButtons ? null : 'auto',
},
flex: 1,
...styles.darkScrollbar,
}}
>
<EditableBudgetName />
<View style={{ flex: 1, flexDirection: 'row' }} />
{!sidebar.alwaysFloats && (
<ToggleButton isFloating={isFloating} onFloat={onFloat} />
)}
</View>
<View style={{ overflow: 'auto' }}>
<Item title="Budget" Icon={SvgWallet} to="/budget" />
<Item title="Reports" Icon={SvgReports} to="/reports" />
<Item title="Schedules" Icon={SvgCalendar} to="/schedules" />
<Tools />
<View
style={{
height: 1,
backgroundColor: theme.sidebarItemBackgroundHover,
marginTop: 15,
flexShrink: 0,
paddingTop: 35,
height: 30,
flexDirection: 'row',
alignItems: 'center',
margin: '0 8px 23px 20px',
transition: 'padding .4s',
...(hasWindowButtons && {
paddingTop: 20,
justifyContent: 'flex-start',
}),
}}
/>
>
<EditableBudgetName />
<Accounts
onAddAccount={onAddAccount}
onToggleClosedAccounts={onToggleClosedAccounts}
onReorder={onReorder}
/>
<View style={{ flex: 1, flexDirection: 'row' }} />
{!sidebar.alwaysFloats && (
<ToggleButton isFloating={isFloating} onFloat={onFloat} />
)}
</View>
<View style={{ overflow: 'auto' }}>
<Item title="Budget" Icon={SvgWallet} to="/budget" />
<Item title="Reports" Icon={SvgReports} to="/reports" />
<Item title="Schedules" Icon={SvgCalendar} to="/schedules" />
<Tools />
<View
style={{
height: 1,
backgroundColor: theme.sidebarItemBackgroundHover,
marginTop: 15,
flexShrink: 0,
}}
/>
<Accounts
onAddAccount={onAddAccount}
onToggleClosedAccounts={onToggleClosedAccounts}
onReorder={onReorder}
/>
</View>
</View>
</View>
</Resizable>
);
}

View File

@@ -1,10 +1,12 @@
import React from 'react';
import { useDebounceCallback } from 'usehooks-ts';
import { useGlobalPref } from '../../hooks/useGlobalPref';
import { useResponsive } from '../../ResponsiveProvider';
import { View } from '../common/View';
import { SIDEBAR_WIDTH, Sidebar } from './Sidebar';
import { Sidebar } from './Sidebar';
import { useSidebar } from './SidebarProvider';
export function FloatableSidebar() {
@@ -14,25 +16,30 @@ export function FloatableSidebar() {
const { isNarrowWidth } = useResponsive();
const sidebarShouldFloat = floatingSidebar || sidebar.alwaysFloats;
const debouncedHideSidebar = useDebounceCallback(
() => sidebar.setHidden(true),
350,
);
return isNarrowWidth ? null : (
<View
onMouseOver={
sidebarShouldFloat
? e => {
debouncedHideSidebar.cancel();
e.stopPropagation();
sidebar.setHidden(false);
}
: undefined
}
onMouseLeave={
sidebarShouldFloat ? () => sidebar.setHidden(true) : undefined
sidebarShouldFloat ? () => debouncedHideSidebar() : undefined
}
style={{
position: sidebarShouldFloat ? 'absolute' : undefined,
top: 12,
top: 8,
// If not floating, the -50 takes into account the transform below
bottom: sidebarShouldFloat ? 12 : -50,
bottom: sidebarShouldFloat ? 8 : -50,
zIndex: 1001,
borderRadius: sidebarShouldFloat ? '0 6px 6px 0' : 0,
overflow: 'hidden',
@@ -40,12 +47,10 @@ export function FloatableSidebar() {
!sidebarShouldFloat || sidebar.hidden
? 'none'
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
transform: `translateY(${!sidebarShouldFloat ? -12 : 0}px)
transform: `translateY(${!sidebarShouldFloat ? -8 : 0}px)
translateX(${
sidebarShouldFloat && sidebar.hidden
? -SIDEBAR_WIDTH
: 0
}px)`,
sidebarShouldFloat && sidebar.hidden ? '-100' : '0'
}%)`,
transition:
'transform .5s, box-shadow .5s, border-radius .5s, bottom .5s',
}}

View File

@@ -14,6 +14,7 @@ import React, {
type UIEvent,
type ReactElement,
type Ref,
type MutableRefObject,
} from 'react';
import { useStore } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
@@ -42,7 +43,6 @@ import {
import { type Binding } from './spreadsheet';
import { type FormatType, useFormat } from './spreadsheet/useFormat';
import { useSheetValue } from './spreadsheet/useSheetValue';
import { IntersectionBoundary } from './tooltips';
export const ROW_HEIGHT = 32;
@@ -276,16 +276,13 @@ type RowProps = ComponentProps<typeof View> & {
inset?: number;
collapsed?: boolean;
};
export function Row({
inset = 0,
collapsed,
children,
height,
style,
...nativeProps
}: RowProps) {
export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
{ inset = 0, collapsed, children, height, style, ...nativeProps },
ref,
) {
return (
<View
ref={ref}
style={{
flexDirection: 'row',
height: height || ROW_HEIGHT,
@@ -302,7 +299,7 @@ export function Row({
{inset !== 0 && <Field width={inset} />}
</View>
);
}
});
const inputCellStyle = {
padding: '5px 3px',
@@ -900,9 +897,8 @@ type TableProps<T extends TableItem = TableItem> = {
loadMore?: () => void;
style?: CSSProperties;
navigator?: ReturnType<typeof useTableNavigator<T>>;
listRef?: unknown;
listContainerRef?: MutableRefObject<HTMLDivElement>;
onScroll?: () => void;
allowPopupsEscape?: boolean;
isSelected?: (id: TableItem['id']) => boolean;
saveScrollWidth?: (parent, child) => void;
};
@@ -924,9 +920,9 @@ export const Table = forwardRef(
style,
navigator,
onScroll,
allowPopupsEscape,
isSelected,
saveScrollWidth,
listContainerRef,
...props
},
ref,
@@ -942,7 +938,8 @@ export const Table = forwardRef(
const { onEdit, editingId, focusedField, getNavigatorProps } = navigator;
const list = useRef(null);
const listContainer = useRef(null);
const listContainerInnerRef = useRef<HTMLDivElement>(null);
const listContainer = listContainerRef || listContainerInnerRef;
const scrollContainer = useRef(null);
const initialScrollTo = useRef(null);
const listInitialized = useRef(false);
@@ -1144,37 +1141,33 @@ export const Table = forwardRef(
}
return (
<IntersectionBoundary.Provider
value={!allowPopupsEscape && listContainer}
>
<AvoidRefocusScrollProvider>
<FixedSizeList
ref={list}
header={contentHeader}
innerRef={listContainer}
outerRef={scrollContainer}
width={width}
height={height}
renderRow={renderRow}
itemCount={count || items.length}
itemSize={rowHeight - 1}
itemKey={
getItemKey || ((index: number) => items[index].id)
}
indexForKey={key =>
items.findIndex(item => item.id === key)
}
initialScrollOffset={
initialScrollTo.current
? getScrollOffset(height, initialScrollTo.current)
: 0
}
overscanCount={5}
onItemsRendered={onItemsRendered}
onScroll={onScroll}
/>
</AvoidRefocusScrollProvider>
</IntersectionBoundary.Provider>
<AvoidRefocusScrollProvider>
<FixedSizeList
ref={list}
header={contentHeader}
innerRef={listContainer}
outerRef={scrollContainer}
width={width}
height={height}
renderRow={renderRow}
itemCount={count || items.length}
itemSize={rowHeight - 1}
itemKey={
getItemKey || ((index: number) => items[index].id)
}
indexForKey={key =>
items.findIndex(item => item.id === key)
}
initialScrollOffset={
initialScrollTo.current
? getScrollOffset(height, initialScrollTo.current)
: 0
}
overscanCount={5}
onItemsRendered={onItemsRendered}
onScroll={onScroll}
/>
</AvoidRefocusScrollProvider>
);
}}
</AutoSizer>

View File

@@ -1,394 +0,0 @@
// @ts-strict-ignore
import {
Component,
createContext,
createRef,
type RefObject,
type ReactNode,
type ContextType,
} from 'react';
import ReactDOM from 'react-dom';
import { css } from 'glamor';
import { type CSSProperties, styles, theme } from '../style';
export const IntersectionBoundary = createContext<RefObject<HTMLElement>>(null);
type TooltipPosition =
| 'top'
| 'top-left'
| 'top-right'
| 'bottom'
| 'bottom-left'
| 'bottom-right'
| 'bottom-stretch'
| 'top-stretch'
| 'bottom-center'
| 'top-center'
| 'left-center'
| 'right';
type TooltipProps = {
position?: TooltipPosition;
onClose?: () => void;
forceLayout?: boolean;
forceTop?: number;
ignoreBoundary?: boolean;
targetRect?: DOMRect;
offset?: number;
style?: CSSProperties;
width?: number;
children: ReactNode;
targetHeight?: number;
};
type MutableDomRect = {
top: number;
left: number;
width: number;
height: number;
};
// @deprecated: please use `Tooltip` component in `common` folder
export class Tooltip extends Component<TooltipProps> {
static contextType = IntersectionBoundary;
position: TooltipPosition;
contentRef: RefObject<HTMLDivElement>;
cleanup: () => void;
target: HTMLDivElement;
context: ContextType<typeof IntersectionBoundary> = this.context; // assign type to context without using declare.
constructor(props) {
super(props);
this.position = props.position || 'bottom-right';
this.contentRef = createRef<HTMLDivElement>();
}
isHTMLElement(element: unknown): element is HTMLElement {
return element instanceof HTMLElement;
}
setup() {
this.layout();
const pointerDownHandler = e => {
let node = e.target;
while (node && node !== document.documentElement) {
// Allow clicking reach popover that mount outside of
// tooltips. Might need to think about this more, like what
// kind of things can be click that shouldn't close a tooltip?
if (node.dataset.istooltip || node.dataset.reachPopover != null) {
break;
}
node = node.parentNode;
}
if (node === document.documentElement) {
this.props.onClose?.();
}
};
const escHandler = e => {
if (e.key === 'Escape') {
this.props.onClose?.();
}
};
window.document.addEventListener('pointerdown', pointerDownHandler, false);
this.contentRef.current?.addEventListener('keydown', escHandler, false);
this.cleanup = () => {
window.document.removeEventListener(
'pointerdown',
pointerDownHandler,
false,
);
this.contentRef.current?.removeEventListener(
'keydown',
escHandler,
false,
);
};
}
componentDidMount() {
if (this.getContainer()) {
this.setup();
} else {
// TODO: write comment :)
this.forceUpdate(() => {
if (this.getContainer()) {
this.setup();
} else {
console.log('Warning: could not mount tooltip, container missing');
}
});
}
}
componentDidUpdate(prevProps) {
// If providing the target rect manually, we can dynamically
// update to it. We can't do this if we are reading directly from
// the DOM since we don't know when it's updated.
if (
prevProps.targetRect !== this.props.targetRect ||
prevProps.forceTop !== this.props.forceTop ||
this.props.forceLayout
) {
this.layout();
}
}
getContainer(): HTMLElement {
const { ignoreBoundary = false } = this.props;
if (!ignoreBoundary && this.context) {
return this.context.current;
}
return document.body;
}
getBoundsContainer() {
// If the container is a scrollable element, we want to do all the
// bounds checking on the parent DOM element instead
const container = this.getContainer();
if (
container.parentNode &&
this.isHTMLElement(container.parentNode) &&
container.parentNode.style.overflow === 'auto'
) {
return container.parentNode;
}
return container;
}
layout() {
const { targetRect, offset = 0 } = this.props;
const contentEl = this.contentRef.current;
if (!contentEl) {
return;
}
const box = contentEl.getBoundingClientRect();
const anchorEl = this.target.parentNode;
let anchorRect: MutableDomRect | undefined =
targetRect ||
(this.isHTMLElement(anchorEl)
? anchorEl?.getBoundingClientRect()
: undefined);
if (!anchorRect) {
return;
}
// Copy it so we can mutate it
anchorRect = {
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
height: anchorRect.height,
};
const container = this.getBoundsContainer();
if (!container) {
return;
}
const containerRect = container.getBoundingClientRect();
anchorRect.left -= containerRect.left;
anchorRect.top -= containerRect.top;
// This is a hack, but allow consumers to force a top position if
// they already know it. This allows them to provide consistent
// updates. We should generalize this and `targetRect`
if (this.props.forceTop) {
anchorRect.top = this.props.forceTop;
} else {
const boxHeight = box.height + offset;
const testTop = anchorRect.top - boxHeight;
const testBottom = anchorRect.top + anchorRect.height + boxHeight;
if (
// If it doesn't fit above it, switch it to below
(this.position.indexOf('top') !== -1 && testTop < containerRect.top) ||
// If it doesn't fit below it, switch it above only if it does
// fit above it
(this.position.indexOf('bottom') !== -1 &&
testBottom > containerRect.height &&
testTop > 0)
) {
// Invert the position
this.position = this.getOppositePosition(this.position);
}
anchorRect.top += container.scrollTop;
}
const style = this.getStyleForPosition(
this.position,
box,
anchorRect,
this.getContainer().getBoundingClientRect(),
offset,
);
contentEl.style.top = style.top;
contentEl.style.bottom = style.bottom;
contentEl.style.left = style.left;
contentEl.style.right = style.right;
contentEl.style.width = style.width;
}
componentWillUnmount() {
if (this.cleanup) {
this.cleanup();
}
}
getOppositePosition(position) {
switch (position) {
case 'top':
case 'top-left':
return 'bottom';
case 'top-right':
return 'bottom-right';
case 'bottom':
case 'bottom-left':
return 'top';
case 'bottom-right':
return 'top-right';
case 'bottom-stretch':
return 'top-stretch';
case 'top-stretch':
return 'bottom-stretch';
case 'bottom-center':
return 'top-center';
case 'top-center':
return 'bottom-center';
case 'right':
return 'right';
default:
}
}
getStyleForPosition(position, boxRect, anchorRect, containerRect, offset) {
const style = {
top: 'inherit',
bottom: 'inherit',
left: 'inherit',
right: 'inherit',
width: undefined as string | undefined,
};
if (
position === 'top' ||
position === 'top-right' ||
position === 'top-left'
) {
style.top = anchorRect.top - boxRect.height - offset + 'px';
if (position === 'top-right') {
style.left =
anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
} else {
style.left = anchorRect.left + 'px';
// style.right = 0;
}
} else if (
position === 'bottom' ||
position === 'bottom-right' ||
position === 'bottom-left'
) {
style.top = anchorRect.top + anchorRect.height + offset + 'px';
if (position === 'bottom-right') {
style.left =
anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
} else {
style.left = anchorRect.left + 'px';
// style.right = 0;
}
} else if (position === 'bottom-center') {
style.top = anchorRect.top + anchorRect.height + offset + 'px';
style.left =
anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
} else if (position === 'top-center') {
style.top = anchorRect.top - boxRect.height - offset + 'px';
style.left =
anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
} else if (position === 'left-center') {
style.top =
anchorRect.top - (boxRect.height - anchorRect.height) / 2 + 'px';
style.left = anchorRect.left - boxRect.width + 'px';
} else if (position === 'top-stretch') {
style.bottom = containerRect.height - anchorRect.top + offset + 'px';
style.left = anchorRect.left + 'px';
style.width = anchorRect.width + 'px';
} else if (position === 'bottom-stretch') {
style.top = anchorRect.top + anchorRect.height + offset + 'px';
style.left = anchorRect.left + 'px';
style.width = anchorRect.width + 'px';
} else if (position === 'right') {
style.top = anchorRect.top + 'px';
style.left = anchorRect.left + anchorRect.width + offset + 'px';
} else {
throw new Error('Invalid position for Tooltip: ' + position);
}
return style;
}
render() {
const { children, width, style } = this.props;
const contentStyle = {
position: 'absolute',
zIndex: 3000,
padding: 5,
width,
...styles.shadowLarge,
borderRadius: 4,
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
// opacity: 0,
// transition: 'transform .1s, opacity .1s',
// transitionTimingFunction: 'ease-out'
};
// const enteredStyle = { opacity: 1, transform: 'none' };
if (!this.getContainer()) {
return null;
}
return (
<div ref={el => (this.target = el)}>
{ReactDOM.createPortal(
<div
className={`${css(contentStyle, style, styles.darkScrollbar)}`}
ref={this.contentRef}
data-testid={this.props['data-testid'] || 'tooltip'}
data-istooltip
onClick={e => {
// Click events inside a tooltip (e.g. when selecting a menu item) will bubble up
// through the portal to parents in the React tree (as opposed to DOM parents).
// This is undesirable. For example, clicking on a category group on a budget sheet
// toggles that group, and so would clicking on a menu item in the settings menu
// of that category group if the click event wasn't stopped from bubbling up.
// This issue could be handled in different ways, but I think stopping propagation
// here is sane; I can't see a scenario where you would want to take advantage of
// click propagation from a tooltip back to its React parent.
e.stopPropagation();
}}
>
{children}
</div>,
this.getContainer(),
)}
</div>
);
}
}

View File

@@ -61,6 +61,7 @@ import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete';
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
import { Button } from '../common/Button';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { getStatusProps } from '../schedules/StatusBadge';
@@ -79,7 +80,6 @@ import {
Table,
UnexposedCellContent,
} from '../table';
import { Tooltip } from '../tooltips';
function getDisplayValue(obj, name) {
return obj ? obj[name] : '';
@@ -342,6 +342,7 @@ function StatusCell({
selected,
status,
isChild,
isPreview,
onEdit,
onUpdate,
}) {
@@ -384,12 +385,19 @@ function StatusCell({
border: '1px solid transparent',
borderRadius: 50,
':focus': {
border: '1px solid ' + theme.formInputBorderSelected,
boxShadow: '0 1px 2px ' + theme.formInputBorderSelected,
...(isPreview
? {
boxShadow: 'none',
}
: {
border: '1px solid ' + theme.formInputBorderSelected,
boxShadow: '0 1px 2px ' + theme.formInputBorderSelected,
}),
},
cursor: isClearedField ? 'pointer' : 'default',
...(isChild && { visibility: 'hidden' }),
}}
disabled={isPreview || isChild}
onEdit={() => onEdit(id, 'cleared')}
onSelect={onSelect}
>
@@ -604,17 +612,17 @@ function PayeeIcons({
transferAccount,
onNavigateToTransferAccount,
onNavigateToSchedule,
children,
}) {
const scheduleId = transaction.schedule;
const scheduleData = useCachedSchedules();
const schedule = scheduleData
? scheduleData.schedules.find(s => s.id === scheduleId)
: null;
const schedule =
scheduleId && scheduleData
? scheduleData.schedules.find(s => s.id === scheduleId)
: null;
if (schedule == null && transferAccount == null) {
// Neither a valid scheduled transaction nor a transfer.
return children;
return null;
}
const buttonStyle = {
@@ -673,6 +681,7 @@ function PayeeIcons({
}
const Transaction = memo(function Transaction({
allTransactions,
transaction: originalTransaction,
subtransactions,
editing,
@@ -703,9 +712,12 @@ const Transaction = memo(function Transaction({
onNavigateToTransferAccount,
onNavigateToSchedule,
onNotesTagClick,
splitError,
listContainerRef,
}) {
const dispatch = useDispatch();
const dispatchSelected = useSelectedDispatch();
const triggerRef = useRef(null);
const [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit);
const [prevTransaction, setPrevTransaction] = useState(originalTransaction);
@@ -873,8 +885,31 @@ const Transaction = memo(function Transaction({
? balance
: balance + (_inverse ? -1 : 1) * amount;
// Ok this entire logic is a dirty, dirty hack.. but let me explain.
// Problem: the split-error Popover (which has the buttons to distribute/add split)
// renders before schedules are added to the table. After schedules finally load
// the entire table gets pushed down. But the Popover does not re-calculate
// its positioning. This is because there is nothing in react-aria that would be
// watching for the position of the trigger element.
// Solution: when transactions (this includes schedules) change - we increment
// a variable (with a small delay in order for the next render cycle to pick up
// the change instead of the current). We pass the integer to the Popover which
// causes it to re-calculate the positioning. Thus fixing the problem.
const [updateId, setUpdateId] = useState(1);
useEffect(() => {
// The hack applies to only transactions with split errors
if (!splitError) {
return;
}
setTimeout(() => {
setUpdateId(state => state + 1);
}, 1);
}, [splitError, allTransactions]);
return (
<Row
ref={triggerRef}
style={{
backgroundColor: selected
? theme.tableRowBackgroundHighlight
@@ -901,6 +936,21 @@ const Transaction = memo(function Transaction({
...(_unmatched && { opacity: 0.5 }),
}}
>
{splitError && listContainerRef.current && (
<Popover
arrowSize={updateId}
triggerRef={triggerRef}
isOpen
isNonModal
style={{ width: 375, padding: 5, maxHeight: '38px !important' }}
shouldFlip={false}
placement="bottom end"
UNSTABLE_portalContainer={listContainerRef.current}
>
{splitError}
</Popover>
)}
{isChild && (
<Field
/* Checkmark blank placeholder for Child transaction */
@@ -1571,6 +1621,7 @@ function NewTransaction({
function TransactionTableInner({
tableNavigator,
tableRef,
listContainerRef,
dateFormat = 'MM/dd/yyyy',
newNavigator,
renderEmpty,
@@ -1617,7 +1668,7 @@ function TransactionTableInner({
}
}, [isAddingPrev, props.isAdding, newNavigator]);
const renderRow = ({ item, index, position, editing }) => {
const renderRow = ({ item, index, editing }) => {
const {
transactions,
selectedItems,
@@ -1661,15 +1712,39 @@ function TransactionTableInner({
);
return (
<>
{hasSplitError && (
<Tooltip
position="bottom-right"
width={350}
forceTop={position}
forceLayout={true}
style={{ transform: 'translate(-5px, 2px)' }}
>
<Transaction
allTransactions={props.transactions}
editing={editing}
transaction={trans}
showAccount={showAccount}
showCategory={showCategory}
showBalance={showBalances}
showCleared={showCleared}
selected={selected}
highlighted={false}
added={isNew?.(trans.id)}
expanded={isExpanded?.(trans.id)}
matched={isMatched?.(trans.id)}
showZeroInDeposit={isChildDeposit}
balance={balances?.[trans.id]?.balance}
focusedField={editing && tableNavigator.focusedField}
accounts={accounts}
categoryGroups={categoryGroups}
payees={payees}
dateFormat={dateFormat}
hideFraction={hideFraction}
onEdit={tableNavigator.onEdit}
onSave={props.onSave}
onDelete={props.onDelete}
onSplit={props.onSplit}
onManagePayees={props.onManagePayees}
onCreatePayee={props.onCreatePayee}
onToggleSplit={props.onToggleSplit}
onNavigateToTransferAccount={onNavigateToTransferAccount}
onNavigateToSchedule={onNavigateToSchedule}
onNotesTagClick={onNotesTagClick}
splitError={
hasSplitError && (
<TransactionError
error={error}
isDeposit={isChildDeposit}
@@ -1679,40 +1754,10 @@ function TransactionTableInner({
}
canDistributeRemainder={emptyChildTransactions.length > 0}
/>
</Tooltip>
)}
<Transaction
editing={editing}
transaction={trans}
showAccount={showAccount}
showCategory={showCategory}
showBalance={showBalances}
showCleared={showCleared}
selected={selected}
highlighted={false}
added={isNew?.(trans.id)}
expanded={isExpanded?.(trans.id)}
matched={isMatched?.(trans.id)}
showZeroInDeposit={isChildDeposit}
balance={balances?.[trans.id]?.balance}
focusedField={editing && tableNavigator.focusedField}
accounts={accounts}
categoryGroups={categoryGroups}
payees={payees}
dateFormat={dateFormat}
hideFraction={hideFraction}
onEdit={tableNavigator.onEdit}
onSave={props.onSave}
onDelete={props.onDelete}
onSplit={props.onSplit}
onManagePayees={props.onManagePayees}
onCreatePayee={props.onCreatePayee}
onToggleSplit={props.onToggleSplit}
onNavigateToTransferAccount={onNavigateToTransferAccount}
onNavigateToSchedule={onNavigateToSchedule}
onNotesTagClick={onNotesTagClick}
/>
</>
)
}
listContainerRef={listContainerRef}
/>
);
};
@@ -1789,6 +1834,7 @@ function TransactionTableInner({
<Table
navigator={tableNavigator}
ref={tableRef}
listContainerRef={listContainerRef}
items={props.transactions}
renderItem={renderRow}
renderEmpty={renderEmpty}
@@ -1825,6 +1871,7 @@ export const TransactionTable = forwardRef((props, ref) => {
const prevSplitsExpanded = useRef(null);
const tableRef = useRef(null);
const listContainerRef = useRef(null);
const mergedRef = useMergedRefs(tableRef, ref);
const transactions = useMemo(() => {
@@ -1965,7 +2012,7 @@ export const TransactionTable = forwardRef((props, ref) => {
);
if (isPreviewId(item.id)) {
fields = ['select', 'cleared'];
fields = ['select'];
}
if (isTemporaryId(item.id)) {
// You can't focus the select/delete button of temporary
@@ -2237,6 +2284,7 @@ export const TransactionTable = forwardRef((props, ref) => {
return (
<TransactionTableInner
tableRef={mergedRef}
listContainerRef={listContainerRef}
{...props}
transactions={transactions}
transactionMap={transactionMap}

View File

@@ -816,9 +816,7 @@ describe('Transactions', () => {
expect(getTransactions()[1].amount).toBe(0);
expectErrorToExist(getTransactions().slice(0, 2));
const toolbars = container.querySelectorAll(
'[data-testid="transaction-error"]',
);
const toolbars = screen.queryAllByTestId('transaction-error');
// Make sure the toolbar has appeared
expect(toolbars.length).toBe(1);
const toolbar = toolbars[0];
@@ -925,9 +923,7 @@ describe('Transactions', () => {
expect(getTransactions().length).toBe(5);
await userEvent.click(screen.getByTestId('split-transaction-button'));
await waitForAutocomplete();
await userEvent.click(
container.querySelector('[data-testid="add-split-button"]'),
);
await userEvent.click(screen.getByTestId('add-split-button'));
expect(getTransactions().length).toBe(7);
// The debit field should show the zeros

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgDownAndRightArrow = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M4.092 3.658 1.267 6.484l.708.708.708.708 1.609-1.608L5.9 4.684V7.4c.001 2.838.022 3.466.132 3.96.393 1.766 1.732 2.972 3.985 3.59.238.065.53.133.65.151.119.018 1.229.055 2.466.082 1.238.028 2.458.06 2.712.072l.462.021-1.561 1.562-1.562 1.562.708.708.708.708 2.7-2.699 2.7-2.7v-.5l-2.7-2.7-2.7-2.7-.7.7-.7.699 1.675 1.679 1.675 1.678-.617-.019c-.339-.011-1.584-.043-2.766-.071-1.191-.028-2.232-.067-2.334-.087a6.822 6.822 0 0 1-1.283-.426c-.754-.356-1.201-.777-1.447-1.365-.167-.399-.17-.463-.17-3.649V4.684L9.55 6.3l1.617 1.616.708-.708.708-.708L9.75 3.667 6.917.833 4.092 3.658"
fill="currentColor"
/>
</svg>
);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.092 3.658 1.267 6.484l.708.708.708.708 1.609-1.608L5.9 4.684V7.4c.001 2.838.022 3.466.132 3.96.393 1.766 1.732 2.972 3.985 3.59.238.065.53.133.65.151.119.018 1.229.055 2.466.082 1.238.028 2.458.06 2.712.072l.462.021-1.561 1.562-1.562 1.562.708.708.708.708 2.7-2.699 2.7-2.7v-.5l-2.7-2.7-2.7-2.7-.7.7-.7.699 1.675 1.679 1.675 1.678-.617-.019c-.339-.011-1.584-.043-2.766-.071-1.191-.028-2.232-.067-2.334-.087a6.822 6.822 0 0 1-1.283-.426c-.754-.356-1.201-.777-1.447-1.365-.167-.399-.17-.463-.17-3.649V4.684L9.55 6.3l1.617 1.616.708-.708.708-.708L9.75 3.667 6.917.833 4.092 3.658" /></svg>

After

Width:  |  Height:  |  Size: 658 B

View File

@@ -16,6 +16,7 @@ export { SvgCheck } from './Check';
export { SvgCloudUnknown } from './CloudUnknown';
export { SvgCloudUpload } from './CloudUpload';
export { SvgCustomNotesPaper } from './CustomNotesPaper';
export { SvgDownAndRightArrow } from './DownAndRightArrow';
export { SvgDownloadThickBottom } from './DownloadThickBottom';
export { SvgEditSkull1 } from './EditSkull1';
export { SvgFavoriteStar } from './FavoriteStar';

View File

@@ -183,6 +183,7 @@ export const pillTextHighlighted = colorPalette.purple200;
export const pillBorder = colorPalette.navy700;
export const pillBorderDark = pillBorder;
export const pillBackgroundSelected = colorPalette.purple600;
export const pillBackgroundHover = 'rgba(200, 200, 200, .3)';
export const pillTextSelected = colorPalette.navy150;
export const pillBorderSelected = colorPalette.purple400;
export const pillTextSubdued = colorPalette.navy500;

View File

@@ -182,6 +182,7 @@ export const pillTextHighlighted = colorPalette.purple600;
export const pillBorder = colorPalette.navy150;
export const pillBorderDark = colorPalette.navy300;
export const pillBackgroundSelected = colorPalette.blue150;
export const pillBackgroundHover = 'rgba(100, 100, 100, .15)';
export const pillTextSelected = colorPalette.blue900;
export const pillBorderSelected = colorPalette.purple500;
export const pillTextSubdued = colorPalette.navy200;

View File

@@ -185,6 +185,7 @@ export const pillTextHighlighted = colorPalette.purple600;
export const pillBorder = colorPalette.navy150;
export const pillBorderDark = colorPalette.navy300;
export const pillBackgroundSelected = colorPalette.blue150;
export const pillBackgroundHover = 'rgba(100, 100, 100, .15)';
export const pillTextSelected = colorPalette.blue900;
export const pillBorderSelected = colorPalette.purple500;
export const pillTextSubdued = colorPalette.navy200;

View File

@@ -185,6 +185,7 @@ export const pillTextHighlighted = colorPalette.purple200;
export const pillBorder = colorPalette.gray500;
export const pillBorderDark = pillBorder;
export const pillBackgroundSelected = colorPalette.purple600;
export const pillBackgroundHover = 'rgba(200, 200, 200, .3)';
export const pillTextSelected = colorPalette.gray150;
export const pillBorderSelected = colorPalette.purple300;
export const pillTextSubdued = colorPalette.gray500;

View File

@@ -1,31 +0,0 @@
const { BrowserWindow } = require('electron');
const isDev = require('electron-is-dev');
let window;
function openAboutWindow() {
if (window != null) {
window.focus();
return window;
}
window = new BrowserWindow({
width: 290,
height: process.platform === 'win32' ? 255 : 240,
show: true,
resizable: isDev,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
window.setBackgroundColor('white');
window.setTitle('');
window.loadURL(`file://${__dirname}/about/about.html`);
window.once('closed', () => {
window = null;
});
}
module.exports = { openAboutWindow, getWindow: () => window };

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
color: #303030;
font-size: 12px;
}
</style>
</head>
<body>
<div id="root" />
<script src="index.js"></script>
</body>
</html>

View File

@@ -1,94 +0,0 @@
const { ipcRenderer } = require('electron');
const root = document.querySelector('#root');
const { version: appVersion } = ipcRenderer.sendSync('get-bootstrap-data');
const iconPath = __dirname + '/../icons/icon.png';
root.style.display = 'flex';
root.style.flexDirection = 'column';
root.style.alignItems = 'center';
root.style.padding = '10px';
root.innerHTML = `
<img src="${iconPath}" width="60" height="60" />
<strong style="font-size:14px; padding-top: 15px">Actual</strong>
<div style="padding-bottom:15px; padding-top: 5px">Version ${appVersion}</div>
<div id="container">
<div id="update-check"><button>Check for updates</button></div>
<div id="apply-update"><button>Restart to Update</button></div>
<div id="success"></div>
<div id="error"></div>
</div>
`;
const container = root.querySelector('#container');
container.style.height = '45px';
container.style.textAlign = 'center';
const updateEl = root.querySelector('#update-check');
const applyUpdateEl = root.querySelector('#apply-update');
applyUpdateEl.style.display = 'none';
const successEl = root.querySelector('#success');
successEl.style.display = 'none';
successEl.style.textAlign = 'center';
const errorEl = root.querySelector('#error');
errorEl.style.display = 'none';
root.querySelector('#update-check button').addEventListener('click', () => {
ipcRenderer.send('check-for-update');
});
root.querySelector('#apply-update button').addEventListener('click', () => {
ipcRenderer.send('apply-update');
});
ipcRenderer.on('update-checking', () => {
updateEl.style.display = 'none';
successEl.innerHTML = 'Checking...';
successEl.style.display = 'block';
errorEl.style.display = 'none';
});
ipcRenderer.on('update-available', () => {
updateEl.style.display = 'none';
successEl.innerHTML = 'Update available! Downloading...';
successEl.style.display = 'block';
});
ipcRenderer.on('update-downloaded', () => {
updateEl.style.display = 'none';
successEl.style.display = 'none';
applyUpdateEl.style.display = 'block';
});
ipcRenderer.on('update-not-available', () => {
updateEl.style.display = 'none';
successEl.innerHTML = 'All up to date!';
successEl.style.display = 'block';
});
ipcRenderer.on('update-error', (event, msg) => {
updateEl.style.display = 'block';
successEl.style.display = 'none';
let text;
if (msg.domain === 'SQRLUpdaterErrorDomain' && msg.code === 8) {
text = `
Error updating the app. It looks like its running outside of the applications
folder and cant be written to. Please install the app before updating.
`;
} else {
text = 'Error updating the app. Please try again later.';
}
errorEl.innerHTML = `<div style="text-align:center; color:#F65151">${text}</div>`;
errorEl.style.display = 'block';
});
document.addEventListener('keydown', e => {
// Disable zoom with keys + and -
if (e.key === '+' || e.key === '-') {
e.preventDefault();
}
});

View File

@@ -1,5 +1,5 @@
import fs from 'fs';
import NodeModule from 'module';
import Module from 'module';
import path from 'path';
import {
@@ -9,29 +9,24 @@ import {
Menu,
dialog,
shell,
powerMonitor,
protocol,
utilityProcess,
UtilityProcess,
} from 'electron';
import isDev from 'electron-is-dev';
// @ts-strict-ignore
import fetch from 'node-fetch';
import promiseRetry from 'promise-retry';
import about from './about';
import { getMenu } from './menu';
import updater from './updater';
import {
get as getWindowState,
listen as listenToWindowState,
} from './window-state';
import './setRequireHook';
import './security';
const Module: typeof NodeModule & { globalPaths: string[] } =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
NodeModule as unknown as any;
Module.globalPaths.push(__dirname + '/..');
// This allows relative URLs to be resolved to app:// which makes
@@ -40,7 +35,7 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true } },
]);
global.fetch = require('node-fetch');
global.fetch = fetch;
if (!isDev || !process.env.ACTUAL_DOCUMENT_DIR) {
process.env.ACTUAL_DOCUMENT_DIR = app.getPath('documents');
@@ -55,17 +50,6 @@ if (!isDev || !process.env.ACTUAL_DATA_DIR) {
let clientWin: BrowserWindow | null;
let serverProcess: UtilityProcess | null;
updater.onEvent((type: string, data: Record<string, string> | string) => {
// Notify both the app and the about window
if (clientWin) {
clientWin.webContents.send(type, data);
}
if (about.getWindow()) {
about.getWindow().webContents.send(type, data);
}
});
if (isDev) {
process.traceProcessWarnings = true;
}
@@ -82,13 +66,6 @@ function createBackgroundProcess() {
case 'captureEvent':
case 'captureBreadcrumb':
break;
case 'shouldAutoUpdate':
if (msg.flag) {
updater.start();
} else {
updater.stop();
}
break;
case 'reply':
case 'error':
case 'push':
@@ -265,7 +242,7 @@ app.on('ready', async () => {
// This is mainly to aid debugging Sentry errors - it will add a
// breadcrumb
require('electron').powerMonitor.on('suspend', () => {
powerMonitor.on('suspend', () => {
console.log('Suspending', new Date());
});
@@ -331,10 +308,6 @@ ipcMain.handle('open-external-url', (event, url) => {
shell.openExternal(url);
});
ipcMain.on('show-about', () => {
about.openAboutWindow();
});
ipcMain.on('message', (_event, msg) => {
if (!serverProcess) {
return;
@@ -354,23 +327,6 @@ ipcMain.on('screenshot', () => {
}
});
ipcMain.on('check-for-update', () => {
// If the updater is in the middle of an update already, send the
// about window the current status
if (updater.isChecking()) {
// This should always come from the about window so we can
// guarantee that it exists. If we ever see an error here
// something is wrong
about.getWindow().webContents.send(updater.getLastEvent());
} else {
updater.check();
}
});
ipcMain.on('apply-update', () => {
updater.apply();
});
ipcMain.on('update-menu', (event, budgetId?: string) => {
updateMenu(budgetId);
});

View File

@@ -182,27 +182,11 @@ export function getMenu(
},
];
if (process.platform === 'win32') {
// Add about to help menu on Windows
(
template[template.length - 1].submenu as MenuItemConstructorOptions[]
).unshift({
label: 'About Actual',
click() {
ipcMain.emit('show-about');
},
});
} else if (process.platform === 'darwin') {
if (process.platform === 'darwin') {
const name = app.getName();
template.unshift({
label: name,
submenu: [
{
label: 'About Actual',
click() {
ipcMain.emit('show-about');
},
},
isDev
? {
label: 'Screenshot',

View File

@@ -0,0 +1,9 @@
declare module 'module' {
const globalPaths: string[];
}
// bundles not available until we build them
declare module 'loot-core/lib-dist/bundle.desktop.js' {
const initApp: (isDev: boolean) => void;
const lib: { getDataDir: () => string };
}

View File

@@ -35,16 +35,23 @@
"icon": "icons/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"artifactName": "${productName}-mac.${ext}",
"notarize": {
"teamId": "79ANZ983YF"
}
},
"target": [
{
"target": "default",
"arch": ["universal"]
}
]
},
"linux": {
"target": [
"flatpak",
"AppImage"
],
"artifactName": "${productName}-${version}-${arch}.${ext}"
"artifactName": "${productName}-linux.${ext}"
},
"flatpak": {
"runtimeVersion": "23.08",
@@ -52,13 +59,13 @@
},
"win": {
"target": "nsis",
"icon": "icons/icon.ico"
"icon": "icons/icon.ico",
"artifactName": "${productName}-windows.${ext}"
}
},
"dependencies": {
"electron-is-dev": "2.0.0",
"electron-log": "4.4.8",
"electron-updater": "6.1.7",
"loot-core": "*",
"node-fetch": "^2.7.0",
"promise-retry": "^2.0.1"

View File

@@ -45,10 +45,6 @@ contextBridge.exposeInMainWorld('Actual', {
ipcRenderer.on(type, handler);
},
applyAppUpdate: () => {
ipcRenderer.send('apply-update');
},
updateAppMenu: budgetId => {
ipcRenderer.send('update-menu', budgetId);
},

View File

@@ -1,8 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<script src="server.js"></script>
</head>
<body>
</body>
</html>

View File

@@ -1,15 +0,0 @@
require('./setRequireHook');
require('module').globalPaths.push(__dirname + '/..');
global.fetch = require('node-fetch');
// Lazy load backend code
function getBackend() {
// eslint-disable-next-line import/extensions
return require('loot-core/lib-dist/bundle.desktop.js');
}
const isDev = false;
// Start the app
getBackend().initApp(isDev);

View File

@@ -0,0 +1,18 @@
import Module from 'module';
// @ts-strict-ignore
import fetch from 'node-fetch';
Module.globalPaths.push(__dirname + '/..');
global.fetch = fetch;
const lazyLoadBackend = async (isDev: boolean) => {
// eslint-disable-next-line import/extensions
const bundle = await import('loot-core/lib-dist/bundle.desktop.js');
bundle.initApp(isDev);
};
const isDev = false;
// Start the app
lazyLoadBackend(isDev);

View File

@@ -1,3 +0,0 @@
require.extensions['.electron.js'] = function (module, filename) {
return require.extensions['.js'](module, filename);
};

View File

@@ -1,58 +0,0 @@
const { execSync } = require('child_process');
const {
SIGN_TOOL_PATH = 'C:\\Program Files (x86)\\Windows Kits\\10\\bin\\x64\\signtool.exe',
TIMESTAMP_SERVER = 'http://timestamp.digicert.com',
} = process.env;
const SITE = 'https://actualbudget.com/';
const importPfx = (certPath, password) => {
/* eslint-disable rulesdir/typography */
const command = [
['certutil'],
['-f'],
['-p', `"${password}"`],
['-importPfx', 'My', `"${certPath}"`, 'NoRoot'],
]
.map(sub => sub.join(' '))
.join(' ');
/* eslint-enable rulesdir/typography */
try {
execSync(command, { stdio: 'inherit' });
} catch {
console.error('Unable to import certificate');
}
};
const signBinary = (path, name) => {
/* eslint-disable rulesdir/typography */
const command = [
[`"${SIGN_TOOL_PATH}"`],
['sign'],
['/a'],
['/s', 'My'],
['/sm'],
['/t', `"${TIMESTAMP_SERVER}"`],
['/d', `"${name}"`],
['/du', `"${SITE}"`],
[`"${path}"`],
]
.map(sub => sub.join(' '))
.join(' ');
/* eslint-enable rulesdir/typography */
try {
execSync(command, { stdio: 'inherit' });
} catch {
console.error(`Signing ${path} failed`);
}
};
exports.default = ({ path, name, cscInfo: { file, password } = {} }) => {
if (!file) return;
importPfx(file, password);
signBinary(path, name, file);
};

View File

@@ -1,103 +0,0 @@
const isDev = require('electron-is-dev');
const { autoUpdater } = require('electron-updater');
// Every 5 minutes
const INTERVAL = 1000 * 60 * 5;
let updateTimer = null;
let isCheckingForUpdates = false;
let emitEvent = null;
let lastEvent;
autoUpdater.on('checking-for-update', () => {
isCheckingForUpdates = true;
fireEvent('update-checking');
});
autoUpdater.on('update-available', () => {
fireEvent('update-available');
});
autoUpdater.on('update-downloaded', info => {
fireEvent('update-downloaded', {
releaseNotes: info.releaseNotes,
releaseName: info.releaseName,
version: info.version,
});
});
autoUpdater.on('update-not-available', () => {
isCheckingForUpdates = false;
fireEvent('update-not-available');
});
autoUpdater.on('error', message => {
isCheckingForUpdates = false;
// This is a common error, so don't report it. All sorts of reasons
// why this can user that isn't our fault.
console.log('There was a problem updating the application: ' + message);
fireEvent('update-error', message);
});
function fireEvent(type, args) {
emitEvent?.(type, args);
lastEvent = type;
}
function start() {
if (updateTimer) {
return null;
}
if (!isDev) {
console.log('Starting autoupdate check...');
updateTimer = setInterval(() => {
if (!isCheckingForUpdates) {
autoUpdater.checkForUpdates().catch(() => {
// Do nothing with the error (make sure it's not logged to sentry)
});
}
}, INTERVAL);
}
}
function onEvent(handler) {
emitEvent = handler;
}
function stop() {
console.log('Stopping autoupdate check...');
clearInterval(updateTimer);
updateTimer = null;
}
function check() {
if (!isDev && !isCheckingForUpdates) {
autoUpdater.checkForUpdates().catch(() => {
// Do nothing with the error (make sure it's not logged to sentry)
});
}
}
function isChecking() {
return isCheckingForUpdates;
}
function getLastEvent() {
return lastEvent;
}
function apply() {
autoUpdater.quitAndInstall();
}
module.exports = {
start,
stop,
onEvent,
apply,
check,
isChecking,
getLastEvent,
};

View File

@@ -1,13 +1,22 @@
const fs = require('fs');
const path = require('path');
import fs from 'fs';
import path from 'path';
const electron = require('electron');
import electron, { BrowserWindow } from 'electron';
// eslint-disable-next-line import/extensions
const backend = require('loot-core/lib-dist/bundle.desktop.js');
const backend = undefined;
const getBackend = async () =>
// eslint-disable-next-line import/extensions
backend || (await import('loot-core/lib-dist/bundle.desktop.js'));
function loadState() {
let state = {};
type WindowState = Electron.Rectangle & {
isMaximized?: boolean;
isFullScreen?: boolean;
displayBounds?: Electron.Rectangle;
};
async function loadState() {
let state: WindowState | undefined = undefined;
const backend = await getBackend();
try {
state = JSON.parse(
fs.readFileSync(
@@ -18,24 +27,27 @@ function loadState() {
} catch (e) {
console.log('Could not load window state');
}
return validateState(state);
}
function updateState(win, state) {
const screen = electron.screen || electron.remote.screen;
function updateState(win: BrowserWindow, state: WindowState) {
const screen = electron.screen;
const bounds = win.getBounds();
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
state.x = bounds.x;
state.y = bounds.y;
state.width = bounds.width;
state.height = bounds.height;
}
state.x = bounds.x;
state.y = bounds.y;
state.isMaximized = win.isMaximized();
state.isFullScreen = win.isFullScreen();
state.displayBounds = screen.getDisplayMatching(bounds).bounds;
}
function saveState(win, state) {
async function saveState(win: BrowserWindow, state: WindowState) {
const backend = await getBackend();
updateState(win, state);
fs.writeFileSync(
path.join(backend.lib.getDataDir(), 'window.json'),
@@ -44,7 +56,7 @@ function saveState(win, state) {
);
}
function listen(win, state) {
export function listen(win: BrowserWindow, state: WindowState) {
if (state.isMaximized) {
win.maximize();
}
@@ -61,7 +73,7 @@ function listen(win, state) {
};
}
function hasBounds(state) {
function hasBounds(state: WindowState) {
return (
Number.isInteger(state.x) &&
Number.isInteger(state.y) &&
@@ -72,15 +84,18 @@ function hasBounds(state) {
);
}
function validateState(state) {
if (!(hasBounds(state) || state.isMaximized || state.isFullScreen)) {
function validateState(state?: WindowState): Partial<WindowState> {
if (
!state ||
!(hasBounds(state) || state.isMaximized || state.isFullScreen)
) {
return {};
}
const newState = Object.assign({}, state);
if (hasBounds(state) && state.displayBounds) {
const screen = electron.screen || electron.remote.screen;
const screen = electron.screen;
// Check if the display where the window was last open is still available
const displayBounds = screen.getDisplayMatching(state).bounds;
@@ -116,22 +131,19 @@ function validateState(state) {
return newState;
}
async function get() {
const screen = electron.screen || electron.remote.screen;
export async function get() {
const screen = electron.screen;
const displayBounds = screen.getPrimaryDisplay().bounds;
let state = loadState();
state = Object.assign(
const state: WindowState = Object.assign(
{
x: 100,
y: 50,
width: Math.min(1000, displayBounds.width - 100),
height: Math.min(700, displayBounds.width - 50),
},
state,
await loadState(),
);
return state;
}
module.exports = { get, listen };

View File

@@ -0,0 +1,19 @@
BEGIN TRANSACTION;
UPDATE transactions AS t1
SET schedule = (
SELECT t2.schedule FROM transactions AS t2
WHERE t2.id = t1.transferred_id
AND t2.schedule IS NOT NULL
LIMIT 1
)
WHERE t1.schedule IS NULL
AND t1.transferred_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM transactions AS t2
WHERE t2.id = t1.transferred_id
AND t2.schedule IS NOT NULL
LIMIT 1
);
COMMIT;

View File

@@ -95,6 +95,11 @@ export function syncAccounts(id?: string) {
.queries.accounts.filter(
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
)
.sort((a, b) =>
a.offbudget === b.offbudget
? a.sort_order - b.sort_order
: a.offbudget - b.offbudget,
)
.map(({ id }) => id);
dispatch(setAccountsSyncing(accountIdsToSync));
@@ -185,6 +190,27 @@ export function parseTransactions(filepath, options) {
};
}
export function importPreviewTransactions(id: string, transactions) {
return async (dispatch: Dispatch): Promise<boolean> => {
const { errors = [], updatedPreview } = await send('transactions-import', {
accountId: id,
transactions,
isPreview: true,
});
errors.forEach(error => {
dispatch(
addNotification({
type: 'error',
message: error.message,
}),
);
});
return updatedPreview;
};
}
export function importTransactions(id: string, transactions, reconcile = true) {
return async (dispatch: Dispatch): Promise<boolean> => {
if (!reconcile) {
@@ -203,6 +229,7 @@ export function importTransactions(id: string, transactions, reconcile = true) {
} = await send('transactions-import', {
accountId: id,
transactions,
isPreview: false,
});
errors.forEach(error => {

View File

@@ -1,9 +1,16 @@
// @ts-strict-ignore
import React, { createContext, useEffect, useState, useContext } from 'react';
import React, {
createContext,
useEffect,
useState,
useContext,
useMemo,
} from 'react';
import { q, type Query } from '../../shared/query';
import { getStatus, getHasTransactionsQuery } from '../../shared/schedules';
import { type ScheduleEntity } from '../../types/models';
import { getAccountFilter } from '../queries';
import { liveQuery } from '../query-helpers';
export type ScheduleStatusType = ReturnType<typeof getStatus>;
@@ -84,3 +91,26 @@ export function SchedulesProvider({ transform, children }) {
export function useCachedSchedules() {
return useContext(SchedulesContext);
}
export function useDefaultSchedulesQueryTransform(accountId) {
return useMemo(() => {
const filterByAccount = getAccountFilter(accountId, '_account');
const filterByPayee = getAccountFilter(accountId, '_payee.transfer_acct');
return (q: Query) => {
q = q.filter({
$and: [{ '_account.closed': false }],
});
if (accountId) {
if (accountId === 'uncategorized') {
q = q.filter({ next_date: null });
} else {
q = q.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return q.orderBy({ next_date: 'desc' });
};
}, [accountId]);
}

View File

@@ -47,6 +47,7 @@ function reconcileFiles(
groupId,
deleted: false,
state: 'unknown',
hasKey: true,
};
}
@@ -85,10 +86,11 @@ function reconcileFiles(
groupId,
deleted: false,
state: 'broken',
hasKey: true,
};
}
} else {
return { ...localFile, deleted: false, state: 'local' };
return { ...localFile, deleted: false, state: 'local', hasKey: true };
}
});

View File

@@ -10,6 +10,16 @@ const initialState: ModalsState = {
export function update(state = initialState, action: Action): ModalsState {
switch (action.type) {
case constants.PUSH_MODAL:
// special case: don't show the keyboard shortcuts modal if there's already a modal open
if (
action.modal.name.endsWith('keyboard-shortcuts') &&
(state.modalStack.length > 0 ||
window.document.querySelector(
'div[data-testid="filters-menu-tooltip"]',
) !== null)
) {
return state;
}
return {
...state,
modalStack: [...state.modalStack, action.modal],

View File

@@ -129,7 +129,6 @@ type FinanceModals = {
'schedules-discover': null;
'schedule-posts-offline-notification': null;
'switch-budget-type': { onSwitch: () => void };
'account-menu': {
accountId: string;
onSave: (account: AccountEntity) => void;
@@ -237,7 +236,6 @@ type FinanceModals = {
onAddCategoryGroup: () => void;
onToggleHiddenCategories: () => void;
onSwitchBudgetFile: () => void;
onSwitchBudgetType: () => void;
};
'rollover-budget-month-menu': {
month: string;

View File

@@ -8,7 +8,11 @@ import {
makeChild as makeChildTransaction,
recalculateSplit,
} from '../../shared/transactions';
import { hasFieldsChanged, amountToInteger } from '../../shared/util';
import {
hasFieldsChanged,
amountToInteger,
integerToAmount,
} from '../../shared/util';
import * as db from '../db';
import { runMutator } from '../mutators';
import { post } from '../post';
@@ -251,55 +255,11 @@ async function normalizeBankSyncTransactions(transactions, acctId) {
throw new Error('`date` is required when adding a transaction');
}
let payee_name;
// When the amount is equal to 0, we need to determine
// if this is a "Credited" or "Debited" transaction. This means
// that it matters whether the amount is a positive or negative zero.
if (trans.amount > 0 || Object.is(Number(trans.amount), 0)) {
const nameParts = [];
const name =
trans.debtorName ||
trans.remittanceInformationUnstructured ||
(trans.remittanceInformationUnstructuredArray || []).join(', ') ||
trans.additionalInformation;
if (name) {
nameParts.push(title(name));
}
if (trans.debtorAccount && trans.debtorAccount.iban) {
nameParts.push(
'(' +
trans.debtorAccount.iban.slice(0, 4) +
' XXX ' +
trans.debtorAccount.iban.slice(-4) +
')',
);
}
payee_name = nameParts.join(' ');
} else {
const nameParts = [];
const name =
trans.creditorName ||
trans.remittanceInformationUnstructured ||
(trans.remittanceInformationUnstructuredArray || []).join(', ') ||
trans.additionalInformation;
if (name) {
nameParts.push(title(name));
}
if (trans.creditorAccount && trans.creditorAccount.iban) {
nameParts.push(
'(' +
trans.creditorAccount.iban.slice(0, 4) +
' XXX ' +
trans.creditorAccount.iban.slice(-4) +
')',
);
}
payee_name = nameParts.join(' ');
if (trans.payeeName == null) {
throw new Error('`payeeName` is required when adding a transaction');
}
trans.imported_payee = trans.imported_payee || payee_name;
trans.imported_payee = trans.imported_payee || trans.payeeName;
if (trans.imported_payee) {
trans.imported_payee = trans.imported_payee.trim();
}
@@ -308,12 +268,12 @@ async function normalizeBankSyncTransactions(transactions, acctId) {
// when rules are run, they have the right data. Resolving payees
// also simplifies the payee creation process
trans.account = acctId;
trans.payee = await resolvePayee(trans, payee_name, payeesToCreate);
trans.payee = await resolvePayee(trans, trans.payeeName, payeesToCreate);
trans.cleared = Boolean(trans.booked);
normalized.push({
payee_name,
payee_name: trans.payeeName,
trans: {
amount: amountToInteger(trans.amount),
payee: trans.payee,
@@ -349,12 +309,117 @@ export async function reconcileTransactions(
acctId,
transactions,
isBankSyncAccount = false,
isPreview = false,
) {
console.log('Performing transaction reconciliation');
const hasMatched = new Set();
const updated = [];
const added = [];
const updatedPreview = [];
const existingPayeeMap = new Map<string, string>();
const {
payeesToCreate,
transactionsStep1,
transactionsStep2,
transactionsStep3,
} = await matchTransactions(acctId, transactions, isBankSyncAccount);
// Finally, generate & commit the changes
for (const { trans, subtransactions, match } of transactionsStep3) {
if (match && !trans.forceAddTransaction) {
// Skip updating already reconciled (locked) transactions
if (match.reconciled) {
updatedPreview.push({ transaction: trans, ignored: true });
continue;
}
// TODO: change the above sql query to use aql
const existing = {
...match,
cleared: match.cleared === 1,
date: db.fromDateRepr(match.date),
};
// Update the transaction
const updates = {
imported_id: trans.imported_id || null,
payee: existing.payee || trans.payee || null,
category: existing.category || trans.category || null,
imported_payee: trans.imported_payee || null,
notes: existing.notes || trans.notes || null,
cleared: trans.cleared != null ? trans.cleared : true,
};
if (hasFieldsChanged(existing, updates, Object.keys(updates))) {
updated.push({ id: existing.id, ...updates });
if (!existingPayeeMap.has(existing.payee)) {
const payee = await db.getPayee(existing.payee);
existingPayeeMap.set(existing.payee, payee?.name);
}
existing.payee_name = existingPayeeMap.get(existing.payee);
existing.amount = integerToAmount(existing.amount);
updatedPreview.push({ transaction: trans, existing });
} else {
updatedPreview.push({ transaction: trans, ignored: true });
}
if (existing.is_parent && existing.cleared !== updates.cleared) {
const children = await db.all(
'SELECT id FROM v_transactions WHERE parent_id = ?',
[existing.id],
);
for (const child of children) {
updated.push({ id: child.id, cleared: updates.cleared });
}
}
} else {
// Insert a new transaction
const { forceAddTransaction, ...newTrans } = trans;
const finalTransaction = {
...newTrans,
id: uuidv4(),
category: trans.category || null,
cleared: trans.cleared != null ? trans.cleared : true,
};
if (subtransactions && subtransactions.length > 0) {
added.push(...makeSplitTransaction(finalTransaction, subtransactions));
} else {
added.push(finalTransaction);
}
}
}
if (!isPreview) {
await createNewPayees(payeesToCreate, [...added, ...updated]);
await batchUpdateTransactions({ added, updated });
}
console.log('Debug data for the operations:', {
transactionsStep1,
transactionsStep2,
transactionsStep3,
added,
updated,
updatedPreview,
});
return {
added: added.map(trans => trans.id),
updated: updated.map(trans => trans.id),
updatedPreview,
};
}
export async function matchTransactions(
acctId,
transactions,
isBankSyncAccount = false,
) {
console.log('Performing transaction reconciliation matching');
const hasMatched = new Set();
const transactionNormalization = isBankSyncAccount
? normalizeBankSyncTransactions
@@ -399,7 +464,7 @@ export async function reconcileTransactions(
// matched transaction. See the final pass below for the needed
// fields.
fuzzyDataset = await db.all(
`SELECT id, is_parent, date, imported_id, payee, category, notes, reconciled FROM v_transactions
`SELECT id, is_parent, date, imported_id, payee, imported_payee, category, notes, reconciled, cleared, amount FROM v_transactions
WHERE date >= ? AND date <= ? AND amount = ? AND account = ?`,
[
db.toDateRepr(monthUtils.subDays(trans.date, 7)),
@@ -474,75 +539,11 @@ export async function reconcileTransactions(
return data;
});
// Finally, generate & commit the changes
for (const { trans, subtransactions, match } of transactionsStep3) {
if (match) {
// Skip updating already reconciled (locked) transactions
if (match.reconciled) {
continue;
}
// TODO: change the above sql query to use aql
const existing = {
...match,
cleared: match.cleared === 1,
date: db.fromDateRepr(match.date),
};
// Update the transaction
const updates = {
imported_id: trans.imported_id || null,
payee: existing.payee || trans.payee || null,
category: existing.category || trans.category || null,
imported_payee: trans.imported_payee || null,
notes: existing.notes || trans.notes || null,
cleared: trans.cleared != null ? trans.cleared : true,
};
if (hasFieldsChanged(existing, updates, Object.keys(updates))) {
updated.push({ id: existing.id, ...updates });
}
if (existing.is_parent && existing.cleared !== updates.cleared) {
const children = await db.all(
'SELECT id FROM v_transactions WHERE parent_id = ?',
[existing.id],
);
for (const child of children) {
updated.push({ id: child.id, cleared: updates.cleared });
}
}
} else {
// Insert a new transaction
const finalTransaction = {
...trans,
id: uuidv4(),
category: trans.category || null,
cleared: trans.cleared != null ? trans.cleared : true,
};
if (subtransactions && subtransactions.length > 0) {
added.push(...makeSplitTransaction(finalTransaction, subtransactions));
} else {
added.push(finalTransaction);
}
}
}
await createNewPayees(payeesToCreate, [...added, ...updated]);
await batchUpdateTransactions({ added, updated });
console.log('Debug data for the operations:', {
return {
payeesToCreate,
transactionsStep1,
transactionsStep2,
transactionsStep3,
added,
updated,
});
return {
added: added.map(trans => trans.id),
updated: updated.map(trans => trans.id),
};
}

View File

@@ -80,6 +80,7 @@ export async function addTransfer(transaction, transferredAccount) {
date: transaction.date,
transfer_id: transaction.id,
notes: transaction.notes || null,
schedule: transaction.schedule,
cleared: false,
});
@@ -130,6 +131,7 @@ export async function updateTransfer(transaction, transferredAccount) {
date: transaction.date,
notes: transaction.notes,
amount: -transaction.amount,
schedule: transaction.schedule,
});
const categoryCleared = await clearCategory(transaction, transferredAccount);

View File

@@ -1,3 +1,4 @@
import { Budget } from '../types/budget';
import type {
AccountEntity,
CategoryEntity,
@@ -5,6 +6,7 @@ import type {
PayeeEntity,
} from '../types/models';
import { RemoteFile } from './cloud-storage';
import * as models from './models';
export type APIAccountEntity = Pick<AccountEntity, 'id' | 'name'> & {
@@ -114,3 +116,39 @@ export const payeeModel = {
return payee as PayeeEntity;
},
};
export type APIFileEntity = Omit<RemoteFile, 'deleted' | 'fileId'> & {
id?: string;
cloudFileId: string;
state?: 'remote';
};
export const remoteFileModel = {
toExternal(file: RemoteFile): APIFileEntity | null {
if (file.deleted) {
return null;
}
return {
cloudFileId: file.fileId,
state: 'remote',
groupId: file.groupId,
name: file.name,
encryptKeyId: file.encryptKeyId,
hasKey: file.hasKey,
};
},
fromExternal(file: APIFileEntity) {
return { deleted: false, fileId: file.cloudFileId, ...file } as RemoteFile;
},
};
export const budgetModel = {
toExternal(file: Budget): APIFileEntity {
return file as APIFileEntity;
},
fromExternal(file: APIFileEntity) {
return file as Budget;
},
};

View File

@@ -22,9 +22,11 @@ import { ServerHandlers } from '../types/server-handlers';
import { addTransactions } from './accounts/sync';
import {
accountModel,
budgetModel,
categoryModel,
categoryGroupModel,
payeeModel,
remoteFileModel,
} from './api-models';
import { runQuery as aqlQuery } from './aql';
import * as cloudStorage from './cloud-storage';
@@ -226,6 +228,15 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
await handlers['load-budget']({ id: result.id });
};
handlers['api/get-budgets'] = async function () {
const budgets = await handlers['get-budgets']();
const files = (await handlers['get-remote-files']()) || [];
return [
...budgets.map(file => budgetModel.toExternal(file)),
...files.map(file => remoteFileModel.toExternal(file)).filter(file => file),
];
};
handlers['api/sync'] = async function () {
const { id } = prefs.getPrefs();
const result = await handlers['sync-budget']();
@@ -411,9 +422,14 @@ handlers['api/transactions-export'] = async function ({
handlers['api/transactions-import'] = withMutation(async function ({
accountId,
transactions,
isPreview = false,
}) {
checkFileOpen();
return handlers['transactions-import']({ accountId, transactions });
return handlers['transactions-import']({
accountId,
transactions,
isPreview,
});
});
handlers['api/transactions-add'] = withMutation(async function ({
@@ -533,6 +549,14 @@ handlers['api/account-delete'] = withMutation(async function ({ id }) {
return handlers['account-close']({ id, forced: true });
});
handlers['api/account-balance'] = withMutation(async function ({
id,
cutoff = new Date(),
}) {
checkFileOpen();
return handlers['account-balance']({ id, cutoff });
});
handlers['api/categories-get'] = async function ({
grouped,
}: { grouped? } = {}) {
@@ -629,6 +653,14 @@ handlers['api/payee-delete'] = withMutation(async function ({ id }) {
return handlers['payees-batch-change']({ deleted: [{ id }] });
});
handlers['api/payees-merge'] = withMutation(async function ({
targetId,
mergeIds,
}) {
checkFileOpen();
return handlers['payees-merge']({ targetId, mergeIds });
});
handlers['api/rules-get'] = async function () {
checkFileOpen();
return handlers['rules-get']();

View File

@@ -69,6 +69,8 @@ export const schema = {
closed: f('boolean'),
sort_order: f('float'),
tombstone: f('boolean'),
account_id: f('string'),
official_name: f('string'),
account_sync_source: f('string'),
},
categories: {

View File

@@ -19,14 +19,12 @@ import { goalsWeek } from './goals/goalsWeek';
export async function applyTemplate({ month }) {
await storeTemplates();
const category_templates = await getTemplates(null);
await resetCategoryTargets({ month, category: null });
return processTemplate(month, false, category_templates);
}
export async function overwriteTemplate({ month }) {
await storeTemplates();
const category_templates = await getTemplates(null);
await resetCategoryTargets({ month, category: null });
return processTemplate(month, true, category_templates);
}
@@ -36,8 +34,7 @@ export async function applySingleCategoryTemplate({ month, category }) {
]);
await storeTemplates();
const category_templates = await getTemplates(categories[0]);
await resetCategoryTargets({ month, category: categories });
return processTemplate(month, true, category_templates);
return processTemplate(month, true, category_templates, categories[0]);
}
export function runCheckTemplates() {
@@ -91,21 +88,21 @@ async function setCategoryTargets({ month, idealTemplate }) {
});
}
async function resetCategoryTargets({ month, category }) {
let categories;
async function resetCategoryTargets(month, category) {
let categories = [];
if (category === null) {
categories = await getCategories();
} else {
categories = category;
}
await batchMessages(async () => {
categories.forEach(element => {
for (let i = 0; i < categories.length; i++) {
setGoal({
category: element.id,
category: categories[i].id,
goal: null,
month,
});
});
}
});
}
@@ -155,6 +152,7 @@ async function processTemplate(
month,
force,
category_templates,
category?,
): Promise<Notification> {
let num_applied = 0;
let errors = [];
@@ -162,8 +160,13 @@ async function processTemplate(
const setToZero = [];
let priority_list = [];
const categories = await getCategories();
let categories = [];
const categories_remove = [];
if (category) {
categories[0] = category;
} else {
categories = await getCategories();
}
//clears templated categories
for (let c = 0; c < categories.length; c++) {
@@ -205,11 +208,12 @@ async function processTemplate(
categories.splice(categories_remove[i], 1);
}
// zero out the categories that need it
// zero out budget and goal from categories that need it
await setGoalBudget({
month,
templateBudget: setToZero.filter(f => f.isTemplate === true),
});
await resetCategoryTargets(month, categories);
// sort and filter down to just the requested priorities
priority_list = priority_list

View File

@@ -13,6 +13,7 @@ import { logger } from '../platform/server/log';
import * as sqlite from '../platform/server/sqlite';
import { isNonProductionEnvironment } from '../shared/environment';
import * as monthUtils from '../shared/months';
import { dayFromDate } from '../shared/months';
import { q, Query } from '../shared/query';
import { amountToInteger, stringToInteger } from '../shared/util';
import { type Budget } from '../types/budget';
@@ -578,6 +579,14 @@ handlers['accounts-get'] = async function () {
return db.getAccounts();
};
handlers['account-balance'] = async function ({ id, cutoff }) {
const { balance } = await db.first(
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?',
[id, db.toDateRepr(dayFromDate(cutoff))],
);
return balance ? balance : 0;
};
handlers['account-properties'] = async function ({ id }) {
const { balance } = await db.first(
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
@@ -1053,7 +1062,8 @@ handlers['accounts-bank-sync'] = async function ({ id }) {
const accounts = await db.runQuery(
`SELECT a.*, b.bank_id as bankId FROM accounts a
LEFT JOIN banks b ON a.bank = b.id
WHERE a.tombstone = 0 AND a.closed = 0 ${id ? 'AND a.id = ?' : ''}`,
WHERE a.tombstone = 0 AND a.closed = 0 ${id ? 'AND a.id = ?' : ''}
ORDER BY a.offbudget, a.sort_order`,
id ? [id] : [],
true,
);
@@ -1131,6 +1141,7 @@ handlers['accounts-bank-sync'] = async function ({ id }) {
handlers['transactions-import'] = mutator(function ({
accountId,
transactions,
isPreview,
}) {
return withUndo(async () => {
if (typeof accountId !== 'string') {
@@ -1138,10 +1149,20 @@ handlers['transactions-import'] = mutator(function ({
}
try {
return await bankSync.reconcileTransactions(accountId, transactions);
return await bankSync.reconcileTransactions(
accountId,
transactions,
false,
isPreview,
);
} catch (err) {
if (err instanceof TransactionError) {
return { errors: [{ message: err.message }], added: [], updated: [] };
return {
errors: [{ message: err.message }],
added: [],
updated: [],
updatedPreview: [],
};
}
throw err;
@@ -1216,13 +1237,6 @@ handlers['save-global-prefs'] = async function (prefs) {
if ('maxMonths' in prefs) {
await asyncStorage.setItem('max-months', '' + prefs.maxMonths);
}
if ('autoUpdate' in prefs) {
await asyncStorage.setItem('auto-update', '' + prefs.autoUpdate);
process.parentPort.postMessage({
type: 'shouldAutoUpdate',
flag: prefs.autoUpdate,
});
}
if ('documentDir' in prefs) {
if (await fs.exists(prefs.documentDir)) {
await asyncStorage.setItem('document-dir', prefs.documentDir);
@@ -1241,14 +1255,12 @@ handlers['load-global-prefs'] = async function () {
const [
[, floatingSidebar],
[, maxMonths],
[, autoUpdate],
[, documentDir],
[, encryptKey],
[, theme],
] = await asyncStorage.multiGet([
'floating-sidebar',
'max-months',
'auto-update',
'document-dir',
'encrypt-key',
'theme',
@@ -1256,7 +1268,6 @@ handlers['load-global-prefs'] = async function () {
return {
floatingSidebar: floatingSidebar === 'true' ? true : false,
maxMonths: stringToInteger(maxMonths || ''),
autoUpdate: autoUpdate == null || autoUpdate === 'true' ? true : false,
documentDir: documentDir || getDefaultDocumentDir(),
keyId: encryptKey && JSON.parse(encryptKey).id,
theme:
@@ -2118,14 +2129,6 @@ export async function initApp(isDev, socketName) {
connection.init(socketName, app.handlers);
if (!isDev && !Platform.isMobile && !Platform.isWeb) {
const autoUpdate = await asyncStorage.getItem('auto-update');
process.parentPort.postMessage({
type: 'shouldAutoUpdate',
flag: autoUpdate == null || autoUpdate === 'true',
});
}
// Allow running DB queries locally
global.$query = aqlQuery;
global.$q = q;

View File

@@ -1,4 +1,5 @@
import { evalArithmetic } from './arithmetic';
import { setNumberFormat } from './util';
describe('arithmetic', () => {
test('handles negative numbers', () => {
@@ -40,5 +41,11 @@ describe('arithmetic', () => {
test('respects current number format', () => {
expect(evalArithmetic('1,222.45')).toEqual(1222.45);
setNumberFormat({ format: 'space-comma', hideFraction: false });
expect(evalArithmetic('1\xa0222,45')).toEqual(1222.45);
setNumberFormat({ format: 'apostrophe-dot', hideFraction: false });
expect(evalArithmetic('1222.45')).toEqual(1222.45);
});
});

View File

@@ -38,7 +38,7 @@ function parsePrimary(state) {
}
let numberStr = '';
while (char(state) && char(state).match(/[0-9,. ]|\p{Sc}/u)) {
while (char(state) && char(state).match(/[0-9,.\xa0 ]|\p{Sc}/u)) {
numberStr += next(state);
}

View File

@@ -85,14 +85,13 @@ describe('utility functions', () => {
expect(formatter.format(Number('1234.56'))).toBe('1\xa0235');
});
test('number formatting works with space-dot format', () => {
setNumberFormat({ format: 'space-dot', hideFraction: false });
test('number formatting works with apostrophe-dot format', () => {
setNumberFormat({ format: 'apostrophe-dot', hideFraction: false });
let formatter = getNumberFormat().formatter;
// grouping separator space char is a non-breaking space, or UTF-16 \xa0
expect(formatter.format(Number('1234.56'))).toBe('1\xa0234.56');
expect(formatter.format(Number('1234.56'))).toBe('1234.56');
setNumberFormat({ format: 'space-dot', hideFraction: true });
setNumberFormat({ format: 'apostrophe-dot', hideFraction: true });
formatter = getNumberFormat().formatter;
expect(formatter.format(Number('1234.56'))).toBe('1\xa0235');
expect(formatter.format(Number('1234.56'))).toBe('1235');
});
});

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