From 49ed0ab628e3d581316fe58a99fa64a7c700f1f0 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 12 Jan 2026 13:45:58 -0800 Subject: [PATCH] Drag and drop --- .../components/budget/BudgetCategoriesV2.tsx | 164 +++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) diff --git a/packages/desktop-client/src/components/budget/BudgetCategoriesV2.tsx b/packages/desktop-client/src/components/budget/BudgetCategoriesV2.tsx index 8fac369bf5..b6bf7ed713 100644 --- a/packages/desktop-client/src/components/budget/BudgetCategoriesV2.tsx +++ b/packages/desktop-client/src/components/budget/BudgetCategoriesV2.tsx @@ -5,6 +5,7 @@ import React, { type ComponentPropsWithoutRef, useCallback, } from 'react'; +import { type DragItem } from 'react-aria'; import { Column, Table, @@ -15,6 +16,8 @@ import { ResizableTableContainer, ColumnResizer, DialogTrigger, + useDragAndDrop, + DropIndicator, } from 'react-aria-components'; import { Trans, useTranslation } from 'react-i18next'; @@ -42,6 +45,10 @@ import { IncomeCategoryRow } from './IncomeCategoryRow'; import { MonthsContext } from './MonthsContext'; import { separateGroups } from './util'; +import { + moveCategory, + moveCategoryGroup, +} from '@desktop-client/budget/budgetSlice'; import { CellValue, CellValueText, @@ -51,6 +58,7 @@ import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { SheetNameProvider } from '@desktop-client/hooks/useSheetName'; import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref'; +import { useDispatch } from '@desktop-client/redux'; import { type SheetNames } from '@desktop-client/spreadsheet'; import { envelopeBudget, @@ -361,6 +369,8 @@ export function BudgetCategories({ [months], ); + const { dragAndDropHooks } = useBudgetCategoriesDragAndDrop(); + return ( ); } + +function useBudgetCategoriesDragAndDrop() { + const { grouped: categoryGroups, list: categories } = useCategories(); + const dispatch = useDispatch(); + return useDragAndDrop({ + getItems: keys => + [...keys].map( + key => + ({ + 'text/plain': key as string, + }) as DragItem, + ), + renderDropIndicator: target => { + return ( + + ); + }, + onReorder: e => { + const [key] = e.keys; + const itemId = key as string; + const isCategoryGroup = itemId.startsWith('expense-group'); + + const targetItemId = e.target.key as string; + + if (isCategoryGroup) { + const categoryGroupId = itemId.replace('expense-group-', ''); + const categoryGroupToMove = categoryGroups.find( + c => c.id === categoryGroupId, + ); + + if (!categoryGroupToMove) { + throw new Error( + `Internal error: category group with ID ${categoryGroupId} not found.`, + ); + } + + if (!targetItemId.startsWith('expense-group')) { + // Cannot drop category group on category + return; + } + + const targetCategoryGroupId = targetItemId.replace( + 'expense-group-', + '', + ); + + if (e.target.dropPosition === 'before') { + dispatch( + moveCategoryGroup({ + id: categoryGroupToMove.id, + targetId: targetCategoryGroupId, + }), + ); + } else if (e.target.dropPosition === 'after') { + const targetGroupIndex = categoryGroups.findIndex( + c => c.id === targetCategoryGroupId, + ); + + if (targetGroupIndex === -1) { + throw new Error( + `Internal error: category group with ID ${targetCategoryGroupId} not found.`, + ); + } + + const nextToTargetCategory = categoryGroups[targetGroupIndex + 1]; + + dispatch( + moveCategoryGroup({ + id: categoryGroupToMove.id, + // Due to the way `moveCategory` works, we use the category next to the + // actual target category here because `moveCategory` always shoves the + // category *before* the target category. + // On the other hand, using `null` as `targetId` moves the category + // to the end of the list. + targetId: nextToTargetCategory?.id || null, + }), + ); + } + } else { + const categoryId = itemId.replace('expense-category-', ''); + const categoryToMove = categories.find(c => c.id === categoryId); + + if (!categoryToMove) { + throw new Error( + `Internal error: category with ID ${categoryId} not found.`, + ); + } + + if (!categoryToMove.group) { + throw new Error( + `Internal error: category ${categoryId} is not in a group and cannot be moved.`, + ); + } + + if (!targetItemId.startsWith('expense-category')) { + // Cannot drop category on category group + return; + } + + const targetCategoryId = targetItemId.replace('expense-category-', ''); + const targetCategoryGroupId = categories.find( + c => c.id === targetCategoryId, + )?.group; + + if (e.target.dropPosition === 'before') { + dispatch( + moveCategory({ + id: categoryToMove.id, + groupId: targetCategoryGroupId, + targetId: targetCategoryId, + }), + ); + } else if (e.target.dropPosition === 'after') { + const targetCategoryIndex = categories.findIndex( + c => c.id === targetCategoryId, + ); + + if (targetCategoryIndex === -1) { + throw new Error( + `Internal error: category with ID ${targetCategoryId} not found.`, + ); + } + + const nextToTargetCategory = categories[targetCategoryIndex + 1]; + + dispatch( + moveCategory({ + id: categoryToMove.id, + groupId: targetCategoryGroupId, + // Due to the way `moveCategory` works, we use the category next to the + // actual target category here because `moveCategory` always shoves the + // category *before* the target category. + // On the other hand, using `null` as `targetId` moves the category + // to the end of the list. + targetId: nextToTargetCategory?.id || null, + }), + ); + } + } + }, + }); +}