From 675918edeabfa59b883d68a07cf8f163ac2f2a33 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 16 Jan 2024 14:43:18 -0800 Subject: [PATCH 01/21] dnd-kit POC --- packages/desktop-client/package.json | 4 + .../src/components/sidebar/Account.tsx | 166 +++++++------- .../src/components/sidebar/Accounts.tsx | 207 ++++++++++-------- yarn.lock | 66 ++++++ 4 files changed, 266 insertions(+), 177 deletions(-) diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 9f6d03494b1..07341e08209 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -6,6 +6,10 @@ "build" ], "devDependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@juggle/resize-observer": "^3.1.2", "@playwright/test": "^1.41.1", "@reach/listbox": "^0.18.0", diff --git a/packages/desktop-client/src/components/sidebar/Account.tsx b/packages/desktop-client/src/components/sidebar/Account.tsx index 3cd28ba2e61..b458a069875 100644 --- a/packages/desktop-client/src/components/sidebar/Account.tsx +++ b/packages/desktop-client/src/components/sidebar/Account.tsx @@ -1,6 +1,8 @@ // @ts-strict-ignore import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { css } from 'glamor'; import { type AccountEntity } from 'loot-core/src/types/models'; @@ -9,13 +11,6 @@ import { styles, theme, type CSSProperties } from '../../style'; import { AlignedText } from '../common/AlignedText'; import { AnchorLink } from '../common/AnchorLink'; import { View } from '../common/View'; -import { - useDraggable, - useDroppable, - DropHighlight, - type OnDragChangeCallback, - type OnDropCallback, -} from '../sort'; import { type Binding } from '../spreadsheet'; import { CellValue } from '../spreadsheet/CellValue'; @@ -42,8 +37,6 @@ type AccountProps = { updated?: boolean; style?: CSSProperties; outerStyle?: CSSProperties; - onDragChange?: OnDragChangeCallback<{ id: string }>; - onDrop?: OnDropCallback; }; export function Account({ @@ -56,92 +49,85 @@ export function Account({ query, style, outerStyle, - onDragChange, - onDrop, }: AccountProps) { - const type = account - ? account.closed - ? 'account-closed' - : account.offbudget - ? 'account-offbudget' - : 'account-onbudget' - : 'title'; + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: account?.id || `sortable-account-${name}` }); - const { dragRef } = useDraggable({ - type, - onDragChange, - item: { id: account && account.id }, - canDrag: account != null, - }); - - const { dropRef, dropPos } = useDroppable({ - types: account ? [type] : [], - id: account && account.id, - onDrop, - }); + const dndStyle = { + opacity: isDragging ? 0.5 : undefined, + transform: CSS.Transform.toString(transform), + transition, + }; return ( - - - - - - -
- - - } - /> - + + + +
- + + } + /> + ); } diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index cd20ccb5856..81d8573ed5d 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -1,11 +1,25 @@ // @ts-strict-ignore -import React, { useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; + +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { type AccountEntity } from 'loot-core/src/types/models'; import { SvgAdd } from '../../icons/v1'; import { View } from '../common/View'; -import { type OnDropCallback } from '../sort'; import { type Binding } from '../spreadsheet'; import { Account } from './Account'; @@ -34,7 +48,7 @@ type AccountsProps = { showClosedAccounts: boolean; onAddAccount: () => void; onToggleClosedAccounts: () => void; - onReorder: OnDropCallback; + onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void; }; export function Accounts({ @@ -54,7 +68,6 @@ export function Accounts({ onToggleClosedAccounts, onReorder, }: AccountsProps) { - const [isDragging, setIsDragging] = useState(false); const offbudgetAccounts = useMemo( () => accounts.filter( @@ -74,100 +87,120 @@ export function Accounts({ [accounts], ); - function onDragChange(drag) { - setIsDragging(drag.state === 'start'); - } + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const onDragEnd = e => { + const { active, over } = e; + + if (active.id !== over.id) { + const dropPos = + active.data.current.sortable.index < over.data.current.sortable.index + ? 'bottom' + : 'top'; - const makeDropPadding = i => { - if (i === 0) { - return { - paddingTop: isDragging ? 15 : 0, - marginTop: isDragging ? -15 : 0, - }; + onReorder(active.id, dropPos, over.id); } - return null; }; return ( - - - {budgetedAccounts.length > 0 && ( - - )} - - {budgetedAccounts.map((account, i) => ( - - ))} - - {offbudgetAccounts.length > 0 && ( + - )} - {offbudgetAccounts.map((account, i) => ( - - ))} - - {closedAccounts.length > 0 && ( - - )} - - {showClosedAccounts && - closedAccounts.map(account => ( + {budgetedAccounts.length > 0 && ( + + )} + + {budgetedAccounts.map((account, i) => ( + + ))} + + + {offbudgetAccounts.length > 0 && ( + )} + + + {offbudgetAccounts.map((account, i) => ( + + ))} + + + {closedAccounts.length > 0 && ( + - ))} + )} + + {showClosedAccounts && ( + + {closedAccounts.map((account, i) => ( + + ))} + + )} + =16.8.0" + checksum: 750a0537877d5dde3753e9ef59d19628b553567e90fc3e3b14a79bded08f47f4a7161bc0d003d7cd6b3bd9e10aa233628dca07d2aa5a2120cac84555ba1653d8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.1.0": + version: 6.1.0 + resolution: "@dnd-kit/core@npm:6.1.0" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.0" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: cf9e99763fbd9220cb6fdde2950c19fdf6248391234f5ee835601814124445fd8a6e4b3f5bc35543c802d359db8cc47f07d87046577adc41952ae981a03fbda0 + languageName: node + linkType: hard + +"@dnd-kit/modifiers@npm:^7.0.0": + version: 7.0.0 + resolution: "@dnd-kit/modifiers@npm:7.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.1.0 + react: ">=16.8.0" + checksum: 9ee0b7b86c23c15f6820d76ec398724597abc9d9e31cf58836e7f0b9935e33f9136a60ee9600eb27818447623f07786d4fed3f1d685d9cc6d860d8f6c5354ae3 + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^8.0.0": + version: 8.0.0 + resolution: "@dnd-kit/sortable@npm:8.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.1.0 + react: ">=16.8.0" + checksum: e2e0d37ace13db2e6aceb65a803195ef29e1a33a37e7722a988d7a9c1aacce77472a93b2adcd8e6780ac98b3d5640c5481892f530177c2eb966df235726942ad + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 6cfe46a5fcdaced943982e7ae66b08b89235493e106eb5bc833737c25905e13375c6ecc3aa0c357d136cb21dae3966213dba063f19b7a60b1235a29a7b05ff84 + languageName: node + linkType: hard + "@electron/asar@npm:^3.2.1": version: 3.2.4 resolution: "@electron/asar@npm:3.2.4" From f46d87fd2dc6a0866940724149af2acbce80ad11 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 16 Jan 2024 14:49:47 -0800 Subject: [PATCH 02/21] Add touch-action --- packages/desktop-client/src/components/sidebar/Account.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop-client/src/components/sidebar/Account.tsx b/packages/desktop-client/src/components/sidebar/Account.tsx index b458a069875..ac036e35c6f 100644 --- a/packages/desktop-client/src/components/sidebar/Account.tsx +++ b/packages/desktop-client/src/components/sidebar/Account.tsx @@ -60,6 +60,7 @@ export function Account({ } = useSortable({ id: account?.id || `sortable-account-${name}` }); const dndStyle = { + touchAction: 'none', opacity: isDragging ? 0.5 : undefined, transform: CSS.Transform.toString(transform), transition, From 72d491963f745029cb88c605043af879e74867a1 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Wed, 17 Jan 2024 08:21:21 -0800 Subject: [PATCH 03/21] Use dnd-kit touch and mouse sensors --- .../src/components/sidebar/Accounts.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index 81d8573ed5d..5c85d36e42d 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -4,7 +4,8 @@ import React, { useMemo } from 'react'; import { DndContext, KeyboardSensor, - PointerSensor, + MouseSensor, + TouchSensor, closestCenter, useSensor, useSensors, @@ -88,7 +89,17 @@ export function Accounts({ ); const sensors = useSensors( - useSensor(PointerSensor), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), From c438a60fd7d993b7e808b9aba93f7f21b75152cc Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 15:24:13 -0800 Subject: [PATCH 04/21] Budget drag and drop --- .../components/budget/BudgetCategories.jsx | 540 +++++++++++------- .../src/components/budget/ExpenseCategory.tsx | 59 +- .../src/components/budget/ExpenseGroup.tsx | 88 +-- .../src/components/budget/IncomeCategory.tsx | 47 +- .../src/components/budget/SidebarCategory.tsx | 4 +- .../src/components/budget/SidebarGroup.tsx | 19 +- .../src/components/sidebar/Account.tsx | 4 +- .../src/components/sidebar/Accounts.tsx | 15 +- 8 files changed, 420 insertions(+), 356 deletions(-) diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.jsx b/packages/desktop-client/src/components/budget/BudgetCategories.jsx index c80bfb2ab35..a6bb4af4a6d 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.jsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.jsx @@ -1,8 +1,23 @@ import React, { memo, useState, useMemo } from 'react'; +import { + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; + import { theme, styles } from '../../style'; import { View } from '../common/View'; -import { DropHighlightPosContext } from '../sort'; import { Row } from '../table'; import { ExpenseCategory } from './ExpenseCategory'; @@ -14,6 +29,8 @@ import { SidebarCategory } from './SidebarCategory'; import { SidebarGroup } from './SidebarGroup'; import { separateGroups } from './util'; +const getItemDndId = item => item.value?.id || item.type; + export const BudgetCategories = memo( ({ categoryGroups, @@ -44,24 +61,24 @@ export const BudgetCategories = memo( let items = Array.prototype.concat.apply( [], - expenseGroups.map(group => { - if (group.hidden && !showHiddenCategories) { + expenseGroups.map(expenseGroup => { + if (expenseGroup.hidden && !showHiddenCategories) { return []; } - const groupCategories = group.categories.filter( + const groupCategories = expenseGroup.categories.filter( cat => showHiddenCategories || !cat.hidden, ); - const items = [{ type: 'expense-group', value: { ...group } }]; + const items = [{ type: 'expense-group', value: { ...expenseGroup } }]; - if (newCategoryForGroup === group.id) { - items.push({ type: 'new-category' }); + if (newCategoryForGroup === expenseGroup.id) { + items.push({ type: 'new-expense-category' }); } return [ ...items, - ...(collapsed.includes(group.id) ? [] : groupCategories).map( + ...(collapsed.includes(expenseGroup.id) ? [] : groupCategories).map( cat => ({ type: 'expense-category', value: cat, @@ -71,16 +88,13 @@ export const BudgetCategories = memo( }), ); - if (isAddingGroup) { - items.push({ type: 'new-group' }); - } - if (incomeGroup) { items = items.concat( [ - { type: 'income-separator' }, { type: 'income-group', value: incomeGroup }, - newCategoryForGroup === incomeGroup.id && { type: 'new-category' }, + newCategoryForGroup === incomeGroup.id && { + type: 'new-income-category', + }, ...(collapsed.includes(incomeGroup.id) ? [] : incomeGroup.categories.filter( @@ -95,56 +109,143 @@ export const BudgetCategories = memo( } return items; - }, [ - categoryGroups, - collapsed, - newCategoryForGroup, - isAddingGroup, - showHiddenCategories, - ]); - - const [dragState, setDragState] = useState(null); - const [savedCollapsed, setSavedCollapsed] = useState(null); - - // TODO: If we turn this into a reducer, we could probably memoize - // each item in the list for better perf - function onDragChange(newDragState) { - const { state } = newDragState; - - if (state === 'start-preview') { - setDragState({ - type: newDragState.type, - item: newDragState.item, - preview: true, - }); - } else if (state === 'start') { - if (dragState) { - setDragState({ - ...dragState, - preview: false, - }); - setSavedCollapsed(collapsed); - } - } else if (state === 'hover') { - setDragState({ - ...dragState, - hoveredId: newDragState.id, - hoveredPos: newDragState.pos, - }); - } else if (state === 'end') { - setDragState(null); - setCollapsed(savedCollapsed || []); - } + }, [categoryGroups, collapsed, newCategoryForGroup, showHiddenCategories]); + + const expenseGroupItems = useMemo( + () => + items.filter( + item => + item.type === 'expense-group' || + item.type === 'expense-category' || + item.type === 'new-expense-category', + ), + [items], + ); + + const incomeGroupItems = useMemo( + () => + items.filter( + item => + item.type === 'income-group' || + item.type === 'income-category' || + item.type === 'new-income-category', + ), + [items], + ); + + function onCollapse(id) { + setCollapsed([...collapsed, id]); + } + + function onExpand(id) { + setCollapsed(collapsed.filter(_id => _id !== id)); } function onToggleCollapse(id) { if (collapsed.includes(id)) { - setCollapsed(collapsed.filter(id_ => id_ !== id)); + onExpand(id); } else { - setCollapsed([...collapsed, id]); + onCollapse(id); } } + const sensors = useSensors( + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [originalCollapsed, setOriginalCollapsed] = useState(null); + const [collapseOnDrag, setCollapseOnDrag] = 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); + + setCollapseOnDrag(groupIds); + break; + default: + break; + } + }; + + const onDragMove = e => { + const { active, over } = e; + // Delay collapsing groups when sorting groups. + if (collapseOnDrag) { + setCollapsed(collapseOnDrag); + setCollapseOnDrag(null); + } + + // Expand groups on hover when sorting 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 (active.id !== over.id) { + const activeItem = items.find(item => getItemDndId(item) === active.id); + + const { top: activeTop, bottom: activeBottom } = + active.rect.current.translated; + const { top: initialTop, bottom: initialBottom } = + active.rect.current.initial; + + const activeCenter = (activeTop + activeBottom) / 2; + const initialCenter = (initialTop + initialBottom) / 2; + + // top - the active item was dragged up + // bottom - the active item was dragged down + const dropPos = activeCenter < initialCenter ? 'top' : 'bottom'; + + 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); + } + } + + setCollapsed(originalCollapsed); + }; + + const expenseGroupIds = useMemo( + () => expenseGroupItems.map(getItemDndId), + [expenseGroupItems], + ); + + const incomeGroupIds = useMemo( + () => incomeGroupItems.map(getItemDndId), + [incomeGroupItems], + ); + return ( - {items.map((item, idx) => { - let content; - switch (item.type) { - case 'new-group': - content = ( - - - - ); - break; - case 'new-category': - content = ( - - g.is_income).id, - id: 'new', - }} - editing={true} - onSave={onSaveCategory} - onHideNewCategory={onHideNewCategory} - onEditName={onEditName} - /> - - ); - break; - - case 'expense-group': - content = ( - - ); - break; - case 'expense-category': - content = ( - - ); - break; - case 'income-separator': - content = ( - - - - ); - break; - case 'income-group': - content = ( - - ); - break; - case 'income-category': - content = ( - - ); - break; - default: - throw new Error('Unknown item type: ' + item.type); - } + + + {expenseGroupItems.map((item, idx) => { + let content; + switch (item.type) { + case 'new-expense-category': + content = ( + + g.is_income).id, + id: 'new', + }} + editing={true} + onSave={onSaveCategory} + onHideNewCategory={onHideNewCategory} + onEditName={onEditName} + /> + + ); + break; + case 'expense-group': + content = ( + + ); + break; + case 'expense-category': + content = ( + + ); + break; + default: + throw new Error('Unknown item type: ' + item.type); + } - const pos = - idx === 0 ? 'first' : idx === items.length - 1 ? 'last' : null; - - return ( - + + {isAddingGroup && ( + + + + )} + + + + + + {incomeGroupItems.map((item, idx) => { + let content; + switch (item.type) { + case 'new-income-category': + content = ( + + g.is_income).id, + id: 'new', + }} + editing={true} + onSave={onSaveCategory} + onHideNewCategory={onHideNewCategory} + onEditName={onEditName} + /> + + ); + break; + case 'income-group': + content = ( + + ); + break; + case 'income-category': + content = ( + + ); + break; + default: + throw new Error('Unknown item type: ' + item.type); } - value={pos} - > - - {content} - - - ); - })} + + return content; + })} + + ); }, diff --git a/packages/desktop-client/src/components/budget/ExpenseCategory.tsx b/packages/desktop-client/src/components/budget/ExpenseCategory.tsx index 660f54646fd..cef92bc5129 100644 --- a/packages/desktop-client/src/components/budget/ExpenseCategory.tsx +++ b/packages/desktop-client/src/components/budget/ExpenseCategory.tsx @@ -1,18 +1,13 @@ // @ts-strict-ignore import React, { type ComponentProps } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + import { type CategoryEntity } from 'loot-core/src/types/models'; import { theme } from '../../style'; import { View } from '../common/View'; -import { - useDraggable, - useDroppable, - DropHighlight, - type DragState, - type OnDragChangeCallback, - type OnDropCallback, -} from '../sort'; import { Row } from '../table'; import { RenderMonths } from './RenderMonths'; @@ -21,22 +16,18 @@ import { SidebarCategory } from './SidebarCategory'; type ExpenseCategoryProps = { cat: CategoryEntity; editingCell: { id: string; cell: string } | null; - dragState: DragState; MonthComponent: ComponentProps['component']; onEditName?: ComponentProps['onEditName']; onEditMonth?: (id: string, monthIndex: number) => void; onSave?: ComponentProps['onSave']; onDelete?: ComponentProps['onDelete']; - onDragChange: OnDragChangeCallback; onBudgetAction: (idx: number, action: string, arg: unknown) => void; onShowActivity: (name: string, id: string, idx: number) => void; - onReorder: OnDropCallback; }; export function ExpenseCategory({ cat, editingCell, - dragState, MonthComponent, onEditName, onEditMonth, @@ -44,45 +35,39 @@ export function ExpenseCategory({ onDelete, onBudgetAction, onShowActivity, - onDragChange, - onReorder, }: ExpenseCategoryProps) { - let dragging = dragState && dragState.item === cat; - - if (dragState && dragState.item.id === cat.cat_group) { - dragging = true; - } + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: cat.id, disabled: !!editingCell }); - const { dragRef } = useDraggable({ - type: 'category', - onDragChange, - item: cat, - canDrag: editingCell === null, - }); - - const { dropRef, dropPos } = useDroppable({ - types: 'category', - id: cat.id, - onDrop: onReorder, - }); + const dndStyle = { + opacity: isDragging ? 0.5 : undefined, + transform: CSS.Transform.toString(transform), + transition, + }; return ( - - ['group']; collapsed: boolean; editingCell: { id: string; cell: string } | null; - dragState: DragState['group']>; MonthComponent: ComponentProps['component']; onEditName?: ComponentProps['onEdit']; onSave?: ComponentProps['onSave']; onDelete?: ComponentProps['onDelete']; - onDragChange: OnDragChangeCallback< - ComponentProps['group'] - >; - onReorderGroup: OnDropCallback; - onReorderCategory: OnDropCallback; onToggleCollapse?: ComponentProps['onToggleCollapse']; onShowNewCategory?: ComponentProps['onShowNewCategory']; }; @@ -38,88 +27,55 @@ export function ExpenseGroup({ group, collapsed, editingCell, - dragState, MonthComponent, onEditName, onSave, onDelete, - onDragChange, - onReorderGroup, - onReorderCategory, onToggleCollapse, onShowNewCategory, }: ExpenseGroupProps) { - const dragging = dragState && dragState.item === group; - - const { dragRef } = useDraggable({ - type: 'group', - onDragChange, - item: group, - canDrag: editingCell === null, - }); - - const { dropRef, dropPos } = useDroppable({ - types: 'group', - id: group.id, - onDrop: onReorderGroup, - }); + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: group.id, disabled: !!editingCell }); - const { dropRef: catDropRef, dropPos: catDropPos } = useDroppable({ - types: 'category', - id: group.id, - onDrop: onReorderCategory, - onLongHover: () => { - if (collapsed) { - onToggleCollapse(group.id); - } - }, - }); + const dndStyle = { + opacity: isDragging ? 0.5 : undefined, + transform: CSS.Transform.toString(transform), + transition, + }; return ( - {dragState && !dragState.preview && dragState.type === 'group' && ( - - - - )} - - - void; onSave: ComponentProps['onSave']; onDelete: ComponentProps['onDelete']; - onDragChange: OnDragChangeCallback; onBudgetAction: (idx: number, action: string, arg: unknown) => void; - onReorder: OnDropCallback; onShowActivity: (name: string, id: string, idx: number) => void; }; @@ -39,30 +33,31 @@ export function IncomeCategory({ onEditMonth, onSave, onDelete, - onDragChange, onBudgetAction, - onReorder, onShowActivity, }: IncomeCategoryProps) { - const { dragRef } = useDraggable({ - type: 'income-category', - onDragChange, - item: cat, - canDrag: editingCell === null, - }); + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: cat.id, disabled: !!editingCell }); - const { dropRef, dropPos } = useDroppable({ - types: 'income-category', - id: cat.id, - onDrop: onReorder, - }); + const dndStyle = { + opacity: isDragging ? 0.5 : undefined, + transform: CSS.Transform.toString(transform), + transition, + }; return ( - - - + ; + innerRef?: Ref; category: CategoryEntity; dragPreview?: boolean; dragging?: boolean; @@ -39,6 +39,7 @@ export function SidebarCategory({ onSave, onDelete, onHideNewCategory, + ...props }: SidebarCategoryProps) { const temporary = category.id === 'new'; const [menuOpen, setMenuOpen] = useState(false); @@ -151,6 +152,7 @@ export function SidebarCategory({ e.stopPropagation(); } }} + {...props} > ; style?: CSSProperties; onEdit?: (id: string) => void; onSave?: (group: object) => Promise; @@ -49,6 +42,7 @@ export function SidebarGroup({ onShowNewCategory, onHideNewGroup, onToggleCollapse, + ...props }: SidebarGroupProps) { const temporary = group.id === 'new'; const [menuOpen, setMenuOpen] = useState(false); @@ -176,6 +170,7 @@ export function SidebarGroup({ e.stopPropagation(); } }} + {...props} > diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index 5c85d36e42d..67e90569506 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -109,10 +109,17 @@ export function Accounts({ const { active, over } = e; if (active.id !== over.id) { - const dropPos = - active.data.current.sortable.index < over.data.current.sortable.index - ? 'bottom' - : 'top'; + const { top: activeTop, bottom: activeBottom } = + active.rect.current.translated; + const { top: initialTop, bottom: initialBottom } = + active.rect.current.initial; + + const activeCenter = (activeTop + activeBottom) / 2; + const initialCenter = (initialTop + initialBottom) / 2; + + // top - the active item was dragged up + // bottom - the active item was dragged down + const dropPos = activeCenter < initialCenter ? 'top' : 'bottom'; onReorder(active.id, dropPos, over.id); } From 30a7701bdb900ace0dedc24d1147fd39fd30025f Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 15:29:10 -0800 Subject: [PATCH 05/21] Fix typecheck error --- .../src/components/budget/IncomeGroup.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/desktop-client/src/components/budget/IncomeGroup.tsx b/packages/desktop-client/src/components/budget/IncomeGroup.tsx index 6a25adeff4c..503aa911f40 100644 --- a/packages/desktop-client/src/components/budget/IncomeGroup.tsx +++ b/packages/desktop-client/src/components/budget/IncomeGroup.tsx @@ -1,6 +1,8 @@ // @ts-strict-ignore import React from 'react'; +import { type CategoryGroupEntity } from 'loot-core/src/types/models'; + import { theme } from '../../style'; import { Row } from '../table'; @@ -8,15 +10,7 @@ import { RenderMonths } from './RenderMonths'; import { SidebarGroup } from './SidebarGroup'; type IncomeGroupProps = { - group: { - id: string; - hidden: number; - categories: object[]; - is_income: number; - name: string; - sort_order: number; - tombstone: number; - }; + group: CategoryGroupEntity; editingCell: { id: string; cell: string } | null; collapsed: boolean; MonthComponent: () => JSX.Element; From ca8860821836f72c6ebdf0fd606b83a74a8733b1 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 15:49:02 -0800 Subject: [PATCH 06/21] Remove react-dnd --- packages/desktop-client/package.json | 2 - .../src/components/FinancesApp.tsx | 6 +- .../desktop-client/src/components/sort.tsx | 177 ------------------ yarn.lock | 72 +------ 4 files changed, 3 insertions(+), 254 deletions(-) delete mode 100644 packages/desktop-client/src/components/sort.tsx diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 07341e08209..b56f38147aa 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -49,8 +49,6 @@ "memoize-one": "^6.0.0", "pikaday": "1.8.2", "react": "18.2.0", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-error-boundary": "^4.0.11", "react-markdown": "^8.0.7", diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 29c241a14da..3cbd1c51907 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -1,7 +1,5 @@ // @ts-strict-ignore import React, { type ReactElement, useEffect, useMemo } from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend as Backend } from 'react-dnd-html5-backend'; import { Route, Routes, @@ -267,9 +265,7 @@ export function FinancesApp() { - - {app} - + {app} diff --git a/packages/desktop-client/src/components/sort.tsx b/packages/desktop-client/src/components/sort.tsx deleted file mode 100644 index 3a93640bedd..00000000000 --- a/packages/desktop-client/src/components/sort.tsx +++ /dev/null @@ -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 = { - state: 'start-preview' | 'start' | 'end'; - type?: string; - item?: T; - preview?: boolean; -}; - -export type DropPosition = 'top' | 'bottom'; - -export type OnDragChangeCallback = ( - drag: DragState, -) => Promise | void; - -type UseDraggableArgs = { - item?: T; - type: string; - canDrag: boolean; - onDragChange: OnDragChangeCallback; -}; - -export function useDraggable({ - item, - type, - canDrag, - onDragChange, -}: UseDraggableArgs) { - 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; - -type OnLongHoverCallback = () => Promise | void; - -type UseDroppableArgs = { - types: string | string[]; - id: unknown; - onDrop: OnDropCallback; - onLongHover?: OnLongHoverCallback; -}; - -export function useDroppable({ - types, - id, - onDrop, - onLongHover, -}: UseDroppableArgs) { - const ref = useRef(null); - const [dropPos, setDropPos] = useState(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 = - 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 ( - - ); -} diff --git a/yarn.lock b/yarn.lock index 0dcc4390b00..da85724f6cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -101,8 +101,6 @@ __metadata: memoize-one: "npm:^6.0.0" pikaday: "npm:1.8.2" react: "npm:18.2.0" - react-dnd: "npm:^16.0.1" - react-dnd-html5-backend: "npm:^16.0.1" react-dom: "npm:18.2.0" react-error-boundary: "npm:^4.0.11" react-markdown: "npm:^8.0.7" @@ -3040,27 +3038,6 @@ __metadata: languageName: node linkType: hard -"@react-dnd/asap@npm:^5.0.1": - version: 5.0.2 - resolution: "@react-dnd/asap@npm:5.0.2" - checksum: a75039720b89da11bc678c2b61b1d2840c8349023ef2b8f8ca9099e7ece6953e9be704bf393bf799eae83d245f62115eb5302499612c2aa009c1d91caa9462df - languageName: node - linkType: hard - -"@react-dnd/invariant@npm:^4.0.1": - version: 4.0.2 - resolution: "@react-dnd/invariant@npm:4.0.2" - checksum: b638e9643e6e93da03ef463be3c1b92055daadc391fc08e4ce8639ef8c7738f91058ec83ee52a0d0df0d3a6dd2811a7703e1450737708f043c2e909c0a99dd31 - languageName: node - linkType: hard - -"@react-dnd/shallowequal@npm:^4.0.1": - version: 4.0.2 - resolution: "@react-dnd/shallowequal@npm:4.0.2" - checksum: 7f21d691bddbfd4d2830948cbeefecca1600b2b46bcb1934926795f07ae8a1fa60a3dfd3a2112be5ef682c3820c80a99711e9fa15843f7e300acb25a4ecb70ab - languageName: node - linkType: hard - "@react-spring/animated@npm:~9.7.2": version: 9.7.2 resolution: "@react-spring/animated@npm:9.7.2" @@ -7247,17 +7224,6 @@ __metadata: languageName: node linkType: hard -"dnd-core@npm:^16.0.1": - version: 16.0.1 - resolution: "dnd-core@npm:16.0.1" - dependencies: - "@react-dnd/asap": "npm:^5.0.1" - "@react-dnd/invariant": "npm:^4.0.1" - redux: "npm:^4.2.0" - checksum: 711dc30f88f7c5cb5308f105b337f6a4db7ad098e985d2e120189f17a3d1865d283aadef1641dc129706e0399746835a90e2a92ef65f0cdcf5aa0d0cb8c79265 - languageName: node - linkType: hard - "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -9427,7 +9393,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": +"hoist-non-react-statics@npm:^3.3.0": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -13847,40 +13813,6 @@ __metadata: languageName: node linkType: hard -"react-dnd-html5-backend@npm:^16.0.1": - version: 16.0.1 - resolution: "react-dnd-html5-backend@npm:16.0.1" - dependencies: - dnd-core: "npm:^16.0.1" - checksum: fa0feacc01ba8c923fc21461cc5919a856f09384f9a684b4c70ab9cdddc4a6ec64f0de4f65946a8061284ed92c5e3104caca56ae58884235604898a909d82e90 - languageName: node - linkType: hard - -"react-dnd@npm:^16.0.1": - version: 16.0.1 - resolution: "react-dnd@npm:16.0.1" - dependencies: - "@react-dnd/invariant": "npm:^4.0.1" - "@react-dnd/shallowequal": "npm:^4.0.1" - dnd-core: "npm:^16.0.1" - fast-deep-equal: "npm:^3.1.3" - hoist-non-react-statics: "npm:^3.3.2" - peerDependencies: - "@types/hoist-non-react-statics": ">= 3.3.1" - "@types/node": ">= 12" - "@types/react": ">= 16" - react: ">= 16.14" - peerDependenciesMeta: - "@types/hoist-non-react-statics": - optional: true - "@types/node": - optional: true - "@types/react": - optional: true - checksum: e27cf5156c306d183585099854c597266eda014c51e7dfca657f7099d5db0a09a4fe07e4c8cbc3b04ca613b805878a8f97f23cc8e13887dbfb1f05efbe5a12e7 - languageName: node - linkType: hard - "react-dom@npm:18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" @@ -14288,7 +14220,7 @@ __metadata: languageName: node linkType: hard -"redux@npm:^4.0.0, redux@npm:^4.0.5, redux@npm:^4.2.0": +"redux@npm:^4.0.0, redux@npm:^4.0.5": version: 4.2.1 resolution: "redux@npm:4.2.1" dependencies: From 96a9966d6b641f89aaa8c2cd4b5d14b453f02edf Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 15:56:01 -0800 Subject: [PATCH 07/21] Fix types --- packages/desktop-client/src/components/budget/util.ts | 9 ++++----- .../desktop-client/src/components/sidebar/Sidebar.tsx | 3 +-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts index cb7b926b734..783d526b24b 100644 --- a/packages/desktop-client/src/components/budget/util.ts +++ b/packages/desktop-client/src/components/budget/util.ts @@ -7,7 +7,6 @@ import { type CategoryGroupEntity } from 'loot-core/src/types/models'; import { type LocalPrefs } from 'loot-core/src/types/prefs'; import { styles, theme } from '../../style'; -import { type DropPosition } from '../sort'; import { getValidMonthBounds } from './MonthsContext'; @@ -79,8 +78,8 @@ export function makeAmountFullStyle(value: number) { } export function findSortDown( - arr: CategoryGroupEntity[], - pos: DropPosition, + arr: { id: string }[], + pos: 'top' | 'bottom', targetId: string, ) { if (pos === 'top') { @@ -103,8 +102,8 @@ export function findSortDown( } export function findSortUp( - arr: CategoryGroupEntity[], - pos: DropPosition, + arr: { id: string }[], + pos: 'top' | 'bottom', targetId: string, ) { if (pos === 'bottom') { diff --git a/packages/desktop-client/src/components/sidebar/Sidebar.tsx b/packages/desktop-client/src/components/sidebar/Sidebar.tsx index dadd3ba95fc..733c65d18fa 100644 --- a/packages/desktop-client/src/components/sidebar/Sidebar.tsx +++ b/packages/desktop-client/src/components/sidebar/Sidebar.tsx @@ -7,7 +7,6 @@ import { SvgReports, SvgWallet } from '../../icons/v1'; import { SvgCalendar } from '../../icons/v2'; import { type CSSProperties, theme } from '../../style'; import { View } from '../common/View'; -import { type OnDropCallback } from '../sort'; import { type Binding } from '../spreadsheet'; import { Accounts } from './Accounts'; @@ -40,7 +39,7 @@ type SidebarProps = { onFloat: () => void; onAddAccount: () => void; onToggleClosedAccounts: () => void; - onReorder: OnDropCallback; + onReorder: (id: string, dropPos: 'top' | 'bottom', targetId: string) => void; }; export function Sidebar({ From fae68c19f5d7021f91e59d97f146a001ab9c62ee Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 16:05:32 -0800 Subject: [PATCH 08/21] Fix sort bug --- .../src/components/budget/BudgetTable.jsx | 11 +++-------- packages/desktop-client/src/components/budget/util.ts | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/desktop-client/src/components/budget/BudgetTable.jsx b/packages/desktop-client/src/components/budget/BudgetTable.jsx index 3000ebb1af4..587737fcd1b 100644 --- a/packages/desktop-client/src/components/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/budget/BudgetTable.jsx @@ -57,14 +57,9 @@ export class BudgetTable extends Component { }); } } else { - let targetGroup; - - for (const group of categoryGroups) { - if (group.categories.find(cat => cat.id === targetId)) { - targetGroup = group; - break; - } - } + const targetGroup = categoryGroups.find(g => + g.categories.find(c => c.id === targetId), + ); this.props.onReorderCategory({ id, diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts index 783d526b24b..f8d035ae199 100644 --- a/packages/desktop-client/src/components/budget/util.ts +++ b/packages/desktop-client/src/components/budget/util.ts @@ -92,7 +92,7 @@ export function findSortDown( } const newIdx = idx + 1; - if (newIdx < arr.length - 1) { + if (newIdx <= arr.length - 1) { return { targetId: arr[newIdx].id }; } else { // Move to the end From 811f9e43004e3c958cd177d383197e1739adf39f Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 16:06:53 -0800 Subject: [PATCH 09/21] Release notes --- upcoming-release-notes/2239.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 upcoming-release-notes/2239.md diff --git a/upcoming-release-notes/2239.md b/upcoming-release-notes/2239.md new file mode 100644 index 00000000000..56c20c3ce24 --- /dev/null +++ b/upcoming-release-notes/2239.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Switch to dnd-kit drag and drop library. From 04e761e08a200c662f8b9cec2529252acec5ecb9 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 16:07:13 -0800 Subject: [PATCH 10/21] Fix lint error --- packages/desktop-client/src/components/table.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index d1c98ec48f2..1dc71e70704 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -44,8 +44,6 @@ import { useFormat } from './spreadsheet/useFormat'; import { useSheetValue } from './spreadsheet/useSheetValue'; import { Tooltip, IntersectionBoundary } from './tooltips'; -export const ROW_HEIGHT = 32; - function fireBlur(onBlur, e) { if (document.hasFocus()) { // We only fire the blur event if the app is still focused From ed539728170e0af2fb9b516a460f145ed6f8b2f8 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 18 Jan 2024 16:11:21 -0800 Subject: [PATCH 11/21] Revert ROW_HEIGHT --- packages/desktop-client/src/components/table.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 1dc71e70704..dbfbebbcac7 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -44,6 +44,8 @@ import { useFormat } from './spreadsheet/useFormat'; import { useSheetValue } from './spreadsheet/useSheetValue'; import { Tooltip, IntersectionBoundary } from './tooltips'; +const ROW_HEIGHT = 32; + function fireBlur(onBlur, e) { if (document.hasFocus()) { // We only fire the blur event if the app is still focused From 8d8cd631b57d42afd3882275e3ca71e172cda082 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 19 Jan 2024 07:57:50 -0800 Subject: [PATCH 12/21] Check for null over --- .../components/budget/BudgetCategories.jsx | 21 +++-- .../src/components/sidebar/Accounts.tsx | 85 +++++++++++-------- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.jsx b/packages/desktop-client/src/components/budget/BudgetCategories.jsx index a6bb4af4a6d..96a91492986 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.jsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.jsx @@ -167,7 +167,7 @@ export const BudgetCategories = memo( ); const [originalCollapsed, setOriginalCollapsed] = useState(null); - const [collapseOnDrag, setCollapseOnDrag] = useState(null); + const [collapsedOnDragMove, setCollapsedOnDragMove] = useState(null); const onDragStart = e => { const { active } = e; @@ -180,7 +180,7 @@ export const BudgetCategories = memo( .filter(item => item.type === 'expense-group') .map(item => item.value?.id); - setCollapseOnDrag(groupIds); + setCollapsedOnDragMove(groupIds); break; default: break; @@ -189,13 +189,18 @@ export const BudgetCategories = memo( const onDragMove = e => { const { active, over } = e; - // Delay collapsing groups when sorting groups. - if (collapseOnDrag) { - setCollapsed(collapseOnDrag); - setCollapseOnDrag(null); + + if (!over) { + return; + } + + // Delay collapsing groups until user moves the group. + if (collapsedOnDragMove) { + setCollapsed(collapsedOnDragMove); + setCollapsedOnDragMove(null); } - // Expand groups on hover when sorting categories. + // Expand groups on hover when moving around categories. const activeItem = items.find(item => getItemDndId(item) === active.id); if ( activeItem?.type === 'expense-category' && @@ -208,7 +213,7 @@ export const BudgetCategories = memo( const onDragEnd = e => { const { active, over } = e; - if (active.id !== over.id) { + if (over && over.id !== active.id) { const activeItem = items.find(item => getItemDndId(item) === active.id); const { top: activeTop, bottom: activeBottom } = diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index 67e90569506..b72ff32e57a 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -127,27 +127,27 @@ export function Accounts({ return ( + + + {budgetedAccounts.length > 0 && ( + + )} - - - {budgetedAccounts.length > 0 && ( - - )} ))} + - {offbudgetAccounts.length > 0 && ( - - )} - + {offbudgetAccounts.length > 0 && ( + + )} + ))} + - {closedAccounts.length > 0 && ( - - )} - - {showClosedAccounts && ( + {closedAccounts.length > 0 && ( + + )} + + {showClosedAccounts && ( + ))} - )} - + + )} Date: Fri, 19 Jan 2024 08:53:21 -0800 Subject: [PATCH 13/21] Fix accounts sorting --- .../components/budget/BudgetCategories.jsx | 16 ++--- .../src/components/budget/BudgetTable.jsx | 3 +- .../src/components/budget/util.ts | 48 ------------- .../src/components/sidebar/Accounts.tsx | 16 ++--- .../components/sidebar/SidebarWithData.tsx | 11 ++- .../src/components/util/sort.ts | 67 +++++++++++++++++++ 6 files changed, 84 insertions(+), 77 deletions(-) create mode 100644 packages/desktop-client/src/components/util/sort.ts diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.jsx b/packages/desktop-client/src/components/budget/BudgetCategories.jsx index 96a91492986..89eafd7b13a 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.jsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.jsx @@ -19,6 +19,7 @@ import { import { theme, styles } from '../../style'; import { View } from '../common/View'; import { Row } from '../table'; +import { getDropPosition } from '../util/sort'; import { ExpenseCategory } from './ExpenseCategory'; import { ExpenseGroup } from './ExpenseGroup'; @@ -216,17 +217,10 @@ export const BudgetCategories = memo( if (over && over.id !== active.id) { const activeItem = items.find(item => getItemDndId(item) === active.id); - const { top: activeTop, bottom: activeBottom } = - active.rect.current.translated; - const { top: initialTop, bottom: initialBottom } = - active.rect.current.initial; - - const activeCenter = (activeTop + activeBottom) / 2; - const initialCenter = (initialTop + initialBottom) / 2; - - // top - the active item was dragged up - // bottom - the active item was dragged down - const dropPos = activeCenter < initialCenter ? 'top' : 'bottom'; + const dropPos = getDropPosition( + active.rect.current.translated, + active.rect.current.initial, + ); if (activeItem.type === 'expense-group') { onReorderGroup(active.id, dropPos, over.id); diff --git a/packages/desktop-client/src/components/budget/BudgetTable.jsx b/packages/desktop-client/src/components/budget/BudgetTable.jsx index 587737fcd1b..5c647e281d9 100644 --- a/packages/desktop-client/src/components/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/budget/BudgetTable.jsx @@ -5,12 +5,13 @@ import * as monthUtils from 'loot-core/src/shared/months'; import { theme, styles } from '../../style'; import { View } from '../common/View'; import { IntersectionBoundary } from '../tooltips'; +import { findSortDown, findSortUp } from '../util/sort'; import { BudgetCategories } from './BudgetCategories'; import { BudgetSummaries } from './BudgetSummaries'; import { BudgetTotals } from './BudgetTotals'; import { MonthsProvider } from './MonthsContext'; -import { findSortDown, findSortUp, getScrollbarWidth } from './util'; +import { getScrollbarWidth } from './util'; export class BudgetTable extends Component { constructor(props) { diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts index f8d035ae199..96e61fe048c 100644 --- a/packages/desktop-client/src/components/budget/util.ts +++ b/packages/desktop-client/src/components/budget/util.ts @@ -77,54 +77,6 @@ export function makeAmountFullStyle(value: number) { }; } -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 }; - } - } -} - export function getScrollbarWidth() { return Math.max(styles.scrollbarWidth - 2, 0); } diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index b72ff32e57a..d12cd774035 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -22,6 +22,7 @@ import { type AccountEntity } from 'loot-core/src/types/models'; import { SvgAdd } from '../../icons/v1'; import { View } from '../common/View'; import { type Binding } from '../spreadsheet'; +import { getDropPosition } from '../util/sort'; import { Account } from './Account'; import { SecondaryItem } from './SecondaryItem'; @@ -109,17 +110,10 @@ export function Accounts({ const { active, over } = e; if (active.id !== over.id) { - const { top: activeTop, bottom: activeBottom } = - active.rect.current.translated; - const { top: initialTop, bottom: initialBottom } = - active.rect.current.initial; - - const activeCenter = (activeTop + activeBottom) / 2; - const initialCenter = (initialTop + initialBottom) / 2; - - // top - the active item was dragged up - // bottom - the active item was dragged down - const dropPos = activeCenter < initialCenter ? 'top' : 'bottom'; + const dropPos = getDropPosition( + active.rect.current.translated, + active.rect.current.initial, + ); onReorder(active.id, dropPos, over.id); } diff --git a/packages/desktop-client/src/components/sidebar/SidebarWithData.tsx b/packages/desktop-client/src/components/sidebar/SidebarWithData.tsx index 32668b2487c..331818a4a44 100644 --- a/packages/desktop-client/src/components/sidebar/SidebarWithData.tsx +++ b/packages/desktop-client/src/components/sidebar/SidebarWithData.tsx @@ -18,6 +18,7 @@ import { Input } from '../common/Input'; import { Menu } from '../common/Menu'; import { Text } from '../common/Text'; import { Tooltip } from '../tooltips'; +import { findSortDown } from '../util/sort'; import { Sidebar } from './Sidebar'; @@ -129,12 +130,10 @@ export function SidebarWithData() { useEffect(() => void getAccounts(), [getAccounts]); async function onReorder(id, dropPos, targetId) { - if (dropPos === 'bottom') { - const idx = accounts.findIndex(a => a.id === targetId) + 1; - targetId = idx < accounts.length ? accounts[idx].id : null; - } - - await send('account-move', { id, targetId }); + await send('account-move', { + id, + ...findSortDown(accounts, dropPos, targetId), + }); await getAccounts(); } diff --git a/packages/desktop-client/src/components/util/sort.ts b/packages/desktop-client/src/components/util/sort.ts new file mode 100644 index 00000000000..c9d3b1fd566 --- /dev/null +++ b/packages/desktop-client/src/components/util/sort.ts @@ -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'; +} From 779f2a5c13ce56df2df9322a8de77c0c971f9e77 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 19 Jan 2024 10:16:30 -0800 Subject: [PATCH 14/21] Restrict drag to parent element --- .../components/budget/BudgetCategories.jsx | 294 +++++++++--------- .../src/components/sidebar/Accounts.tsx | 136 ++++---- 2 files changed, 223 insertions(+), 207 deletions(-) diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.jsx b/packages/desktop-client/src/components/budget/BudgetCategories.jsx index 89eafd7b13a..37b73600c02 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.jsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.jsx @@ -9,7 +9,7 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { SortableContext, sortableKeyboardCoordinates, @@ -256,81 +256,83 @@ export const BudgetCategories = memo( flex: 1, }} > - - + - {expenseGroupItems.map((item, idx) => { - let content; - switch (item.type) { - case 'new-expense-category': - content = ( - - g.is_income).id, - id: 'new', - }} - editing={true} - onSave={onSaveCategory} - onHideNewCategory={onHideNewCategory} + + {expenseGroupItems.map((item, idx) => { + let content; + switch (item.type) { + case 'new-expense-category': + content = ( + + g.is_income).id, + id: 'new', + }} + editing={true} + onSave={onSaveCategory} + onHideNewCategory={onHideNewCategory} + onEditName={onEditName} + /> + + ); + break; + case 'expense-group': + content = ( + + ); + break; + case 'expense-category': + content = ( + - - ); - break; - case 'expense-group': - content = ( - - ); - break; - case 'expense-category': - content = ( - - ); - break; - default: - throw new Error('Unknown item type: ' + item.type); - } - - return content; - })} - - + ); + break; + default: + throw new Error('Unknown item type: ' + item.type); + } + + return content; + })} + + + {isAddingGroup && ( - - + - {incomeGroupItems.map((item, idx) => { - let content; - switch (item.type) { - case 'new-income-category': - content = ( - - g.is_income).id, - id: 'new', - }} - editing={true} - onSave={onSaveCategory} - onHideNewCategory={onHideNewCategory} + + {incomeGroupItems.map((item, idx) => { + let content; + switch (item.type) { + case 'new-income-category': + content = ( + + g.is_income).id, + id: 'new', + }} + editing={true} + onSave={onSaveCategory} + onHideNewCategory={onHideNewCategory} + onEditName={onEditName} + /> + + ); + break; + case 'income-group': + content = ( + + ); + break; + case 'income-category': + content = ( + - - ); - break; - case 'income-group': - content = ( - - ); - break; - case 'income-category': - content = ( - - ); - break; - default: - throw new Error('Unknown item type: ' + item.type); - } - - return content; - })} - - + ); + break; + default: + throw new Error('Unknown item type: ' + item.type); + } + + return content; + })} + + + ); }, diff --git a/packages/desktop-client/src/components/sidebar/Accounts.tsx b/packages/desktop-client/src/components/sidebar/Accounts.tsx index d12cd774035..56bef30f7ff 100644 --- a/packages/desktop-client/src/components/sidebar/Accounts.tsx +++ b/packages/desktop-client/src/components/sidebar/Accounts.tsx @@ -10,7 +10,10 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + restrictToParentElement, + restrictToVerticalAxis, +} from '@dnd-kit/modifiers'; import { SortableContext, sortableKeyboardCoordinates, @@ -136,31 +139,34 @@ export function Accounts({ style={{ fontWeight, marginTop: 13 }} /> )} - - + - {budgetedAccounts.map((account, i) => ( - - ))} - - - + + {budgetedAccounts.map((account, i) => ( + + ))} + + + {offbudgetAccounts.length > 0 && ( )} - - - {offbudgetAccounts.map((account, i) => ( - - ))} - - - - {closedAccounts.length > 0 && ( - - )} - - {showClosedAccounts && ( + - {closedAccounts.map((account, i) => ( + {offbudgetAccounts.map((account, i) => ( ))} + + + {closedAccounts.length > 0 && ( + + )} + + {showClosedAccounts && ( + + + + {closedAccounts.map((account, i) => ( + + ))} + + + )} Date: Sat, 20 Jan 2024 01:47:52 -0800 Subject: [PATCH 15/21] Remove Group: text when sorting groups + use onDragOver --- .../components/budget/BudgetCategories.jsx | 27 +++++++++---------- .../src/components/budget/SidebarGroup.tsx | 1 - 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.jsx b/packages/desktop-client/src/components/budget/BudgetCategories.jsx index 37b73600c02..813513bc2de 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.jsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.jsx @@ -9,7 +9,10 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + restrictToParentElement, + restrictToVerticalAxis, +} from '@dnd-kit/modifiers'; import { SortableContext, sortableKeyboardCoordinates, @@ -168,7 +171,7 @@ export const BudgetCategories = memo( ); const [originalCollapsed, setOriginalCollapsed] = useState(null); - const [collapsedOnDragMove, setCollapsedOnDragMove] = useState(null); + const [collapsedOnDragOver, setCollapsedOnDragOver] = useState(null); const onDragStart = e => { const { active } = e; @@ -181,24 +184,20 @@ export const BudgetCategories = memo( .filter(item => item.type === 'expense-group') .map(item => item.value?.id); - setCollapsedOnDragMove(groupIds); + setCollapsedOnDragOver(groupIds); break; default: break; } }; - const onDragMove = e => { + const onDragOver = e => { const { active, over } = e; - if (!over) { - return; - } - - // Delay collapsing groups until user moves the group. - if (collapsedOnDragMove) { - setCollapsed(collapsedOnDragMove); - setCollapsedOnDragMove(null); + // Delay collapsing groups until user drags/hovers on another item. + if (collapsedOnDragOver) { + setCollapsed(collapsedOnDragOver); + setCollapsedOnDragOver(null); } // Expand groups on hover when moving around categories. @@ -262,7 +261,7 @@ export const BudgetCategories = memo( collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragStart={onDragStart} - onDragMove={onDragMove} + onDragOver={onDragOver} onDragEnd={onDragEnd} > - {dragPreview && Group: } {group.name}
{!dragPreview && ( From 59168a284ecc9b53e4f72a8735b8c0305a5b86e0 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Sat, 20 Jan 2024 01:50:59 -0800 Subject: [PATCH 16/21] Fix lint --- packages/desktop-client/src/components/budget/SidebarGroup.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/desktop-client/src/components/budget/SidebarGroup.tsx b/packages/desktop-client/src/components/budget/SidebarGroup.tsx index 3d66af7424b..550d2bae5ec 100644 --- a/packages/desktop-client/src/components/budget/SidebarGroup.tsx +++ b/packages/desktop-client/src/components/budget/SidebarGroup.tsx @@ -8,7 +8,6 @@ import { SvgCheveronDown } from '../../icons/v1'; import { theme } from '../../style'; import { Button } from '../common/Button'; import { Menu } from '../common/Menu'; -import { Text } from '../common/Text'; import { View } from '../common/View'; import { NotesButton } from '../NotesButton'; import { InputCell } from '../table'; From 140f564e2efbcc918cb178503c2bd75d69f0415d Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Sat, 20 Jan 2024 13:25:10 -0800 Subject: [PATCH 17/21] Delay uncollapsed when sorting groups --- .../desktop-client/src/components/budget/BudgetCategories.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/desktop-client/src/components/budget/BudgetCategories.jsx b/packages/desktop-client/src/components/budget/BudgetCategories.jsx index 813513bc2de..5c3a61ba1ec 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategories.jsx +++ b/packages/desktop-client/src/components/budget/BudgetCategories.jsx @@ -230,8 +230,7 @@ export const BudgetCategories = memo( onReorderCategory(active.id, dropPos, over.id); } } - - setCollapsed(originalCollapsed); + setTimeout(() => setCollapsed(originalCollapsed), 100); }; const expenseGroupIds = useMemo( From 2aa94b5d8996f4ba50d8640a39ab1572770f1a34 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Sat, 20 Jan 2024 15:56:26 -0800 Subject: [PATCH 18/21] Sortable mobile accounts --- .../components/accounts/MobileAccounts.jsx | 158 +++++++++++++++--- .../src/components/sidebar/Account.tsx | 1 - 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/packages/desktop-client/src/components/accounts/MobileAccounts.jsx b/packages/desktop-client/src/components/accounts/MobileAccounts.jsx index 158dbec52fa..731466ca6eb 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccounts.jsx +++ b/packages/desktop-client/src/components/accounts/MobileAccounts.jsx @@ -1,7 +1,27 @@ import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; +import { + DndContext, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + restrictToParentElement, + restrictToVerticalAxis, +} from '@dnd-kit/modifiers'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + import * as queries from 'loot-core/src/client/queries'; +import { send } from 'loot-core/src/platform/client/fetch'; import { useActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; @@ -16,6 +36,7 @@ import { View } from '../common/View'; import { Page } from '../Page'; import { PullToRefresh } from '../responsive/PullToRefresh'; import { CellValue } from '../spreadsheet/CellValue'; +import { findSortDown, getDropPosition } from '../util/sort'; function AccountHeader({ name, amount, style = {} }) { return ( @@ -52,8 +73,26 @@ function AccountHeader({ name, amount, style = {} }) { } function AccountCard({ account, updated, getBalanceQuery, onSelect }) { + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: account.id }); + + const dndStyle = { + opacity: isDragging ? 0.5 : undefined, + transform: CSS.Transform.toString(transform), + transition, + }; + return (