mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 18:20:24 -05:00
Compare commits
4 Commits
fix-exhaus
...
mobile-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efaee98d2a | ||
|
|
0b156d1815 | ||
|
|
5e9f38ea45 | ||
|
|
b901e7a6bd |
@@ -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 { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
|
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
|
||||||
|
|
||||||
import { moveCategory } from '@desktop-client/budget/budgetSlice';
|
import { moveCategory } from '@desktop-client/budget/budgetSlice';
|
||||||
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
import { useDispatch } from '@desktop-client/redux';
|
import { useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
|
const DRAG_TYPE = 'mobile-expense-category-list/category-id';
|
||||||
|
|
||||||
type ExpenseCategoryListProps = {
|
type ExpenseCategoryListProps = {
|
||||||
categoryGroup: CategoryGroupEntity;
|
categoryGroup: CategoryGroupEntity;
|
||||||
categories: CategoryEntity[];
|
categories: CategoryEntity[];
|
||||||
@@ -37,14 +40,14 @@ export function ExpenseCategoryList({
|
|||||||
shouldHideCategory,
|
shouldHideCategory,
|
||||||
}: ExpenseCategoryListProps) {
|
}: ExpenseCategoryListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const { reorderCategory } = useReorderCategory();
|
||||||
|
|
||||||
const { dragAndDropHooks } = useDragAndDrop({
|
const { dragAndDropHooks } = useDragAndDrop({
|
||||||
getItems: keys =>
|
getItems: keys =>
|
||||||
[...keys].map(
|
[...keys].map(
|
||||||
key =>
|
key =>
|
||||||
({
|
({
|
||||||
'text/plain': key as CategoryEntity['id'],
|
[DRAG_TYPE]: key as CategoryEntity['id'],
|
||||||
}) as DragItem,
|
}) as DragItem,
|
||||||
),
|
),
|
||||||
renderDropIndicator: target => {
|
renderDropIndicator: target => {
|
||||||
@@ -54,7 +57,7 @@ export function ExpenseCategoryList({
|
|||||||
className={css({
|
className={css({
|
||||||
'&[data-drop-target]': {
|
'&[data-drop-target]': {
|
||||||
height: 4,
|
height: 4,
|
||||||
backgroundColor: theme.tableBorderSeparator,
|
backgroundColor: theme.tableBorderHover,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
borderRadius: 4,
|
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 => {
|
onReorder: e => {
|
||||||
const [key] = e.keys;
|
const [key] = e.keys;
|
||||||
const categoryIdToMove = key as CategoryEntity['id'];
|
reorderCategory({
|
||||||
const categoryToMove = categories.find(c => c.id === categoryIdToMove);
|
id: key as CategoryEntity['id'],
|
||||||
|
targetId: e.target.key as CategoryEntity['id'],
|
||||||
if (!categoryToMove) {
|
dropPosition: e.target.dropPosition,
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,3 +118,76 @@ export function ExpenseCategoryList({
|
|||||||
</GridList>
|
</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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import {
|
|||||||
} from './ExpenseGroupListItem';
|
} from './ExpenseGroupListItem';
|
||||||
|
|
||||||
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
|
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
|
||||||
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
import { useDispatch } from '@desktop-client/redux';
|
import { useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
|
const DRAG_TYPE = 'mobile-expense-group-list/category-group-id';
|
||||||
|
|
||||||
type ExpenseGroupListProps = {
|
type ExpenseGroupListProps = {
|
||||||
categoryGroups: CategoryGroupEntity[];
|
categoryGroups: CategoryGroupEntity[];
|
||||||
show3Columns: boolean;
|
show3Columns: boolean;
|
||||||
@@ -44,14 +47,14 @@ export function ExpenseGroupList({
|
|||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: ExpenseGroupListProps) {
|
}: ExpenseGroupListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
|
const { reorderCategoryGroup } = useReorderCategoryGroup();
|
||||||
const { dragAndDropHooks } = useDragAndDrop({
|
const { dragAndDropHooks } = useDragAndDrop({
|
||||||
getItems: keys =>
|
getItems: keys =>
|
||||||
[...keys].map(
|
[...keys].map(
|
||||||
key =>
|
key =>
|
||||||
({
|
({
|
||||||
'text/plain': key as CategoryEntity['id'],
|
[DRAG_TYPE]: key as CategoryGroupEntity['id'],
|
||||||
}) as DragItem,
|
}) as DragItem,
|
||||||
),
|
),
|
||||||
renderDropIndicator: target => {
|
renderDropIndicator: target => {
|
||||||
@@ -61,7 +64,7 @@ export function ExpenseGroupList({
|
|||||||
className={css({
|
className={css({
|
||||||
'&[data-drop-target]': {
|
'&[data-drop-target]': {
|
||||||
height: 4,
|
height: 4,
|
||||||
backgroundColor: theme.tableBorderSeparator,
|
backgroundColor: theme.tableBorderHover,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
@@ -70,7 +73,7 @@ export function ExpenseGroupList({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
renderDragPreview: items => {
|
renderDragPreview: items => {
|
||||||
const draggedGroupId = items[0]['text/plain'];
|
const draggedGroupId = items[0][DRAG_TYPE];
|
||||||
const group = categoryGroups.find(c => c.id === draggedGroupId);
|
const group = categoryGroups.find(c => c.id === draggedGroupId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -92,49 +95,11 @@ export function ExpenseGroupList({
|
|||||||
},
|
},
|
||||||
onReorder: e => {
|
onReorder: e => {
|
||||||
const [key] = e.keys;
|
const [key] = e.keys;
|
||||||
const groupIdToMove = key as CategoryGroupEntity['id'];
|
reorderCategoryGroup({
|
||||||
const groupToMove = categoryGroups.find(c => c.id === groupIdToMove);
|
id: key as CategoryGroupEntity['id'],
|
||||||
|
targetId: e.target.key as CategoryGroupEntity['id'],
|
||||||
if (!groupToMove) {
|
dropPosition: e.target.dropPosition,
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,3 +139,60 @@ export function ExpenseGroupList({
|
|||||||
</GridList>
|
</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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
6
upcoming-release-notes/6634.md
Normal file
6
upcoming-release-notes/6634.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Bugfix
|
||||||
|
authors: [joel-jeremy]
|
||||||
|
---
|
||||||
|
|
||||||
|
[Mobile] Fix drag and drop across category groups
|
||||||
Reference in New Issue
Block a user