[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
This commit is contained in:
Joel Jeremy Marquez
2025-04-15 14:13:44 -07:00
committed by GitHub
parent 31fe766a2b
commit 925efc4cb6
9 changed files with 288 additions and 116 deletions

View File

@@ -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 (
<ExpenseGroup
// Re-render when columns are toggled.
key={`${group.id}|${show3Columns}|${showBudgetedColumn}`}
group={group}
showBudgetedColumn={showBudgetedColumn}
month={month}
onEditGroup={onEditGroup}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
show3Columns={show3Columns}
showHiddenCategories={showHiddenCategories}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
);
})}
<ExpenseGroupList
groups={expenseGroups}
showBudgetedColumn={showBudgetedColumn}
month={month}
onEditGroup={onEditGroup}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
show3Columns={show3Columns}
showHiddenCategories={showHiddenCategories}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
{incomeGroup && (
<IncomeGroup

View File

@@ -6,13 +6,17 @@ import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';
import { moveCategory } from 'loot-core/client/queries/queriesSlice';
import { type CategoryEntity } from 'loot-core/types/models';
import {
type CategoryGroupEntity,
type CategoryEntity,
} from 'loot-core/types/models';
import { useDispatch } from '../../../redux';
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
type ExpenseCategoryListProps = {
group: CategoryGroupEntity;
categories: CategoryEntity[];
shouldHideCategory: (category: CategoryEntity) => boolean;
month: string;
@@ -23,6 +27,7 @@ type ExpenseCategoryListProps = {
};
export function ExpenseCategoryList({
group,
categories,
month,
onEditCategory,
@@ -115,7 +120,9 @@ export function ExpenseCategoryList({
return (
<GridList
aria-label={t('Expense categories')}
aria-label={t('{{groupName}} expense group categories', {
groupName: group.name,
})}
items={categories}
dragAndDropHooks={dragAndDropHooks}
dependencies={[

View File

@@ -551,6 +551,7 @@ export function ExpenseCategoryListItem({
show3Columns={show3Columns}
/>
<ExpenseCategoryCells
key={`${category.id}-${show3Columns}-${showBudgetedColumn}`}
category={category}
month={month}
onBudgetAction={onBudgetAction}

View File

@@ -0,0 +1,172 @@
import { type DragItem } from 'react-aria';
import { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';
import { moveCategoryGroup } from 'loot-core/client/queries/queriesSlice';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { useDispatch } from '../../../redux';
import {
ExpenseGroupHeader,
ExpenseGroupListItem,
} from './ExpenseGroupListItem';
type ExpenseGroupListProps = {
groups: CategoryGroupEntity[];
show3Columns: boolean;
showBudgetedColumn: boolean;
month: string;
onEditGroup: (id: CategoryGroupEntity['id']) => 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 (
<DropIndicator
target={target}
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
opacity: 1,
borderRadius: 4,
},
})}
/>
);
},
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 (
<ExpenseGroupHeader
group={group}
month={month}
showBudgetedColumn={showBudgetedColumn}
show3Columns={show3Columns}
onEdit={() => {}}
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 (
<GridList
aria-label={t('Expense category groups')}
items={groups}
dependencies={[
month,
onEditGroup,
onEditCategory,
onBudgetAction,
show3Columns,
showBudgetedColumn,
showHiddenCategories,
isCollapsed,
onToggleCollapse,
]}
dragAndDropHooks={dragAndDropHooks}
>
{group => (
<ExpenseGroupListItem
key={group.id}
value={group}
month={month}
onEditGroup={onEditGroup}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
showBudgetedColumn={showBudgetedColumn}
show3Columns={show3Columns}
showHiddenCategories={showHiddenCategories}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
)}
</GridList>
);
}

View File

@@ -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<CategoryGroupEntity>
> & {
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 (
<Card
style={{
marginTop: 4,
marginBottom: 4,
}}
>
<ExpenseGroupHeader
group={group}
month={month}
showBudgetedColumn={showBudgetedColumn}
show3Columns={show3Columns}
onEdit={onEditGroup}
isCollapsed={isCollapsed(group.id)}
onToggleCollapse={onToggleCollapse}
/>
if (!group) {
return null;
}
<ExpenseCategoryList
categories={categories}
month={month}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
shouldHideCategory={shouldHideCategory}
show3Columns={show3Columns}
showBudgetedColumn={showBudgetedColumn}
/>
</Card>
return (
<GridListItem textValue={group.name} {...props}>
<Card
style={{
marginTop: 4,
marginBottom: 4,
}}
>
<ExpenseGroupHeader
group={group}
month={month}
showBudgetedColumn={showBudgetedColumn}
show3Columns={show3Columns}
onEdit={onEditGroup}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
<ExpenseCategoryList
group={group}
categories={categories}
month={month}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
shouldHideCategory={shouldHideCategory}
show3Columns={show3Columns}
showBudgetedColumn={showBudgetedColumn}
/>
</Card>
</GridListItem>
);
}
@@ -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 (
<ListItem
<View
style={{
height: ROW_HEIGHT,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: 5,
paddingRight: 5,
opacity: !!group.hidden ? 0.5 : undefined,
paddingLeft: 0,
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetHeaderCurrentMonth
: theme.budgetHeaderOtherMonth,
@@ -144,14 +158,14 @@ function ExpenseGroupHeader({
show3Columns={show3Columns}
showBudgetedColumn={showBudgetedColumn}
/>
</ListItem>
</View>
);
}
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 */}
<Button
slot="drag"
style={{
opacity: 0,
width: 1,
height: 1,
position: 'absolute',
overflow: 'hidden',
}}
/>
<Button
variant="bare"
className={css({
@@ -185,6 +210,7 @@ function ExpenseGroupName({
'&[data-pressed]': {
backgroundColor: 'transparent',
},
marginLeft: -5,
})}
onPress={() => onToggleCollapse(group.id)}
>
@@ -194,7 +220,7 @@ function ExpenseGroupName({
style={{
flexShrink: 0,
transition: 'transform .1s',
transform: isCollapsed ? 'rotate(-90deg)' : '',
transform: isCollapsed(group.id) ? 'rotate(-90deg)' : '',
}}
/>
</Button>

View File

@@ -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}
/>
<IncomeCategoryList
@@ -102,7 +101,7 @@ type IncomeGroupHeaderProps = {
group: CategoryGroupEntity;
month: string;
onEdit: (id: CategoryGroupEntity['id']) => void;
isCollapsed: boolean;
isCollapsed: (id: CategoryGroupEntity['id']) => boolean;
onToggleCollapse: (id: CategoryGroupEntity['id']) => void;
style?: CSSProperties;
};
@@ -116,13 +115,17 @@ function IncomeGroupHeader({
style,
}: IncomeGroupHeaderProps) {
return (
<ListItem
<View
style={{
height: ROW_HEIGHT,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: 5,
paddingRight: 5,
opacity: !!group.hidden ? 0.5 : undefined,
paddingLeft: 0,
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetHeaderCurrentMonth
: theme.budgetHeaderOtherMonth,
@@ -137,14 +140,14 @@ function IncomeGroupHeader({
onToggleCollapse={onToggleCollapse}
/>
<IncomeGroupCells group={group} />
</ListItem>
</View>
);
}
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)' : '',
}}
/>
</Button>

View File

@@ -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<typeof View> & {
children?: ReactNode;
style: CSSProperties;
};
export const ListItem = ({ children, style, ...props }: ListItemProps) => {
return (
<View
style={{
height: ROW_HEIGHT,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 5,
paddingRight: 5,
zIndex: 1,
...style,
}}
{...props}
>
{children}
</View>
);
};

View File

@@ -358,7 +358,7 @@ export const moveCategory = createAppAsyncThunk(
type MoveCategoryGroupPayload = {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
};
export const moveCategoryGroup = createAppAsyncThunk(

View File

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