Compare commits

..

21 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
1877286702 yarn install 2024-01-24 13:01:36 -08:00
Joel Jeremy Marquez
ab2857521f Fix lint errors 2024-01-24 13:00:34 -08:00
Joel Jeremy Marquez
ae43ed86ea Cleanup 2024-01-24 13:00:34 -08:00
Joel Jeremy Marquez
2aa94b5d89 Sortable mobile accounts 2024-01-24 13:00:34 -08:00
Joel Jeremy Marquez
140f564e2e Delay uncollapsed when sorting groups 2024-01-24 13:00:34 -08:00
Joel Jeremy Marquez
59168a284e Fix lint 2024-01-24 13:00:34 -08:00
Joel Jeremy Marquez
61a65895cb Remove Group: text when sorting groups + use onDragOver 2024-01-24 13:00:34 -08:00
Joel Jeremy Marquez
779f2a5c13 Restrict drag to parent element 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
def0aed7c6 Fix accounts sorting 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
8d8cd631b5 Check for null over 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
ed53972817 Revert ROW_HEIGHT 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
04e761e08a Fix lint error 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
811f9e4300 Release notes 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
fae68c19f5 Fix sort bug 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
96a9966d6b Fix types 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
ca88608218 Remove react-dnd 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
30a7701bdb Fix typecheck error 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
c438a60fd7 Budget drag and drop 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
72d491963f Use dnd-kit touch and mouse sensors 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
f46d87fd2d Add touch-action 2024-01-24 12:59:54 -08:00
Joel Jeremy Marquez
675918edea dnd-kit POC 2024-01-24 12:59:54 -08:00
85 changed files with 1057 additions and 1155 deletions

View File

@@ -32,12 +32,11 @@ Prerequisites:
#### Running against the local server
First start a dev instance:
First start the dev server:
```sh
HTTPS=true yarn start
```
Note the network IP address and port the dev instance is listening on.
Next, navigate to the root of your project folder, run the standartised docker container, and launch the visual regression tests from within it.
@@ -48,11 +47,11 @@ docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/
# 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
# Run the VRT tests: important - they MUST be ran against a HTTPS server. Use the ip and port noted earlier
E2E_START_URL=https://ip:port yarn vrt
# Run the VRT tests: important - they MUST be ran against a HTTPS server
E2E_START_URL=https://192.168.0.178:3001 yarn vrt
# To update snapshots, use the following command:
E2E_START_URL=https://ip:port yarn vrt --update-snapshots
E2E_START_URL=https://192.168.0.178:3001 yarn vrt --update-snapshots
```
#### Running against a remote server

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -6,6 +6,10 @@
"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",
"@reach/listbox": "^0.18.0",
@@ -45,8 +49,6 @@
"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",

View File

@@ -1,7 +1,5 @@
// @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,
@@ -267,9 +265,7 @@ export function FinancesApp() {
<BudgetMonthCountProvider>
<PayeesProvider>
<AccountsProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
<ScrollProvider>{app}</ScrollProvider>
</AccountsProvider>
</PayeesProvider>
</BudgetMonthCountProvider>

View File

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

View File

@@ -1,7 +1,27 @@
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';
@@ -16,6 +36,7 @@ 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 (
@@ -52,8 +73,26 @@ 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',
@@ -63,20 +102,18 @@ function AccountCard({ account, updated, getBalanceQuery, onSelect }) {
marginTop: 10,
marginRight: 10,
width: '100%',
...dndStyle,
}}
data-testid="account"
>
<Button
onMouseDown={() => onSelect(account.id)}
onClick={() => onSelect(account.id)}
style={{
flexDirection: 'row',
border: '1px solid ' + theme.pillBorder,
flex: 1,
alignItems: 'center',
borderRadius: 6,
'&:active': {
opacity: 0.1,
},
}}
>
<View
@@ -149,6 +186,7 @@ function AccountList({
onAddAccount,
onSelectAccount,
onSync,
onReorder,
}) {
const budgetedAccounts = accounts.filter(account => account.offbudget === 0);
const offbudgetAccounts = accounts.filter(account => account.offbudget === 1);
@@ -157,6 +195,41 @@ 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"
@@ -178,20 +251,35 @@ function AccountList({
style={{ flex: 1, backgroundColor: theme.mobilePageBackground }}
>
{accounts.length === 0 && <EmptyMessage />}
<PullToRefresh onRefresh={onSync}>
<PullToRefresh isPullable={!isDragging} onRefresh={onSync}>
<View style={{ margin: 10 }}>
{budgetedAccounts.length > 0 && (
<AccountHeader name="For Budget" amount={getOnBudgetBalance()} />
)}
{budgetedAccounts.map(acct => (
<AccountCard
account={acct}
key={acct.id}
updated={updatedAccounts.includes(acct.id)}
getBalanceQuery={getBalanceQuery}
onSelect={onSelectAccount}
/>
))}
<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>
{offbudgetAccounts.length > 0 && (
<AccountHeader
@@ -200,15 +288,30 @@ function AccountList({
style={{ marginTop: 30 }}
/>
)}
{offbudgetAccounts.map(acct => (
<AccountCard
account={acct}
key={acct.id}
updated={updatedAccounts.includes(acct.id)}
getBalanceQuery={getBalanceQuery}
onSelect={onSelectAccount}
/>
))}
<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>
</View>
</PullToRefresh>
</Page>
@@ -244,6 +347,14 @@ 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 (
@@ -264,6 +375,7 @@ export function Accounts() {
onSelectAccount={onSelectAccount}
onSelectTransaction={onSelectTransaction}
onSync={syncAndDownload}
onReorder={onReorder}
/>
</View>
);

View File

@@ -1,9 +1,28 @@
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';
@@ -14,6 +33,8 @@ 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,
@@ -44,24 +65,24 @@ export const BudgetCategories = memo(
let items = Array.prototype.concat.apply(
[],
expenseGroups.map(group => {
if (group.hidden && !showHiddenCategories) {
expenseGroups.map(expenseGroup => {
if (expenseGroup.hidden && !showHiddenCategories) {
return [];
}
const groupCategories = group.categories.filter(
const groupCategories = expenseGroup.categories.filter(
cat => showHiddenCategories || !cat.hidden,
);
const items = [{ type: 'expense-group', value: { ...group } }];
const items = [{ type: 'expense-group', value: { ...expenseGroup } }];
if (newCategoryForGroup === group.id) {
items.push({ type: 'new-category' });
if (newCategoryForGroup === expenseGroup.id) {
items.push({ type: 'new-expense-category' });
}
return [
...items,
...(collapsed.includes(group.id) ? [] : groupCategories).map(
...(collapsed.includes(expenseGroup.id) ? [] : groupCategories).map(
cat => ({
type: 'expense-category',
value: cat,
@@ -71,16 +92,13 @@ 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-category' },
newCategoryForGroup === incomeGroup.id && {
type: 'new-income-category',
},
...(collapsed.includes(incomeGroup.id)
? []
: incomeGroup.categories.filter(
@@ -95,56 +113,136 @@ export const BudgetCategories = memo(
}
return items;
}, [
categoryGroups,
collapsed,
newCategoryForGroup,
isAddingGroup,
showHiddenCategories,
]);
}, [categoryGroups, collapsed, newCategoryForGroup, showHiddenCategories]);
const [dragState, setDragState] = useState(null);
const [savedCollapsed, setSavedCollapsed] = useState(null);
const expenseGroupItems = useMemo(
() =>
items.filter(
item =>
item.type === 'expense-group' ||
item.type === 'expense-category' ||
item.type === 'new-expense-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;
const incomeGroupItems = useMemo(
() =>
items.filter(
item =>
item.type === 'income-group' ||
item.type === 'income-category' ||
item.type === 'new-income-category',
),
[items],
);
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 onCollapse(id) {
setCollapsed([...collapsed, id]);
}
function onExpand(id) {
setCollapsed(collapsed.filter(_id => _id !== id));
}
function onToggleCollapse(id) {
if (collapsed.includes(id)) {
setCollapsed(collapsed.filter(id_ => id_ !== id));
onExpand(id);
} else {
setCollapsed([...collapsed, id]);
onCollapse(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={{
@@ -156,159 +254,188 @@ export const BudgetCategories = memo(
flex: 1,
}}
>
{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;
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);
}
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>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<SortableContext
items={expenseGroupIds}
strategy={verticalListSortingStrategy}
>
<View
style={
!dragState && {
':hover': { backgroundColor: theme.tableBackground },
}
{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);
}
>
{content}
</View>
</DropHighlightPosContext.Provider>
);
})}
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);
}
return content;
})}
</SortableContext>
</DndContext>
</View>
</View>
);
},

View File

@@ -5,12 +5,13 @@ 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 { findSortDown, findSortUp, getScrollbarWidth } from './util';
import { getScrollbarWidth } from './util';
export class BudgetTable extends Component {
constructor(props) {
@@ -57,14 +58,9 @@ export class BudgetTable extends Component {
});
}
} else {
let targetGroup;
for (const group of categoryGroups) {
if (group.categories.find(cat => cat.id === targetId)) {
targetGroup = group;
break;
}
}
const targetGroup = categoryGroups.find(g =>
g.categories.find(c => c.id === targetId),
);
this.props.onReorderCategory({
id,

View File

@@ -1,18 +1,13 @@
// @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';
@@ -21,22 +16,18 @@ 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,
@@ -44,45 +35,39 @@ export function ExpenseCategory({
onDelete,
onBudgetAction,
onShowActivity,
onDragChange,
onReorder,
}: ExpenseCategoryProps) {
let dragging = dragState && dragState.item === cat;
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: cat.id, disabled: !!editingCell });
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,
});
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Row
innerRef={dropRef}
innerRef={setNodeRef}
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
innerRef={dragRef}
{...attributes}
{...listeners}
dragPreview={isDragging}
dragging={isDragging}
category={cat}
dragPreview={dragging && dragState.preview}
dragging={dragging && !dragState.preview}
editing={
editingCell &&
editingCell.cell === 'name' &&

View File

@@ -1,17 +1,12 @@
// @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 {
useDraggable,
useDroppable,
DropHighlight,
type OnDragChangeCallback,
type OnDropCallback,
type DragState,
} from '../sort';
import { Row, ROW_HEIGHT } from '../table';
import { Row } from '../table';
import { RenderMonths } from './RenderMonths';
import { SidebarGroup } from './SidebarGroup';
@@ -20,16 +15,10 @@ 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'];
};
@@ -38,88 +27,55 @@ export function ExpenseGroup({
group,
collapsed,
editingCell,
dragState,
MonthComponent,
onEditName,
onSave,
onDelete,
onDragChange,
onReorderGroup,
onReorderCategory,
onToggleCollapse,
onShowNewCategory,
}: ExpenseGroupProps) {
const dragging = dragState && dragState.item === group;
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: group.id, disabled: !!editingCell });
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);
}
},
});
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
transform: CSS.Transform.toString(transform),
transition,
};
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
innerRef={dragRef}
{...attributes}
{...listeners}
dragPreview={isDragging}
group={group}
editing={
editingCell &&
editingCell.cell === 'name' &&
editingCell.id === group.id
}
dragPreview={dragging && dragState.preview}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
onEdit={onEditName}

View File

@@ -1,15 +1,11 @@
// @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';
@@ -24,9 +20,7 @@ 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;
};
@@ -39,30 +33,31 @@ export function IncomeCategory({
onEditMonth,
onSave,
onDelete,
onDragChange,
onBudgetAction,
onReorder,
onShowActivity,
}: IncomeCategoryProps) {
const { dragRef } = useDraggable({
type: 'income-category',
onDragChange,
item: cat,
canDrag: editingCell === null,
});
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: cat.id, disabled: !!editingCell });
const { dropRef, dropPos } = useDroppable({
types: 'income-category',
id: cat.id,
onDrop: onReorder,
});
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Row innerRef={dropRef} collapsed={true}>
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
<Row innerRef={setNodeRef} collapsed={true} style={dndStyle}>
<SidebarCategory
innerRef={dragRef}
{...attributes}
{...listeners}
dragPreview={isDragging}
dragging={isDragging}
category={cat}
isLast={isLast}
editing={

View File

@@ -1,6 +1,8 @@
// @ts-strict-ignore
import React from 'react';
import { type CategoryGroupEntity } from 'loot-core/src/types/models';
import { theme } from '../../style';
import { Row } from '../table';
@@ -8,15 +10,7 @@ import { RenderMonths } from './RenderMonths';
import { SidebarGroup } from './SidebarGroup';
type IncomeGroupProps = {
group: {
id: string;
hidden: number;
categories: object[];
is_income: number;
name: string;
sort_order: number;
tombstone: number;
};
group: CategoryGroupEntity;
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,6 +39,7 @@ export function SidebarCategory({
onSave,
onDelete,
onHideNewCategory,
...props
}: SidebarCategoryProps) {
const temporary = category.id === 'new';
const [menuOpen, setMenuOpen] = useState(false);
@@ -151,6 +152,7 @@ export function SidebarCategory({
e.stopPropagation();
}
}}
{...props}
>
<InputCell
value={category.name}

View File

@@ -1,32 +1,24 @@
// @ts-strict-ignore
import React, { type CSSProperties, useState } from 'react';
import { type ConnectDragSource } from 'react-dnd';
import React, { type CSSProperties, useState, type Ref } from 'react';
import { type CategoryGroupEntity } from 'loot-core/src/types/models';
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: {
id: string;
hidden: number;
categories: object[];
is_income: number;
name: string;
sort_order: number;
tombstone: number;
};
group: CategoryGroupEntity;
editing?: boolean;
collapsed: boolean;
dragPreview?: boolean;
innerRef?: ConnectDragSource;
innerRef?: Ref<HTMLDivElement>;
style?: CSSProperties;
onEdit?: (id: string) => void;
onSave?: (group: object) => Promise<void>;
@@ -49,6 +41,7 @@ export function SidebarGroup({
onShowNewCategory,
onHideNewGroup,
onToggleCollapse,
...props
}: SidebarGroupProps) {
const temporary = group.id === 'new';
const [menuOpen, setMenuOpen] = useState(false);
@@ -86,7 +79,6 @@ export function SidebarGroup({
minWidth: 0,
}}
>
{dragPreview && <Text style={{ fontWeight: 500 }}>Group: </Text>}
{group.name}
</div>
{!dragPreview && (
@@ -176,6 +168,7 @@ export function SidebarGroup({
e.stopPropagation();
}
}}
{...props}
>
<InputCell
value={group.name}

View File

@@ -7,7 +7,6 @@ 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';
@@ -78,54 +77,6 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
// @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';
@@ -9,13 +11,6 @@ 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';
@@ -41,9 +36,6 @@ type AccountProps = {
failed?: boolean;
updated?: boolean;
style?: CSSProperties;
outerStyle?: CSSProperties;
onDragChange?: OnDragChangeCallback<{ id: string }>;
onDrop?: OnDropCallback;
};
export function Account({
@@ -55,93 +47,85 @@ export function Account({
to,
query,
style,
outerStyle,
onDragChange,
onDrop,
}: AccountProps) {
const type = account
? account.closed
? 'account-closed'
: account.offbudget
? 'account-offbudget'
: 'account-onbudget'
: 'title';
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: account?.id || `sortable-account-${name}` });
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,
});
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
transform: CSS.Transform.toString(transform),
transition,
};
return (
<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>
<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>
<AlignedText
left={name}
right={<CellValue binding={query} type="financial" />}
/>
</AnchorLink>
</View>
);
}

View File

@@ -1,12 +1,31 @@
// @ts-strict-ignore
import React, { useState, useMemo } from 'react';
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 { 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';
@@ -34,7 +53,7 @@ type AccountsProps = {
showClosedAccounts: boolean;
onAddAccount: () => void;
onToggleClosedAccounts: () => void;
onReorder: OnDropCallback;
onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void;
};
export function Accounts({
@@ -54,7 +73,6 @@ export function Accounts({
onToggleClosedAccounts,
onReorder,
}: AccountsProps) {
const [isDragging, setIsDragging] = useState(false);
const offbudgetAccounts = useMemo(
() =>
accounts.filter(
@@ -74,18 +92,34 @@ export function Accounts({
[accounts],
);
function onDragChange(drag) {
setIsDragging(drag.state === 'start');
}
const sensors = useSensors(
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
}),
useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const makeDropPadding = i => {
if (i === 0) {
return {
paddingTop: isDragging ? 15 : 0,
marginTop: isDragging ? -15 : 0,
};
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);
}
return null;
};
return (
@@ -105,23 +139,34 @@ export function Accounts({
style={{ fontWeight, marginTop: 13 }}
/>
)}
{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)}
/>
))}
<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>
{offbudgetAccounts.length > 0 && (
<Account
name="Off budget"
@@ -130,22 +175,34 @@ export function Accounts({
style={{ fontWeight, marginTop: 13 }}
/>
)}
{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)}
/>
))}
<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>
{closedAccounts.length > 0 && (
<SecondaryItem
@@ -156,18 +213,31 @@ export function Accounts({
/>
)}
{showClosedAccounts &&
closedAccounts.map(account => (
<Account
key={account.id}
name={account.name}
account={account}
to={getAccountPath(account)}
query={getBalanceQuery(account)}
onDragChange={onDragChange}
onDrop={onReorder}
/>
))}
{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>
)}
<SecondaryItem
style={{

View File

@@ -7,7 +7,6 @@ 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';
@@ -40,7 +39,7 @@ type SidebarProps = {
onFloat: () => void;
onAddAccount: () => void;
onToggleClosedAccounts: () => void;
onReorder: OnDropCallback;
onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void;
};
export function Sidebar({

View File

@@ -18,6 +18,7 @@ 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';
@@ -129,12 +130,10 @@ export function SidebarWithData() {
useEffect(() => void getAccounts(), [getAccounts]);
async function onReorder(id, 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 send('account-move', {
id,
...findSortDown(accounts, dropPos, targetId),
});
await getAccounts();
}

View File

@@ -1,177 +0,0 @@
// @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';
export const ROW_HEIGHT = 32;
const ROW_HEIGHT = 32;
function fireBlur(onBlur, e) {
if (document.hasFocus()) {

View File

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

View File

@@ -0,0 +1,67 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -3,4 +3,4 @@ category: Maintenance
authors: [joel-jeremy]
---
Migrate LoadBackup to ts
Switch to dnd-kit drag and drop library.

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Add Off Budget category label to mobile transactions page

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [youngcw]
---
Update vrt instructions

140
yarn.lock
View File

@@ -58,6 +58,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "@actual-app/web@workspace:packages/desktop-client"
dependencies:
"@dnd-kit/core": "npm:^6.1.0"
"@dnd-kit/modifiers": "npm:^7.0.0"
"@dnd-kit/sortable": "npm:^8.0.0"
"@dnd-kit/utilities": "npm:^3.2.2"
"@juggle/resize-observer": "npm:^3.1.2"
"@playwright/test": "npm:^1.41.1"
"@reach/listbox": "npm:^0.18.0"
@@ -97,8 +101,6 @@ __metadata:
memoize-one: "npm:^6.0.0"
pikaday: "npm:1.8.2"
react: "npm:18.2.0"
react-dnd: "npm:^16.0.1"
react-dnd-html5-backend: "npm:^16.0.1"
react-dom: "npm:18.2.0"
react-error-boundary: "npm:^4.0.11"
react-markdown: "npm:^8.0.7"
@@ -1744,6 +1746,68 @@ __metadata:
languageName: node
linkType: hard
"@dnd-kit/accessibility@npm:^3.1.0":
version: 3.1.0
resolution: "@dnd-kit/accessibility@npm:3.1.0"
dependencies:
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: 750a0537877d5dde3753e9ef59d19628b553567e90fc3e3b14a79bded08f47f4a7161bc0d003d7cd6b3bd9e10aa233628dca07d2aa5a2120cac84555ba1653d8
languageName: node
linkType: hard
"@dnd-kit/core@npm:^6.1.0":
version: 6.1.0
resolution: "@dnd-kit/core@npm:6.1.0"
dependencies:
"@dnd-kit/accessibility": "npm:^3.1.0"
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: cf9e99763fbd9220cb6fdde2950c19fdf6248391234f5ee835601814124445fd8a6e4b3f5bc35543c802d359db8cc47f07d87046577adc41952ae981a03fbda0
languageName: node
linkType: hard
"@dnd-kit/modifiers@npm:^7.0.0":
version: 7.0.0
resolution: "@dnd-kit/modifiers@npm:7.0.0"
dependencies:
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
"@dnd-kit/core": ^6.1.0
react: ">=16.8.0"
checksum: 9ee0b7b86c23c15f6820d76ec398724597abc9d9e31cf58836e7f0b9935e33f9136a60ee9600eb27818447623f07786d4fed3f1d685d9cc6d860d8f6c5354ae3
languageName: node
linkType: hard
"@dnd-kit/sortable@npm:^8.0.0":
version: 8.0.0
resolution: "@dnd-kit/sortable@npm:8.0.0"
dependencies:
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
"@dnd-kit/core": ^6.1.0
react: ">=16.8.0"
checksum: e2e0d37ace13db2e6aceb65a803195ef29e1a33a37e7722a988d7a9c1aacce77472a93b2adcd8e6780ac98b3d5640c5481892f530177c2eb966df235726942ad
languageName: node
linkType: hard
"@dnd-kit/utilities@npm:^3.2.2":
version: 3.2.2
resolution: "@dnd-kit/utilities@npm:3.2.2"
dependencies:
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: 6cfe46a5fcdaced943982e7ae66b08b89235493e106eb5bc833737c25905e13375c6ecc3aa0c357d136cb21dae3966213dba063f19b7a60b1235a29a7b05ff84
languageName: node
linkType: hard
"@electron/asar@npm:^3.2.1":
version: 3.2.4
resolution: "@electron/asar@npm:3.2.4"
@@ -2974,27 +3038,6 @@ __metadata:
languageName: node
linkType: hard
"@react-dnd/asap@npm:^5.0.1":
version: 5.0.2
resolution: "@react-dnd/asap@npm:5.0.2"
checksum: a75039720b89da11bc678c2b61b1d2840c8349023ef2b8f8ca9099e7ece6953e9be704bf393bf799eae83d245f62115eb5302499612c2aa009c1d91caa9462df
languageName: node
linkType: hard
"@react-dnd/invariant@npm:^4.0.1":
version: 4.0.2
resolution: "@react-dnd/invariant@npm:4.0.2"
checksum: b638e9643e6e93da03ef463be3c1b92055daadc391fc08e4ce8639ef8c7738f91058ec83ee52a0d0df0d3a6dd2811a7703e1450737708f043c2e909c0a99dd31
languageName: node
linkType: hard
"@react-dnd/shallowequal@npm:^4.0.1":
version: 4.0.2
resolution: "@react-dnd/shallowequal@npm:4.0.2"
checksum: 7f21d691bddbfd4d2830948cbeefecca1600b2b46bcb1934926795f07ae8a1fa60a3dfd3a2112be5ef682c3820c80a99711e9fa15843f7e300acb25a4ecb70ab
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.2":
version: 9.7.2
resolution: "@react-spring/animated@npm:9.7.2"
@@ -7181,17 +7224,6 @@ __metadata:
languageName: node
linkType: hard
"dnd-core@npm:^16.0.1":
version: 16.0.1
resolution: "dnd-core@npm:16.0.1"
dependencies:
"@react-dnd/asap": "npm:^5.0.1"
"@react-dnd/invariant": "npm:^4.0.1"
redux: "npm:^4.2.0"
checksum: 711dc30f88f7c5cb5308f105b337f6a4db7ad098e985d2e120189f17a3d1865d283aadef1641dc129706e0399746835a90e2a92ef65f0cdcf5aa0d0cb8c79265
languageName: node
linkType: hard
"doctrine@npm:^2.1.0":
version: 2.1.0
resolution: "doctrine@npm:2.1.0"
@@ -9361,7 +9393,7 @@ __metadata:
languageName: node
linkType: hard
"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2":
"hoist-non-react-statics@npm:^3.3.0":
version: 3.3.2
resolution: "hoist-non-react-statics@npm:3.3.2"
dependencies:
@@ -13781,40 +13813,6 @@ __metadata:
languageName: node
linkType: hard
"react-dnd-html5-backend@npm:^16.0.1":
version: 16.0.1
resolution: "react-dnd-html5-backend@npm:16.0.1"
dependencies:
dnd-core: "npm:^16.0.1"
checksum: fa0feacc01ba8c923fc21461cc5919a856f09384f9a684b4c70ab9cdddc4a6ec64f0de4f65946a8061284ed92c5e3104caca56ae58884235604898a909d82e90
languageName: node
linkType: hard
"react-dnd@npm:^16.0.1":
version: 16.0.1
resolution: "react-dnd@npm:16.0.1"
dependencies:
"@react-dnd/invariant": "npm:^4.0.1"
"@react-dnd/shallowequal": "npm:^4.0.1"
dnd-core: "npm:^16.0.1"
fast-deep-equal: "npm:^3.1.3"
hoist-non-react-statics: "npm:^3.3.2"
peerDependencies:
"@types/hoist-non-react-statics": ">= 3.3.1"
"@types/node": ">= 12"
"@types/react": ">= 16"
react: ">= 16.14"
peerDependenciesMeta:
"@types/hoist-non-react-statics":
optional: true
"@types/node":
optional: true
"@types/react":
optional: true
checksum: e27cf5156c306d183585099854c597266eda014c51e7dfca657f7099d5db0a09a4fe07e4c8cbc3b04ca613b805878a8f97f23cc8e13887dbfb1f05efbe5a12e7
languageName: node
linkType: hard
"react-dom@npm:18.2.0":
version: 18.2.0
resolution: "react-dom@npm:18.2.0"
@@ -14222,7 +14220,7 @@ __metadata:
languageName: node
linkType: hard
"redux@npm:^4.0.0, redux@npm:^4.0.5, redux@npm:^4.2.0":
"redux@npm:^4.0.0, redux@npm:^4.0.5":
version: 4.2.1
resolution: "redux@npm:4.2.1"
dependencies:
@@ -16027,7 +16025,7 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.0.3, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2":
"tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2":
version: 2.6.2
resolution: "tslib@npm:2.6.2"
checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca