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
21 changed files with 908 additions and 853 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Switch to dnd-kit drag and drop library.

140
yarn.lock
View File

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