Compare commits

..

15 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
8e71dcccd8 Release notes 2024-01-28 23:49:56 -08:00
Joel Jeremy Marquez
de9a1880a7 Migrate LoadBackup to ts 2024-01-28 23:49:10 -08:00
youngcw
43ebe9e0fd fix bad account sort order in demo (#2279)
* fix bad sort order in demo

* note

* add async back

* fix tests

* fix2

* fix3

* update vrt

* fix image
2024-01-26 09:12:54 -07:00
youngcw
515bdf5a74 update vrt instructions (#2287)
* update

* note

* revise
2024-01-26 09:06:12 -07:00
Ed
018714610a Updating link for GoCardless Bank Account Sync (#2276)
* Updating link for GoCardless Bank Account Sync

* Adding release notes

* Update URL to point to docs

* Fixing lint
2024-01-26 07:11:18 -08:00
Joel Jeremy Marquez
00ee165f8e Mobile Off Budget category label (#2284)
* Mobile Off Budget category label

* Release notes

* Fix error

* Fix release notes
2024-01-26 07:09:29 -08:00
Neil
68442ae9e6 Custom reports: hide "show ..." checkboxes in menu (#2174)
* Add Toggles

* budget table

* testing

* updates

* updates

* fixes

* updates

* fix Menu

* lint fixes

* fix keybindings

* revert budget menu changes

* notes

* remove default exports

* fixes

* disabled fix

* add style option

* lint fix

* remove css

* lint fixes

* color updates

* merge menu with togglemenu

* host

* menu fixes

* fix regression

* remove host

* adjustments

* onToggle

* vrt fix

* typecheck

* merge fixes

* colors

* lint fix
2024-01-24 21:49:12 +00:00
shall0pass
b937bfae04 [Bugfix] Goals: Database entry (#2281)
* fix database insertion

* dbMonth format

* release note
2024-01-24 14:51:38 -06:00
DJ Mountney
317e7f135e 🐛 Avoid passing a boolean to the import trans category title (#2278)
* Avoid passing a boolean to the import trans category title

- Fixes an error regading passing false to title
  when category is not available
2024-01-24 11:23:02 -08:00
DJ Mountney
5adb083575 🐛 Fix a missing ref param warning for forwardRef (#2277)
* Fix a missing ref param warning for forwardRef

- Drop unused usage of forwardRef on TableWithNavigator
2024-01-24 11:13:43 -08:00
Joel Jeremy Marquez
524bd4e9eb Update vite / swc / ts versions (#2268)
* Update vite / swc / ts versions

* Release notes

* Revert root tsconfig module changes

* yarn dedupe

* Dummy update to run pipeline

* Update webpack and playwright

* Update playwright docker images
2024-01-24 10:49:12 -08:00
Matiss Janis Aboltins
9dfd6ce34c 🐛 fix uncategorized transaction banner flashing on load (#2273) 2024-01-23 08:23:32 +00:00
Matiss Janis Aboltins
5d28bc0e3b 🏷️ making some files comply with strict TS (#2247) 2024-01-22 19:01:05 +00:00
Matiss Janis Aboltins
a4e97e0070 ♻️ refactored cash-flow report from victory to recharts (#2260) 2024-01-22 08:34:51 +00:00
Matiss Janis Aboltins
a6e38ad2ae allow un-reconciling (unlocking) transactions (#2252) 2024-01-22 08:34:40 +00:00
104 changed files with 1025 additions and 1335 deletions

View File

@@ -31,7 +31,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.37.0-jammy
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- uses: actions/checkout@v3
- name: Set up environment
@@ -51,7 +51,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.37.0-jammy
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- uses: actions/checkout@v3
- name: Set up environment

View File

@@ -43,20 +43,20 @@
},
"devDependencies": {
"cross-env": "^7.0.3",
"eslint": "^8.56.0",
"eslint": "^8.37.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "7.0.1",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.1",
"eslint-import-resolver-typescript": "3.5.5",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-react": "7.33.2",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-rulesdir": "^0.2.2",
"node-jq": "^4.2.2",
"npm-run-all": "^4.1.5",
"node-jq": "^4.0.1",
"npm-run-all": "^4.1.3",
"prettier": "3.2.4",
"react-refresh": "^0.14.0",
"source-map-support": "^0.5.21",
"typescript": "^5.3.3",
"typescript": "^5.0.2",
"typescript-strict-plugin": "^2.2.2-beta.2"
},
"engines": {

View File

@@ -21,18 +21,18 @@
"clean": "rm -rf dist @types"
},
"dependencies": {
"better-sqlite3": "^9.3.0",
"better-sqlite3": "^9.2.2",
"compare-versions": "^6.1.0",
"node-fetch": "^3.3.2",
"uuid": "^9.0.1"
"uuid": "^9.0.0"
},
"devDependencies": {
"@swc/core": "^1.3.105",
"@swc/jest": "^0.2.31",
"@types/jest": "^27.5.2",
"@types/uuid": "^9.0.7",
"jest": "^27.5.1",
"@types/jest": "^27.5.0",
"@types/uuid": "^9.0.2",
"jest": "^27.0.0",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3"
"typescript": "^5.0.2"
}
}

View File

@@ -4,8 +4,7 @@
// Using ES2021 because thats the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "Node16",
"moduleResolution": "Node16",
"module": "CommonJS",
"noEmit": false,
"declaration": true,
"outDir": "dist",

View File

@@ -15,17 +15,17 @@
"test": "jest -c jest.config.js"
},
"dependencies": {
"google-protobuf": "^3.21.2",
"google-protobuf": "^3.12.0-rc.1",
"murmurhash": "^2.0.1",
"uuid": "^9.0.1"
"uuid": "^9.0.0"
},
"devDependencies": {
"@swc/core": "^1.3.105",
"@swc/jest": "^0.2.31",
"@types/jest": "^27.5.2",
"@types/uuid": "^9.0.7",
"jest": "^27.5.1",
"@types/jest": "^27.5.0",
"@types/uuid": "^9.0.2",
"jest": "^27.0.0",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.3.3"
"typescript": "^5.0.2"
}
}

View File

@@ -4,8 +4,7 @@
// Using ES2021 because thats the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "Node16",
"moduleResolution": "Node16",
"module": "CommonJS",
"noEmit": false,
"declaration": true,
"strict": true,

View File

@@ -32,26 +32,27 @@ Prerequisites:
#### Running against the local server
First start the dev server:
First start a dev instance:
```sh
HTTPS=true yarn start
```
Note the network IP address and port the dev instance is listening on.
Next, navigate to the root of your project folder, run the standartised docker container, and launch the visual regression tests from within it.
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.37.1-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.37.1-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
# Run the VRT tests: important - they MUST be ran against a HTTPS server
E2E_START_URL=https://192.168.0.178:3001 yarn vrt
# Run the VRT tests: important - they MUST be ran against a HTTPS server. Use the ip and port noted earlier
E2E_START_URL=https://ip:port yarn vrt
# To update snapshots, use the following command:
E2E_START_URL=https://192.168.0.178:3001 yarn vrt --update-snapshots
E2E_START_URL=https://ip:port yarn vrt --update-snapshots
```
#### Running against a remote server

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -49,7 +49,7 @@ test.describe('Mobile', () => {
test('opens the accounts page and asserts on balances', async () => {
const accountsPage = await navigation.goToAccountsPage();
const account = await accountsPage.getNthAccount(0);
const account = await accountsPage.getNthAccount(1);
await expect(account.name).toHaveText('Ally Savings');
await expect(account.balance).toHaveText('7,653.00');
@@ -58,7 +58,7 @@ test.describe('Mobile', () => {
test('opens individual account page and checks that filtering is working', async () => {
const accountsPage = await navigation.goToAccountsPage();
const accountPage = await accountsPage.openNthAccount(1);
const accountPage = await accountsPage.openNthAccount(0);
await expect(accountPage.heading).toHaveText('Bank of America');
expect(await accountPage.getBalance()).toBeGreaterThan(0);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

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

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -7,7 +7,7 @@
],
"devDependencies": {
"@juggle/resize-observer": "^3.1.2",
"@playwright/test": "^1.37.1",
"@playwright/test": "^1.41.1",
"@reach/listbox": "^0.18.0",
"@react-aria/focus": "^3.14.0",
"@react-aria/listbox": "^3.10.1",
@@ -16,8 +16,8 @@
"@react-stately/list": "^3.9.1",
"@rollup/plugin-inject": "^5.0.5",
"@svgr/cli": "^8.0.1",
"@swc/core": "^1.3.82",
"@swc/helpers": "^0.5.1",
"@swc/core": "^1.3.105",
"@swc/helpers": "^0.5.3",
"@swc/plugin-react-remove-properties": "^1.5.108",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
@@ -26,9 +26,9 @@
"@types/react-modal": "^3.16.0",
"@types/react-redux": "^7.1.25",
"@types/uuid": "^9.0.2",
"@types/webpack-bundle-analyzer": "^4.6.0",
"@types/webpack-bundle-analyzer": "^4.6.3",
"@use-gesture/react": "^10.3.0",
"@vitejs/plugin-basic-ssl": "^1.0.2",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"chokidar": "^3.5.3",
"cross-env": "^7.0.3",
@@ -65,12 +65,13 @@
"sass": "^1.63.6",
"swc-loader": "^0.2.3",
"terser-webpack-plugin": "^5.3.9",
"typescript": "^5.0.2",
"uuid": "^9.0.0",
"victory": "^36.6.8",
"vite": "^5.0.10",
"vite-tsconfig-paths": "^4.2.2",
"vitest": "^1.0.4",
"webpack-bundle-analyzer": "^4.9.1",
"vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.2.1",
"webpack-bundle-analyzer": "^4.10.1",
"xml2js": "^0.6.2"
},
"scripts": {

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import React, {
createContext,
useState,
@@ -61,7 +60,14 @@ export type TitlebarContextValue = {
subscribe: (listener: Listener) => () => void;
};
export const TitlebarContext = createContext<TitlebarContextValue>(null);
export const TitlebarContext = createContext<TitlebarContextValue>({
sendEvent() {
throw new Error('TitlebarContext not initialized');
},
subscribe() {
throw new Error('TitlebarContext not initialized');
},
});
type TitlebarProviderProps = {
children?: ReactNode;
@@ -88,26 +94,32 @@ export function TitlebarProvider({ children }: TitlebarProviderProps) {
}
function UncategorizedButton() {
const count = useSheetValue(queries.uncategorizedCount());
const count: number | null = useSheetValue(queries.uncategorizedCount());
if (count === null || count <= 0) {
return null;
}
return (
count !== 0 && (
<Link
variant="button"
type="bare"
to="/accounts/uncategorized"
style={{
color: theme.errorText,
}}
>
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
</Link>
)
<Link
variant="button"
type="bare"
to="/accounts/uncategorized"
style={{
color: theme.errorText,
}}
>
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
</Link>
);
}
function PrivacyButton({ style }) {
type PrivacyButtonProps = {
style?: CSSProperties;
};
function PrivacyButton({ style }: PrivacyButtonProps) {
const isPrivacyEnabled = useSelector(
state => state.prefs.local.isPrivacyEnabled,
state => state.prefs.local?.isPrivacyEnabled,
);
const { savePrefs } = useActions();
@@ -134,11 +146,13 @@ type SyncButtonProps = {
isMobile?: boolean;
};
function SyncButton({ style, isMobile = false }: SyncButtonProps) {
const cloudFileId = useSelector(state => state.prefs.local.cloudFileId);
const cloudFileId = useSelector(state => state.prefs.local?.cloudFileId);
const { sync } = useActions();
const [syncing, setSyncing] = useState(false);
const [syncState, setSyncState] = useState(null);
const [syncState, setSyncState] = useState<
null | 'offline' | 'local' | 'disabled' | 'error'
>(null);
useEffect(() => {
const unlisten = listen('sync-event', ({ type, subtype, syncDisabled }) => {
@@ -272,8 +286,8 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
}
function BudgetTitlebar() {
const maxMonths = useSelector(state => state.prefs.global.maxMonths);
const budgetType = useSelector(state => state.prefs.local.budgetType);
const maxMonths = useSelector(state => state.prefs.global?.maxMonths);
const budgetType = useSelector(state => state.prefs.local?.budgetType);
const { saveGlobalPrefs } = useActions();
const { sendEvent } = useContext(TitlebarContext);
@@ -366,14 +380,18 @@ function BudgetTitlebar() {
);
}
export function Titlebar({ style }) {
type TitlebarProps = {
style?: CSSProperties;
};
export function Titlebar({ style }: TitlebarProps) {
const navigate = useNavigate();
const location = useLocation();
const sidebar = useSidebar();
const { isNarrowWidth } = useResponsive();
const serverURL = useServerURL();
const floatingSidebar = useSelector(
state => state.prefs.global.floatingSidebar,
state => state.prefs.global?.floatingSidebar,
);
return isNarrowWidth ? null : (

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import React from 'react';
import { useSelector } from 'react-redux';
@@ -11,14 +10,6 @@ import { LinkButton } from './common/LinkButton';
import { Text } from './common/Text';
import { View } from './common/View';
function closeNotification(setAppState) {
// Set a flag to never show an update notification again for this session
setAppState({
updateInfo: null,
showUpdateNotification: false,
});
}
export function UpdateNotification() {
const updateInfo = useSelector(state => state.app.updateInfo);
const showUpdateNotification = useSelector(
@@ -68,7 +59,7 @@ export function UpdateNotification() {
textDecoration: 'underline',
}}
onClick={() =>
window.Actual.openURLInBrowser(
window.Actual?.openURLInBrowser(
'https://actualbudget.org/docs/releases',
)
}
@@ -80,7 +71,13 @@ export function UpdateNotification() {
type="bare"
aria-label="Close"
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
onClick={() => closeNotification(setAppState)}
onClick={() => {
// Set a flag to never show an update notification again for this session
setAppState({
updateInfo: null,
showUpdateNotification: false,
});
}}
>
<SvgClose
width={9}

View File

@@ -199,11 +199,11 @@ export function AccountDetails({
</View>
<PullToRefresh onRefresh={onRefresh}>
<TransactionList
account={account}
transactions={allTransactions}
categories={categories}
accounts={accounts}
payees={payees}
showCategory={!account.offbudget}
isNew={isNewTransaction}
onLoadMore={onLoadMore}
onSelect={onSelectTransaction}

View File

@@ -691,7 +691,7 @@ function MultiAutocomplete<T extends Item>({
type AutocompleteFooterProps = {
show?: boolean;
embedded: boolean;
embedded?: boolean;
children: ReactNode;
};
export function AutocompleteFooter({
@@ -699,18 +699,20 @@ export function AutocompleteFooter({
embedded,
children,
}: AutocompleteFooterProps) {
if (!show) {
return null;
}
return (
show && (
<View
style={{
flexShrink: 0,
...(embedded ? { paddingTop: 5 } : { padding: 5 }),
}}
onMouseDown={e => e.preventDefault()}
>
{children}
</View>
)
<View
style={{
flexShrink: 0,
...(embedded ? { paddingTop: 5 } : { padding: 5 }),
}}
onMouseDown={e => e.preventDefault()}
>
{children}
</View>
);
}

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import React, {
type ComponentProps,
Fragment,
@@ -25,9 +24,11 @@ import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
export type CategoryListProps = {
items: Array<CategoryEntity & { group?: CategoryGroupEntity }>;
getItemProps?: (arg: { item }) => Partial<ComponentProps<typeof View>>;
getItemProps?: (arg: {
item: CategoryEntity;
}) => Partial<ComponentProps<typeof View>>;
highlightedIndex: number;
embedded: boolean;
embedded?: boolean;
footer?: ReactNode;
renderSplitTransactionButton?: (
props: SplitTransactionButtonProps,
@@ -47,7 +48,7 @@ function CategoryList({
renderCategoryItemGroupHeader = defaultRenderCategoryItemGroupHeader,
renderCategoryItem = defaultRenderCategoryItem,
}: CategoryListProps) {
let lastGroup = null;
let lastGroup: string | undefined | null = null;
return (
<View>
@@ -72,10 +73,10 @@ function CategoryList({
lastGroup = item.cat_group;
return (
<Fragment key={item.id}>
{showGroup && (
<Fragment key={item.group?.name}>
{showGroup && item.group?.name && (
<Fragment key={item.group.name}>
{renderCategoryItemGroupHeader({
title: item.group?.name,
title: item.group.name,
})}
</Fragment>
)}
@@ -125,7 +126,7 @@ export function CategoryAutocomplete({
categoryGroups.reduce(
(list, group) =>
list.concat(
group.categories
(group.categories || [])
.filter(category => category.cat_group === group.id)
.map(category => ({
...category,
@@ -214,8 +215,7 @@ type SplitTransactionButtonProps = {
style?: CSSProperties;
};
// eslint-disable-next-line import/no-unused-modules
export function SplitTransactionButton({
function SplitTransactionButton({
Icon,
highlighted,
embedded,

View File

@@ -10,6 +10,7 @@ import {
import { type CSSProperties, theme } from '../../style';
import { Text } from './Text';
import { Toggle } from './Toggle';
import { View } from './View';
type KeybindingProps = {
@@ -33,6 +34,8 @@ type MenuItem = {
text: string;
key?: string;
style?: CSSProperties;
toggle?: boolean;
tooltip?: string;
};
type MenuProps<T extends MenuItem = MenuItem> = {
@@ -164,23 +167,48 @@ export function Menu<T extends MenuItem>({
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() =>
!item.disabled && onMenuSelect && onMenuSelect(item.name)
!item.disabled &&
onMenuSelect &&
item.toggle === undefined &&
onMenuSelect(item.name)
}
>
{/* Force it to line up evenly */}
<Text style={{ lineHeight: 0 }}>
{item.icon &&
createElement(item.icon, {
width: item.iconSize || 10,
height: item.iconSize || 10,
style: {
marginRight: 7,
width: item.iconSize || 10,
},
})}
</Text>
<Text>{item.text}</Text>
<View style={{ flex: 1 }} />
{item.toggle === undefined ? (
<>
<Text style={{ lineHeight: 0 }}>
{item.icon &&
createElement(item.icon, {
width: item.iconSize || 10,
height: item.iconSize || 10,
style: {
marginRight: 7,
width: item.iconSize || 10,
},
})}
</Text>
<Text title={item.tooltip}>{item.text}</Text>
<View style={{ flex: 1 }} />
</>
) : (
<>
<label htmlFor={item.name} title={item.tooltip}>
{item.text}
</label>
<View style={{ flex: 1 }} />
<Toggle
id={item.name}
checked={item.toggle}
onColor={theme.pageTextPositive}
style={{ marginLeft: 5, ...item.style }}
onToggle={() =>
!item.disabled &&
item.toggle !== undefined &&
onMenuSelect(item.name)
}
/>
</>
)}
{item.key && <Keybinding keyName={item.key} />}
</View>
);

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { css } from 'glamor';
import { theme, type CSSProperties } from '../../style';
type ToggleProps = {
id: string;
checked: boolean;
onToggle?: () => void;
onColor?: string;
style?: CSSProperties;
};
export const Toggle = ({
id,
checked,
onToggle,
onColor,
style,
}: ToggleProps) => {
return (
<div style={{ marginTop: -20, ...style }}>
<input
id={id}
checked={checked}
onChange={onToggle}
className={`${css({
height: 0,
width: 0,
visibility: 'hidden',
})}`}
type="checkbox"
/>
<label
style={{
background: checked ? onColor : theme.checkboxToggleBackground,
}}
className={`${css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
width: '32px',
height: '16px',
borderRadius: '100px',
position: 'relative',
transition: 'background-color .2s',
})}`}
htmlFor={id}
>
<span
className={`${css(
{
content: '',
position: 'absolute',
top: '2px',
left: '2px',
width: '12px',
height: '12px',
borderRadius: '100px',
transition: '0.2s',
background: '#fff',
boxShadow: '0 0 2px 0 rgba(10, 10, 10, 0.29)',
},
checked && {
left: 'calc(100% - 2px)',
transform: 'translateX(-100%)',
},
)}`}
/>
</label>
</div>
);
};

View File

@@ -42,6 +42,12 @@ export function ConfirmTransactionEdit({
Saving your changes to this reconciled transaction may bring your
reconciliation out of balance.
</Block>
) : confirmReason === 'unlockReconciled' ? (
<Block>
Unlocking this transaction means you wont be warned about changes
that can impact your reconciled balance. (Changes to amount,
account, payee, etc).
</Block>
) : confirmReason === 'deleteReconciled' ? (
<Block>
Deleting this reconciled transaction may bring your reconciliation

View File

@@ -58,7 +58,10 @@ export const GoCardlessInitialise = ({
In order to enable bank-sync via GoCardless (only for EU banks) you
will need to create access credentials. This can be done by creating
an account with{' '}
<ExternalLink to="https://gocardless.com/" linkColor="purple">
<ExternalLink
to="https://actualbudget.org/docs/advanced/bank-sync/"
linkColor="purple"
>
GoCardless
</ExternalLink>
.

View File

@@ -376,7 +376,9 @@ function Transaction({
<Field
width="flex"
title={
categoryList.includes(transaction.category) && transaction.category
categoryList.includes(transaction.category)
? transaction.category
: undefined
}
>
{categoryList.includes(transaction.category) && transaction.category}

View File

@@ -1,4 +1,4 @@
import React, { Component, useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { send, listen, unlisten } from 'loot-core/src/platform/client/fetch';
@@ -9,52 +9,63 @@ import { Modal } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { Row, Cell } from '../table';
import { Backup } from 'loot-core/server/backups';
import { CommonModalProps } from '../../types/modals';
import { BoundActions } from '../../hooks/useActions';
class BackupTable extends Component {
state = { hoveredBackup: null };
type LoadBackupProps = {
backups: Backup[];
onSelect: (id) => void;
};
onHover = id => {
this.setState({ hoveredBackup: id });
function BackupTable({ backups, onSelect }: LoadBackupProps) {
const [hoveredBackup, setHoveredBackup] = useState(null);
const onHover = id => {
setHoveredBackup(id);
};
render() {
const { backups, onSelect } = this.props;
const { hoveredBackup } = this.state;
return (
<View
style={{ flex: 1, maxHeight: 200, overflow: 'auto' }}
onMouseLeave={() => this.onHover(null)}
>
{backups.map((backup, idx) => (
<Row
key={backup.id}
collapsed={idx !== 0}
focused={hoveredBackup === backup.id}
onMouseEnter={() => this.onHover(backup.id)}
onClick={() => onSelect(backup.id)}
style={{ cursor: 'pointer' }}
>
<Cell
width="flex"
value={backup.date ? backup.date : 'Revert to Latest'}
valueStyle={{ paddingLeft: 20 }}
/>
</Row>
))}
</View>
);
}
return (
<View
style={{ flex: 1, maxHeight: 200, overflow: 'auto' }}
onMouseLeave={() => onHover(null)}
>
{backups.map((backup, idx) => (
<Row
key={backup.id}
collapsed={idx !== 0}
focused={hoveredBackup === backup.id}
onMouseEnter={() => onHover(backup.id)}
onClick={() => onSelect(backup.id)}
style={{ cursor: 'pointer' }}
>
<Cell
width="flex"
value={backup.date ? backup.date : 'Revert to Latest'}
valueStyle={{ paddingLeft: 20 }}
/>
</Row>
))}
</View>
);
}
type LoadBackupProps = {
budgetId: string;
watchUpdates: boolean;
backupDisabled: boolean;
actions: BoundActions;
modalProps: CommonModalProps;
};
export function LoadBackup({
budgetId,
watchUpdates,
backupDisabled,
actions,
modalProps,
}) {
const [backups, setBackups] = useState([]);
}: LoadBackupProps) {
const [backups, setBackups] = useState<Backup>([]);
useEffect(() => {
send('backups-get', { id: budgetId }).then(setBackups);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import React, { useState } from 'react';
import * as monthUtils from 'loot-core/src/shared/months';
import { theme } from '../../style';
import { Button } from '../common/Button';
import { Menu } from '../common/Menu';
import { Select } from '../common/Select';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { Checkbox } from '../forms';
import { Tooltip } from '../tooltips';
import { CategorySelector } from './CategorySelector';
import {
@@ -39,6 +41,7 @@ export function ReportSidebar({
onChangeDates,
onChangeViews,
}) {
const [menuOpen, setMenuOpen] = useState(false);
const onSelectRange = cond => {
setDateRange(cond);
switch (cond) {
@@ -242,70 +245,62 @@ export function ReportSidebar({
}}
>
<Text style={{ width: 40, textAlign: 'right', marginRight: 5 }} />
<Checkbox
id="show-empty-columns"
checked={customReportItems.showEmpty}
value={customReportItems.showEmpty}
onChange={() => setShowEmpty(!customReportItems.showEmpty)}
/>
<label
htmlFor="show-empty-columns"
title="Show rows that are zero or blank"
style={{ fontSize: 12 }}
<Button
onClick={() => {
setMenuOpen(true);
}}
style={{
color: 'currentColor',
padding: '5px 10px',
}}
>
Show Empty Rows
</label>
</View>
<View
style={{
flexDirection: 'row',
padding: 5,
alignItems: 'center',
}}
>
<Text style={{ width: 40, textAlign: 'right', marginRight: 5 }} />
<Checkbox
id="show-hidden-columns"
checked={customReportItems.showOffBudgetHidden}
value={customReportItems.showOffBudgetHidden}
onChange={() =>
setShowOffBudgetHidden(!customReportItems.showOffBudgetHidden)
}
/>
<label
htmlFor="show-hidden-columns"
title="Show off budget accounts and hidden categories"
style={{ fontSize: 12 }}
>
Off Budget Items
</label>
</View>
<View
style={{
flexDirection: 'row',
padding: 5,
alignItems: 'center',
}}
>
<Text style={{ width: 40, textAlign: 'right', marginRight: 5 }} />
<Checkbox
id="show-uncategorized"
checked={customReportItems.showUncategorized}
value={customReportItems.showUncategorized}
onChange={() =>
setShowUncategorized(!customReportItems.showUncategorized)
}
/>
<label
htmlFor="show-uncategorized"
title="Show uncategorized transactions"
style={{ fontSize: 12 }}
>
Uncategorized
</label>
Options
{menuOpen && (
<Tooltip
position="bottom-left"
style={{ padding: 0 }}
onClose={() => {
setMenuOpen(false);
}}
>
<Menu
onMenuSelect={type => {
if (type === 'show-hidden-categories') {
setShowOffBudgetHidden(
!customReportItems.showOffBudgetHidden,
);
} else if (type === 'show-empty-rows') {
setShowEmpty(!customReportItems.showEmpty);
} else if (type === 'show-uncategorized') {
setShowUncategorized(
!customReportItems.showUncategorized,
);
}
}}
items={[
{
name: 'show-empty-rows',
text: 'Show Empty Rows',
tooltip: 'Show rows that are zero or blank',
toggle: customReportItems.showEmpty,
},
{
name: 'show-hidden-categories',
text: 'Show Off Budget',
tooltip: 'Show off budget accounts and hidden categories',
toggle: customReportItems.showOffBudgetHidden,
},
{
name: 'show-uncategorized',
text: 'Show Uncategorized',
tooltip: 'Show uncategorized transactions',
toggle: customReportItems.showUncategorized,
},
]}
/>
</Tooltip>
)}
</Button>
</View>
<View
style={{

View File

@@ -1,66 +1,164 @@
// @ts-strict-ignore
import React from 'react';
import React, { useState } from 'react';
import * as d from 'date-fns';
import { css } from 'glamor';
import {
VictoryChart,
VictoryBar,
VictoryLine,
VictoryAxis,
VictoryVoronoiContainer,
VictoryGroup,
} from 'victory';
Bar,
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type TooltipProps,
} from 'recharts';
import { usePrivacyMode } from 'loot-core/src/client/privacy';
import {
amountToCurrency,
amountToCurrencyNoDecimal,
} from 'loot-core/src/shared/util';
import { theme } from '../../../style';
import { AlignedText } from '../../common/AlignedText';
import { chartTheme } from '../chart-theme';
import { Container } from '../Container';
import { Tooltip } from '../Tooltip';
const MAX_BAR_SIZE = 50;
const ANIMATION_DURATION = 1000; // in ms
type CustomTooltipProps = TooltipProps<number, 'date'> & {
isConcise: boolean;
};
function CustomTooltip({ active, payload, isConcise }: CustomTooltipProps) {
if (!active || !payload) {
return null;
}
const [{ payload: data }] = payload;
return (
<div
className={`${css({
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
})}`}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>
{d.format(data.date, isConcise ? 'MMMM yyyy' : 'MMMM dd, yyyy')}
</strong>
</div>
<div style={{ lineHeight: 1.5 }}>
<AlignedText left="Income:" right={amountToCurrency(data.income)} />
<AlignedText
left="Expenses:"
right={amountToCurrency(data.expenses)}
/>
<AlignedText
left="Change:"
right={
<strong>{amountToCurrency(data.income + data.expenses)}</strong>
}
/>
{data.transfers !== 0 && (
<AlignedText
left="Transfers:"
right={amountToCurrency(data.transfers)}
/>
)}
<AlignedText left="Balance:" right={amountToCurrency(data.balance)} />
</div>
</div>
</div>
);
}
type CashFlowGraphProps = {
graphData: { expenses; income; balances };
graphData: {
expenses: { x: Date; y: number }[];
income: { x: Date; y: number }[];
balances: { x: Date; y: number }[];
transfers: { x: Date; y: number }[];
};
isConcise: boolean;
};
export function CashFlowGraph({ graphData, isConcise }: CashFlowGraphProps) {
const privacyMode = usePrivacyMode();
const [yAxisIsHovered, setYAxisIsHovered] = useState(false);
const data = graphData.expenses.map((row, idx) => ({
date: row.x,
expenses: row.y,
income: graphData.income[idx].y,
balance: graphData.balances[idx].y,
transfers: graphData.transfers[idx].y,
}));
return (
<Container>
{(width, height, portalHost) =>
graphData && (
<VictoryChart
scale={{ x: 'time', y: 'linear' }}
theme={chartTheme}
domainPadding={10}
width={width}
height={height}
containerComponent={
<VictoryVoronoiContainer voronoiDimension="x" />
}
>
<VictoryGroup>
<VictoryBar
data={graphData.expenses}
style={{ data: { fill: chartTheme.colors.red } }}
/>
<VictoryBar data={graphData.income} />
</VictoryGroup>
<VictoryLine
data={graphData.balances}
labelComponent={<Tooltip portalHost={portalHost} />}
labels={x => x.premadeLabel}
style={{
data: { stroke: theme.pageTextLight },
}}
/>
<VictoryAxis
// eslint-disable-next-line rulesdir/typography
tickFormat={x => d.format(x, isConcise ? "MMM ''yy" : 'MMM d')}
tickValues={graphData.balances.map(item => item.x)}
tickCount={Math.min(5, graphData.balances.length)}
offsetY={50}
/>
<VictoryAxis dependentAxis crossAxis={false} />
</VictoryChart>
)
}
</Container>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart stackOffset="sign" data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: theme.reportsLabel }}
tickFormatter={x => {
// eslint-disable-next-line rulesdir/typography
return d.format(x, isConcise ? "MMM ''yy" : 'MMM d');
}}
minTickGap={50}
/>
<YAxis
tick={{ fill: theme.reportsLabel }}
tickCount={8}
tickFormatter={value =>
privacyMode && !yAxisIsHovered
? '...'
: amountToCurrencyNoDecimal(value)
}
onMouseEnter={() => setYAxisIsHovered(true)}
onMouseLeave={() => setYAxisIsHovered(false)}
/>
<Tooltip
labelFormatter={x => {
// eslint-disable-next-line rulesdir/typography
return d.format(x, isConcise ? "MMM ''yy" : 'MMM d');
}}
content={<CustomTooltip isConcise={isConcise} />}
isAnimationActive={false}
/>
<ReferenceLine y={0} stroke="#000" />
<Bar
dataKey="income"
stackId="a"
fill={chartTheme.colors.blue}
maxBarSize={MAX_BAR_SIZE}
animationDuration={ANIMATION_DURATION}
/>
<Bar
dataKey="expenses"
stackId="a"
fill={chartTheme.colors.red}
maxBarSize={MAX_BAR_SIZE}
animationDuration={ANIMATION_DURATION}
/>
<Line
type="monotone"
dataKey="balance"
dot={false}
stroke={theme.pageTextLight}
strokeWidth={2}
animationDuration={ANIMATION_DURATION}
/>
</ComposedChart>
</ResponsiveContainer>
);
}

View File

@@ -7,7 +7,7 @@ export const adjustTextSize = (
let source;
switch (type) {
case 'variable':
source = variableLookup.find(({ value }) => values > value).arr;
source = variableLookup.find(({ value }) => values >= value).arr;
break;
case 'donut':
source = donutLookup;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo, useCallback } from 'react';
import { VictoryBar, VictoryGroup, VictoryVoronoiContainer } from 'victory';
import { Bar, BarChart, ResponsiveContainer } from 'recharts';
import * as monthUtils from 'loot-core/src/shared/months';
import { integerToCurrency } from 'loot-core/src/shared/util';
@@ -11,14 +11,37 @@ import { View } from '../../common/View';
import { PrivacyFilter } from '../../PrivacyFilter';
import { Change } from '../Change';
import { chartTheme } from '../chart-theme';
import { Container } from '../Container';
import { DateRange } from '../DateRange';
import { LoadingIndicator } from '../LoadingIndicator';
import { ReportCard } from '../ReportCard';
import { simpleCashFlow } from '../spreadsheets/cash-flow-spreadsheet';
import { Tooltip } from '../Tooltip';
import { useReport } from '../useReport';
function CustomLabel({ value, name, position, ...props }) {
return (
<>
<text
{...props}
dy={10}
dx={position === 'right' ? 20 : -5}
textAnchor={position === 'right' ? 'start' : 'end'}
fill={theme.tableText}
>
{name}
</text>
<text
{...props}
dy={26}
dx={position === 'right' ? 20 : -4}
textAnchor={position === 'right' ? 'start' : 'end'}
fill={theme.tableText}
>
<PrivacyFilter>{integerToCurrency(value)}</PrivacyFilter>
</text>
</>
);
}
export function CashFlowCard() {
const end = monthUtils.currentDay();
const start = monthUtils.currentMonth() + '-01';
@@ -31,7 +54,7 @@ export function CashFlowCard() {
const onCardHoverEnd = useCallback(() => setIsCardHovered(false));
const { graphData } = data || {};
const expense = -(graphData?.expense || 0);
const expenses = -(graphData?.expense || 0);
const income = graphData?.income || 0;
return (
@@ -55,7 +78,7 @@ export function CashFlowCard() {
<View style={{ textAlign: 'right' }}>
<PrivacyFilter activationFilters={[!isCardHovered]}>
<Change
amount={income - expense}
amount={income - expenses}
style={{ color: theme.tableText, fontWeight: 300 }}
/>
</PrivacyFilter>
@@ -64,84 +87,33 @@ export function CashFlowCard() {
</View>
{data ? (
<Container style={{ height: 'auto', flex: 1 }}>
{(width, height, portalHost) => (
<VictoryGroup
colorScale={[chartTheme.colors.blue, chartTheme.colors.red]}
width={100}
height={height}
theme={chartTheme}
domain={{
x: [0, 100],
y: [0, Math.max(income, expense, 100)],
}}
containerComponent={
<VictoryVoronoiContainer voronoiDimension="x" />
}
labelComponent={
<Tooltip
portalHost={portalHost}
offsetX={(width - 100) / 2}
offsetY={y => (y + 40 > height ? height - 40 : y)}
light={true}
forceActive={true}
style={{
padding: 0,
}}
/>
}
padding={{
top: 0,
bottom: 0,
left: 0,
right: 0,
}}
>
<VictoryBar
barWidth={13}
data={[
{
x: 30,
y: Math.max(income, 5),
premadeLabel: (
<View style={{ textAlign: 'right' }}>
Income
<View>
<PrivacyFilter activationFilters={[!isCardHovered]}>
{integerToCurrency(income)}
</PrivacyFilter>
</View>
</View>
),
labelPosition: 'left',
},
]}
labels={d => d.premadeLabel}
/>
<VictoryBar
barWidth={13}
data={[
{
x: 60,
y: Math.max(expense, 5),
premadeLabel: (
<View>
Expenses
<View>
<PrivacyFilter activationFilters={[!isCardHovered]}>
{integerToCurrency(expense)}
</PrivacyFilter>
</View>
</View>
),
labelPosition: 'right',
},
]}
labels={d => d.premadeLabel}
/>
</VictoryGroup>
)}
</Container>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{
income,
expenses,
},
]}
margin={{
top: 10,
bottom: 0,
}}
>
<Bar
dataKey="income"
fill={chartTheme.colors.blue}
barSize={14}
label={<CustomLabel name="Income" position="left" />}
/>
<Bar
dataKey="expenses"
fill={chartTheme.colors.red}
barSize={14}
label={<CustomLabel name="Expenses" position="right" />}
/>
</BarChart>
</ResponsiveContainer>
) : (
<LoadingIndicator />
)}

View File

@@ -180,6 +180,10 @@ function recalculate(data, start, end, isConcise) {
res.income.push({ x, y: integerToAmount(income) });
res.expenses.push({ x, y: integerToAmount(expense) });
res.transfers.push({
x,
y: integerToAmount(creditTransfers + debitTransfers),
});
res.balances.push({
x,
y: integerToAmount(balance),
@@ -188,7 +192,7 @@ function recalculate(data, start, end, isConcise) {
});
return res;
},
{ expenses: [], income: [], balances: [] },
{ expenses: [], income: [], transfers: [], balances: [] },
);
const { balances } = graphData;

View File

@@ -848,13 +848,14 @@ type TableHandleRef<T extends TableItem = TableItem> = {
type TableWithNavigatorProps = TableProps & {
fields;
};
export const TableWithNavigator = forwardRef<
TableHandleRef<TableItem>,
TableWithNavigatorProps
>(({ fields, ...props }) => {
export function TableWithNavigator({
fields,
...props
}: TableWithNavigatorProps) {
const navigator = useTableNavigator(props.items, fields);
return <Table {...props} navigator={navigator} />;
});
}
type TableItem = { id: number | string };

View File

@@ -1131,10 +1131,10 @@ export const TransactionEdit = props => {
const Transaction = memo(function Transaction({
transaction,
account,
accounts,
categories,
payees,
showCategory,
added,
onSelect,
style,
@@ -1169,11 +1169,15 @@ const Transaction = memo(function Transaction({
payee,
transferAcct,
);
const prettyCategory = transferAcct
? 'Transfer'
: isParent
? 'Split'
: categoryName;
const specialCategory = account?.offbudget
? 'Off Budget'
: transferAcct
? 'Transfer'
: isParent
? 'Split'
: null;
const prettyCategory = specialCategory || categoryName;
const isPreview = isPreviewId(id);
const isReconciled = transaction.reconciled;
@@ -1260,22 +1264,21 @@ const Transaction = memo(function Transaction({
}}
/>
)}
{showCategory && (
<TextOneLine
style={{
fontSize: 11,
marginTop: 1,
fontWeight: '400',
color: prettyCategory
? theme.tableTextSelected
: theme.menuItemTextSelected,
fontStyle: prettyCategory ? null : 'italic',
textAlign: 'left',
}}
>
{prettyCategory || 'Uncategorized'}
</TextOneLine>
)}
<TextOneLine
style={{
fontSize: 11,
marginTop: 1,
fontWeight: '400',
color: prettyCategory
? theme.tableTextSelected
: theme.menuItemTextSelected,
fontStyle:
specialCategory || !prettyCategory ? 'italic' : undefined,
textAlign: 'left',
}}
>
{prettyCategory || 'Uncategorized'}
</TextOneLine>
</View>
)}
</View>
@@ -1296,11 +1299,11 @@ const Transaction = memo(function Transaction({
});
export function TransactionList({
account,
accounts,
categories,
payees,
transactions,
showCategory,
isNew,
onSelect,
scrollProps = {},
@@ -1384,10 +1387,10 @@ export function TransactionList({
>
<Transaction
transaction={transaction}
account={account}
categories={categories}
accounts={accounts}
payees={payees}
showCategory={showCategory}
added={isNew(transaction.id)}
onSelect={onSelect} // onSelect(transaction)}
/>

View File

@@ -773,6 +773,16 @@ const Transaction = memo(function Transaction(props) {
onUpdateAfterConfirm(name, value);
}
}
// Allow un-reconciling (unlocking) transactions
if (name === 'cleared' && transaction.reconciled) {
props.pushModal('confirm-transaction-edit', {
onConfirm: () => {
onUpdateAfterConfirm('reconciled', false);
},
confirmReason: 'unlockReconciled',
});
}
}
function onUpdateAfterConfirm(name, value) {

View File

@@ -174,6 +174,7 @@ export const checkboxText = tableText;
export const checkboxBackgroundSelected = colorPalette.purple300;
export const checkboxBorderSelected = colorPalette.purple300;
export const checkboxShadowSelected = colorPalette.purple500;
export const checkboxToggleBackground = colorPalette.gray700;
export const pillBackground = colorPalette.navy800;
export const pillBackgroundLight = colorPalette.navy900;

View File

@@ -174,6 +174,7 @@ export const checkboxText = tableBackground;
export const checkboxBackgroundSelected = colorPalette.blue500;
export const checkboxBorderSelected = colorPalette.blue500;
export const checkboxShadowSelected = colorPalette.blue300;
export const checkboxToggleBackground = colorPalette.gray400;
export const pillBackground = colorPalette.navy150;
export const pillBackgroundLight = colorPalette.navy100;

View File

@@ -11,9 +11,9 @@
"requireindex": "^1.2.0"
},
"devDependencies": {
"eslint-plugin-eslint-plugin": "^5.2.1",
"eslint-plugin-eslint-plugin": "^5.0.0",
"eslint-plugin-node": "^11.1.0",
"jest": "^27.5.1",
"jest": "^27.0.0",
"npm-run-all": "^4.1.5"
},
"peerDependencies": {

View File

@@ -41,17 +41,17 @@
"devDependencies": {
"@actual-app/api": "*",
"@actual-app/crdt": "*",
"@swc/core": "^1.3.82",
"@swc/helpers": "^0.5.1",
"@swc/jest": "^0.2.29",
"@swc/core": "^1.3.105",
"@swc/helpers": "^0.5.3",
"@swc/jest": "^0.2.31",
"@types/better-sqlite3": "^7.6.8",
"@types/jest": "^27.5.0",
"@types/jlongster__sql.js": "npm:@types/sql.js@latest",
"@types/pegjs": "^0.10.3",
"@types/react-redux": "^7.1.25",
"@types/uuid": "^9.0.2",
"@types/webpack": "^5.28.2",
"@types/webpack-bundle-analyzer": "^4.6.0",
"@types/webpack": "^5.28.5",
"@types/webpack-bundle-analyzer": "^4.6.3",
"adm-zip": "^0.5.9",
"buffer": "^6.0.3",
"cross-env": "^7.0.3",
@@ -73,10 +73,10 @@
"terser-webpack-plugin": "^5.3.9",
"throttleit": "^1.0.0",
"ts-node": "^10.7.0",
"typescript": "^4.6.4",
"typescript": "^5.0.2",
"uuid": "^9.0.0",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.9.1",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^5.1.4",
"yargs": "^9.0.1"
}

View File

@@ -589,13 +589,12 @@ export async function createTestBudget(handlers: Handlers) {
{ name: 'House Asset', offBudget: true },
{ name: 'Roth IRA', offBudget: true },
];
await runMutator(() =>
batchMessages(async () => {
for (const account of accounts) {
account.id = await handlers['account-create'](account);
}
}),
);
await runMutator(async () => {
for (const account of accounts) {
account.id = await handlers['account-create'](account);
}
});
const payees: Array<MockPayeeEntity> = [
{ name: 'Starting Balance' },

View File

@@ -1,4 +1,5 @@
// @ts-strict-ignore
import { APIError } from '../../../server/errors';
import { runHandler, isMutating } from '../../../server/mutators';
import { captureException } from '../../exceptions';
@@ -70,7 +71,7 @@ export const init: T.Init = function (_socketName, handlers) {
type: 'reply',
id,
result: null,
error: { type: 'APIError', message: 'Unknown method: ' + name },
error: APIError('Unknown method: ' + name),
});
}
});

View File

@@ -1,4 +1,5 @@
// @ts-strict-ignore
import { APIError } from '../../../server/errors';
import { runHandler, isMutating } from '../../../server/mutators';
import { captureException } from '../../exceptions';
@@ -90,7 +91,7 @@ export const init: T.Init = function (serverChn, handlers) {
type: 'reply',
id,
result: null,
error: { type: 'APIError', message: 'Unknown method: ' + name },
error: APIError('Unknown method: ' + name),
});
}
},

View File

@@ -28,6 +28,7 @@ import {
import { runQuery as aqlQuery } from './aql';
import * as cloudStorage from './cloud-storage';
import * as db from './db';
import { APIError } from './errors';
import { runMutator } from './mutators';
import * as prefs from './prefs';
import * as sheet from './sheet';
@@ -35,11 +36,6 @@ import { setSyncingMode, batchMessages } from './sync';
let IMPORT_MODE = false;
// This is duplicate from main.js...
function APIError(msg, meta?) {
return { type: 'APIError', message: msg, meta };
}
// The API is different in two ways: we never want undo enabled, and
// we also need to notify the UI manually if stuff has changed (if
// they are connecting to an already running instance, the UI should

View File

@@ -127,7 +127,9 @@ export function setGoal({ month, category, goal }): Promise<void> {
});
}
return db.insert(table, {
id: month,
id: `${dbMonth(month)}-${category}`,
month: dbMonth(month),
category,
goal,
});
}

View File

@@ -1,7 +1,6 @@
// @ts-strict-ignore
export const SORT_INCREMENT = 16384;
function midpoint(items, to) {
function midpoint<T extends { sort_order: number }>(items: T[], to: number) {
const below = items[to - 1];
const above = items[to];
@@ -14,11 +13,14 @@ function midpoint(items, to) {
}
}
export function shoveSortOrders(items, targetId?: string) {
export function shoveSortOrders<T extends { id: string; sort_order: number }>(
items: T[],
targetId?: string,
) {
const to = items.findIndex(item => item.id === targetId);
const target = items[to];
const before = items[to - 1];
const updates = [];
const updates: Array<{ id: string; sort_order: number }> = [];
// If no target is specified, append at the end
if (!targetId || to === -1) {

View File

@@ -1,11 +1,10 @@
// @ts-strict-ignore
// TODO: normalize error types
export class PostError extends Error {
meta;
reason;
type;
meta?: { meta: string };
reason: string;
type: 'PostError';
constructor(reason, meta?) {
constructor(reason: string, meta?: { meta: string }) {
super('PostError: ' + reason);
this.type = 'PostError';
this.reason = reason;
@@ -14,10 +13,10 @@ export class PostError extends Error {
}
export class HTTPError extends Error {
statusCode;
responseBody;
statusCode: number;
responseBody: string;
constructor(code, body) {
constructor(code: number, body: string) {
super(`HTTPError: unsuccessful status code (${code}): ${body}`);
this.statusCode = code;
this.responseBody = body;
@@ -25,10 +24,27 @@ export class HTTPError extends Error {
}
export class SyncError extends Error {
meta;
reason;
meta?:
| {
isMissingKey: boolean;
}
| {
error: { message: string; stack: string };
query: { sql: string; params: Array<string | number> };
};
reason: string;
constructor(reason, meta?) {
constructor(
reason: string,
meta?:
| {
isMissingKey: boolean;
}
| {
error: { message: string; stack: string };
query: { sql: string; params: Array<string | number> };
},
) {
super('SyncError: ' + reason);
this.reason = reason;
this.meta = meta;
@@ -46,14 +62,20 @@ export class RuleError extends Error {
}
}
export function APIError(msg, meta?) {
return { type: 'APIError', message: msg, meta };
export function APIError(msg: string) {
return { type: 'APIError', message: msg };
}
export function FileDownloadError(reason, meta?) {
export function FileDownloadError(
reason: string,
meta?: { fileId?: string; isMissingKey?: boolean },
) {
return { type: 'FileDownloadError', reason, meta };
}
export function FileUploadError(reason, meta?) {
export function FileUploadError(
reason: string,
meta?: { isMissingKey: boolean },
) {
return { type: 'FileUploadError', reason, meta };
}

View File

@@ -1,19 +1,30 @@
// @ts-strict-ignore
export function requiredFields(name, row, fields, update) {
import {
AccountEntity,
CategoryEntity,
CategoryGroupEntity,
PayeeEntity,
} from '../types/models';
export function requiredFields<T extends object, K extends keyof T>(
name: string,
row: T,
fields: K[],
update?: boolean,
) {
fields.forEach(field => {
if (update) {
if (row.hasOwnProperty(field) && row[field] == null) {
throw new Error(`${name} is missing field ${field}`);
throw new Error(`${name} is missing field ${String(field)}`);
}
} else {
if (!row.hasOwnProperty(field) || row[field] == null) {
throw new Error(`${name} is missing field ${field}`);
throw new Error(`${name} is missing field ${String(field)}`);
}
}
});
}
export function toDateRepr(str) {
export function toDateRepr(str: string) {
if (typeof str !== 'string') {
throw new Error('toDateRepr not passed a string: ' + str);
}
@@ -21,7 +32,7 @@ export function toDateRepr(str) {
return parseInt(str.replace(/-/g, ''));
}
export function fromDateRepr(number) {
export function fromDateRepr(number: number) {
if (typeof number !== 'number') {
throw new Error('fromDateRepr not passed a number: ' + number);
}
@@ -37,7 +48,7 @@ export function fromDateRepr(number) {
}
export const accountModel = {
validate(account, { update }: { update?: boolean } = {}) {
validate(account: AccountEntity, { update }: { update?: boolean } = {}) {
requiredFields(
'account',
account,
@@ -50,7 +61,7 @@ export const accountModel = {
};
export const categoryModel = {
validate(category, { update }: { update?: boolean } = {}) {
validate(category: CategoryEntity, { update }: { update?: boolean } = {}) {
requiredFields(
'category',
category,
@@ -64,7 +75,10 @@ export const categoryModel = {
};
export const categoryGroupModel = {
validate(categoryGroup, { update }: { update?: boolean } = {}) {
validate(
categoryGroup: CategoryGroupEntity,
{ update }: { update?: boolean } = {},
) {
requiredFields(
'categoryGroup',
categoryGroup,
@@ -78,78 +92,8 @@ export const categoryGroupModel = {
};
export const payeeModel = {
validate(payee, { update }: { update?: boolean } = {}) {
validate(payee: PayeeEntity, { update }: { update?: boolean } = {}) {
requiredFields('payee', payee, ['name'], update);
return payee;
},
};
export const transactionModel = {
validate(trans, { update }: { update?: boolean } = {}) {
requiredFields('transaction', trans, ['date', 'acct'], update);
if ('date' in trans) {
// Make sure it's the right format, and also do a sanity check.
// Really old dates can mess up the system and can happen by
// accident
if (
trans.date.match(/^\d{4}-\d{2}-\d{2}$/) == null ||
trans.date < '2000-01-01'
) {
throw new Error('Invalid transaction date: ' + trans.date);
}
}
return trans;
},
toJS(row) {
// Check a non-important field that typically wouldn't be passed in
// manually, and use it as a smoke test to see if this is a
// fully-formed transaction or not.
if (!('location' in row)) {
throw new Error(
'A full transaction is required to be passed to `toJS`. Instead got: ' +
JSON.stringify(row),
);
}
const trans = { ...row };
trans.error = row.error ? JSON.parse(row.error) : null;
trans.isParent = row.isParent === 1 ? true : false;
trans.isChild = row.isChild === 1 ? true : false;
trans.starting_balance_flag =
row.starting_balance_flag === 1 ? true : false;
trans.cleared = row.cleared === 1 ? true : false;
trans.pending = row.pending === 1 ? true : false;
trans.date = trans.date && fromDateRepr(trans.date);
return trans;
},
fromJS(trans) {
const row = { ...trans };
if ('error' in row) {
row.error = trans.error ? JSON.stringify(trans.error) : null;
}
if ('isParent' in row) {
row.isParent = trans.isParent ? 1 : 0;
}
if ('isChild' in row) {
row.isChild = trans.isChild ? 1 : 0;
}
if ('cleared' in row) {
row.cleared = trans.cleared ? 1 : 0;
}
if ('pending' in row) {
row.pending = trans.pending ? 1 : 0;
}
if ('starting_balance_flag' in row) {
row.starting_balance_flag = trans.starting_balance_flag ? 1 : 0;
}
if ('date' in row) {
row.date = toDateRepr(trans.date);
}
return row;
},
};

View File

@@ -15,7 +15,7 @@ import { ReportsHandlers } from './types/handlers';
const reportModel = {
validate(report: CustomReportEntity, { update }: { update?: boolean } = {}) {
requiredFields('reports', report, ['conditions'], update);
requiredFields('reports', report, ['conditionsOp'], update);
if (!update || 'conditionsOp' in report) {
if (!['and', 'or'].includes(report.conditionsOp)) {

View File

@@ -142,8 +142,7 @@ async function fetchAll(table, ids) {
message: error.message,
stack: error.stack,
},
sql,
params: partIds,
query: { sql, params: partIds },
});
}
}

View File

@@ -6,8 +6,8 @@
],
"compilerOptions": {
// "composite": true,
"target": "ES2021",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
@@ -25,8 +25,8 @@
"checkJs": false,
// Used for temp builds
"outDir": "build",
"moduleResolution": "Node16",
"module": "Node16",
"moduleResolution": "Node10",
"module": "ES2022",
// Until/if we build using tsc
"noEmit": true,
"paths": {
@@ -44,7 +44,7 @@
"exclude": ["**/node_modules/*", "**/build/*", "**/lib-dist/*"],
"ts-node": {
"compilerOptions": {
"module": "commonjs"
"module": "CommonJS"
}
}
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [carkom]
---
Hide "show ..." checkboxes within menu for custom reports page. Introduce toggle switches.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
TypeScript: making some files comply with strict TS.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Allow un-reconcile (unlock) transactions by clicking on the lock icon

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactored cash flow report from `victory` to `recharts`

View File

@@ -3,4 +3,4 @@ category: Maintenance
authors: [joel-jeremy]
---
Update api/crdt/eslint/root package versions.
Update vite / swc / ts versions.

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MatissJanis]
---
Fix 'uncategorized transactions' flashing in the header on page load

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [edleeman17]
---
Fix link for registering with GoCardless

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [twk3]
---
Fix a missing ref param warning for forwardRef

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [twk3]
---
Fix 'false' passed as title in import transactions modal

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [youngcw]
---
Fix same account sort_order when creating a demo budget

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [shall0pass]
---
Fix database entry when applying goal templates

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