From 925efc4cb6e93c131906b0768fd82a7206a4003a Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 15 Apr 2025 14:13:44 -0700 Subject: [PATCH] [Mobile] Drag and drop expense category groups to re-order (#4599) * Update to GridList * VRT - minimal diff between 2 rows * Implement a hidden drag button * Revert VRT * VRT * [Mobile] Drag and drop income categories to re-order * Update drag preview * Release notes * Fix drag preview * Fix typecheck errors * Fix group header margins * Coderabbit suggestion * Fix group * Yarn lint fix --- .../components/mobile/budget/BudgetTable.jsx | 34 ++-- .../mobile/budget/ExpenseCategoryList.tsx | 11 +- .../mobile/budget/ExpenseCategoryListItem.tsx | 1 + .../mobile/budget/ExpenseGroupList.tsx | 172 ++++++++++++++++++ ...enseGroup.tsx => ExpenseGroupListItem.tsx} | 118 +++++++----- .../components/mobile/budget/IncomeGroup.tsx | 24 ++- .../src/components/mobile/budget/ListItem.tsx | 36 ---- .../src/client/queries/queriesSlice.ts | 2 +- upcoming-release-notes/4599.md | 6 + 9 files changed, 288 insertions(+), 116 deletions(-) create mode 100644 packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx rename packages/desktop-client/src/components/mobile/budget/{ExpenseGroup.tsx => ExpenseGroupListItem.tsx} (79%) delete mode 100644 packages/desktop-client/src/components/mobile/budget/ListItem.tsx create mode 100644 upcoming-release-notes/4599.md diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index dc90725b6d..69e04db62d 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -57,7 +57,7 @@ import { useSheetValue } from '../../spreadsheet/useSheetValue'; import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs'; import { PullToRefresh } from '../PullToRefresh'; -import { ExpenseGroup } from './ExpenseGroup'; +import { ExpenseGroupList } from './ExpenseGroupList'; import { IncomeGroup } from './IncomeGroup'; export const ROW_HEIGHT = 50; @@ -282,26 +282,18 @@ function BudgetGroups({ data-testid="budget-groups" style={{ flex: '1 0 auto', overflowY: 'auto', paddingBottom: 15 }} > - {expenseGroups - .filter(group => !group.hidden || showHiddenCategories) - .map(group => { - return ( - - ); - })} + {incomeGroup && ( boolean; month: string; @@ -23,6 +27,7 @@ type ExpenseCategoryListProps = { }; export function ExpenseCategoryList({ + group, categories, month, onEditCategory, @@ -115,7 +120,9 @@ export function ExpenseCategoryList({ return ( void; + onEditCategory: (id: CategoryEntity['id']) => void; + onBudgetAction: (month: string, action: string, args: unknown) => void; + showHiddenCategories: boolean; + isCollapsed: (id: CategoryGroupEntity['id']) => boolean; + onToggleCollapse: (id: CategoryGroupEntity['id']) => void; +}; + +export function ExpenseGroupList({ + groups, + show3Columns, + showBudgetedColumn, + month, + onEditGroup, + onEditCategory, + onBudgetAction, + showHiddenCategories, + isCollapsed, + onToggleCollapse, +}: ExpenseGroupListProps) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const { dragAndDropHooks } = useDragAndDrop({ + getItems: keys => + [...keys].map( + key => + ({ + 'text/plain': key as CategoryEntity['id'], + }) as DragItem, + ), + renderDropIndicator: target => { + return ( + + ); + }, + renderDragPreview: items => { + const draggedGroupId = items[0]['text/plain']; + const group = groups.find(c => c.id === draggedGroupId); + if (!group) { + throw new Error( + `Internal error: category group with ID ${draggedGroupId} not found.`, + ); + } + return ( + {}} + isCollapsed={() => true} + onToggleCollapse={() => {}} + /> + ); + }, + onReorder: e => { + const [key] = e.keys; + const groupIdToMove = key as CategoryGroupEntity['id']; + const groupToMove = groups.find(c => c.id === groupIdToMove); + + if (!groupToMove) { + throw new Error( + `Internal error: category group with ID ${groupIdToMove} not found.`, + ); + } + + const targetGroupId = e.target.key as CategoryEntity['id']; + + if (e.target.dropPosition === 'before') { + dispatch( + moveCategoryGroup({ + id: groupToMove.id, + targetId: targetGroupId, + }), + ); + } else if (e.target.dropPosition === 'after') { + const targetGroupIndex = groups.findIndex(c => c.id === targetGroupId); + + if (targetGroupIndex === -1) { + throw new Error( + `Internal error: category group with ID ${targetGroupId} not found.`, + ); + } + + const nextToTargetCategory = groups[targetGroupIndex + 1]; + + dispatch( + moveCategoryGroup({ + id: groupToMove.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, + }), + ); + } + }, + }); + + return ( + + {group => ( + + )} + + ); +} diff --git a/packages/desktop-client/src/components/mobile/budget/ExpenseGroup.tsx b/packages/desktop-client/src/components/mobile/budget/ExpenseGroupListItem.tsx similarity index 79% rename from packages/desktop-client/src/components/mobile/budget/ExpenseGroup.tsx rename to packages/desktop-client/src/components/mobile/budget/ExpenseGroupListItem.tsx index 988df91a80..f8bfd58db6 100644 --- a/packages/desktop-client/src/components/mobile/budget/ExpenseGroup.tsx +++ b/packages/desktop-client/src/components/mobile/budget/ExpenseGroupListItem.tsx @@ -1,4 +1,5 @@ -import { useCallback, useMemo } from 'react'; +import { type ComponentPropsWithoutRef, useCallback, useMemo } from 'react'; +import { GridListItem } from 'react-aria-components'; import { Button } from '@actual-app/components/button'; import { Card } from '@actual-app/components/card'; @@ -23,12 +24,12 @@ import { PrivacyFilter } from '../../PrivacyFilter'; import { CellValue } from '../../spreadsheet/CellValue'; import { useFormat } from '../../spreadsheet/useFormat'; -import { getColumnWidth } from './BudgetTable'; +import { getColumnWidth, ROW_HEIGHT } from './BudgetTable'; import { ExpenseCategoryList } from './ExpenseCategoryList'; -import { ListItem } from './ListItem'; -type ExpenseGroupProps = { - group: CategoryGroupEntity; +type ExpenseGroupListItemProps = ComponentPropsWithoutRef< + typeof GridListItem +> & { month: string; showHiddenCategories: boolean; onEditGroup: (id: CategoryGroupEntity['id']) => void; @@ -40,8 +41,7 @@ type ExpenseGroupProps = { show3Columns: boolean; }; -export function ExpenseGroup({ - group, +export function ExpenseGroupListItem({ onEditGroup, onEditCategory, month, @@ -51,51 +51,61 @@ export function ExpenseGroup({ showHiddenCategories, isCollapsed, onToggleCollapse, -}: ExpenseGroupProps) { + ...props +}: ExpenseGroupListItemProps) { + const { value: group } = props; + const categories = useMemo( () => - isCollapsed(group.id) + !group || isCollapsed(group.id) ? [] : (group.categories?.filter( category => !category.hidden || showHiddenCategories, ) ?? []), - [group.categories, group.id, isCollapsed, showHiddenCategories], + [group, isCollapsed, showHiddenCategories], ); const shouldHideCategory = useCallback( (category: CategoryEntity) => { - return !!(category.hidden || group.hidden); + return !!(category.hidden || group?.hidden); }, - [group.hidden], + [group?.hidden], ); - return ( - - + if (!group) { + return null; + } - - + return ( + + + + + + + ); } @@ -103,13 +113,13 @@ type ExpenseGroupHeaderProps = { group: CategoryGroupEntity; month: string; onEdit: (id: CategoryGroupEntity['id']) => void; - isCollapsed: boolean; + isCollapsed: (id: CategoryGroupEntity['id']) => boolean; onToggleCollapse: (id: CategoryGroupEntity['id']) => void; show3Columns: boolean; showBudgetedColumn: boolean; }; -function ExpenseGroupHeader({ +export function ExpenseGroupHeader({ group, month, onEdit, @@ -119,13 +129,17 @@ function ExpenseGroupHeader({ onToggleCollapse, }: ExpenseGroupHeaderProps) { return ( - - + ); } type ExpenseGroupNameProps = { group: CategoryGroupEntity; onEdit: (id: CategoryGroupEntity['id']) => void; - isCollapsed: boolean; + isCollapsed: (id: CategoryGroupEntity['id']) => boolean; onToggleCollapse: (id: CategoryGroupEntity['id']) => void; show3Columns: boolean; }; @@ -177,6 +191,17 @@ function ExpenseGroupName({ width: sidebarColumnWidth, }} > + {/* Hidden drag button */} + diff --git a/packages/desktop-client/src/components/mobile/budget/IncomeGroup.tsx b/packages/desktop-client/src/components/mobile/budget/IncomeGroup.tsx index 73784a8a83..eb7f4dc11f 100644 --- a/packages/desktop-client/src/components/mobile/budget/IncomeGroup.tsx +++ b/packages/desktop-client/src/components/mobile/budget/IncomeGroup.tsx @@ -22,9 +22,8 @@ import { PrivacyFilter } from '../../PrivacyFilter'; import { CellValue } from '../../spreadsheet/CellValue'; import { useFormat } from '../../spreadsheet/useFormat'; -import { getColumnWidth } from './BudgetTable'; +import { getColumnWidth, ROW_HEIGHT } from './BudgetTable'; import { IncomeCategoryList } from './IncomeCategoryList'; -import { ListItem } from './ListItem'; type IncomeGroupProps = { group: CategoryGroupEntity; @@ -84,7 +83,7 @@ export function IncomeGroup({ group={group} month={month} onEdit={onEditGroup} - isCollapsed={isCollapsed(group.id)} + isCollapsed={isCollapsed} onToggleCollapse={onToggleCollapse} /> void; - isCollapsed: boolean; + isCollapsed: (id: CategoryGroupEntity['id']) => boolean; onToggleCollapse: (id: CategoryGroupEntity['id']) => void; style?: CSSProperties; }; @@ -116,13 +115,17 @@ function IncomeGroupHeader({ style, }: IncomeGroupHeaderProps) { return ( - - + ); } type IncomeGroupNameProps = { group: CategoryGroupEntity; onEdit: (id: CategoryGroupEntity['id']) => void; - isCollapsed: boolean; + isCollapsed: (id: CategoryGroupEntity['id']) => boolean; onToggleCollapse: (id: CategoryGroupEntity['id']) => void; }; @@ -175,6 +178,7 @@ function IncomeGroupName({ '&[data-pressed]': { backgroundColor: 'transparent', }, + marginLeft: -5, })} onPress={() => onToggleCollapse(group.id)} > @@ -184,7 +188,7 @@ function IncomeGroupName({ style={{ flexShrink: 0, transition: 'transform .1s', - transform: isCollapsed ? 'rotate(-90deg)' : '', + transform: isCollapsed(group.id) ? 'rotate(-90deg)' : '', }} /> diff --git a/packages/desktop-client/src/components/mobile/budget/ListItem.tsx b/packages/desktop-client/src/components/mobile/budget/ListItem.tsx deleted file mode 100644 index b6aeceec56..0000000000 --- a/packages/desktop-client/src/components/mobile/budget/ListItem.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { - type ComponentProps, - type ReactNode, - type CSSProperties, -} from 'react'; - -import { theme } from '@actual-app/components/theme'; -import { View } from '@actual-app/components/view'; - -const ROW_HEIGHT = 50; - -type ListItemProps = ComponentProps & { - children?: ReactNode; - style: CSSProperties; -}; - -export const ListItem = ({ children, style, ...props }: ListItemProps) => { - return ( - - {children} - - ); -}; diff --git a/packages/loot-core/src/client/queries/queriesSlice.ts b/packages/loot-core/src/client/queries/queriesSlice.ts index 85d1bfcd28..1a3720ab36 100644 --- a/packages/loot-core/src/client/queries/queriesSlice.ts +++ b/packages/loot-core/src/client/queries/queriesSlice.ts @@ -358,7 +358,7 @@ export const moveCategory = createAppAsyncThunk( type MoveCategoryGroupPayload = { id: CategoryGroupEntity['id']; - targetId: CategoryGroupEntity['id']; + targetId: CategoryGroupEntity['id'] | null; }; export const moveCategoryGroup = createAppAsyncThunk( diff --git a/upcoming-release-notes/4599.md b/upcoming-release-notes/4599.md new file mode 100644 index 0000000000..015116a708 --- /dev/null +++ b/upcoming-release-notes/4599.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +[Mobile] Drag and drop to reorder expense category groups in budget page (only supports for Chromium-based browsers for now).