Compare commits
15 Commits
package-up
...
ts-LoadBac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e71dcccd8 | ||
|
|
de9a1880a7 | ||
|
|
43ebe9e0fd | ||
|
|
515bdf5a74 | ||
|
|
018714610a | ||
|
|
00ee165f8e | ||
|
|
68442ae9e6 | ||
|
|
b937bfae04 | ||
|
|
317e7f135e | ||
|
|
5adb083575 | ||
|
|
524bd4e9eb | ||
|
|
9dfd6ce34c | ||
|
|
5d28bc0e3b | ||
|
|
a4e97e0070 | ||
|
|
a6e38ad2ae |
4
.github/workflows/e2e-test.yml
vendored
@@ -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
|
||||
|
||||
14
package.json
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
// Using ES2021 because that’s 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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
// Using ES2021 because that’s 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
@@ -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": {
|
||||
|
||||
@@ -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 : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
75
packages/desktop-client/src/components/common/Toggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 won‘t 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
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -142,8 +142,7 @@ async function fetchAll(table, ids) {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
sql,
|
||||
params: partIds,
|
||||
query: { sql, params: partIds },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/2174.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [carkom]
|
||||
---
|
||||
|
||||
Hide "show ..." checkboxes within menu for custom reports page. Introduce toggle switches.
|
||||
6
upcoming-release-notes/2247.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
TypeScript: making some files comply with strict TS.
|
||||
6
upcoming-release-notes/2252.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Allow un-reconcile (unlock) transactions by clicking on the lock icon
|
||||
6
upcoming-release-notes/2260.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Refactored cash flow report from `victory` to `recharts`
|
||||
@@ -3,4 +3,4 @@ category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Update api/crdt/eslint/root package versions.
|
||||
Update vite / swc / ts versions.
|
||||
6
upcoming-release-notes/2273.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Fix 'uncategorized transactions' flashing in the header on page load
|
||||
6
upcoming-release-notes/2276.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [edleeman17]
|
||||
---
|
||||
|
||||
Fix link for registering with GoCardless
|
||||
6
upcoming-release-notes/2277.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [twk3]
|
||||
---
|
||||
|
||||
Fix a missing ref param warning for forwardRef
|
||||
6
upcoming-release-notes/2278.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [twk3]
|
||||
---
|
||||
|
||||
Fix 'false' passed as title in import transactions modal
|
||||
6
upcoming-release-notes/2279.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Fix same account sort_order when creating a demo budget
|
||||
6
upcoming-release-notes/2281.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [shall0pass]
|
||||
---
|
||||
|
||||
Fix database entry when applying goal templates
|
||||