Compare commits

...

4 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
efaee98d2a Update error message 2026-01-12 15:29:58 -08:00
Joel Jeremy Marquez
0b156d1815 Add onInsert logic to useDragAndDrop 2026-01-12 15:00:45 -08:00
autofix-ci[bot]
5e9f38ea45 [autofix.ci] apply automated fixes 2026-01-12 21:54:47 +00:00
Joel Jeremy Marquez
b901e7a6bd [Mobile] Fix drag and drop across category groups 2026-01-12 13:53:34 -08:00
3 changed files with 172 additions and 102 deletions

View File

@@ -1,4 +1,4 @@
import { type DragItem } from 'react-aria';
import { isTextDropItem, type DragItem } from 'react-aria';
import { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
@@ -13,8 +13,11 @@ import {
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
import { moveCategory } from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDispatch } from '@desktop-client/redux';
const DRAG_TYPE = 'mobile-expense-category-list/category-id';
type ExpenseCategoryListProps = {
categoryGroup: CategoryGroupEntity;
categories: CategoryEntity[];
@@ -37,14 +40,14 @@ export function ExpenseCategoryList({
shouldHideCategory,
}: ExpenseCategoryListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const { reorderCategory } = useReorderCategory();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
[...keys].map(
key =>
({
'text/plain': key as CategoryEntity['id'],
[DRAG_TYPE]: key as CategoryEntity['id'],
}) as DragItem,
),
renderDropIndicator: target => {
@@ -54,7 +57,7 @@ export function ExpenseCategoryList({
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
backgroundColor: theme.tableBorderHover,
opacity: 1,
borderRadius: 4,
},
@@ -62,59 +65,25 @@ export function ExpenseCategoryList({
/>
);
},
acceptedDragTypes: [DRAG_TYPE],
getDropOperation: () => 'move',
onInsert: async e => {
const [id] = await Promise.all(
e.items.filter(isTextDropItem).map(item => item.getText(DRAG_TYPE)),
);
reorderCategory({
id: id as CategoryEntity['id'],
targetId: e.target.key as CategoryEntity['id'],
dropPosition: e.target.dropPosition,
});
},
onReorder: e => {
const [key] = e.keys;
const categoryIdToMove = key as CategoryEntity['id'];
const categoryToMove = categories.find(c => c.id === categoryIdToMove);
if (!categoryToMove) {
throw new Error(
`Internal error: category with ID ${categoryIdToMove} not found.`,
);
}
if (!categoryToMove.group) {
throw new Error(
`Internal error: category ${categoryIdToMove} is not in a group and cannot be moved.`,
);
}
const targetCategoryId = e.target.key as CategoryEntity['id'];
if (e.target.dropPosition === 'before') {
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: categoryToMove.group,
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: categoryToMove.group,
// 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,
}),
);
}
reorderCategory({
id: key as CategoryEntity['id'],
targetId: e.target.key as CategoryEntity['id'],
dropPosition: e.target.dropPosition,
});
},
});
@@ -149,3 +118,76 @@ export function ExpenseCategoryList({
</GridList>
);
}
function useReorderCategory() {
const dispatch = useDispatch();
const { list: categories } = useCategories();
const reorderCategory = ({
id,
targetId,
dropPosition,
}: {
id: CategoryEntity['id'];
targetId: CategoryEntity['id'];
dropPosition: 'on' | 'before' | 'after';
}) => {
const categoryToMove = categories.find(c => c.id === id);
if (!categoryToMove) {
throw new Error(`Internal error: category with ID ${id} not found.`);
}
if (!categoryToMove.group) {
throw new Error(
`Internal error: Failed to move category ${id} because it is not in a group.`,
);
}
const targetCategoryGroupId = categories.find(
c => c.id === targetId,
)?.group;
if (!targetCategoryGroupId) {
throw new Error(
`Internal error: Failed to move category ${id} because target category ${targetId} is not in a group.`,
);
}
if (dropPosition === 'before') {
dispatch(
moveCategory({
id: categoryToMove.id,
groupId: targetCategoryGroupId,
targetId,
}),
);
} else if (dropPosition === 'after') {
const targetCategoryIndex = categories.findIndex(c => c.id === targetId);
if (targetCategoryIndex === -1) {
throw new Error(
`Internal error: category with ID ${targetId} 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,
}),
);
}
};
return {
reorderCategory,
};
}

View File

@@ -16,8 +16,11 @@ import {
} from './ExpenseGroupListItem';
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDispatch } from '@desktop-client/redux';
const DRAG_TYPE = 'mobile-expense-group-list/category-group-id';
type ExpenseGroupListProps = {
categoryGroups: CategoryGroupEntity[];
show3Columns: boolean;
@@ -44,14 +47,14 @@ export function ExpenseGroupList({
onToggleCollapse,
}: ExpenseGroupListProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const { reorderCategoryGroup } = useReorderCategoryGroup();
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
[...keys].map(
key =>
({
'text/plain': key as CategoryEntity['id'],
[DRAG_TYPE]: key as CategoryGroupEntity['id'],
}) as DragItem,
),
renderDropIndicator: target => {
@@ -61,7 +64,7 @@ export function ExpenseGroupList({
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
backgroundColor: theme.tableBorderHover,
opacity: 1,
borderRadius: 4,
},
@@ -70,7 +73,7 @@ export function ExpenseGroupList({
);
},
renderDragPreview: items => {
const draggedGroupId = items[0]['text/plain'];
const draggedGroupId = items[0][DRAG_TYPE];
const group = categoryGroups.find(c => c.id === draggedGroupId);
if (!group) {
throw new Error(
@@ -92,49 +95,11 @@ export function ExpenseGroupList({
},
onReorder: e => {
const [key] = e.keys;
const groupIdToMove = key as CategoryGroupEntity['id'];
const groupToMove = categoryGroups.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 = categoryGroups.findIndex(
c => c.id === targetGroupId,
);
if (targetGroupIndex === -1) {
throw new Error(
`Internal error: category group with ID ${targetGroupId} not found.`,
);
}
const nextToTargetCategory = categoryGroups[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,
}),
);
}
reorderCategoryGroup({
id: key as CategoryGroupEntity['id'],
targetId: e.target.key as CategoryGroupEntity['id'],
dropPosition: e.target.dropPosition,
});
},
});
@@ -174,3 +139,60 @@ export function ExpenseGroupList({
</GridList>
);
}
function useReorderCategoryGroup() {
const dispatch = useDispatch();
const { list: categoryGroups } = useCategories();
const reorderCategoryGroup = ({
id,
targetId,
dropPosition,
}: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'];
dropPosition: 'on' | 'before' | 'after';
}) => {
const groupToMove = categoryGroups.find(c => c.id === id);
if (!groupToMove) {
throw new Error(
`Internal error: category group with ID ${id} not found.`,
);
}
if (dropPosition === 'before') {
dispatch(
moveCategoryGroup({
id: groupToMove.id,
targetId,
}),
);
} else if (dropPosition === 'after') {
const targetGroupIndex = categoryGroups.findIndex(c => c.id === targetId);
if (targetGroupIndex === -1) {
throw new Error(
`Internal error: category group with ID ${targetId} not found.`,
);
}
const nextToTargetCategory = categoryGroups[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 {
reorderCategoryGroup,
};
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [joel-jeremy]
---
[Mobile] Fix drag and drop across category groups