Compare commits
2 Commits
dnd-kit
...
package-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38368224d3 | ||
|
|
f964552b5b |
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.41.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.37.0-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.41.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.37.0-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.37.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-import-resolver-typescript": "3.5.5",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"node-jq": "^4.0.1",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"node-jq": "^4.2.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "3.2.4",
|
||||
"react-refresh": "^0.14.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-strict-plugin": "^2.2.2-beta.2"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -21,18 +21,18 @@
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"better-sqlite3": "^9.3.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.0.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^27.5.1",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"typescript": "^5.0.2"
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
"test": "jest -c jest.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.12.0-rc.1",
|
||||
"google-protobuf": "^3.21.2",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.0.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^27.5.1",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.0.2"
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
|
||||
@@ -42,10 +42,10 @@ Next, navigate to the root of your project folder, run the standartised docker c
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.37.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.41.1-jammy /bin/bash
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.37.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
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
@@ -6,12 +6,8 @@
|
||||
"build"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@juggle/resize-observer": "^3.1.2",
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@playwright/test": "^1.37.1",
|
||||
"@reach/listbox": "^0.18.0",
|
||||
"@react-aria/focus": "^3.14.0",
|
||||
"@react-aria/listbox": "^3.10.1",
|
||||
@@ -20,8 +16,8 @@
|
||||
"@react-stately/list": "^3.9.1",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@svgr/cli": "^8.0.1",
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/helpers": "^0.5.3",
|
||||
"@swc/core": "^1.3.82",
|
||||
"@swc/helpers": "^0.5.1",
|
||||
"@swc/plugin-react-remove-properties": "^1.5.108",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
@@ -30,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.3",
|
||||
"@types/webpack-bundle-analyzer": "^4.6.0",
|
||||
"@use-gesture/react": "^10.3.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.0.2",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -49,6 +45,8 @@
|
||||
"memoize-one": "^6.0.0",
|
||||
"pikaday": "1.8.2",
|
||||
"react": "18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-markdown": "^8.0.7",
|
||||
@@ -67,13 +65,12 @@
|
||||
"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.12",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.1",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"vite": "^5.0.10",
|
||||
"vite-tsconfig-paths": "^4.2.2",
|
||||
"vitest": "^1.0.4",
|
||||
"webpack-bundle-analyzer": "^4.9.1",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ReactElement, useEffect, useMemo } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend as Backend } from 'react-dnd-html5-backend';
|
||||
import {
|
||||
Route,
|
||||
Routes,
|
||||
@@ -265,7 +267,9 @@ export function FinancesApp() {
|
||||
<BudgetMonthCountProvider>
|
||||
<PayeesProvider>
|
||||
<AccountsProvider>
|
||||
<ScrollProvider>{app}</ScrollProvider>
|
||||
<DndProvider backend={Backend}>
|
||||
<ScrollProvider>{app}</ScrollProvider>
|
||||
</DndProvider>
|
||||
</AccountsProvider>
|
||||
</PayeesProvider>
|
||||
</BudgetMonthCountProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
@@ -60,14 +61,7 @@ export type TitlebarContextValue = {
|
||||
subscribe: (listener: Listener) => () => void;
|
||||
};
|
||||
|
||||
export const TitlebarContext = createContext<TitlebarContextValue>({
|
||||
sendEvent() {
|
||||
throw new Error('TitlebarContext not initialized');
|
||||
},
|
||||
subscribe() {
|
||||
throw new Error('TitlebarContext not initialized');
|
||||
},
|
||||
});
|
||||
export const TitlebarContext = createContext<TitlebarContextValue>(null);
|
||||
|
||||
type TitlebarProviderProps = {
|
||||
children?: ReactNode;
|
||||
@@ -94,32 +88,26 @@ export function TitlebarProvider({ children }: TitlebarProviderProps) {
|
||||
}
|
||||
|
||||
function UncategorizedButton() {
|
||||
const count: number | null = useSheetValue(queries.uncategorizedCount());
|
||||
if (count === null || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = useSheetValue(queries.uncategorizedCount());
|
||||
return (
|
||||
<Link
|
||||
variant="button"
|
||||
type="bare"
|
||||
to="/accounts/uncategorized"
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
}}
|
||||
>
|
||||
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
|
||||
</Link>
|
||||
count !== 0 && (
|
||||
<Link
|
||||
variant="button"
|
||||
type="bare"
|
||||
to="/accounts/uncategorized"
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
}}
|
||||
>
|
||||
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type PrivacyButtonProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
function PrivacyButton({ style }) {
|
||||
const isPrivacyEnabled = useSelector(
|
||||
state => state.prefs.local?.isPrivacyEnabled,
|
||||
state => state.prefs.local.isPrivacyEnabled,
|
||||
);
|
||||
const { savePrefs } = useActions();
|
||||
|
||||
@@ -146,13 +134,11 @@ 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 | 'offline' | 'local' | 'disabled' | 'error'
|
||||
>(null);
|
||||
const [syncState, setSyncState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen('sync-event', ({ type, subtype, syncDisabled }) => {
|
||||
@@ -286,8 +272,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);
|
||||
|
||||
@@ -380,18 +366,14 @@ function BudgetTitlebar() {
|
||||
);
|
||||
}
|
||||
|
||||
type TitlebarProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function Titlebar({ style }: TitlebarProps) {
|
||||
export function Titlebar({ style }) {
|
||||
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,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
@@ -10,6 +11,14 @@ 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(
|
||||
@@ -59,7 +68,7 @@ export function UpdateNotification() {
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
onClick={() =>
|
||||
window.Actual?.openURLInBrowser(
|
||||
window.Actual.openURLInBrowser(
|
||||
'https://actualbudget.org/docs/releases',
|
||||
)
|
||||
}
|
||||
@@ -71,13 +80,7 @@ export function UpdateNotification() {
|
||||
type="bare"
|
||||
aria-label="Close"
|
||||
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
|
||||
onClick={() => {
|
||||
// Set a flag to never show an update notification again for this session
|
||||
setAppState({
|
||||
updateInfo: null,
|
||||
showUpdateNotification: false,
|
||||
});
|
||||
}}
|
||||
onClick={() => closeNotification(setAppState)}
|
||||
>
|
||||
<SvgClose
|
||||
width={9}
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
@@ -36,7 +16,6 @@ import { View } from '../common/View';
|
||||
import { Page } from '../Page';
|
||||
import { PullToRefresh } from '../responsive/PullToRefresh';
|
||||
import { CellValue } from '../spreadsheet/CellValue';
|
||||
import { findSortDown, getDropPosition } from '../util/sort';
|
||||
|
||||
function AccountHeader({ name, amount, style = {} }) {
|
||||
return (
|
||||
@@ -73,26 +52,8 @@ function AccountHeader({ name, amount, style = {} }) {
|
||||
}
|
||||
|
||||
function AccountCard({ account, updated, getBalanceQuery, onSelect }) {
|
||||
const {
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: account.id });
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
innerRef={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
@@ -102,18 +63,20 @@ function AccountCard({ account, updated, getBalanceQuery, onSelect }) {
|
||||
marginTop: 10,
|
||||
marginRight: 10,
|
||||
width: '100%',
|
||||
...dndStyle,
|
||||
}}
|
||||
data-testid="account"
|
||||
>
|
||||
<Button
|
||||
onClick={() => onSelect(account.id)}
|
||||
onMouseDown={() => onSelect(account.id)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
border: '1px solid ' + theme.pillBorder,
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
'&:active': {
|
||||
opacity: 0.1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -186,7 +149,6 @@ function AccountList({
|
||||
onAddAccount,
|
||||
onSelectAccount,
|
||||
onSync,
|
||||
onReorder,
|
||||
}) {
|
||||
const budgetedAccounts = accounts.filter(account => account.offbudget === 0);
|
||||
const offbudgetAccounts = accounts.filter(account => account.offbudget === 1);
|
||||
@@ -195,41 +157,6 @@ function AccountList({
|
||||
color: 'white',
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const onDragStart = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const onDragEnd = e => {
|
||||
const { active, over } = e;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
const dropPos = getDropPosition(
|
||||
active.rect.current.translated,
|
||||
active.rect.current.initial,
|
||||
);
|
||||
|
||||
onReorder(active.id, dropPos, over.id);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
title="Accounts"
|
||||
@@ -251,35 +178,20 @@ function AccountList({
|
||||
style={{ flex: 1, backgroundColor: theme.mobilePageBackground }}
|
||||
>
|
||||
{accounts.length === 0 && <EmptyMessage />}
|
||||
<PullToRefresh isPullable={!isDragging} onRefresh={onSync}>
|
||||
<PullToRefresh onRefresh={onSync}>
|
||||
<View style={{ margin: 10 }}>
|
||||
{budgetedAccounts.length > 0 && (
|
||||
<AccountHeader name="For Budget" amount={getOnBudgetBalance()} />
|
||||
)}
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={budgetedAccounts}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{budgetedAccounts.map(acct => (
|
||||
<AccountCard
|
||||
account={acct}
|
||||
key={acct.id}
|
||||
updated={updatedAccounts.includes(acct.id)}
|
||||
getBalanceQuery={getBalanceQuery}
|
||||
onSelect={onSelectAccount}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
{budgetedAccounts.map(acct => (
|
||||
<AccountCard
|
||||
account={acct}
|
||||
key={acct.id}
|
||||
updated={updatedAccounts.includes(acct.id)}
|
||||
getBalanceQuery={getBalanceQuery}
|
||||
onSelect={onSelectAccount}
|
||||
/>
|
||||
))}
|
||||
|
||||
{offbudgetAccounts.length > 0 && (
|
||||
<AccountHeader
|
||||
@@ -288,30 +200,15 @@ function AccountList({
|
||||
style={{ marginTop: 30 }}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={offbudgetAccounts}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{offbudgetAccounts.map(acct => (
|
||||
<AccountCard
|
||||
account={acct}
|
||||
key={acct.id}
|
||||
updated={updatedAccounts.includes(acct.id)}
|
||||
getBalanceQuery={getBalanceQuery}
|
||||
onSelect={onSelectAccount}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
{offbudgetAccounts.map(acct => (
|
||||
<AccountCard
|
||||
account={acct}
|
||||
key={acct.id}
|
||||
updated={updatedAccounts.includes(acct.id)}
|
||||
getBalanceQuery={getBalanceQuery}
|
||||
onSelect={onSelectAccount}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</PullToRefresh>
|
||||
</Page>
|
||||
@@ -347,14 +244,6 @@ export function Accounts() {
|
||||
navigate(`/transaction/${transaction}`);
|
||||
};
|
||||
|
||||
const onReorder = async (id, dropPos, targetId) => {
|
||||
await send('account-move', {
|
||||
id,
|
||||
...findSortDown(accounts, dropPos, targetId),
|
||||
});
|
||||
await getAccounts();
|
||||
};
|
||||
|
||||
useSetThemeColor(theme.mobileViewTheme);
|
||||
|
||||
return (
|
||||
@@ -375,7 +264,6 @@ export function Accounts() {
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectTransaction={onSelectTransaction}
|
||||
onSync={syncAndDownload}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -691,7 +691,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
|
||||
type AutocompleteFooterProps = {
|
||||
show?: boolean;
|
||||
embedded?: boolean;
|
||||
embedded: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
export function AutocompleteFooter({
|
||||
@@ -699,20 +699,18 @@ export function AutocompleteFooter({
|
||||
embedded,
|
||||
children,
|
||||
}: AutocompleteFooterProps) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
...(embedded ? { paddingTop: 5 } : { padding: 5 }),
|
||||
}}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
show && (
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
...(embedded ? { paddingTop: 5 } : { padding: 5 }),
|
||||
}}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
Fragment,
|
||||
@@ -24,11 +25,9 @@ import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
|
||||
|
||||
export type CategoryListProps = {
|
||||
items: Array<CategoryEntity & { group?: CategoryGroupEntity }>;
|
||||
getItemProps?: (arg: {
|
||||
item: CategoryEntity;
|
||||
}) => Partial<ComponentProps<typeof View>>;
|
||||
getItemProps?: (arg: { item }) => Partial<ComponentProps<typeof View>>;
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
embedded: boolean;
|
||||
footer?: ReactNode;
|
||||
renderSplitTransactionButton?: (
|
||||
props: SplitTransactionButtonProps,
|
||||
@@ -48,7 +47,7 @@ function CategoryList({
|
||||
renderCategoryItemGroupHeader = defaultRenderCategoryItemGroupHeader,
|
||||
renderCategoryItem = defaultRenderCategoryItem,
|
||||
}: CategoryListProps) {
|
||||
let lastGroup: string | undefined | null = null;
|
||||
let lastGroup = null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -73,10 +72,10 @@ function CategoryList({
|
||||
lastGroup = item.cat_group;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showGroup && item.group?.name && (
|
||||
<Fragment key={item.group.name}>
|
||||
{showGroup && (
|
||||
<Fragment key={item.group?.name}>
|
||||
{renderCategoryItemGroupHeader({
|
||||
title: item.group.name,
|
||||
title: item.group?.name,
|
||||
})}
|
||||
</Fragment>
|
||||
)}
|
||||
@@ -126,7 +125,7 @@ export function CategoryAutocomplete({
|
||||
categoryGroups.reduce(
|
||||
(list, group) =>
|
||||
list.concat(
|
||||
(group.categories || [])
|
||||
group.categories
|
||||
.filter(category => category.cat_group === group.id)
|
||||
.map(category => ({
|
||||
...category,
|
||||
@@ -215,7 +214,8 @@ type SplitTransactionButtonProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
function SplitTransactionButton({
|
||||
// eslint-disable-next-line import/no-unused-modules
|
||||
export function SplitTransactionButton({
|
||||
Icon,
|
||||
highlighted,
|
||||
embedded,
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import React, { memo, useState, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import { theme, styles } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { DropHighlightPosContext } from '../sort';
|
||||
import { Row } from '../table';
|
||||
import { getDropPosition } from '../util/sort';
|
||||
|
||||
import { ExpenseCategory } from './ExpenseCategory';
|
||||
import { ExpenseGroup } from './ExpenseGroup';
|
||||
@@ -33,8 +14,6 @@ import { SidebarCategory } from './SidebarCategory';
|
||||
import { SidebarGroup } from './SidebarGroup';
|
||||
import { separateGroups } from './util';
|
||||
|
||||
const getItemDndId = item => item.value?.id || item.type;
|
||||
|
||||
export const BudgetCategories = memo(
|
||||
({
|
||||
categoryGroups,
|
||||
@@ -65,24 +44,24 @@ export const BudgetCategories = memo(
|
||||
|
||||
let items = Array.prototype.concat.apply(
|
||||
[],
|
||||
expenseGroups.map(expenseGroup => {
|
||||
if (expenseGroup.hidden && !showHiddenCategories) {
|
||||
expenseGroups.map(group => {
|
||||
if (group.hidden && !showHiddenCategories) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupCategories = expenseGroup.categories.filter(
|
||||
const groupCategories = group.categories.filter(
|
||||
cat => showHiddenCategories || !cat.hidden,
|
||||
);
|
||||
|
||||
const items = [{ type: 'expense-group', value: { ...expenseGroup } }];
|
||||
const items = [{ type: 'expense-group', value: { ...group } }];
|
||||
|
||||
if (newCategoryForGroup === expenseGroup.id) {
|
||||
items.push({ type: 'new-expense-category' });
|
||||
if (newCategoryForGroup === group.id) {
|
||||
items.push({ type: 'new-category' });
|
||||
}
|
||||
|
||||
return [
|
||||
...items,
|
||||
...(collapsed.includes(expenseGroup.id) ? [] : groupCategories).map(
|
||||
...(collapsed.includes(group.id) ? [] : groupCategories).map(
|
||||
cat => ({
|
||||
type: 'expense-category',
|
||||
value: cat,
|
||||
@@ -92,13 +71,16 @@ export const BudgetCategories = memo(
|
||||
}),
|
||||
);
|
||||
|
||||
if (isAddingGroup) {
|
||||
items.push({ type: 'new-group' });
|
||||
}
|
||||
|
||||
if (incomeGroup) {
|
||||
items = items.concat(
|
||||
[
|
||||
{ type: 'income-separator' },
|
||||
{ type: 'income-group', value: incomeGroup },
|
||||
newCategoryForGroup === incomeGroup.id && {
|
||||
type: 'new-income-category',
|
||||
},
|
||||
newCategoryForGroup === incomeGroup.id && { type: 'new-category' },
|
||||
...(collapsed.includes(incomeGroup.id)
|
||||
? []
|
||||
: incomeGroup.categories.filter(
|
||||
@@ -113,136 +95,56 @@ export const BudgetCategories = memo(
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [categoryGroups, collapsed, newCategoryForGroup, showHiddenCategories]);
|
||||
}, [
|
||||
categoryGroups,
|
||||
collapsed,
|
||||
newCategoryForGroup,
|
||||
isAddingGroup,
|
||||
showHiddenCategories,
|
||||
]);
|
||||
|
||||
const expenseGroupItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
item =>
|
||||
item.type === 'expense-group' ||
|
||||
item.type === 'expense-category' ||
|
||||
item.type === 'new-expense-category',
|
||||
),
|
||||
[items],
|
||||
);
|
||||
const [dragState, setDragState] = useState(null);
|
||||
const [savedCollapsed, setSavedCollapsed] = useState(null);
|
||||
|
||||
const incomeGroupItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
item =>
|
||||
item.type === 'income-group' ||
|
||||
item.type === 'income-category' ||
|
||||
item.type === 'new-income-category',
|
||||
),
|
||||
[items],
|
||||
);
|
||||
// TODO: If we turn this into a reducer, we could probably memoize
|
||||
// each item in the list for better perf
|
||||
function onDragChange(newDragState) {
|
||||
const { state } = newDragState;
|
||||
|
||||
function onCollapse(id) {
|
||||
setCollapsed([...collapsed, id]);
|
||||
}
|
||||
|
||||
function onExpand(id) {
|
||||
setCollapsed(collapsed.filter(_id => _id !== id));
|
||||
if (state === 'start-preview') {
|
||||
setDragState({
|
||||
type: newDragState.type,
|
||||
item: newDragState.item,
|
||||
preview: true,
|
||||
});
|
||||
} else if (state === 'start') {
|
||||
if (dragState) {
|
||||
setDragState({
|
||||
...dragState,
|
||||
preview: false,
|
||||
});
|
||||
setSavedCollapsed(collapsed);
|
||||
}
|
||||
} else if (state === 'hover') {
|
||||
setDragState({
|
||||
...dragState,
|
||||
hoveredId: newDragState.id,
|
||||
hoveredPos: newDragState.pos,
|
||||
});
|
||||
} else if (state === 'end') {
|
||||
setDragState(null);
|
||||
setCollapsed(savedCollapsed || []);
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleCollapse(id) {
|
||||
if (collapsed.includes(id)) {
|
||||
onExpand(id);
|
||||
setCollapsed(collapsed.filter(id_ => id_ !== id));
|
||||
} else {
|
||||
onCollapse(id);
|
||||
setCollapsed([...collapsed, id]);
|
||||
}
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const [originalCollapsed, setOriginalCollapsed] = useState(null);
|
||||
const [collapsedOnDragOver, setCollapsedOnDragOver] = useState(null);
|
||||
|
||||
const onDragStart = e => {
|
||||
const { active } = e;
|
||||
setOriginalCollapsed(collapsed);
|
||||
|
||||
const activeItem = items.find(item => getItemDndId(item) === active.id);
|
||||
switch (activeItem?.type) {
|
||||
case 'expense-group':
|
||||
const groupIds = expenseGroupItems
|
||||
.filter(item => item.type === 'expense-group')
|
||||
.map(item => item.value?.id);
|
||||
|
||||
setCollapsedOnDragOver(groupIds);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = e => {
|
||||
const { active, over } = e;
|
||||
|
||||
// Delay collapsing groups until user drags/hovers on another item.
|
||||
if (collapsedOnDragOver) {
|
||||
setCollapsed(collapsedOnDragOver);
|
||||
setCollapsedOnDragOver(null);
|
||||
}
|
||||
|
||||
// Expand groups on hover when moving around categories.
|
||||
const activeItem = items.find(item => getItemDndId(item) === active.id);
|
||||
if (
|
||||
activeItem?.type === 'expense-category' &&
|
||||
collapsed.includes(over.id)
|
||||
) {
|
||||
onToggleCollapse(over.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = e => {
|
||||
const { active, over } = e;
|
||||
|
||||
if (over && over.id !== active.id) {
|
||||
const activeItem = items.find(item => getItemDndId(item) === active.id);
|
||||
|
||||
const dropPos = getDropPosition(
|
||||
active.rect.current.translated,
|
||||
active.rect.current.initial,
|
||||
);
|
||||
|
||||
if (activeItem.type === 'expense-group') {
|
||||
onReorderGroup(active.id, dropPos, over.id);
|
||||
} else if (
|
||||
activeItem.type === 'expense-category' ||
|
||||
activeItem.type === 'income-category'
|
||||
) {
|
||||
onReorderCategory(active.id, dropPos, over.id);
|
||||
}
|
||||
}
|
||||
setTimeout(() => setCollapsed(originalCollapsed), 100);
|
||||
};
|
||||
|
||||
const expenseGroupIds = useMemo(
|
||||
() => expenseGroupItems.map(getItemDndId),
|
||||
[expenseGroupItems],
|
||||
);
|
||||
|
||||
const incomeGroupIds = useMemo(
|
||||
() => incomeGroupItems.map(getItemDndId),
|
||||
[incomeGroupItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -254,188 +156,159 @@ export const BudgetCategories = memo(
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={expenseGroupIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{expenseGroupItems.map(item => {
|
||||
let content;
|
||||
switch (item.type) {
|
||||
case 'new-expense-category':
|
||||
content = (
|
||||
<Row key="new-expense-category">
|
||||
<SidebarCategory
|
||||
category={{
|
||||
name: '',
|
||||
cat_group: newCategoryForGroup,
|
||||
is_income:
|
||||
newCategoryForGroup ===
|
||||
categoryGroups.find(g => g.is_income).id,
|
||||
id: 'new',
|
||||
}}
|
||||
editing={true}
|
||||
onSave={onSaveCategory}
|
||||
onHideNewCategory={onHideNewCategory}
|
||||
onEditName={onEditName}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case 'expense-group':
|
||||
content = (
|
||||
<ExpenseGroup
|
||||
key={item.value.id}
|
||||
group={item.value}
|
||||
editingCell={editingCell}
|
||||
collapsed={collapsed.includes(item.value.id)}
|
||||
MonthComponent={dataComponents.ExpenseGroupComponent}
|
||||
onEditName={onEditName}
|
||||
onSave={onSaveGroup}
|
||||
onDelete={onDeleteGroup}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'expense-category':
|
||||
content = (
|
||||
<ExpenseCategory
|
||||
key={item.value.id}
|
||||
cat={item.value}
|
||||
editingCell={editingCell}
|
||||
MonthComponent={dataComponents.ExpenseCategoryComponent}
|
||||
onEditName={onEditName}
|
||||
onEditMonth={onEditMonth}
|
||||
onSave={onSaveCategory}
|
||||
onDelete={onDeleteCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown item type: ' + item.type);
|
||||
}
|
||||
{items.map((item, idx) => {
|
||||
let content;
|
||||
switch (item.type) {
|
||||
case 'new-group':
|
||||
content = (
|
||||
<Row
|
||||
style={{ backgroundColor: theme.tableRowHeaderBackground }}
|
||||
>
|
||||
<SidebarGroup
|
||||
group={{ id: 'new', name: '' }}
|
||||
editing={true}
|
||||
onSave={onSaveGroup}
|
||||
onHideNewGroup={onHideNewGroup}
|
||||
onEdit={onEditName}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case 'new-category':
|
||||
content = (
|
||||
<Row>
|
||||
<SidebarCategory
|
||||
category={{
|
||||
name: '',
|
||||
cat_group: newCategoryForGroup,
|
||||
is_income:
|
||||
newCategoryForGroup ===
|
||||
categoryGroups.find(g => g.is_income).id,
|
||||
id: 'new',
|
||||
}}
|
||||
editing={true}
|
||||
onSave={onSaveCategory}
|
||||
onHideNewCategory={onHideNewCategory}
|
||||
onEditName={onEditName}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
|
||||
return content;
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
{isAddingGroup && (
|
||||
<Row
|
||||
key="new-group"
|
||||
style={{
|
||||
backgroundColor: theme.tableRowHeaderBackground,
|
||||
}}
|
||||
>
|
||||
<SidebarGroup
|
||||
group={{ id: 'new', name: '' }}
|
||||
editing={true}
|
||||
onSave={onSaveGroup}
|
||||
onHideNewGroup={onHideNewGroup}
|
||||
onEdit={onEditName}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<View
|
||||
key="income-separator"
|
||||
style={{
|
||||
height: styles.incomeHeaderHeight,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
<IncomeHeader
|
||||
MonthComponent={dataComponents.IncomeHeaderComponent}
|
||||
onShowNewGroup={onShowNewGroup}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={incomeGroupIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{incomeGroupItems.map((item, idx) => {
|
||||
let content;
|
||||
switch (item.type) {
|
||||
case 'new-income-category':
|
||||
content = (
|
||||
<Row key="new-income-category">
|
||||
<SidebarCategory
|
||||
category={{
|
||||
name: '',
|
||||
cat_group: newCategoryForGroup,
|
||||
is_income:
|
||||
newCategoryForGroup ===
|
||||
categoryGroups.find(g => g.is_income).id,
|
||||
id: 'new',
|
||||
}}
|
||||
editing={true}
|
||||
onSave={onSaveCategory}
|
||||
onHideNewCategory={onHideNewCategory}
|
||||
onEditName={onEditName}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case 'income-group':
|
||||
content = (
|
||||
<IncomeGroup
|
||||
key={item.value.id}
|
||||
group={item.value}
|
||||
editingCell={editingCell}
|
||||
MonthComponent={dataComponents.IncomeGroupComponent}
|
||||
collapsed={collapsed.includes(item.value.id)}
|
||||
onEditName={onEditName}
|
||||
onSave={onSaveGroup}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'income-category':
|
||||
content = (
|
||||
<IncomeCategory
|
||||
key={item.value.id}
|
||||
cat={item.value}
|
||||
editingCell={editingCell}
|
||||
isLast={idx === items.length - 1}
|
||||
MonthComponent={dataComponents.IncomeCategoryComponent}
|
||||
onEditName={onEditName}
|
||||
onEditMonth={onEditMonth}
|
||||
onSave={onSaveCategory}
|
||||
onDelete={onDeleteCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown item type: ' + item.type);
|
||||
}
|
||||
case 'expense-group':
|
||||
content = (
|
||||
<ExpenseGroup
|
||||
group={item.value}
|
||||
editingCell={editingCell}
|
||||
collapsed={collapsed.includes(item.value.id)}
|
||||
MonthComponent={dataComponents.ExpenseGroupComponent}
|
||||
dragState={dragState}
|
||||
onEditName={onEditName}
|
||||
onSave={onSaveGroup}
|
||||
onDelete={onDeleteGroup}
|
||||
onDragChange={onDragChange}
|
||||
onReorderGroup={onReorderGroup}
|
||||
onReorderCategory={onReorderCategory}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'expense-category':
|
||||
content = (
|
||||
<ExpenseCategory
|
||||
cat={item.value}
|
||||
editingCell={editingCell}
|
||||
MonthComponent={dataComponents.ExpenseCategoryComponent}
|
||||
dragState={dragState}
|
||||
onEditName={onEditName}
|
||||
onEditMonth={onEditMonth}
|
||||
onSave={onSaveCategory}
|
||||
onDelete={onDeleteCategory}
|
||||
onDragChange={onDragChange}
|
||||
onReorder={onReorderCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'income-separator':
|
||||
content = (
|
||||
<View
|
||||
style={{
|
||||
height: styles.incomeHeaderHeight,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
<IncomeHeader
|
||||
MonthComponent={dataComponents.IncomeHeaderComponent}
|
||||
onShowNewGroup={onShowNewGroup}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
break;
|
||||
case 'income-group':
|
||||
content = (
|
||||
<IncomeGroup
|
||||
group={item.value}
|
||||
editingCell={editingCell}
|
||||
MonthComponent={dataComponents.IncomeGroupComponent}
|
||||
collapsed={collapsed.includes(item.value.id)}
|
||||
onEditName={onEditName}
|
||||
onSave={onSaveGroup}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'income-category':
|
||||
content = (
|
||||
<IncomeCategory
|
||||
cat={item.value}
|
||||
editingCell={editingCell}
|
||||
isLast={idx === items.length - 1}
|
||||
MonthComponent={dataComponents.IncomeCategoryComponent}
|
||||
onEditName={onEditName}
|
||||
onEditMonth={onEditMonth}
|
||||
onSave={onSaveCategory}
|
||||
onDelete={onDeleteCategory}
|
||||
onDragChange={onDragChange}
|
||||
onReorder={onReorderCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown item type: ' + item.type);
|
||||
}
|
||||
|
||||
return content;
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
const pos =
|
||||
idx === 0 ? 'first' : idx === items.length - 1 ? 'last' : null;
|
||||
|
||||
return (
|
||||
<DropHighlightPosContext.Provider
|
||||
key={
|
||||
item.value
|
||||
? item.value.id
|
||||
: item.type === 'income-separator'
|
||||
? 'separator'
|
||||
: idx
|
||||
}
|
||||
value={pos}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
!dragState && {
|
||||
':hover': { backgroundColor: theme.tableBackground },
|
||||
}
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
</DropHighlightPosContext.Provider>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,13 +5,12 @@ import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { theme, styles } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { IntersectionBoundary } from '../tooltips';
|
||||
import { findSortDown, findSortUp } from '../util/sort';
|
||||
|
||||
import { BudgetCategories } from './BudgetCategories';
|
||||
import { BudgetSummaries } from './BudgetSummaries';
|
||||
import { BudgetTotals } from './BudgetTotals';
|
||||
import { MonthsProvider } from './MonthsContext';
|
||||
import { getScrollbarWidth } from './util';
|
||||
import { findSortDown, findSortUp, getScrollbarWidth } from './util';
|
||||
|
||||
export class BudgetTable extends Component {
|
||||
constructor(props) {
|
||||
@@ -58,9 +57,14 @@ export class BudgetTable extends Component {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const targetGroup = categoryGroups.find(g =>
|
||||
g.categories.find(c => c.id === targetId),
|
||||
);
|
||||
let targetGroup;
|
||||
|
||||
for (const group of categoryGroups) {
|
||||
if (group.categories.find(cat => cat.id === targetId)) {
|
||||
targetGroup = group;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.props.onReorderCategory({
|
||||
id,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ComponentProps } from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { theme } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import {
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
DropHighlight,
|
||||
type DragState,
|
||||
type OnDragChangeCallback,
|
||||
type OnDropCallback,
|
||||
} from '../sort';
|
||||
import { Row } from '../table';
|
||||
|
||||
import { RenderMonths } from './RenderMonths';
|
||||
@@ -16,18 +21,22 @@ import { SidebarCategory } from './SidebarCategory';
|
||||
type ExpenseCategoryProps = {
|
||||
cat: CategoryEntity;
|
||||
editingCell: { id: string; cell: string } | null;
|
||||
dragState: DragState<CategoryEntity>;
|
||||
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
|
||||
onEditName?: ComponentProps<typeof SidebarCategory>['onEditName'];
|
||||
onEditMonth?: (id: string, monthIndex: number) => void;
|
||||
onSave?: ComponentProps<typeof SidebarCategory>['onSave'];
|
||||
onDelete?: ComponentProps<typeof SidebarCategory>['onDelete'];
|
||||
onDragChange: OnDragChangeCallback<CategoryEntity>;
|
||||
onBudgetAction: (idx: number, action: string, arg: unknown) => void;
|
||||
onShowActivity: (name: string, id: string, idx: number) => void;
|
||||
onReorder: OnDropCallback;
|
||||
};
|
||||
|
||||
export function ExpenseCategory({
|
||||
cat,
|
||||
editingCell,
|
||||
dragState,
|
||||
MonthComponent,
|
||||
onEditName,
|
||||
onEditMonth,
|
||||
@@ -35,39 +44,45 @@ export function ExpenseCategory({
|
||||
onDelete,
|
||||
onBudgetAction,
|
||||
onShowActivity,
|
||||
onDragChange,
|
||||
onReorder,
|
||||
}: ExpenseCategoryProps) {
|
||||
const {
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: cat.id, disabled: !!editingCell });
|
||||
let dragging = dragState && dragState.item === cat;
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
if (dragState && dragState.item.id === cat.cat_group) {
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
const { dragRef } = useDraggable({
|
||||
type: 'category',
|
||||
onDragChange,
|
||||
item: cat,
|
||||
canDrag: editingCell === null,
|
||||
});
|
||||
|
||||
const { dropRef, dropPos } = useDroppable({
|
||||
types: 'category',
|
||||
id: cat.id,
|
||||
onDrop: onReorder,
|
||||
});
|
||||
|
||||
return (
|
||||
<Row
|
||||
innerRef={setNodeRef}
|
||||
innerRef={dropRef}
|
||||
collapsed={true}
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
opacity: cat.hidden ? 0.5 : undefined,
|
||||
...dndStyle,
|
||||
}}
|
||||
>
|
||||
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
|
||||
|
||||
<View style={{ flex: 1, flexDirection: 'row' }}>
|
||||
<SidebarCategory
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
dragPreview={isDragging}
|
||||
dragging={isDragging}
|
||||
innerRef={dragRef}
|
||||
category={cat}
|
||||
dragPreview={dragging && dragState.preview}
|
||||
dragging={dragging && !dragState.preview}
|
||||
editing={
|
||||
editingCell &&
|
||||
editingCell.cell === 'name' &&
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ComponentProps } from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { theme } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { Row } from '../table';
|
||||
import {
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
DropHighlight,
|
||||
type OnDragChangeCallback,
|
||||
type OnDropCallback,
|
||||
type DragState,
|
||||
} from '../sort';
|
||||
import { Row, ROW_HEIGHT } from '../table';
|
||||
|
||||
import { RenderMonths } from './RenderMonths';
|
||||
import { SidebarGroup } from './SidebarGroup';
|
||||
@@ -15,10 +20,16 @@ type ExpenseGroupProps = {
|
||||
group: ComponentProps<typeof SidebarGroup>['group'];
|
||||
collapsed: boolean;
|
||||
editingCell: { id: string; cell: string } | null;
|
||||
dragState: DragState<ComponentProps<typeof SidebarGroup>['group']>;
|
||||
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
|
||||
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
|
||||
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
|
||||
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
|
||||
onDragChange: OnDragChangeCallback<
|
||||
ComponentProps<typeof SidebarGroup>['group']
|
||||
>;
|
||||
onReorderGroup: OnDropCallback;
|
||||
onReorderCategory: OnDropCallback;
|
||||
onToggleCollapse?: ComponentProps<typeof SidebarGroup>['onToggleCollapse'];
|
||||
onShowNewCategory?: ComponentProps<typeof SidebarGroup>['onShowNewCategory'];
|
||||
};
|
||||
@@ -27,55 +38,88 @@ export function ExpenseGroup({
|
||||
group,
|
||||
collapsed,
|
||||
editingCell,
|
||||
dragState,
|
||||
MonthComponent,
|
||||
onEditName,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDragChange,
|
||||
onReorderGroup,
|
||||
onReorderCategory,
|
||||
onToggleCollapse,
|
||||
onShowNewCategory,
|
||||
}: ExpenseGroupProps) {
|
||||
const {
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: group.id, disabled: !!editingCell });
|
||||
const dragging = dragState && dragState.item === group;
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const { dragRef } = useDraggable({
|
||||
type: 'group',
|
||||
onDragChange,
|
||||
item: group,
|
||||
canDrag: editingCell === null,
|
||||
});
|
||||
|
||||
const { dropRef, dropPos } = useDroppable({
|
||||
types: 'group',
|
||||
id: group.id,
|
||||
onDrop: onReorderGroup,
|
||||
});
|
||||
|
||||
const { dropRef: catDropRef, dropPos: catDropPos } = useDroppable({
|
||||
types: 'category',
|
||||
id: group.id,
|
||||
onDrop: onReorderCategory,
|
||||
onLongHover: () => {
|
||||
if (collapsed) {
|
||||
onToggleCollapse(group.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Row
|
||||
innerRef={setNodeRef}
|
||||
collapsed={true}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
opacity: group.hidden ? 0.33 : undefined,
|
||||
backgroundColor: theme.tableRowHeaderBackground,
|
||||
...dndStyle,
|
||||
}}
|
||||
>
|
||||
{dragState && !dragState.preview && dragState.type === 'group' && (
|
||||
<View
|
||||
innerRef={dropRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: collapsed
|
||||
? ROW_HEIGHT - 1
|
||||
: (1 + group.categories.length) * (ROW_HEIGHT - 1) + 1,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DropHighlight pos={catDropPos} offset={{ top: 1 }} />
|
||||
|
||||
<View
|
||||
innerRef={catDropRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
opacity: dragging && !dragState.preview ? 0.3 : 1,
|
||||
}}
|
||||
>
|
||||
<SidebarGroup
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
dragPreview={isDragging}
|
||||
innerRef={dragRef}
|
||||
group={group}
|
||||
editing={
|
||||
editingCell &&
|
||||
editingCell.cell === 'name' &&
|
||||
editingCell.id === group.id
|
||||
}
|
||||
dragPreview={dragging && dragState.preview}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onEdit={onEditName}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ComponentProps } from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import {
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
DropHighlight,
|
||||
type OnDragChangeCallback,
|
||||
type OnDropCallback,
|
||||
} from '../sort';
|
||||
import { Row } from '../table';
|
||||
|
||||
import { RenderMonths } from './RenderMonths';
|
||||
@@ -20,7 +24,9 @@ type IncomeCategoryProps = {
|
||||
onEditMonth?: (id: string, monthIndex: number) => void;
|
||||
onSave: ComponentProps<typeof SidebarCategory>['onSave'];
|
||||
onDelete: ComponentProps<typeof SidebarCategory>['onDelete'];
|
||||
onDragChange: OnDragChangeCallback<CategoryEntity>;
|
||||
onBudgetAction: (idx: number, action: string, arg: unknown) => void;
|
||||
onReorder: OnDropCallback;
|
||||
onShowActivity: (name: string, id: string, idx: number) => void;
|
||||
};
|
||||
|
||||
@@ -33,31 +39,30 @@ export function IncomeCategory({
|
||||
onEditMonth,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDragChange,
|
||||
onBudgetAction,
|
||||
onReorder,
|
||||
onShowActivity,
|
||||
}: IncomeCategoryProps) {
|
||||
const {
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: cat.id, disabled: !!editingCell });
|
||||
const { dragRef } = useDraggable({
|
||||
type: 'income-category',
|
||||
onDragChange,
|
||||
item: cat,
|
||||
canDrag: editingCell === null,
|
||||
});
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const { dropRef, dropPos } = useDroppable({
|
||||
types: 'income-category',
|
||||
id: cat.id,
|
||||
onDrop: onReorder,
|
||||
});
|
||||
|
||||
return (
|
||||
<Row innerRef={setNodeRef} collapsed={true} style={dndStyle}>
|
||||
<Row innerRef={dropRef} collapsed={true}>
|
||||
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
|
||||
|
||||
<SidebarCategory
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
dragPreview={isDragging}
|
||||
dragging={isDragging}
|
||||
innerRef={dragRef}
|
||||
category={cat}
|
||||
isLast={isLast}
|
||||
editing={
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React from 'react';
|
||||
|
||||
import { type CategoryGroupEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { theme } from '../../style';
|
||||
import { Row } from '../table';
|
||||
|
||||
@@ -10,7 +8,15 @@ import { RenderMonths } from './RenderMonths';
|
||||
import { SidebarGroup } from './SidebarGroup';
|
||||
|
||||
type IncomeGroupProps = {
|
||||
group: CategoryGroupEntity;
|
||||
group: {
|
||||
id: string;
|
||||
hidden: number;
|
||||
categories: object[];
|
||||
is_income: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
tombstone: number;
|
||||
};
|
||||
editingCell: { id: string; cell: string } | null;
|
||||
collapsed: boolean;
|
||||
MonthComponent: () => JSX.Element;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { InputCell } from '../table';
|
||||
import { Tooltip } from '../tooltips';
|
||||
|
||||
type SidebarCategoryProps = {
|
||||
innerRef?: Ref<HTMLDivElement>;
|
||||
innerRef: Ref<HTMLDivElement>;
|
||||
category: CategoryEntity;
|
||||
dragPreview?: boolean;
|
||||
dragging?: boolean;
|
||||
@@ -39,7 +39,6 @@ export function SidebarCategory({
|
||||
onSave,
|
||||
onDelete,
|
||||
onHideNewCategory,
|
||||
...props
|
||||
}: SidebarCategoryProps) {
|
||||
const temporary = category.id === 'new';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -152,7 +151,6 @@ export function SidebarCategory({
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<InputCell
|
||||
value={category.name}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type CSSProperties, useState, type Ref } from 'react';
|
||||
|
||||
import { type CategoryGroupEntity } from 'loot-core/src/types/models';
|
||||
import React, { type CSSProperties, useState } from 'react';
|
||||
import { type ConnectDragSource } from 'react-dnd';
|
||||
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { NotesButton } from '../NotesButton';
|
||||
import { InputCell } from '../table';
|
||||
import { Tooltip } from '../tooltips';
|
||||
|
||||
type SidebarGroupProps = {
|
||||
group: CategoryGroupEntity;
|
||||
group: {
|
||||
id: string;
|
||||
hidden: number;
|
||||
categories: object[];
|
||||
is_income: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
tombstone: number;
|
||||
};
|
||||
editing?: boolean;
|
||||
collapsed: boolean;
|
||||
dragPreview?: boolean;
|
||||
innerRef?: Ref<HTMLDivElement>;
|
||||
innerRef?: ConnectDragSource;
|
||||
style?: CSSProperties;
|
||||
onEdit?: (id: string) => void;
|
||||
onSave?: (group: object) => Promise<void>;
|
||||
@@ -41,7 +49,6 @@ export function SidebarGroup({
|
||||
onShowNewCategory,
|
||||
onHideNewGroup,
|
||||
onToggleCollapse,
|
||||
...props
|
||||
}: SidebarGroupProps) {
|
||||
const temporary = group.id === 'new';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -79,6 +86,7 @@ export function SidebarGroup({
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{dragPreview && <Text style={{ fontWeight: 500 }}>Group: </Text>}
|
||||
{group.name}
|
||||
</div>
|
||||
{!dragPreview && (
|
||||
@@ -168,7 +176,6 @@ export function SidebarGroup({
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<InputCell
|
||||
value={group.name}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type CategoryGroupEntity } from 'loot-core/src/types/models';
|
||||
import { type LocalPrefs } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { styles, theme } from '../../style';
|
||||
import { type DropPosition } from '../sort';
|
||||
|
||||
import { getValidMonthBounds } from './MonthsContext';
|
||||
|
||||
@@ -77,6 +78,54 @@ export function makeAmountFullStyle(value: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function findSortDown(
|
||||
arr: CategoryGroupEntity[],
|
||||
pos: DropPosition,
|
||||
targetId: string,
|
||||
) {
|
||||
if (pos === 'top') {
|
||||
return { targetId };
|
||||
} else {
|
||||
const idx = arr.findIndex(item => item.id === targetId);
|
||||
|
||||
if (idx === -1) {
|
||||
throw new Error('findSort: item not found: ' + targetId);
|
||||
}
|
||||
|
||||
const newIdx = idx + 1;
|
||||
if (newIdx < arr.length - 1) {
|
||||
return { targetId: arr[newIdx].id };
|
||||
} else {
|
||||
// Move to the end
|
||||
return { targetId: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findSortUp(
|
||||
arr: CategoryGroupEntity[],
|
||||
pos: DropPosition,
|
||||
targetId: string,
|
||||
) {
|
||||
if (pos === 'bottom') {
|
||||
return { targetId };
|
||||
} else {
|
||||
const idx = arr.findIndex(item => item.id === targetId);
|
||||
|
||||
if (idx === -1) {
|
||||
throw new Error('findSort: item not found: ' + targetId);
|
||||
}
|
||||
|
||||
const newIdx = idx - 1;
|
||||
if (newIdx >= 0) {
|
||||
return { targetId: arr[newIdx].id };
|
||||
} else {
|
||||
// Move to the beginning
|
||||
return { targetId: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getScrollbarWidth() {
|
||||
return Math.max(styles.scrollbarWidth - 2, 0);
|
||||
}
|
||||
|
||||
@@ -42,12 +42,6 @@ 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
|
||||
|
||||
@@ -376,9 +376,7 @@ function Transaction({
|
||||
<Field
|
||||
width="flex"
|
||||
title={
|
||||
categoryList.includes(transaction.category)
|
||||
? transaction.category
|
||||
: undefined
|
||||
categoryList.includes(transaction.category) && transaction.category
|
||||
}
|
||||
>
|
||||
{categoryList.includes(transaction.category) && transaction.category}
|
||||
|
||||
@@ -1,164 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
// @ts-strict-ignore
|
||||
import React from 'react';
|
||||
|
||||
import * as d from 'date-fns';
|
||||
import { css } from 'glamor';
|
||||
import {
|
||||
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';
|
||||
VictoryChart,
|
||||
VictoryBar,
|
||||
VictoryLine,
|
||||
VictoryAxis,
|
||||
VictoryVoronoiContainer,
|
||||
VictoryGroup,
|
||||
} from 'victory';
|
||||
|
||||
import { theme } from '../../../style';
|
||||
import { AlignedText } from '../../common/AlignedText';
|
||||
import { chartTheme } from '../chart-theme';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { Container } from '../Container';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
type CashFlowGraphProps = {
|
||||
graphData: {
|
||||
expenses: { x: Date; y: number }[];
|
||||
income: { x: Date; y: number }[];
|
||||
balances: { x: Date; y: number }[];
|
||||
transfers: { x: Date; y: number }[];
|
||||
};
|
||||
graphData: { expenses; income; balances };
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer } from 'recharts';
|
||||
import { VictoryBar, VictoryGroup, VictoryVoronoiContainer } from 'victory';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
@@ -11,37 +11,14 @@ 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';
|
||||
@@ -54,7 +31,7 @@ export function CashFlowCard() {
|
||||
const onCardHoverEnd = useCallback(() => setIsCardHovered(false));
|
||||
|
||||
const { graphData } = data || {};
|
||||
const expenses = -(graphData?.expense || 0);
|
||||
const expense = -(graphData?.expense || 0);
|
||||
const income = graphData?.income || 0;
|
||||
|
||||
return (
|
||||
@@ -78,7 +55,7 @@ export function CashFlowCard() {
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<PrivacyFilter activationFilters={[!isCardHovered]}>
|
||||
<Change
|
||||
amount={income - expenses}
|
||||
amount={income - expense}
|
||||
style={{ color: theme.tableText, fontWeight: 300 }}
|
||||
/>
|
||||
</PrivacyFilter>
|
||||
@@ -87,33 +64,84 @@ export function CashFlowCard() {
|
||||
</View>
|
||||
|
||||
{data ? (
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
|
||||
@@ -180,10 +180,6 @@ 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),
|
||||
@@ -192,7 +188,7 @@ function recalculate(data, start, end, isConcise) {
|
||||
});
|
||||
return res;
|
||||
},
|
||||
{ expenses: [], income: [], transfers: [], balances: [] },
|
||||
{ expenses: [], income: [], balances: [] },
|
||||
);
|
||||
|
||||
const { balances } = graphData;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
@@ -11,6 +9,13 @@ import { styles, theme, type CSSProperties } from '../../style';
|
||||
import { AlignedText } from '../common/AlignedText';
|
||||
import { AnchorLink } from '../common/AnchorLink';
|
||||
import { View } from '../common/View';
|
||||
import {
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
DropHighlight,
|
||||
type OnDragChangeCallback,
|
||||
type OnDropCallback,
|
||||
} from '../sort';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
import { CellValue } from '../spreadsheet/CellValue';
|
||||
|
||||
@@ -36,6 +41,9 @@ type AccountProps = {
|
||||
failed?: boolean;
|
||||
updated?: boolean;
|
||||
style?: CSSProperties;
|
||||
outerStyle?: CSSProperties;
|
||||
onDragChange?: OnDragChangeCallback<{ id: string }>;
|
||||
onDrop?: OnDropCallback;
|
||||
};
|
||||
|
||||
export function Account({
|
||||
@@ -47,85 +55,93 @@ export function Account({
|
||||
to,
|
||||
query,
|
||||
style,
|
||||
outerStyle,
|
||||
onDragChange,
|
||||
onDrop,
|
||||
}: AccountProps) {
|
||||
const {
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: account?.id || `sortable-account-${name}` });
|
||||
const type = account
|
||||
? account.closed
|
||||
? 'account-closed'
|
||||
: account.offbudget
|
||||
? 'account-offbudget'
|
||||
: 'account-onbudget'
|
||||
: 'title';
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const { dragRef } = useDraggable({
|
||||
type,
|
||||
onDragChange,
|
||||
item: { id: account && account.id },
|
||||
canDrag: account != null,
|
||||
});
|
||||
|
||||
const { dropRef, dropPos } = useDroppable({
|
||||
types: account ? [type] : [],
|
||||
id: account && account.id,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
innerRef={setNodeRef}
|
||||
style={{ flexShrink: 0, ...dndStyle }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<AnchorLink
|
||||
to={to}
|
||||
style={{
|
||||
...accountNameStyle,
|
||||
...style,
|
||||
position: 'relative',
|
||||
borderLeft: '4px solid transparent',
|
||||
...(updated && { fontWeight: 700 }),
|
||||
...(isDragging && { pointerEvents: 'none' }),
|
||||
}}
|
||||
activeStyle={{
|
||||
borderColor: theme.sidebarItemAccentSelected,
|
||||
color: theme.sidebarItemTextSelected,
|
||||
// This is kind of a hack, but we don't ever want the account
|
||||
// that the user is looking at to be "bolded" which means it
|
||||
// has unread transactions. The system does mark is read and
|
||||
// unbolds it, but it still "flashes" bold so this just
|
||||
// ignores it if it's active
|
||||
fontWeight: (style && style.fontWeight) || 'normal',
|
||||
'& .dot': {
|
||||
backgroundColor: theme.sidebarItemAccentSelected,
|
||||
transform: 'translateX(-4.5px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`dot ${css({
|
||||
marginRight: 3,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 5,
|
||||
backgroundColor: failed
|
||||
? theme.sidebarItemBackgroundFailed
|
||||
: theme.sidebarItemBackgroundPositive,
|
||||
marginLeft: 2,
|
||||
transition: 'transform .3s',
|
||||
opacity: connected ? 1 : 0,
|
||||
})}`}
|
||||
/>
|
||||
</View>
|
||||
<View innerRef={dropRef} style={{ flexShrink: 0, ...outerStyle }}>
|
||||
<View>
|
||||
<DropHighlight pos={dropPos} />
|
||||
<View innerRef={dragRef}>
|
||||
<AnchorLink
|
||||
to={to}
|
||||
style={{
|
||||
...accountNameStyle,
|
||||
...style,
|
||||
position: 'relative',
|
||||
borderLeft: '4px solid transparent',
|
||||
...(updated && { fontWeight: 700 }),
|
||||
}}
|
||||
activeStyle={{
|
||||
borderColor: theme.sidebarItemAccentSelected,
|
||||
color: theme.sidebarItemTextSelected,
|
||||
// This is kind of a hack, but we don't ever want the account
|
||||
// that the user is looking at to be "bolded" which means it
|
||||
// has unread transactions. The system does mark is read and
|
||||
// unbolds it, but it still "flashes" bold so this just
|
||||
// ignores it if it's active
|
||||
fontWeight: (style && style.fontWeight) || 'normal',
|
||||
'& .dot': {
|
||||
backgroundColor: theme.sidebarItemAccentSelected,
|
||||
transform: 'translateX(-4.5px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`dot ${css({
|
||||
marginRight: 3,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 5,
|
||||
backgroundColor: failed
|
||||
? theme.sidebarItemBackgroundFailed
|
||||
: theme.sidebarItemBackgroundPositive,
|
||||
marginLeft: 2,
|
||||
transition: 'transform .3s',
|
||||
opacity: connected ? 1 : 0,
|
||||
})}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AlignedText
|
||||
left={name}
|
||||
right={<CellValue binding={query} type="financial" />}
|
||||
/>
|
||||
</AnchorLink>
|
||||
<AlignedText
|
||||
left={name}
|
||||
right={<CellValue binding={query} type="financial" />}
|
||||
/>
|
||||
</AnchorLink>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
import { View } from '../common/View';
|
||||
import { type OnDropCallback } from '../sort';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
import { getDropPosition } from '../util/sort';
|
||||
|
||||
import { Account } from './Account';
|
||||
import { SecondaryItem } from './SecondaryItem';
|
||||
@@ -53,7 +34,7 @@ type AccountsProps = {
|
||||
showClosedAccounts: boolean;
|
||||
onAddAccount: () => void;
|
||||
onToggleClosedAccounts: () => void;
|
||||
onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void;
|
||||
onReorder: OnDropCallback;
|
||||
};
|
||||
|
||||
export function Accounts({
|
||||
@@ -73,6 +54,7 @@ export function Accounts({
|
||||
onToggleClosedAccounts,
|
||||
onReorder,
|
||||
}: AccountsProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const offbudgetAccounts = useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
@@ -92,34 +74,18 @@ export function Accounts({
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
function onDragChange(drag) {
|
||||
setIsDragging(drag.state === 'start');
|
||||
}
|
||||
|
||||
const onDragEnd = e => {
|
||||
const { active, over } = e;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
const dropPos = getDropPosition(
|
||||
active.rect.current.translated,
|
||||
active.rect.current.initial,
|
||||
);
|
||||
|
||||
onReorder(active.id, dropPos, over.id);
|
||||
const makeDropPadding = i => {
|
||||
if (i === 0) {
|
||||
return {
|
||||
paddingTop: isDragging ? 15 : 0,
|
||||
marginTop: isDragging ? -15 : 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -139,34 +105,23 @@ export function Accounts({
|
||||
style={{ fontWeight, marginTop: 13 }}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={budgetedAccounts}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{budgetedAccounts.map(account => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bank}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={
|
||||
updatedAccounts && updatedAccounts.includes(account.id)
|
||||
}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
|
||||
{budgetedAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bank}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
outerStyle={makeDropPadding(i)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{offbudgetAccounts.length > 0 && (
|
||||
<Account
|
||||
name="Off budget"
|
||||
@@ -175,34 +130,22 @@ export function Accounts({
|
||||
style={{ fontWeight, marginTop: 13 }}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={offbudgetAccounts}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{offbudgetAccounts.map(account => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bank}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={
|
||||
updatedAccounts && updatedAccounts.includes(account.id)
|
||||
}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
|
||||
{offbudgetAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bank}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
outerStyle={makeDropPadding(i)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{closedAccounts.length > 0 && (
|
||||
<SecondaryItem
|
||||
@@ -213,31 +156,18 @@ export function Accounts({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClosedAccounts && (
|
||||
<View>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={offbudgetAccounts}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{closedAccounts.map(account => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</View>
|
||||
)}
|
||||
{showClosedAccounts &&
|
||||
closedAccounts.map(account => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SecondaryItem
|
||||
style={{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SvgReports, SvgWallet } from '../../icons/v1';
|
||||
import { SvgCalendar } from '../../icons/v2';
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { type OnDropCallback } from '../sort';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
|
||||
import { Accounts } from './Accounts';
|
||||
@@ -39,7 +40,7 @@ type SidebarProps = {
|
||||
onFloat: () => void;
|
||||
onAddAccount: () => void;
|
||||
onToggleClosedAccounts: () => void;
|
||||
onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void;
|
||||
onReorder: OnDropCallback;
|
||||
};
|
||||
|
||||
export function Sidebar({
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Text } from '../common/Text';
|
||||
import { Tooltip } from '../tooltips';
|
||||
import { findSortDown } from '../util/sort';
|
||||
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
@@ -130,10 +129,12 @@ export function SidebarWithData() {
|
||||
useEffect(() => void getAccounts(), [getAccounts]);
|
||||
|
||||
async function onReorder(id, dropPos, targetId) {
|
||||
await send('account-move', {
|
||||
id,
|
||||
...findSortDown(accounts, dropPos, targetId),
|
||||
});
|
||||
if (dropPos === 'bottom') {
|
||||
const idx = accounts.findIndex(a => a.id === targetId) + 1;
|
||||
targetId = idx < accounts.length ? accounts[idx].id : null;
|
||||
}
|
||||
|
||||
await send('account-move', { id, targetId });
|
||||
await getAccounts();
|
||||
}
|
||||
|
||||
|
||||
177
packages/desktop-client/src/components/sort.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
createContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useContext,
|
||||
type Context,
|
||||
} from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
|
||||
import { useMergedRefs } from '../hooks/useMergedRefs';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { View } from './common/View';
|
||||
|
||||
export type DragState<T> = {
|
||||
state: 'start-preview' | 'start' | 'end';
|
||||
type?: string;
|
||||
item?: T;
|
||||
preview?: boolean;
|
||||
};
|
||||
|
||||
export type DropPosition = 'top' | 'bottom';
|
||||
|
||||
export type OnDragChangeCallback<T> = (
|
||||
drag: DragState<T>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
type UseDraggableArgs<T> = {
|
||||
item?: T;
|
||||
type: string;
|
||||
canDrag: boolean;
|
||||
onDragChange: OnDragChangeCallback<T>;
|
||||
};
|
||||
|
||||
export function useDraggable<T>({
|
||||
item,
|
||||
type,
|
||||
canDrag,
|
||||
onDragChange,
|
||||
}: UseDraggableArgs<T>) {
|
||||
const _onDragChange = useRef(onDragChange);
|
||||
|
||||
const [, dragRef] = useDrag({
|
||||
type,
|
||||
item: () => {
|
||||
_onDragChange.current({ state: 'start-preview', type, item });
|
||||
|
||||
setTimeout(() => {
|
||||
_onDragChange.current({ state: 'start' });
|
||||
}, 0);
|
||||
|
||||
return { type, item };
|
||||
},
|
||||
collect: monitor => ({ isDragging: monitor.isDragging() }),
|
||||
|
||||
end(dragState) {
|
||||
_onDragChange.current({ state: 'end', type, item: dragState.item });
|
||||
},
|
||||
|
||||
canDrag() {
|
||||
return canDrag;
|
||||
},
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
_onDragChange.current = onDragChange;
|
||||
});
|
||||
|
||||
return { dragRef };
|
||||
}
|
||||
|
||||
export type OnDropCallback = (
|
||||
id: string,
|
||||
dropPos: DropPosition,
|
||||
targetId: unknown,
|
||||
) => Promise<void> | void;
|
||||
|
||||
type OnLongHoverCallback = () => Promise<void> | void;
|
||||
|
||||
type UseDroppableArgs = {
|
||||
types: string | string[];
|
||||
id: unknown;
|
||||
onDrop: OnDropCallback;
|
||||
onLongHover?: OnLongHoverCallback;
|
||||
};
|
||||
|
||||
export function useDroppable<T extends { id: string }>({
|
||||
types,
|
||||
id,
|
||||
onDrop,
|
||||
onLongHover,
|
||||
}: UseDroppableArgs) {
|
||||
const ref = useRef(null);
|
||||
const [dropPos, setDropPos] = useState<DropPosition>(null);
|
||||
|
||||
const [{ isOver }, dropRef] = useDrop<
|
||||
{ item: T },
|
||||
unknown,
|
||||
{ isOver: boolean }
|
||||
>({
|
||||
accept: types,
|
||||
drop({ item }) {
|
||||
onDrop(item.id, dropPos, id);
|
||||
},
|
||||
hover(_, monitor) {
|
||||
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
const pos: DropPosition = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
|
||||
|
||||
setDropPos(pos);
|
||||
},
|
||||
collect(monitor) {
|
||||
return { isOver: monitor.isOver() };
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timeout;
|
||||
if (onLongHover && isOver) {
|
||||
timeout = setTimeout(onLongHover, 700);
|
||||
}
|
||||
|
||||
return () => timeout && clearTimeout(timeout);
|
||||
}, [isOver]);
|
||||
|
||||
return {
|
||||
dropRef: useMergedRefs(dropRef, ref),
|
||||
dropPos: isOver ? dropPos : null,
|
||||
};
|
||||
}
|
||||
|
||||
type ItemPosition = 'first' | 'last';
|
||||
export const DropHighlightPosContext: Context<ItemPosition> =
|
||||
createContext(null);
|
||||
|
||||
type DropHighlightProps = {
|
||||
pos: DropPosition;
|
||||
offset?: {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
};
|
||||
export function DropHighlight({ pos, offset }: DropHighlightProps) {
|
||||
const itemPos = useContext(DropHighlightPosContext);
|
||||
|
||||
if (pos == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top || 0);
|
||||
const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom || 0);
|
||||
|
||||
const posStyle =
|
||||
pos === 'top' ? { top: -2 + topOffset } : { bottom: -1 + bottomOffset };
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 2,
|
||||
right: 2,
|
||||
borderRadius: 3,
|
||||
height: 3,
|
||||
background: theme.pageTextLink,
|
||||
zIndex: 10000,
|
||||
pointerEvents: 'none',
|
||||
...posStyle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import { useFormat } from './spreadsheet/useFormat';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
import { Tooltip, IntersectionBoundary } from './tooltips';
|
||||
|
||||
const ROW_HEIGHT = 32;
|
||||
export const ROW_HEIGHT = 32;
|
||||
|
||||
function fireBlur(onBlur, e) {
|
||||
if (document.hasFocus()) {
|
||||
@@ -848,14 +848,13 @@ type TableHandleRef<T extends TableItem = TableItem> = {
|
||||
type TableWithNavigatorProps = TableProps & {
|
||||
fields;
|
||||
};
|
||||
|
||||
export function TableWithNavigator({
|
||||
fields,
|
||||
...props
|
||||
}: TableWithNavigatorProps) {
|
||||
export const TableWithNavigator = forwardRef<
|
||||
TableHandleRef<TableItem>,
|
||||
TableWithNavigatorProps
|
||||
>(({ fields, ...props }) => {
|
||||
const navigator = useTableNavigator(props.items, fields);
|
||||
return <Table {...props} navigator={navigator} />;
|
||||
}
|
||||
});
|
||||
|
||||
type TableItem = { id: number | string };
|
||||
|
||||
|
||||
@@ -773,16 +773,6 @@ 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) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export function findSortDown(
|
||||
arr: { id: string }[],
|
||||
pos: 'top' | 'bottom',
|
||||
targetId: string,
|
||||
) {
|
||||
if (pos === 'top') {
|
||||
return { targetId };
|
||||
} else {
|
||||
const idx = arr.findIndex(item => item.id === targetId);
|
||||
|
||||
if (idx === -1) {
|
||||
throw new Error('findSort: item not found: ' + targetId);
|
||||
}
|
||||
|
||||
const newIdx = idx + 1;
|
||||
if (newIdx <= arr.length - 1) {
|
||||
return { targetId: arr[newIdx].id };
|
||||
} else {
|
||||
// Move to the end
|
||||
return { targetId: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findSortUp(
|
||||
arr: { id: string }[],
|
||||
pos: 'top' | 'bottom',
|
||||
targetId: string,
|
||||
) {
|
||||
if (pos === 'bottom') {
|
||||
return { targetId };
|
||||
} else {
|
||||
const idx = arr.findIndex(item => item.id === targetId);
|
||||
|
||||
if (idx === -1) {
|
||||
throw new Error('findSort: item not found: ' + targetId);
|
||||
}
|
||||
|
||||
const newIdx = idx - 1;
|
||||
if (newIdx >= 0) {
|
||||
return { targetId: arr[newIdx].id };
|
||||
} else {
|
||||
// Move to the beginning
|
||||
return { targetId: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Coordinates = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
export function getDropPosition(
|
||||
active: Coordinates,
|
||||
original: Coordinates,
|
||||
): 'top' | 'bottom' {
|
||||
const { top: activeTop, bottom: activeBottom } = active;
|
||||
const { top: initialTop, bottom: initialBottom } = original;
|
||||
|
||||
const activeCenter = (activeTop + activeBottom) / 2;
|
||||
const initialCenter = (initialTop + initialBottom) / 2;
|
||||
|
||||
// top - the active item was dragged up
|
||||
// bottom - the active item was dragged down
|
||||
return activeCenter < initialCenter ? 'top' : 'bottom';
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
"requireindex": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-eslint-plugin": "^5.0.0",
|
||||
"eslint-plugin-eslint-plugin": "^5.2.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"jest": "^27.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"npm-run-all": "^4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -41,17 +41,17 @@
|
||||
"devDependencies": {
|
||||
"@actual-app/api": "*",
|
||||
"@actual-app/crdt": "*",
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/helpers": "^0.5.3",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@swc/core": "^1.3.82",
|
||||
"@swc/helpers": "^0.5.1",
|
||||
"@swc/jest": "^0.2.29",
|
||||
"@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.5",
|
||||
"@types/webpack-bundle-analyzer": "^4.6.3",
|
||||
"@types/webpack": "^5.28.2",
|
||||
"@types/webpack-bundle-analyzer": "^4.6.0",
|
||||
"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": "^5.0.2",
|
||||
"typescript": "^4.6.4",
|
||||
"uuid": "^9.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-bundle-analyzer": "^4.9.1",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"yargs": "^9.0.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { APIError } from '../../../server/errors';
|
||||
import { runHandler, isMutating } from '../../../server/mutators';
|
||||
import { captureException } from '../../exceptions';
|
||||
|
||||
@@ -71,7 +70,7 @@ export const init: T.Init = function (_socketName, handlers) {
|
||||
type: 'reply',
|
||||
id,
|
||||
result: null,
|
||||
error: APIError('Unknown method: ' + name),
|
||||
error: { type: 'APIError', message: 'Unknown method: ' + name },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { APIError } from '../../../server/errors';
|
||||
import { runHandler, isMutating } from '../../../server/mutators';
|
||||
import { captureException } from '../../exceptions';
|
||||
|
||||
@@ -91,7 +90,7 @@ export const init: T.Init = function (serverChn, handlers) {
|
||||
type: 'reply',
|
||||
id,
|
||||
result: null,
|
||||
error: APIError('Unknown method: ' + name),
|
||||
error: { type: 'APIError', message: 'Unknown method: ' + name },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@ 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';
|
||||
@@ -36,6 +35,11 @@ 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,9 +127,7 @@ export function setGoal({ month, category, goal }): Promise<void> {
|
||||
});
|
||||
}
|
||||
return db.insert(table, {
|
||||
id: `${dbMonth(month)}-${category}`,
|
||||
month: dbMonth(month),
|
||||
category,
|
||||
id: month,
|
||||
goal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-strict-ignore
|
||||
export const SORT_INCREMENT = 16384;
|
||||
|
||||
function midpoint<T extends { sort_order: number }>(items: T[], to: number) {
|
||||
function midpoint(items, to) {
|
||||
const below = items[to - 1];
|
||||
const above = items[to];
|
||||
|
||||
@@ -13,14 +14,11 @@ function midpoint<T extends { sort_order: number }>(items: T[], to: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export function shoveSortOrders<T extends { id: string; sort_order: number }>(
|
||||
items: T[],
|
||||
targetId?: string,
|
||||
) {
|
||||
export function shoveSortOrders(items, targetId?: string) {
|
||||
const to = items.findIndex(item => item.id === targetId);
|
||||
const target = items[to];
|
||||
const before = items[to - 1];
|
||||
const updates: Array<{ id: string; sort_order: number }> = [];
|
||||
const updates = [];
|
||||
|
||||
// If no target is specified, append at the end
|
||||
if (!targetId || to === -1) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// @ts-strict-ignore
|
||||
// TODO: normalize error types
|
||||
export class PostError extends Error {
|
||||
meta?: { meta: string };
|
||||
reason: string;
|
||||
type: 'PostError';
|
||||
meta;
|
||||
reason;
|
||||
type;
|
||||
|
||||
constructor(reason: string, meta?: { meta: string }) {
|
||||
constructor(reason, meta?) {
|
||||
super('PostError: ' + reason);
|
||||
this.type = 'PostError';
|
||||
this.reason = reason;
|
||||
@@ -13,10 +14,10 @@ export class PostError extends Error {
|
||||
}
|
||||
|
||||
export class HTTPError extends Error {
|
||||
statusCode: number;
|
||||
responseBody: string;
|
||||
statusCode;
|
||||
responseBody;
|
||||
|
||||
constructor(code: number, body: string) {
|
||||
constructor(code, body) {
|
||||
super(`HTTPError: unsuccessful status code (${code}): ${body}`);
|
||||
this.statusCode = code;
|
||||
this.responseBody = body;
|
||||
@@ -24,27 +25,10 @@ export class HTTPError extends Error {
|
||||
}
|
||||
|
||||
export class SyncError extends Error {
|
||||
meta?:
|
||||
| {
|
||||
isMissingKey: boolean;
|
||||
}
|
||||
| {
|
||||
error: { message: string; stack: string };
|
||||
query: { sql: string; params: Array<string | number> };
|
||||
};
|
||||
reason: string;
|
||||
meta;
|
||||
reason;
|
||||
|
||||
constructor(
|
||||
reason: string,
|
||||
meta?:
|
||||
| {
|
||||
isMissingKey: boolean;
|
||||
}
|
||||
| {
|
||||
error: { message: string; stack: string };
|
||||
query: { sql: string; params: Array<string | number> };
|
||||
},
|
||||
) {
|
||||
constructor(reason, meta?) {
|
||||
super('SyncError: ' + reason);
|
||||
this.reason = reason;
|
||||
this.meta = meta;
|
||||
@@ -62,20 +46,14 @@ export class RuleError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function APIError(msg: string) {
|
||||
return { type: 'APIError', message: msg };
|
||||
export function APIError(msg, meta?) {
|
||||
return { type: 'APIError', message: msg, meta };
|
||||
}
|
||||
|
||||
export function FileDownloadError(
|
||||
reason: string,
|
||||
meta?: { fileId?: string; isMissingKey?: boolean },
|
||||
) {
|
||||
export function FileDownloadError(reason, meta?) {
|
||||
return { type: 'FileDownloadError', reason, meta };
|
||||
}
|
||||
|
||||
export function FileUploadError(
|
||||
reason: string,
|
||||
meta?: { isMissingKey: boolean },
|
||||
) {
|
||||
export function FileUploadError(reason, meta?) {
|
||||
return { type: 'FileUploadError', reason, meta };
|
||||
}
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
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,
|
||||
) {
|
||||
// @ts-strict-ignore
|
||||
export function requiredFields(name, row, fields, update) {
|
||||
fields.forEach(field => {
|
||||
if (update) {
|
||||
if (row.hasOwnProperty(field) && row[field] == null) {
|
||||
throw new Error(`${name} is missing field ${String(field)}`);
|
||||
throw new Error(`${name} is missing field ${field}`);
|
||||
}
|
||||
} else {
|
||||
if (!row.hasOwnProperty(field) || row[field] == null) {
|
||||
throw new Error(`${name} is missing field ${String(field)}`);
|
||||
throw new Error(`${name} is missing field ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function toDateRepr(str: string) {
|
||||
export function toDateRepr(str) {
|
||||
if (typeof str !== 'string') {
|
||||
throw new Error('toDateRepr not passed a string: ' + str);
|
||||
}
|
||||
@@ -32,7 +21,7 @@ export function toDateRepr(str: string) {
|
||||
return parseInt(str.replace(/-/g, ''));
|
||||
}
|
||||
|
||||
export function fromDateRepr(number: number) {
|
||||
export function fromDateRepr(number) {
|
||||
if (typeof number !== 'number') {
|
||||
throw new Error('fromDateRepr not passed a number: ' + number);
|
||||
}
|
||||
@@ -48,7 +37,7 @@ export function fromDateRepr(number: number) {
|
||||
}
|
||||
|
||||
export const accountModel = {
|
||||
validate(account: AccountEntity, { update }: { update?: boolean } = {}) {
|
||||
validate(account, { update }: { update?: boolean } = {}) {
|
||||
requiredFields(
|
||||
'account',
|
||||
account,
|
||||
@@ -61,7 +50,7 @@ export const accountModel = {
|
||||
};
|
||||
|
||||
export const categoryModel = {
|
||||
validate(category: CategoryEntity, { update }: { update?: boolean } = {}) {
|
||||
validate(category, { update }: { update?: boolean } = {}) {
|
||||
requiredFields(
|
||||
'category',
|
||||
category,
|
||||
@@ -75,10 +64,7 @@ export const categoryModel = {
|
||||
};
|
||||
|
||||
export const categoryGroupModel = {
|
||||
validate(
|
||||
categoryGroup: CategoryGroupEntity,
|
||||
{ update }: { update?: boolean } = {},
|
||||
) {
|
||||
validate(categoryGroup, { update }: { update?: boolean } = {}) {
|
||||
requiredFields(
|
||||
'categoryGroup',
|
||||
categoryGroup,
|
||||
@@ -92,8 +78,78 @@ export const categoryGroupModel = {
|
||||
};
|
||||
|
||||
export const payeeModel = {
|
||||
validate(payee: PayeeEntity, { update }: { update?: boolean } = {}) {
|
||||
validate(payee, { 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, ['conditionsOp'], update);
|
||||
requiredFields('reports', report, ['conditions'], update);
|
||||
|
||||
if (!update || 'conditionsOp' in report) {
|
||||
if (!['and', 'or'].includes(report.conditionsOp)) {
|
||||
|
||||
@@ -142,7 +142,8 @@ async function fetchAll(table, ids) {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
query: { sql, params: partIds },
|
||||
sql,
|
||||
params: partIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
// "composite": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
@@ -25,8 +25,8 @@
|
||||
"checkJs": false,
|
||||
// Used for temp builds
|
||||
"outDir": "build",
|
||||
"moduleResolution": "Node10",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Node16",
|
||||
"module": "Node16",
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
TypeScript: making some files comply with strict TS.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Allow un-reconcile (unlock) transactions by clicking on the lock icon
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Refactored cash flow report from `victory` to `recharts`
|
||||
@@ -3,4 +3,4 @@ category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Switch to dnd-kit drag and drop library.
|
||||
Update api/crdt/eslint/root package versions.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Update vite / swc / ts versions.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Fix 'uncategorized transactions' flashing in the header on page load
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [twk3]
|
||||
---
|
||||
|
||||
Fix a missing ref param warning for forwardRef
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [twk3]
|
||||
---
|
||||
|
||||
Fix 'false' passed as title in import transactions modal
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [shall0pass]
|
||||
---
|
||||
|
||||
Fix database entry when applying goal templates
|
||||