Compare commits

..

2 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
38368224d3 Release notes 2024-01-21 03:27:31 -08:00
Joel Jeremy Marquez
f964552b5b Update package versions 2024-01-21 03:26:22 -08:00
62 changed files with 2033 additions and 1625 deletions

View File

@@ -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

View File

@@ -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": {

View File

@@ -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"
}
}

View File

@@ -4,7 +4,8 @@
// Using ES2021 because thats 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",

View File

@@ -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"
}
}

View File

@@ -4,7 +4,8 @@
// Using ES2021 because thats 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,

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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 : (

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -691,7 +691,7 @@ function MultiAutocomplete<T extends Item>({
type AutocompleteFooterProps = {
show?: boolean;
embedded?: boolean;
embedded: boolean;
children: ReactNode;
};
export function AutocompleteFooter({
@@ -699,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>
)
);
}

View File

@@ -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,

View File

@@ -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>
);
},

View File

@@ -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,

View File

@@ -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' &&

View File

@@ -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}

View File

@@ -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={

View File

@@ -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;

View File

@@ -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}

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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 wont be warned about changes
that can impact your reconciled balance. (Changes to amount,
account, payee, etc).
</Block>
) : confirmReason === 'deleteReconciled' ? (
<Block>
Deleting this reconciled transaction may bring your reconciliation

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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 />
)}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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={{

View File

@@ -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({

View File

@@ -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();
}

View 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,
}}
/>
);
}

View File

@@ -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 };

View File

@@ -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) {

View File

@@ -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';
}

View File

@@ -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": {

View File

@@ -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"
}

View File

@@ -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 },
});
}
});

View File

@@ -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 },
});
}
},

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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) {

View File

@@ -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 };
}

View File

@@ -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;
},
};

View File

@@ -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)) {

View File

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

View File

@@ -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"
}
}
}

View File

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

View File

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

View File

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

View File

@@ -3,4 +3,4 @@ category: Maintenance
authors: [joel-jeremy]
---
Switch to dnd-kit drag and drop library.
Update api/crdt/eslint/root package versions.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Update vite / swc / ts versions.

View File

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

View File

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

View File

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

View File

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

1199
yarn.lock

File diff suppressed because it is too large Load Diff