mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
Compare commits
21 Commits
tsconfig-c
...
dnd-kit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1877286702 | ||
|
|
ab2857521f | ||
|
|
ae43ed86ea | ||
|
|
2aa94b5d89 | ||
|
|
140f564e2e | ||
|
|
59168a284e | ||
|
|
61a65895cb | ||
|
|
779f2a5c13 | ||
|
|
def0aed7c6 | ||
|
|
8d8cd631b5 | ||
|
|
ed53972817 | ||
|
|
04e761e08a | ||
|
|
811f9e4300 | ||
|
|
fae68c19f5 | ||
|
|
96a9966d6b | ||
|
|
ca88608218 | ||
|
|
30a7701bdb | ||
|
|
c438a60fd7 | ||
|
|
72d491963f | ||
|
|
f46d87fd2d | ||
|
|
675918edea |
@@ -6,6 +6,10 @@
|
|||||||
"build"
|
"build"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"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",
|
"@juggle/resize-observer": "^3.1.2",
|
||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@reach/listbox": "^0.18.0",
|
"@reach/listbox": "^0.18.0",
|
||||||
@@ -45,8 +49,6 @@
|
|||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"pikaday": "1.8.2",
|
"pikaday": "1.8.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dnd": "^16.0.1",
|
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "^4.0.11",
|
"react-error-boundary": "^4.0.11",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React, { type ReactElement, useEffect, useMemo } from 'react';
|
import React, { type ReactElement, useEffect, useMemo } from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
|
||||||
import { HTML5Backend as Backend } from 'react-dnd-html5-backend';
|
|
||||||
import {
|
import {
|
||||||
Route,
|
Route,
|
||||||
Routes,
|
Routes,
|
||||||
@@ -267,9 +265,7 @@ export function FinancesApp() {
|
|||||||
<BudgetMonthCountProvider>
|
<BudgetMonthCountProvider>
|
||||||
<PayeesProvider>
|
<PayeesProvider>
|
||||||
<AccountsProvider>
|
<AccountsProvider>
|
||||||
<DndProvider backend={Backend}>
|
<ScrollProvider>{app}</ScrollProvider>
|
||||||
<ScrollProvider>{app}</ScrollProvider>
|
|
||||||
</DndProvider>
|
|
||||||
</AccountsProvider>
|
</AccountsProvider>
|
||||||
</PayeesProvider>
|
</PayeesProvider>
|
||||||
</BudgetMonthCountProvider>
|
</BudgetMonthCountProvider>
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
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 * as queries from 'loot-core/src/client/queries';
|
||||||
|
import { send } from 'loot-core/src/platform/client/fetch';
|
||||||
|
|
||||||
import { useActions } from '../../hooks/useActions';
|
import { useActions } from '../../hooks/useActions';
|
||||||
import { useCategories } from '../../hooks/useCategories';
|
import { useCategories } from '../../hooks/useCategories';
|
||||||
@@ -16,6 +36,7 @@ import { View } from '../common/View';
|
|||||||
import { Page } from '../Page';
|
import { Page } from '../Page';
|
||||||
import { PullToRefresh } from '../responsive/PullToRefresh';
|
import { PullToRefresh } from '../responsive/PullToRefresh';
|
||||||
import { CellValue } from '../spreadsheet/CellValue';
|
import { CellValue } from '../spreadsheet/CellValue';
|
||||||
|
import { findSortDown, getDropPosition } from '../util/sort';
|
||||||
|
|
||||||
function AccountHeader({ name, amount, style = {} }) {
|
function AccountHeader({ name, amount, style = {} }) {
|
||||||
return (
|
return (
|
||||||
@@ -52,8 +73,26 @@ function AccountHeader({ name, amount, style = {} }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AccountCard({ account, updated, getBalanceQuery, onSelect }) {
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
|
innerRef={setNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -63,20 +102,18 @@ function AccountCard({ account, updated, getBalanceQuery, onSelect }) {
|
|||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
marginRight: 10,
|
marginRight: 10,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
...dndStyle,
|
||||||
}}
|
}}
|
||||||
data-testid="account"
|
data-testid="account"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onMouseDown={() => onSelect(account.id)}
|
onClick={() => onSelect(account.id)}
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
border: '1px solid ' + theme.pillBorder,
|
border: '1px solid ' + theme.pillBorder,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
'&:active': {
|
|
||||||
opacity: 0.1,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -149,6 +186,7 @@ function AccountList({
|
|||||||
onAddAccount,
|
onAddAccount,
|
||||||
onSelectAccount,
|
onSelectAccount,
|
||||||
onSync,
|
onSync,
|
||||||
|
onReorder,
|
||||||
}) {
|
}) {
|
||||||
const budgetedAccounts = accounts.filter(account => account.offbudget === 0);
|
const budgetedAccounts = accounts.filter(account => account.offbudget === 0);
|
||||||
const offbudgetAccounts = accounts.filter(account => account.offbudget === 1);
|
const offbudgetAccounts = accounts.filter(account => account.offbudget === 1);
|
||||||
@@ -157,6 +195,41 @@ function AccountList({
|
|||||||
color: 'white',
|
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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title="Accounts"
|
title="Accounts"
|
||||||
@@ -178,20 +251,35 @@ function AccountList({
|
|||||||
style={{ flex: 1, backgroundColor: theme.mobilePageBackground }}
|
style={{ flex: 1, backgroundColor: theme.mobilePageBackground }}
|
||||||
>
|
>
|
||||||
{accounts.length === 0 && <EmptyMessage />}
|
{accounts.length === 0 && <EmptyMessage />}
|
||||||
<PullToRefresh onRefresh={onSync}>
|
<PullToRefresh isPullable={!isDragging} onRefresh={onSync}>
|
||||||
<View style={{ margin: 10 }}>
|
<View style={{ margin: 10 }}>
|
||||||
{budgetedAccounts.length > 0 && (
|
{budgetedAccounts.length > 0 && (
|
||||||
<AccountHeader name="For Budget" amount={getOnBudgetBalance()} />
|
<AccountHeader name="For Budget" amount={getOnBudgetBalance()} />
|
||||||
)}
|
)}
|
||||||
{budgetedAccounts.map(acct => (
|
<View>
|
||||||
<AccountCard
|
<DndContext
|
||||||
account={acct}
|
sensors={sensors}
|
||||||
key={acct.id}
|
collisionDetection={closestCenter}
|
||||||
updated={updatedAccounts.includes(acct.id)}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
getBalanceQuery={getBalanceQuery}
|
onDragStart={onDragStart}
|
||||||
onSelect={onSelectAccount}
|
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 && (
|
{offbudgetAccounts.length > 0 && (
|
||||||
<AccountHeader
|
<AccountHeader
|
||||||
@@ -200,15 +288,30 @@ function AccountList({
|
|||||||
style={{ marginTop: 30 }}
|
style={{ marginTop: 30 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{offbudgetAccounts.map(acct => (
|
<View>
|
||||||
<AccountCard
|
<DndContext
|
||||||
account={acct}
|
sensors={sensors}
|
||||||
key={acct.id}
|
collisionDetection={closestCenter}
|
||||||
updated={updatedAccounts.includes(acct.id)}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
getBalanceQuery={getBalanceQuery}
|
onDragStart={onDragStart}
|
||||||
onSelect={onSelectAccount}
|
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>
|
</View>
|
||||||
</PullToRefresh>
|
</PullToRefresh>
|
||||||
</Page>
|
</Page>
|
||||||
@@ -244,6 +347,14 @@ export function Accounts() {
|
|||||||
navigate(`/transaction/${transaction}`);
|
navigate(`/transaction/${transaction}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onReorder = async (id, dropPos, targetId) => {
|
||||||
|
await send('account-move', {
|
||||||
|
id,
|
||||||
|
...findSortDown(accounts, dropPos, targetId),
|
||||||
|
});
|
||||||
|
await getAccounts();
|
||||||
|
};
|
||||||
|
|
||||||
useSetThemeColor(theme.mobileViewTheme);
|
useSetThemeColor(theme.mobileViewTheme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -264,6 +375,7 @@ export function Accounts() {
|
|||||||
onSelectAccount={onSelectAccount}
|
onSelectAccount={onSelectAccount}
|
||||||
onSelectTransaction={onSelectTransaction}
|
onSelectTransaction={onSelectTransaction}
|
||||||
onSync={syncAndDownload}
|
onSync={syncAndDownload}
|
||||||
|
onReorder={onReorder}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import React, { memo, useState, useMemo } from 'react';
|
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 { theme, styles } from '../../style';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import { DropHighlightPosContext } from '../sort';
|
|
||||||
import { Row } from '../table';
|
import { Row } from '../table';
|
||||||
|
import { getDropPosition } from '../util/sort';
|
||||||
|
|
||||||
import { ExpenseCategory } from './ExpenseCategory';
|
import { ExpenseCategory } from './ExpenseCategory';
|
||||||
import { ExpenseGroup } from './ExpenseGroup';
|
import { ExpenseGroup } from './ExpenseGroup';
|
||||||
@@ -14,6 +33,8 @@ import { SidebarCategory } from './SidebarCategory';
|
|||||||
import { SidebarGroup } from './SidebarGroup';
|
import { SidebarGroup } from './SidebarGroup';
|
||||||
import { separateGroups } from './util';
|
import { separateGroups } from './util';
|
||||||
|
|
||||||
|
const getItemDndId = item => item.value?.id || item.type;
|
||||||
|
|
||||||
export const BudgetCategories = memo(
|
export const BudgetCategories = memo(
|
||||||
({
|
({
|
||||||
categoryGroups,
|
categoryGroups,
|
||||||
@@ -44,24 +65,24 @@ export const BudgetCategories = memo(
|
|||||||
|
|
||||||
let items = Array.prototype.concat.apply(
|
let items = Array.prototype.concat.apply(
|
||||||
[],
|
[],
|
||||||
expenseGroups.map(group => {
|
expenseGroups.map(expenseGroup => {
|
||||||
if (group.hidden && !showHiddenCategories) {
|
if (expenseGroup.hidden && !showHiddenCategories) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupCategories = group.categories.filter(
|
const groupCategories = expenseGroup.categories.filter(
|
||||||
cat => showHiddenCategories || !cat.hidden,
|
cat => showHiddenCategories || !cat.hidden,
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = [{ type: 'expense-group', value: { ...group } }];
|
const items = [{ type: 'expense-group', value: { ...expenseGroup } }];
|
||||||
|
|
||||||
if (newCategoryForGroup === group.id) {
|
if (newCategoryForGroup === expenseGroup.id) {
|
||||||
items.push({ type: 'new-category' });
|
items.push({ type: 'new-expense-category' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...items,
|
...items,
|
||||||
...(collapsed.includes(group.id) ? [] : groupCategories).map(
|
...(collapsed.includes(expenseGroup.id) ? [] : groupCategories).map(
|
||||||
cat => ({
|
cat => ({
|
||||||
type: 'expense-category',
|
type: 'expense-category',
|
||||||
value: cat,
|
value: cat,
|
||||||
@@ -71,16 +92,13 @@ export const BudgetCategories = memo(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAddingGroup) {
|
|
||||||
items.push({ type: 'new-group' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incomeGroup) {
|
if (incomeGroup) {
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
[
|
[
|
||||||
{ type: 'income-separator' },
|
|
||||||
{ type: 'income-group', value: incomeGroup },
|
{ type: 'income-group', value: incomeGroup },
|
||||||
newCategoryForGroup === incomeGroup.id && { type: 'new-category' },
|
newCategoryForGroup === incomeGroup.id && {
|
||||||
|
type: 'new-income-category',
|
||||||
|
},
|
||||||
...(collapsed.includes(incomeGroup.id)
|
...(collapsed.includes(incomeGroup.id)
|
||||||
? []
|
? []
|
||||||
: incomeGroup.categories.filter(
|
: incomeGroup.categories.filter(
|
||||||
@@ -95,56 +113,136 @@ export const BudgetCategories = memo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [
|
}, [categoryGroups, collapsed, newCategoryForGroup, showHiddenCategories]);
|
||||||
categoryGroups,
|
|
||||||
collapsed,
|
|
||||||
newCategoryForGroup,
|
|
||||||
isAddingGroup,
|
|
||||||
showHiddenCategories,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [dragState, setDragState] = useState(null);
|
const expenseGroupItems = useMemo(
|
||||||
const [savedCollapsed, setSavedCollapsed] = useState(null);
|
() =>
|
||||||
|
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
|
const incomeGroupItems = useMemo(
|
||||||
// each item in the list for better perf
|
() =>
|
||||||
function onDragChange(newDragState) {
|
items.filter(
|
||||||
const { state } = newDragState;
|
item =>
|
||||||
|
item.type === 'income-group' ||
|
||||||
|
item.type === 'income-category' ||
|
||||||
|
item.type === 'new-income-category',
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
if (state === 'start-preview') {
|
function onCollapse(id) {
|
||||||
setDragState({
|
setCollapsed([...collapsed, id]);
|
||||||
type: newDragState.type,
|
}
|
||||||
item: newDragState.item,
|
|
||||||
preview: true,
|
function onExpand(id) {
|
||||||
});
|
setCollapsed(collapsed.filter(_id => _id !== id));
|
||||||
} 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) {
|
function onToggleCollapse(id) {
|
||||||
if (collapsed.includes(id)) {
|
if (collapsed.includes(id)) {
|
||||||
setCollapsed(collapsed.filter(id_ => id_ !== id));
|
onExpand(id);
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -156,159 +254,188 @@ export const BudgetCategories = memo(
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map((item, idx) => {
|
<View>
|
||||||
let content;
|
<DndContext
|
||||||
switch (item.type) {
|
sensors={sensors}
|
||||||
case 'new-group':
|
collisionDetection={closestCenter}
|
||||||
content = (
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
<Row
|
onDragStart={onDragStart}
|
||||||
style={{ backgroundColor: theme.tableRowHeaderBackground }}
|
onDragOver={onDragOver}
|
||||||
>
|
onDragEnd={onDragEnd}
|
||||||
<SidebarGroup
|
>
|
||||||
group={{ id: 'new', name: '' }}
|
<SortableContext
|
||||||
editing={true}
|
items={expenseGroupIds}
|
||||||
onSave={onSaveGroup}
|
strategy={verticalListSortingStrategy}
|
||||||
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
|
{expenseGroupItems.map(item => {
|
||||||
style={
|
let content;
|
||||||
!dragState && {
|
switch (item.type) {
|
||||||
':hover': { backgroundColor: theme.tableBackground },
|
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}
|
return content;
|
||||||
</View>
|
})}
|
||||||
</DropHighlightPosContext.Provider>
|
</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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import * as monthUtils from 'loot-core/src/shared/months';
|
|||||||
import { theme, styles } from '../../style';
|
import { theme, styles } from '../../style';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import { IntersectionBoundary } from '../tooltips';
|
import { IntersectionBoundary } from '../tooltips';
|
||||||
|
import { findSortDown, findSortUp } from '../util/sort';
|
||||||
|
|
||||||
import { BudgetCategories } from './BudgetCategories';
|
import { BudgetCategories } from './BudgetCategories';
|
||||||
import { BudgetSummaries } from './BudgetSummaries';
|
import { BudgetSummaries } from './BudgetSummaries';
|
||||||
import { BudgetTotals } from './BudgetTotals';
|
import { BudgetTotals } from './BudgetTotals';
|
||||||
import { MonthsProvider } from './MonthsContext';
|
import { MonthsProvider } from './MonthsContext';
|
||||||
import { findSortDown, findSortUp, getScrollbarWidth } from './util';
|
import { getScrollbarWidth } from './util';
|
||||||
|
|
||||||
export class BudgetTable extends Component {
|
export class BudgetTable extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -57,14 +58,9 @@ export class BudgetTable extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let targetGroup;
|
const targetGroup = categoryGroups.find(g =>
|
||||||
|
g.categories.find(c => c.id === targetId),
|
||||||
for (const group of categoryGroups) {
|
);
|
||||||
if (group.categories.find(cat => cat.id === targetId)) {
|
|
||||||
targetGroup = group;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onReorderCategory({
|
this.props.onReorderCategory({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React, { type ComponentProps } from 'react';
|
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 { type CategoryEntity } from 'loot-core/src/types/models';
|
||||||
|
|
||||||
import { theme } from '../../style';
|
import { theme } from '../../style';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import {
|
|
||||||
useDraggable,
|
|
||||||
useDroppable,
|
|
||||||
DropHighlight,
|
|
||||||
type DragState,
|
|
||||||
type OnDragChangeCallback,
|
|
||||||
type OnDropCallback,
|
|
||||||
} from '../sort';
|
|
||||||
import { Row } from '../table';
|
import { Row } from '../table';
|
||||||
|
|
||||||
import { RenderMonths } from './RenderMonths';
|
import { RenderMonths } from './RenderMonths';
|
||||||
@@ -21,22 +16,18 @@ import { SidebarCategory } from './SidebarCategory';
|
|||||||
type ExpenseCategoryProps = {
|
type ExpenseCategoryProps = {
|
||||||
cat: CategoryEntity;
|
cat: CategoryEntity;
|
||||||
editingCell: { id: string; cell: string } | null;
|
editingCell: { id: string; cell: string } | null;
|
||||||
dragState: DragState<CategoryEntity>;
|
|
||||||
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
|
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
|
||||||
onEditName?: ComponentProps<typeof SidebarCategory>['onEditName'];
|
onEditName?: ComponentProps<typeof SidebarCategory>['onEditName'];
|
||||||
onEditMonth?: (id: string, monthIndex: number) => void;
|
onEditMonth?: (id: string, monthIndex: number) => void;
|
||||||
onSave?: ComponentProps<typeof SidebarCategory>['onSave'];
|
onSave?: ComponentProps<typeof SidebarCategory>['onSave'];
|
||||||
onDelete?: ComponentProps<typeof SidebarCategory>['onDelete'];
|
onDelete?: ComponentProps<typeof SidebarCategory>['onDelete'];
|
||||||
onDragChange: OnDragChangeCallback<CategoryEntity>;
|
|
||||||
onBudgetAction: (idx: number, action: string, arg: unknown) => void;
|
onBudgetAction: (idx: number, action: string, arg: unknown) => void;
|
||||||
onShowActivity: (name: string, id: string, idx: number) => void;
|
onShowActivity: (name: string, id: string, idx: number) => void;
|
||||||
onReorder: OnDropCallback;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ExpenseCategory({
|
export function ExpenseCategory({
|
||||||
cat,
|
cat,
|
||||||
editingCell,
|
editingCell,
|
||||||
dragState,
|
|
||||||
MonthComponent,
|
MonthComponent,
|
||||||
onEditName,
|
onEditName,
|
||||||
onEditMonth,
|
onEditMonth,
|
||||||
@@ -44,45 +35,39 @@ export function ExpenseCategory({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onBudgetAction,
|
onBudgetAction,
|
||||||
onShowActivity,
|
onShowActivity,
|
||||||
onDragChange,
|
|
||||||
onReorder,
|
|
||||||
}: ExpenseCategoryProps) {
|
}: 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) {
|
const dndStyle = {
|
||||||
dragging = true;
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
}
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
const { dragRef } = useDraggable({
|
};
|
||||||
type: 'category',
|
|
||||||
onDragChange,
|
|
||||||
item: cat,
|
|
||||||
canDrag: editingCell === null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { dropRef, dropPos } = useDroppable({
|
|
||||||
types: 'category',
|
|
||||||
id: cat.id,
|
|
||||||
onDrop: onReorder,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
innerRef={dropRef}
|
innerRef={setNodeRef}
|
||||||
collapsed={true}
|
collapsed={true}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.tableBackground,
|
backgroundColor: theme.tableBackground,
|
||||||
opacity: cat.hidden ? 0.5 : undefined,
|
opacity: cat.hidden ? 0.5 : undefined,
|
||||||
|
...dndStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
|
|
||||||
|
|
||||||
<View style={{ flex: 1, flexDirection: 'row' }}>
|
<View style={{ flex: 1, flexDirection: 'row' }}>
|
||||||
<SidebarCategory
|
<SidebarCategory
|
||||||
innerRef={dragRef}
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
dragPreview={isDragging}
|
||||||
|
dragging={isDragging}
|
||||||
category={cat}
|
category={cat}
|
||||||
dragPreview={dragging && dragState.preview}
|
|
||||||
dragging={dragging && !dragState.preview}
|
|
||||||
editing={
|
editing={
|
||||||
editingCell &&
|
editingCell &&
|
||||||
editingCell.cell === 'name' &&
|
editingCell.cell === 'name' &&
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React, { type ComponentProps } from 'react';
|
import React, { type ComponentProps } from 'react';
|
||||||
|
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
import { theme } from '../../style';
|
import { theme } from '../../style';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import {
|
import { Row } from '../table';
|
||||||
useDraggable,
|
|
||||||
useDroppable,
|
|
||||||
DropHighlight,
|
|
||||||
type OnDragChangeCallback,
|
|
||||||
type OnDropCallback,
|
|
||||||
type DragState,
|
|
||||||
} from '../sort';
|
|
||||||
import { Row, ROW_HEIGHT } from '../table';
|
|
||||||
|
|
||||||
import { RenderMonths } from './RenderMonths';
|
import { RenderMonths } from './RenderMonths';
|
||||||
import { SidebarGroup } from './SidebarGroup';
|
import { SidebarGroup } from './SidebarGroup';
|
||||||
@@ -20,16 +15,10 @@ type ExpenseGroupProps = {
|
|||||||
group: ComponentProps<typeof SidebarGroup>['group'];
|
group: ComponentProps<typeof SidebarGroup>['group'];
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
editingCell: { id: string; cell: string } | null;
|
editingCell: { id: string; cell: string } | null;
|
||||||
dragState: DragState<ComponentProps<typeof SidebarGroup>['group']>;
|
|
||||||
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
|
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
|
||||||
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
|
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
|
||||||
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
|
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
|
||||||
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
|
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
|
||||||
onDragChange: OnDragChangeCallback<
|
|
||||||
ComponentProps<typeof SidebarGroup>['group']
|
|
||||||
>;
|
|
||||||
onReorderGroup: OnDropCallback;
|
|
||||||
onReorderCategory: OnDropCallback;
|
|
||||||
onToggleCollapse?: ComponentProps<typeof SidebarGroup>['onToggleCollapse'];
|
onToggleCollapse?: ComponentProps<typeof SidebarGroup>['onToggleCollapse'];
|
||||||
onShowNewCategory?: ComponentProps<typeof SidebarGroup>['onShowNewCategory'];
|
onShowNewCategory?: ComponentProps<typeof SidebarGroup>['onShowNewCategory'];
|
||||||
};
|
};
|
||||||
@@ -38,88 +27,55 @@ export function ExpenseGroup({
|
|||||||
group,
|
group,
|
||||||
collapsed,
|
collapsed,
|
||||||
editingCell,
|
editingCell,
|
||||||
dragState,
|
|
||||||
MonthComponent,
|
MonthComponent,
|
||||||
onEditName,
|
onEditName,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDragChange,
|
|
||||||
onReorderGroup,
|
|
||||||
onReorderCategory,
|
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onShowNewCategory,
|
onShowNewCategory,
|
||||||
}: ExpenseGroupProps) {
|
}: ExpenseGroupProps) {
|
||||||
const dragging = dragState && dragState.item === group;
|
const {
|
||||||
|
isDragging,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({ id: group.id, disabled: !!editingCell });
|
||||||
|
|
||||||
const { dragRef } = useDraggable({
|
const dndStyle = {
|
||||||
type: 'group',
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
onDragChange,
|
transform: CSS.Transform.toString(transform),
|
||||||
item: group,
|
transition,
|
||||||
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 (
|
return (
|
||||||
<Row
|
<Row
|
||||||
|
innerRef={setNodeRef}
|
||||||
collapsed={true}
|
collapsed={true}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
opacity: group.hidden ? 0.33 : undefined,
|
opacity: group.hidden ? 0.33 : undefined,
|
||||||
backgroundColor: theme.tableRowHeaderBackground,
|
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
|
<View
|
||||||
innerRef={catDropRef}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
opacity: dragging && !dragState.preview ? 0.3 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
innerRef={dragRef}
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
dragPreview={isDragging}
|
||||||
group={group}
|
group={group}
|
||||||
editing={
|
editing={
|
||||||
editingCell &&
|
editingCell &&
|
||||||
editingCell.cell === 'name' &&
|
editingCell.cell === 'name' &&
|
||||||
editingCell.id === group.id
|
editingCell.id === group.id
|
||||||
}
|
}
|
||||||
dragPreview={dragging && dragState.preview}
|
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onEdit={onEditName}
|
onEdit={onEditName}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React, { type ComponentProps } from 'react';
|
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 { type CategoryEntity } from 'loot-core/src/types/models';
|
||||||
|
|
||||||
import {
|
|
||||||
useDraggable,
|
|
||||||
useDroppable,
|
|
||||||
DropHighlight,
|
|
||||||
type OnDragChangeCallback,
|
|
||||||
type OnDropCallback,
|
|
||||||
} from '../sort';
|
|
||||||
import { Row } from '../table';
|
import { Row } from '../table';
|
||||||
|
|
||||||
import { RenderMonths } from './RenderMonths';
|
import { RenderMonths } from './RenderMonths';
|
||||||
@@ -24,9 +20,7 @@ type IncomeCategoryProps = {
|
|||||||
onEditMonth?: (id: string, monthIndex: number) => void;
|
onEditMonth?: (id: string, monthIndex: number) => void;
|
||||||
onSave: ComponentProps<typeof SidebarCategory>['onSave'];
|
onSave: ComponentProps<typeof SidebarCategory>['onSave'];
|
||||||
onDelete: ComponentProps<typeof SidebarCategory>['onDelete'];
|
onDelete: ComponentProps<typeof SidebarCategory>['onDelete'];
|
||||||
onDragChange: OnDragChangeCallback<CategoryEntity>;
|
|
||||||
onBudgetAction: (idx: number, action: string, arg: unknown) => void;
|
onBudgetAction: (idx: number, action: string, arg: unknown) => void;
|
||||||
onReorder: OnDropCallback;
|
|
||||||
onShowActivity: (name: string, id: string, idx: number) => void;
|
onShowActivity: (name: string, id: string, idx: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,30 +33,31 @@ export function IncomeCategory({
|
|||||||
onEditMonth,
|
onEditMonth,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDragChange,
|
|
||||||
onBudgetAction,
|
onBudgetAction,
|
||||||
onReorder,
|
|
||||||
onShowActivity,
|
onShowActivity,
|
||||||
}: IncomeCategoryProps) {
|
}: IncomeCategoryProps) {
|
||||||
const { dragRef } = useDraggable({
|
const {
|
||||||
type: 'income-category',
|
isDragging,
|
||||||
onDragChange,
|
attributes,
|
||||||
item: cat,
|
listeners,
|
||||||
canDrag: editingCell === null,
|
setNodeRef,
|
||||||
});
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({ id: cat.id, disabled: !!editingCell });
|
||||||
|
|
||||||
const { dropRef, dropPos } = useDroppable({
|
const dndStyle = {
|
||||||
types: 'income-category',
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
id: cat.id,
|
transform: CSS.Transform.toString(transform),
|
||||||
onDrop: onReorder,
|
transition,
|
||||||
});
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row innerRef={dropRef} collapsed={true}>
|
<Row innerRef={setNodeRef} collapsed={true} style={dndStyle}>
|
||||||
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
|
|
||||||
|
|
||||||
<SidebarCategory
|
<SidebarCategory
|
||||||
innerRef={dragRef}
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
dragPreview={isDragging}
|
||||||
|
dragging={isDragging}
|
||||||
category={cat}
|
category={cat}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
editing={
|
editing={
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { type CategoryGroupEntity } from 'loot-core/src/types/models';
|
||||||
|
|
||||||
import { theme } from '../../style';
|
import { theme } from '../../style';
|
||||||
import { Row } from '../table';
|
import { Row } from '../table';
|
||||||
|
|
||||||
@@ -8,15 +10,7 @@ import { RenderMonths } from './RenderMonths';
|
|||||||
import { SidebarGroup } from './SidebarGroup';
|
import { SidebarGroup } from './SidebarGroup';
|
||||||
|
|
||||||
type IncomeGroupProps = {
|
type IncomeGroupProps = {
|
||||||
group: {
|
group: CategoryGroupEntity;
|
||||||
id: string;
|
|
||||||
hidden: number;
|
|
||||||
categories: object[];
|
|
||||||
is_income: number;
|
|
||||||
name: string;
|
|
||||||
sort_order: number;
|
|
||||||
tombstone: number;
|
|
||||||
};
|
|
||||||
editingCell: { id: string; cell: string } | null;
|
editingCell: { id: string; cell: string } | null;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
MonthComponent: () => JSX.Element;
|
MonthComponent: () => JSX.Element;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { InputCell } from '../table';
|
|||||||
import { Tooltip } from '../tooltips';
|
import { Tooltip } from '../tooltips';
|
||||||
|
|
||||||
type SidebarCategoryProps = {
|
type SidebarCategoryProps = {
|
||||||
innerRef: Ref<HTMLDivElement>;
|
innerRef?: Ref<HTMLDivElement>;
|
||||||
category: CategoryEntity;
|
category: CategoryEntity;
|
||||||
dragPreview?: boolean;
|
dragPreview?: boolean;
|
||||||
dragging?: boolean;
|
dragging?: boolean;
|
||||||
@@ -39,6 +39,7 @@ export function SidebarCategory({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onHideNewCategory,
|
onHideNewCategory,
|
||||||
|
...props
|
||||||
}: SidebarCategoryProps) {
|
}: SidebarCategoryProps) {
|
||||||
const temporary = category.id === 'new';
|
const temporary = category.id === 'new';
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -151,6 +152,7 @@ export function SidebarCategory({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<InputCell
|
<InputCell
|
||||||
value={category.name}
|
value={category.name}
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React, { type CSSProperties, useState } from 'react';
|
import React, { type CSSProperties, useState, type Ref } from 'react';
|
||||||
import { type ConnectDragSource } from 'react-dnd';
|
|
||||||
|
import { type CategoryGroupEntity } from 'loot-core/src/types/models';
|
||||||
|
|
||||||
import { SvgExpandArrow } from '../../icons/v0';
|
import { SvgExpandArrow } from '../../icons/v0';
|
||||||
import { SvgCheveronDown } from '../../icons/v1';
|
import { SvgCheveronDown } from '../../icons/v1';
|
||||||
import { theme } from '../../style';
|
import { theme } from '../../style';
|
||||||
import { Button } from '../common/Button';
|
import { Button } from '../common/Button';
|
||||||
import { Menu } from '../common/Menu';
|
import { Menu } from '../common/Menu';
|
||||||
import { Text } from '../common/Text';
|
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import { NotesButton } from '../NotesButton';
|
import { NotesButton } from '../NotesButton';
|
||||||
import { InputCell } from '../table';
|
import { InputCell } from '../table';
|
||||||
import { Tooltip } from '../tooltips';
|
import { Tooltip } from '../tooltips';
|
||||||
|
|
||||||
type SidebarGroupProps = {
|
type SidebarGroupProps = {
|
||||||
group: {
|
group: CategoryGroupEntity;
|
||||||
id: string;
|
|
||||||
hidden: number;
|
|
||||||
categories: object[];
|
|
||||||
is_income: number;
|
|
||||||
name: string;
|
|
||||||
sort_order: number;
|
|
||||||
tombstone: number;
|
|
||||||
};
|
|
||||||
editing?: boolean;
|
editing?: boolean;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
dragPreview?: boolean;
|
dragPreview?: boolean;
|
||||||
innerRef?: ConnectDragSource;
|
innerRef?: Ref<HTMLDivElement>;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
onEdit?: (id: string) => void;
|
onEdit?: (id: string) => void;
|
||||||
onSave?: (group: object) => Promise<void>;
|
onSave?: (group: object) => Promise<void>;
|
||||||
@@ -49,6 +41,7 @@ export function SidebarGroup({
|
|||||||
onShowNewCategory,
|
onShowNewCategory,
|
||||||
onHideNewGroup,
|
onHideNewGroup,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
|
...props
|
||||||
}: SidebarGroupProps) {
|
}: SidebarGroupProps) {
|
||||||
const temporary = group.id === 'new';
|
const temporary = group.id === 'new';
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -86,7 +79,6 @@ export function SidebarGroup({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dragPreview && <Text style={{ fontWeight: 500 }}>Group: </Text>}
|
|
||||||
{group.name}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
{!dragPreview && (
|
{!dragPreview && (
|
||||||
@@ -176,6 +168,7 @@ export function SidebarGroup({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<InputCell
|
<InputCell
|
||||||
value={group.name}
|
value={group.name}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { type CategoryGroupEntity } from 'loot-core/src/types/models';
|
|||||||
import { type LocalPrefs } from 'loot-core/src/types/prefs';
|
import { type LocalPrefs } from 'loot-core/src/types/prefs';
|
||||||
|
|
||||||
import { styles, theme } from '../../style';
|
import { styles, theme } from '../../style';
|
||||||
import { type DropPosition } from '../sort';
|
|
||||||
|
|
||||||
import { getValidMonthBounds } from './MonthsContext';
|
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() {
|
export function getScrollbarWidth() {
|
||||||
return Math.max(styles.scrollbarWidth - 2, 0);
|
return Math.max(styles.scrollbarWidth - 2, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { css } from 'glamor';
|
import { css } from 'glamor';
|
||||||
|
|
||||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
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 { AlignedText } from '../common/AlignedText';
|
||||||
import { AnchorLink } from '../common/AnchorLink';
|
import { AnchorLink } from '../common/AnchorLink';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import {
|
|
||||||
useDraggable,
|
|
||||||
useDroppable,
|
|
||||||
DropHighlight,
|
|
||||||
type OnDragChangeCallback,
|
|
||||||
type OnDropCallback,
|
|
||||||
} from '../sort';
|
|
||||||
import { type Binding } from '../spreadsheet';
|
import { type Binding } from '../spreadsheet';
|
||||||
import { CellValue } from '../spreadsheet/CellValue';
|
import { CellValue } from '../spreadsheet/CellValue';
|
||||||
|
|
||||||
@@ -41,9 +36,6 @@ type AccountProps = {
|
|||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
outerStyle?: CSSProperties;
|
|
||||||
onDragChange?: OnDragChangeCallback<{ id: string }>;
|
|
||||||
onDrop?: OnDropCallback;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Account({
|
export function Account({
|
||||||
@@ -55,93 +47,85 @@ export function Account({
|
|||||||
to,
|
to,
|
||||||
query,
|
query,
|
||||||
style,
|
style,
|
||||||
outerStyle,
|
|
||||||
onDragChange,
|
|
||||||
onDrop,
|
|
||||||
}: AccountProps) {
|
}: AccountProps) {
|
||||||
const type = account
|
const {
|
||||||
? account.closed
|
isDragging,
|
||||||
? 'account-closed'
|
attributes,
|
||||||
: account.offbudget
|
listeners,
|
||||||
? 'account-offbudget'
|
setNodeRef,
|
||||||
: 'account-onbudget'
|
transform,
|
||||||
: 'title';
|
transition,
|
||||||
|
} = useSortable({ id: account?.id || `sortable-account-${name}` });
|
||||||
|
|
||||||
const { dragRef } = useDraggable({
|
const dndStyle = {
|
||||||
type,
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
onDragChange,
|
transform: CSS.Transform.toString(transform),
|
||||||
item: { id: account && account.id },
|
transition,
|
||||||
canDrag: account != null,
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const { dropRef, dropPos } = useDroppable({
|
|
||||||
types: account ? [type] : [],
|
|
||||||
id: account && account.id,
|
|
||||||
onDrop,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View innerRef={dropRef} style={{ flexShrink: 0, ...outerStyle }}>
|
<View
|
||||||
<View>
|
innerRef={setNodeRef}
|
||||||
<DropHighlight pos={dropPos} />
|
style={{ flexShrink: 0, ...dndStyle }}
|
||||||
<View innerRef={dragRef}>
|
{...attributes}
|
||||||
<AnchorLink
|
{...listeners}
|
||||||
to={to}
|
>
|
||||||
style={{
|
<AnchorLink
|
||||||
...accountNameStyle,
|
to={to}
|
||||||
...style,
|
style={{
|
||||||
position: 'relative',
|
...accountNameStyle,
|
||||||
borderLeft: '4px solid transparent',
|
...style,
|
||||||
...(updated && { fontWeight: 700 }),
|
position: 'relative',
|
||||||
}}
|
borderLeft: '4px solid transparent',
|
||||||
activeStyle={{
|
...(updated && { fontWeight: 700 }),
|
||||||
borderColor: theme.sidebarItemAccentSelected,
|
...(isDragging && { pointerEvents: 'none' }),
|
||||||
color: theme.sidebarItemTextSelected,
|
}}
|
||||||
// This is kind of a hack, but we don't ever want the account
|
activeStyle={{
|
||||||
// that the user is looking at to be "bolded" which means it
|
borderColor: theme.sidebarItemAccentSelected,
|
||||||
// has unread transactions. The system does mark is read and
|
color: theme.sidebarItemTextSelected,
|
||||||
// unbolds it, but it still "flashes" bold so this just
|
// This is kind of a hack, but we don't ever want the account
|
||||||
// ignores it if it's active
|
// that the user is looking at to be "bolded" which means it
|
||||||
fontWeight: (style && style.fontWeight) || 'normal',
|
// has unread transactions. The system does mark is read and
|
||||||
'& .dot': {
|
// unbolds it, but it still "flashes" bold so this just
|
||||||
backgroundColor: theme.sidebarItemAccentSelected,
|
// ignores it if it's active
|
||||||
transform: 'translateX(-4.5px)',
|
fontWeight: (style && style.fontWeight) || 'normal',
|
||||||
},
|
'& .dot': {
|
||||||
}}
|
backgroundColor: theme.sidebarItemAccentSelected,
|
||||||
>
|
transform: 'translateX(-4.5px)',
|
||||||
<View
|
},
|
||||||
style={{
|
}}
|
||||||
position: 'absolute',
|
>
|
||||||
left: 0,
|
<View
|
||||||
top: 0,
|
style={{
|
||||||
bottom: 0,
|
position: 'absolute',
|
||||||
flexDirection: 'row',
|
left: 0,
|
||||||
alignItems: 'center',
|
top: 0,
|
||||||
}}
|
bottom: 0,
|
||||||
>
|
flexDirection: 'row',
|
||||||
<div
|
alignItems: 'center',
|
||||||
className={`dot ${css({
|
}}
|
||||||
marginRight: 3,
|
>
|
||||||
width: 5,
|
<div
|
||||||
height: 5,
|
className={`dot ${css({
|
||||||
borderRadius: 5,
|
marginRight: 3,
|
||||||
backgroundColor: failed
|
width: 5,
|
||||||
? theme.sidebarItemBackgroundFailed
|
height: 5,
|
||||||
: theme.sidebarItemBackgroundPositive,
|
borderRadius: 5,
|
||||||
marginLeft: 2,
|
backgroundColor: failed
|
||||||
transition: 'transform .3s',
|
? theme.sidebarItemBackgroundFailed
|
||||||
opacity: connected ? 1 : 0,
|
: theme.sidebarItemBackgroundPositive,
|
||||||
})}`}
|
marginLeft: 2,
|
||||||
/>
|
transition: 'transform .3s',
|
||||||
</View>
|
opacity: connected ? 1 : 0,
|
||||||
|
})}`}
|
||||||
<AlignedText
|
/>
|
||||||
left={name}
|
|
||||||
right={<CellValue binding={query} type="financial" />}
|
|
||||||
/>
|
|
||||||
</AnchorLink>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
<AlignedText
|
||||||
|
left={name}
|
||||||
|
right={<CellValue binding={query} type="financial" />}
|
||||||
|
/>
|
||||||
|
</AnchorLink>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
// @ts-strict-ignore
|
// @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 { type AccountEntity } from 'loot-core/src/types/models';
|
||||||
|
|
||||||
import { SvgAdd } from '../../icons/v1';
|
import { SvgAdd } from '../../icons/v1';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import { type OnDropCallback } from '../sort';
|
|
||||||
import { type Binding } from '../spreadsheet';
|
import { type Binding } from '../spreadsheet';
|
||||||
|
import { getDropPosition } from '../util/sort';
|
||||||
|
|
||||||
import { Account } from './Account';
|
import { Account } from './Account';
|
||||||
import { SecondaryItem } from './SecondaryItem';
|
import { SecondaryItem } from './SecondaryItem';
|
||||||
@@ -34,7 +53,7 @@ type AccountsProps = {
|
|||||||
showClosedAccounts: boolean;
|
showClosedAccounts: boolean;
|
||||||
onAddAccount: () => void;
|
onAddAccount: () => void;
|
||||||
onToggleClosedAccounts: () => void;
|
onToggleClosedAccounts: () => void;
|
||||||
onReorder: OnDropCallback;
|
onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Accounts({
|
export function Accounts({
|
||||||
@@ -54,7 +73,6 @@ export function Accounts({
|
|||||||
onToggleClosedAccounts,
|
onToggleClosedAccounts,
|
||||||
onReorder,
|
onReorder,
|
||||||
}: AccountsProps) {
|
}: AccountsProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const offbudgetAccounts = useMemo(
|
const offbudgetAccounts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
accounts.filter(
|
accounts.filter(
|
||||||
@@ -74,18 +92,34 @@ export function Accounts({
|
|||||||
[accounts],
|
[accounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
function onDragChange(drag) {
|
const sensors = useSensors(
|
||||||
setIsDragging(drag.state === 'start');
|
useSensor(TouchSensor, {
|
||||||
}
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const makeDropPadding = i => {
|
const onDragEnd = e => {
|
||||||
if (i === 0) {
|
const { active, over } = e;
|
||||||
return {
|
|
||||||
paddingTop: isDragging ? 15 : 0,
|
if (active.id !== over.id) {
|
||||||
marginTop: isDragging ? -15 : 0,
|
const dropPos = getDropPosition(
|
||||||
};
|
active.rect.current.translated,
|
||||||
|
active.rect.current.initial,
|
||||||
|
);
|
||||||
|
|
||||||
|
onReorder(active.id, dropPos, over.id);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,23 +139,34 @@ export function Accounts({
|
|||||||
style={{ fontWeight, marginTop: 13 }}
|
style={{ fontWeight, marginTop: 13 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<View>
|
||||||
{budgetedAccounts.map((account, i) => (
|
<DndContext
|
||||||
<Account
|
sensors={sensors}
|
||||||
key={account.id}
|
collisionDetection={closestCenter}
|
||||||
name={account.name}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
account={account}
|
onDragEnd={onDragEnd}
|
||||||
connected={!!account.bank}
|
>
|
||||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
<SortableContext
|
||||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
items={budgetedAccounts}
|
||||||
to={getAccountPath(account)}
|
strategy={verticalListSortingStrategy}
|
||||||
query={getBalanceQuery(account)}
|
>
|
||||||
onDragChange={onDragChange}
|
{budgetedAccounts.map(account => (
|
||||||
onDrop={onReorder}
|
<Account
|
||||||
outerStyle={makeDropPadding(i)}
|
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 && (
|
{offbudgetAccounts.length > 0 && (
|
||||||
<Account
|
<Account
|
||||||
name="Off budget"
|
name="Off budget"
|
||||||
@@ -130,22 +175,34 @@ export function Accounts({
|
|||||||
style={{ fontWeight, marginTop: 13 }}
|
style={{ fontWeight, marginTop: 13 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<View>
|
||||||
{offbudgetAccounts.map((account, i) => (
|
<DndContext
|
||||||
<Account
|
sensors={sensors}
|
||||||
key={account.id}
|
collisionDetection={closestCenter}
|
||||||
name={account.name}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
account={account}
|
onDragEnd={onDragEnd}
|
||||||
connected={!!account.bank}
|
>
|
||||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
<SortableContext
|
||||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
items={offbudgetAccounts}
|
||||||
to={getAccountPath(account)}
|
strategy={verticalListSortingStrategy}
|
||||||
query={getBalanceQuery(account)}
|
>
|
||||||
onDragChange={onDragChange}
|
{offbudgetAccounts.map(account => (
|
||||||
onDrop={onReorder}
|
<Account
|
||||||
outerStyle={makeDropPadding(i)}
|
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 && (
|
{closedAccounts.length > 0 && (
|
||||||
<SecondaryItem
|
<SecondaryItem
|
||||||
@@ -156,18 +213,31 @@ export function Accounts({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showClosedAccounts &&
|
{showClosedAccounts && (
|
||||||
closedAccounts.map(account => (
|
<View>
|
||||||
<Account
|
<DndContext
|
||||||
key={account.id}
|
sensors={sensors}
|
||||||
name={account.name}
|
collisionDetection={closestCenter}
|
||||||
account={account}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
to={getAccountPath(account)}
|
onDragEnd={onDragEnd}
|
||||||
query={getBalanceQuery(account)}
|
>
|
||||||
onDragChange={onDragChange}
|
<SortableContext
|
||||||
onDrop={onReorder}
|
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
|
<SecondaryItem
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { SvgReports, SvgWallet } from '../../icons/v1';
|
|||||||
import { SvgCalendar } from '../../icons/v2';
|
import { SvgCalendar } from '../../icons/v2';
|
||||||
import { type CSSProperties, theme } from '../../style';
|
import { type CSSProperties, theme } from '../../style';
|
||||||
import { View } from '../common/View';
|
import { View } from '../common/View';
|
||||||
import { type OnDropCallback } from '../sort';
|
|
||||||
import { type Binding } from '../spreadsheet';
|
import { type Binding } from '../spreadsheet';
|
||||||
|
|
||||||
import { Accounts } from './Accounts';
|
import { Accounts } from './Accounts';
|
||||||
@@ -40,7 +39,7 @@ type SidebarProps = {
|
|||||||
onFloat: () => void;
|
onFloat: () => void;
|
||||||
onAddAccount: () => void;
|
onAddAccount: () => void;
|
||||||
onToggleClosedAccounts: () => void;
|
onToggleClosedAccounts: () => void;
|
||||||
onReorder: OnDropCallback;
|
onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Input } from '../common/Input';
|
|||||||
import { Menu } from '../common/Menu';
|
import { Menu } from '../common/Menu';
|
||||||
import { Text } from '../common/Text';
|
import { Text } from '../common/Text';
|
||||||
import { Tooltip } from '../tooltips';
|
import { Tooltip } from '../tooltips';
|
||||||
|
import { findSortDown } from '../util/sort';
|
||||||
|
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
@@ -129,12 +130,10 @@ export function SidebarWithData() {
|
|||||||
useEffect(() => void getAccounts(), [getAccounts]);
|
useEffect(() => void getAccounts(), [getAccounts]);
|
||||||
|
|
||||||
async function onReorder(id, dropPos, targetId) {
|
async function onReorder(id, dropPos, targetId) {
|
||||||
if (dropPos === 'bottom') {
|
await send('account-move', {
|
||||||
const idx = accounts.findIndex(a => a.id === targetId) + 1;
|
id,
|
||||||
targetId = idx < accounts.length ? accounts[idx].id : null;
|
...findSortDown(accounts, dropPos, targetId),
|
||||||
}
|
});
|
||||||
|
|
||||||
await send('account-move', { id, targetId });
|
|
||||||
await getAccounts();
|
await getAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,7 @@ import { useFormat } from './spreadsheet/useFormat';
|
|||||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||||
import { Tooltip, IntersectionBoundary } from './tooltips';
|
import { Tooltip, IntersectionBoundary } from './tooltips';
|
||||||
|
|
||||||
export const ROW_HEIGHT = 32;
|
const ROW_HEIGHT = 32;
|
||||||
|
|
||||||
function fireBlur(onBlur, e) {
|
function fireBlur(onBlur, e) {
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
|
|||||||
67
packages/desktop-client/src/components/util/sort.ts
Normal file
67
packages/desktop-client/src/components/util/sort.ts
Normal 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';
|
||||||
|
}
|
||||||
6
upcoming-release-notes/2239.md
Normal file
6
upcoming-release-notes/2239.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Maintenance
|
||||||
|
authors: [joel-jeremy]
|
||||||
|
---
|
||||||
|
|
||||||
|
Switch to dnd-kit drag and drop library.
|
||||||
140
yarn.lock
140
yarn.lock
@@ -58,6 +58,10 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@actual-app/web@workspace:packages/desktop-client"
|
resolution: "@actual-app/web@workspace:packages/desktop-client"
|
||||||
dependencies:
|
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"
|
"@juggle/resize-observer": "npm:^3.1.2"
|
||||||
"@playwright/test": "npm:^1.41.1"
|
"@playwright/test": "npm:^1.41.1"
|
||||||
"@reach/listbox": "npm:^0.18.0"
|
"@reach/listbox": "npm:^0.18.0"
|
||||||
@@ -97,8 +101,6 @@ __metadata:
|
|||||||
memoize-one: "npm:^6.0.0"
|
memoize-one: "npm:^6.0.0"
|
||||||
pikaday: "npm:1.8.2"
|
pikaday: "npm:1.8.2"
|
||||||
react: "npm:18.2.0"
|
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-dom: "npm:18.2.0"
|
||||||
react-error-boundary: "npm:^4.0.11"
|
react-error-boundary: "npm:^4.0.11"
|
||||||
react-markdown: "npm:^8.0.7"
|
react-markdown: "npm:^8.0.7"
|
||||||
@@ -1744,6 +1746,68 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@electron/asar@npm:^3.2.1":
|
||||||
version: 3.2.4
|
version: 3.2.4
|
||||||
resolution: "@electron/asar@npm:3.2.4"
|
resolution: "@electron/asar@npm:3.2.4"
|
||||||
@@ -2974,27 +3038,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@react-spring/animated@npm:~9.7.2":
|
||||||
version: 9.7.2
|
version: 9.7.2
|
||||||
resolution: "@react-spring/animated@npm:9.7.2"
|
resolution: "@react-spring/animated@npm:9.7.2"
|
||||||
@@ -7181,17 +7224,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"doctrine@npm:^2.1.0":
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
resolution: "doctrine@npm:2.1.0"
|
resolution: "doctrine@npm:2.1.0"
|
||||||
@@ -9361,7 +9393,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 3.3.2
|
||||||
resolution: "hoist-non-react-statics@npm:3.3.2"
|
resolution: "hoist-non-react-statics@npm:3.3.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13781,40 +13813,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"react-dom@npm:18.2.0":
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
resolution: "react-dom@npm:18.2.0"
|
resolution: "react-dom@npm:18.2.0"
|
||||||
@@ -14222,7 +14220,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 4.2.1
|
||||||
resolution: "redux@npm:4.2.1"
|
resolution: "redux@npm:4.2.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16027,7 +16025,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 2.6.2
|
||||||
resolution: "tslib@npm:2.6.2"
|
resolution: "tslib@npm:2.6.2"
|
||||||
checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca
|
checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca
|
||||||
|
|||||||
Reference in New Issue
Block a user