[Mobile] Drag and drop income categories to re-order (#4482)

* [Mobile] Drag and drop income categories to re-order

* Fix category group ID

* Update borders

* Use list item value prop

* Cleanup

* useCallback

* Remvoe usage of useListData

* Fix typecheck error

* Release notes

* Fix import

* Update DropIndicator

* Update DropIndicator

* Fix drop indicator
This commit is contained in:
Joel Jeremy Marquez
2025-03-10 08:14:33 -07:00
committed by GitHub
parent af068d1f89
commit e5c2ef47ac
9 changed files with 863 additions and 535 deletions

View File

@@ -13,9 +13,11 @@ import {
import { useTranslation, Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAdd, SvgCheveronRight } from '@actual-app/components/icons/v1';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { TextOneLine } from '@actual-app/components/text-one-line';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
@@ -29,9 +31,7 @@ import { useAccounts } from '../../../hooks/useAccounts';
import { useFailedAccounts } from '../../../hooks/useFailedAccounts';
import { useNavigate } from '../../../hooks/useNavigate';
import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { SvgAdd, SvgCheveronRight } from '../../../icons/v1';
import { useDispatch, useSelector } from '../../../redux';
import { theme } from '../../../style';
import { makeAmountFullStyle } from '../../budget/util';
import { MobilePageHeader, Page } from '../../Page';
import { type Binding, type SheetFields } from '../../spreadsheet';
@@ -343,14 +343,14 @@ function AccountList({
return (
<DropIndicator
target={target}
style={{
backgroundColor: theme.tableBorderSeparator,
position: 'absolute',
left: 2,
right: 2,
borderRadius: 3,
height: 3,
}}
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
opacity: 1,
borderRadius: 4,
},
})}
/>
);
},

View File

@@ -0,0 +1,172 @@
import { useCallback, type ComponentPropsWithoutRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { type CSSProperties } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { AutoTextSize } from 'auto-text-size';
import { pushModal } from 'loot-core/client/modals/modalsSlice';
import { integerToCurrency } from 'loot-core/shared/util';
import { type CategoryEntity } from 'loot-core/types/models';
import { useNotes } from '../../../hooks/useNotes';
import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { useUndo } from '../../../hooks/useUndo';
import { useDispatch } from '../../../redux';
import { makeAmountGrey } from '../../budget/util';
import { PrivacyFilter } from '../../PrivacyFilter';
import { type SheetFields } from '../../spreadsheet';
import { CellValue } from '../../spreadsheet/CellValue';
import { useFormat } from '../../spreadsheet/useFormat';
import { getColumnWidth, PILL_STYLE } from './BudgetTable';
type BudgetCellProps<
SheetFieldName extends SheetFields<'envelope-budget' | 'tracking-budget'>,
> = ComponentPropsWithoutRef<
typeof CellValue<'envelope-budget' | 'tracking-budget', SheetFieldName>
> & {
category: CategoryEntity;
style?: CSSProperties;
month: string;
onBudgetAction: (month: string, action: string, args: unknown) => void;
};
export function BudgetCell<
SheetFieldName extends SheetFields<'envelope-budget' | 'tracking-budget'>,
>({
binding,
category,
month,
onBudgetAction,
style,
children,
...props
}: BudgetCellProps<SheetFieldName>) {
const { t } = useTranslation();
const columnWidth = getColumnWidth();
const dispatch = useDispatch();
const format = useFormat();
const { showUndoNotification } = useUndo();
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const categoryNotes = useNotes(category.id);
const onOpenCategoryBudgetMenu = useCallback(() => {
const modalBudgetType = budgetType === 'rollover' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
dispatch(
pushModal({
modal: {
name: categoryBudgetMenuModal,
options: {
categoryId: category.id,
month,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${integerToCurrency(amount)}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set last to months budgeted amount.`,
});
},
onSetMonthsAverage: numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
},
},
},
}),
);
}, [
budgetType,
category.id,
category.name,
categoryNotes,
dispatch,
month,
onBudgetAction,
showUndoNotification,
]);
return (
<CellValue
binding={binding}
type="financial"
aria-label={t('Budgeted amount for {{categoryName}} category', {
categoryName: category.name,
})}
{...props}
>
{({ type, name, value }) =>
children?.({
type,
name,
value,
}) || (
<Button
variant="bare"
style={{
...PILL_STYLE,
maxWidth: columnWidth,
...makeAmountGrey(value),
}}
onPress={onOpenCategoryBudgetMenu}
aria-label={t('Open budget menu for {{categoryName}} category', {
categoryName: category.name,
})}
>
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
maxWidth: columnWidth,
textAlign: 'right',
fontSize: 12,
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
</Button>
)
}
</CellValue>
);
}

View File

@@ -25,7 +25,6 @@ import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
import { useLocale } from '../../../hooks/useLocale';
import { useLocalPref } from '../../../hooks/useLocalPref';
import { useNavigate } from '../../../hooks/useNavigate';
import { useNotes } from '../../../hooks/useNotes';
import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { useUndo } from '../../../hooks/useUndo';
import { SvgLogo } from '../../../icons/logo';
@@ -51,15 +50,21 @@ import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
import { PullToRefresh } from '../PullToRefresh';
import { BudgetCell } from './BudgetCell';
import { IncomeGroup } from './IncomeGroup';
import { ListItem } from './ListItem';
const PILL_STYLE = {
export const PILL_STYLE = {
borderRadius: 16,
color: theme.pillText,
backgroundColor: theme.pillBackgroundLight,
};
function getColumnWidth({ show3Cols, isSidebar = false, offset = 0 } = {}) {
export function getColumnWidth({
show3Cols,
isSidebar = false,
offset = 0,
} = {}) {
// If show3Cols = 35vw | 20vw | 20vw | 20vw,
// Else = 45vw | 25vw | 25vw,
if (!isSidebar) {
@@ -220,136 +225,6 @@ function Saved({ projected, onPress, show3Cols }) {
);
}
function BudgetCell({
name,
binding,
style,
category,
month,
onBudgetAction,
children,
...props
}) {
const { t } = useTranslation();
const columnWidth = getColumnWidth();
const dispatch = useDispatch();
const format = useFormat();
const { showUndoNotification } = useUndo();
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const modalBudgetType = budgetType === 'rollover' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu`;
const categoryNotes = useNotes(category.id);
const onOpenCategoryBudgetMenu = () => {
dispatch(
pushModal({
modal: {
name: categoryBudgetMenuModal,
options: {
categoryId: category.id,
month,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${integerToCurrency(amount)}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set last to months budgeted amount.`,
});
},
onSetMonthsAverage: numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes,
});
},
},
},
}),
);
};
return (
<CellValue
binding={binding}
type="financial"
aria-label={t('Budgeted amount for {{categoryName}} category', {
categoryName: category.name,
})}
{...props}
>
{({ type, name, value }) =>
children?.({
type,
name,
value,
onPress: onOpenCategoryBudgetMenu,
}) || (
<Button
variant="bare"
style={{
...PILL_STYLE,
maxWidth: columnWidth,
...makeAmountGrey(value),
}}
onPress={onOpenCategoryBudgetMenu}
aria-label={t('Open budget menu for {{categoryName}} category', {
categoryName: category.name,
})}
>
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
maxWidth: columnWidth,
textAlign: 'right',
fontSize: 12,
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
</Button>
)
}
</CellValue>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function ExpenseGroupPreview({ group, pending, style }) {
return (
@@ -1020,294 +895,6 @@ const ExpenseGroupHeader = memo(function ExpenseGroupHeader({
// </Droppable>
});
const IncomeGroupHeader = memo(function IncomeGroupHeader({
group,
budgeted,
balance,
onEdit,
collapsed,
onToggleCollapse,
style,
}) {
const listItemRef = useRef();
const format = useFormat();
const sidebarColumnWidth = getColumnWidth({ isSidebar: true, offset: -13.5 });
const columnWidth = getColumnWidth();
return (
<ListItem
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
opacity: !!group.hidden ? 0.5 : undefined,
paddingLeft: 0,
...style,
}}
innerRef={listItemRef}
data-testid="category-group-row"
>
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-start',
width: sidebarColumnWidth,
}}
>
<Button
variant="bare"
className={css({
flexShrink: 0,
color: theme.pageTextSubdued,
'&[data-pressed]': {
backgroundColor: 'transparent',
},
})}
onPress={() => onToggleCollapse?.(group.id)}
>
<SvgExpandArrow
width={8}
height={8}
style={{
flexShrink: 0,
transition: 'transform .1s',
transform: collapsed ? 'rotate(-90deg)' : '',
}}
/>
</Button>
<Button
variant="bare"
style={{
maxWidth: sidebarColumnWidth,
}}
onPress={() => onEdit?.(group.id)}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<Text
style={{
...styles.lineClamp(2),
width: sidebarColumnWidth,
textAlign: 'left',
...styles.smallText,
}}
data-testid="category-group-name"
>
{group.name}
</Text>
<SvgCheveronRight
style={{ flexShrink: 0, color: theme.tableTextSubdued }}
width={14}
height={14}
/>
</View>
</Button>
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
paddingRight: 5,
}}
>
{budgeted && (
<CellValue binding={budgeted} type="financial">
{({ type, value }) => (
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
paddingLeft: 5,
textAlign: 'right',
fontSize: 12,
fontWeight: '500',
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
)}
</CellValue>
)}
<CellValue binding={balance} type="financial">
{({ type, value }) => (
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
paddingLeft: 5,
textAlign: 'right',
fontSize: 12,
fontWeight: '500',
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
)}
</CellValue>
</View>
</ListItem>
);
});
const IncomeCategory = memo(function IncomeCategory({
index,
category,
budgeted,
balance,
month,
style,
onEdit,
onBudgetAction,
}) {
const { t } = useTranslation();
const listItemRef = useRef();
const format = useFormat();
const sidebarColumnWidth = getColumnWidth({ isSidebar: true, offset: -10 });
const columnWidth = getColumnWidth();
return (
<ListItem
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'transparent',
borderBottomWidth: 0,
borderTopWidth: index > 0 ? 1 : 0,
opacity: !!category.hidden ? 0.5 : undefined,
...style,
}}
data-testid="category-row"
innerRef={listItemRef}
>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'flex-start',
width: sidebarColumnWidth,
}}
>
<Button
variant="bare"
style={{
maxWidth: sidebarColumnWidth,
}}
onPress={() => onEdit?.(category.id)}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<Text
style={{
...styles.lineClamp(2),
width: sidebarColumnWidth,
textAlign: 'left',
...styles.smallText,
}}
data-testid="category-name"
>
{category.name}
</Text>
<SvgCheveronRight
style={{ flexShrink: 0, color: theme.tableTextSubdued }}
width={14}
height={14}
/>
</View>
</Button>
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
{budgeted && (
<View
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
}}
>
<BudgetCell
binding={budgeted}
type="financial"
category={category}
month={month}
onBudgetAction={onBudgetAction}
/>
</View>
)}
<CellValue
binding={balance}
type="financial"
aria-label={t('Balance for {{categoryName}} category', {
categoryName: category.name,
})} // Translated aria-label
>
{({ type, value }) => (
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
textAlign: 'right',
fontSize: 12,
paddingRight: 5,
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
)}
</CellValue>
</View>
</ListItem>
);
});
const ExpenseGroup = memo(function ExpenseGroup({
type,
group,
@@ -1456,101 +1043,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
);
});
function IncomeGroup({
type,
group,
month,
onAddCategory,
showHiddenCategories,
editMode,
onEditGroup,
onEditCategory,
onBudgetAction,
collapsed,
onToggleCollapse,
}) {
const { t } = useTranslation();
const columnWidth = getColumnWidth();
return (
<View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 50,
marginBottom: 5,
marginRight: 15,
}}
>
{type === 'report' && (
<Label title={t('Budgeted')} style={{ width: columnWidth }} />
)}
<Label title={t('Received')} style={{ width: columnWidth }} />
</View>
<Card style={{ marginTop: 0 }}>
<IncomeGroupHeader
group={group}
budgeted={
type === 'report' ? trackingBudget.groupBudgeted(group.id) : null
}
balance={
type === 'report'
? trackingBudget.groupSumAmount(group.id)
: envelopeBudget.groupSumAmount(group.id)
}
onAddCategory={onAddCategory}
editMode={editMode}
onEdit={onEditGroup}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
style={{
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetHeaderCurrentMonth
: theme.budgetHeaderOtherMonth,
}}
/>
{group.categories
.filter(
category =>
!collapsed && (!category.hidden || showHiddenCategories),
)
.map((category, index) => {
return (
<IncomeCategory
key={category.id}
index={index}
category={category}
month={month}
type={type}
budgeted={
type === 'report'
? trackingBudget.catBudgeted(category.id)
: null
}
balance={
type === 'report'
? trackingBudget.catSumAmount(category.id)
: envelopeBudget.catSumAmount(category.id)
}
style={{
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetCurrentMonth
: theme.budgetOtherMonth,
}}
editMode={editMode}
onEdit={onEditCategory}
onBudgetAction={onBudgetAction}
/>
);
})}
</Card>
</View>
);
}
function UncategorizedButton() {
const count = useSheetValue(uncategorizedCount());
if (count === null || count <= 0) {
@@ -1655,18 +1147,13 @@ function BudgetGroups({
{incomeGroup && (
<IncomeGroup
type={type}
group={incomeGroup}
month={month}
onAddCategory={onAddCategory}
onSaveCategory={onSaveCategory}
onDeleteCategory={onDeleteCategory}
showHiddenCategories={showHiddenCategories}
editMode={editMode}
onEditGroup={onEditGroup}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
collapsed={collapsedGroupIds.includes(incomeGroup.id)}
isCollapsed={collapsedGroupIds.includes(incomeGroup.id)}
onToggleCollapse={onToggleCollapse}
/>
)}

View File

@@ -0,0 +1,343 @@
import { type ComponentPropsWithoutRef, useRef } from 'react';
import { type DragItem } from 'react-aria';
import {
DropIndicator,
ListBox,
ListBoxItem,
useDragAndDrop,
} from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCheveronRight } from '@actual-app/components/icons/v1';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { AutoTextSize } from 'auto-text-size';
import { envelopeBudget, trackingBudget } from 'loot-core/client/queries';
import { moveCategory } from 'loot-core/client/queries/queriesSlice';
import * as monthUtils from 'loot-core/shared/months';
import { type CategoryEntity } from 'loot-core/types/models';
import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { useDispatch } from '../../../redux';
import { PrivacyFilter } from '../../PrivacyFilter';
import { CellValue } from '../../spreadsheet/CellValue';
import { useFormat } from '../../spreadsheet/useFormat';
import { BudgetCell } from './BudgetCell';
import { getColumnWidth } from './BudgetTable';
type IncomeCategoryListProps = {
categories: CategoryEntity[];
month: string;
onEditCategory: (id: string) => void;
onBudgetAction: (month: string, action: string, args: unknown) => void;
};
export function IncomeCategoryList({
categories,
month,
onEditCategory,
onBudgetAction,
}: IncomeCategoryListProps) {
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,
},
})}
/>
);
},
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.cat_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.cat_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.cat_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,
}),
);
}
},
});
return (
<ListBox
aria-label={t('Income categories')}
items={categories}
dragAndDropHooks={dragAndDropHooks}
>
{category => (
<IncomeCategoryListItem
key={category.id}
value={category}
month={month}
onEdit={onEditCategory}
onBudgetAction={onBudgetAction}
/>
)}
</ListBox>
);
}
type IncomeCategoryNameProps = {
category: CategoryEntity;
onEdit: (id: CategoryEntity['id']) => void;
};
function IncomeCategoryName({ category, onEdit }: IncomeCategoryNameProps) {
const sidebarColumnWidth = getColumnWidth({
show3Cols: false,
isSidebar: true,
offset: -10,
});
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'flex-start',
width: sidebarColumnWidth,
}}
>
<Button
variant="bare"
style={{
maxWidth: sidebarColumnWidth,
}}
onPress={() => onEdit?.(category.id)}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<Text
style={{
...styles.lineClamp(2),
width: sidebarColumnWidth,
textAlign: 'left',
...styles.smallText,
}}
data-testid="category-name"
>
{category.name}
</Text>
<SvgCheveronRight
style={{ flexShrink: 0, color: theme.tableTextSubdued }}
width={14}
height={14}
/>
</View>
</Button>
</View>
);
}
type IncomeCategoryCellsProps = {
category: CategoryEntity;
month: string;
onBudgetAction: (month: string, action: string, args: unknown) => void;
};
function IncomeCategoryCells({
category,
month,
onBudgetAction,
}: IncomeCategoryCellsProps) {
const { t } = useTranslation();
const format = useFormat();
const columnWidth = getColumnWidth();
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const budgeted =
budgetType === 'report' ? trackingBudget.catBudgeted(category.id) : null;
const balance =
budgetType === 'report'
? trackingBudget.catSumAmount(category.id)
: envelopeBudget.catSumAmount(category.id);
return (
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
{budgeted && (
<View
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
}}
>
<BudgetCell
binding={budgeted}
type="financial"
category={category}
month={month}
onBudgetAction={onBudgetAction}
/>
</View>
)}
<CellValue<'envelope-budget' | 'tracking-budget', 'sum-amount'>
binding={balance}
type="financial"
aria-label={t('Balance for {{categoryName}} category', {
categoryName: category.name,
})} // Translated aria-label
>
{({ type, value }) => (
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
textAlign: 'right',
fontSize: 12,
paddingRight: 5,
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
)}
</CellValue>
</View>
);
}
type IncomeCategoryListItemProps = ComponentPropsWithoutRef<
typeof ListBoxItem<CategoryEntity>
> & {
month: string;
onEdit: (id: CategoryEntity['id']) => void;
onBudgetAction: (month: string, action: string, args: unknown) => void;
};
function IncomeCategoryListItem({
month,
onEdit,
onBudgetAction,
...props
}: IncomeCategoryListItemProps) {
const listItemRef = useRef<HTMLDivElement | null>(null);
const { value: category } = props;
if (!category) {
return null;
}
return (
<ListBoxItem
textValue={category.name}
data-testid="category-row"
{...props}
>
<View
style={{
height: 50,
borderColor: theme.tableBorder,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 5,
paddingRight: 5,
zIndex: 1,
justifyContent: 'space-between',
borderBottomWidth: 0.5,
borderTopWidth: 0.5,
opacity: !!category.hidden ? 0.5 : undefined,
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetCurrentMonth
: theme.budgetOtherMonth,
}}
innerRef={listItemRef}
>
<IncomeCategoryName category={category} onEdit={onEdit} />
<IncomeCategoryCells
category={category}
month={month}
onBudgetAction={onBudgetAction}
/>
</View>
</ListBoxItem>
);
}

View File

@@ -0,0 +1,320 @@
import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { Card } from '@actual-app/components/card';
import { Label } from '@actual-app/components/label';
import { type CSSProperties, styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { AutoTextSize } from 'auto-text-size';
import { envelopeBudget, trackingBudget } from 'loot-core/client/queries';
import * as monthUtils from 'loot-core/shared/months';
import { type CategoryGroupEntity } from 'loot-core/types/models';
import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { SvgExpandArrow } from '../../../icons/v0';
import { SvgCheveronRight } from '../../../icons/v1';
import { PrivacyFilter } from '../../PrivacyFilter';
import { CellValue } from '../../spreadsheet/CellValue';
import { useFormat } from '../../spreadsheet/useFormat';
import { getColumnWidth } from './BudgetTable';
import { IncomeCategoryList } from './IncomeCategoryList';
import { ListItem } from './ListItem';
type IncomeGroupProps = {
group: CategoryGroupEntity;
month: string;
showHiddenCategories: boolean;
onEditGroup: (id: CategoryGroupEntity['id']) => void;
onEditCategory: (id: string) => void;
onBudgetAction: (month: string, action: string, args: unknown) => void;
isCollapsed: boolean;
onToggleCollapse: (id: CategoryGroupEntity['id']) => void;
};
export function IncomeGroup({
group,
month,
showHiddenCategories,
onEditGroup,
onEditCategory,
onBudgetAction,
isCollapsed,
onToggleCollapse,
}: IncomeGroupProps) {
const { t } = useTranslation();
const columnWidth = getColumnWidth();
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const categories = useMemo(
() =>
isCollapsed
? []
: (group.categories?.filter(
category => !category.hidden || showHiddenCategories,
) ?? []),
[group.categories, isCollapsed, showHiddenCategories],
);
return (
<View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 50,
marginBottom: 5,
marginRight: 15,
}}
>
{budgetType === 'report' && (
<Label title={t('Budgeted')} style={{ width: columnWidth }} />
)}
<Label title={t('Received')} style={{ width: columnWidth }} />
</View>
<Card style={{ marginTop: 0 }}>
<IncomeGroupHeader
group={group}
month={month}
onEdit={onEditGroup}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
<IncomeCategoryList
categories={categories}
month={month}
onEditCategory={onEditCategory}
onBudgetAction={onBudgetAction}
/>
</Card>
</View>
);
}
type IncomeGroupHeaderProps = {
group: CategoryGroupEntity;
month: string;
onEdit: (id: CategoryGroupEntity['id']) => void;
isCollapsed: boolean;
onToggleCollapse: (id: CategoryGroupEntity['id']) => void;
style?: CSSProperties;
};
function IncomeGroupHeader({
group,
month,
onEdit,
isCollapsed,
onToggleCollapse,
style,
}: IncomeGroupHeaderProps) {
const listItemRef = useRef<HTMLDivElement | null>(null);
return (
<ListItem
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
opacity: !!group.hidden ? 0.5 : undefined,
paddingLeft: 0,
backgroundColor: monthUtils.isCurrentMonth(month)
? theme.budgetHeaderCurrentMonth
: theme.budgetHeaderOtherMonth,
...style,
}}
innerRef={listItemRef}
data-testid="category-group-row"
>
<IncomeGroupName
group={group}
onEdit={onEdit}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
<IncomeGroupCells group={group} />
</ListItem>
);
}
type IncomeGroupNameProps = {
group: CategoryGroupEntity;
onEdit?: (id: CategoryGroupEntity['id']) => void;
isCollapsed: boolean;
onToggleCollapse?: (id: CategoryGroupEntity['id']) => void;
};
function IncomeGroupName({
group,
onEdit,
isCollapsed,
onToggleCollapse,
}: IncomeGroupNameProps) {
const sidebarColumnWidth = getColumnWidth({
show3Cols: false,
isSidebar: true,
offset: -13.5,
});
return (
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-start',
width: sidebarColumnWidth,
}}
>
<Button
variant="bare"
className={css({
flexShrink: 0,
color: theme.pageTextSubdued,
'&[data-pressed]': {
backgroundColor: 'transparent',
},
})}
onPress={() => onToggleCollapse?.(group.id)}
>
<SvgExpandArrow
width={8}
height={8}
style={{
flexShrink: 0,
transition: 'transform .1s',
transform: isCollapsed ? 'rotate(-90deg)' : '',
}}
/>
</Button>
<Button
variant="bare"
style={{
maxWidth: sidebarColumnWidth,
}}
onPress={() => onEdit?.(group.id)}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<Text
style={{
...styles.lineClamp(2),
width: sidebarColumnWidth,
textAlign: 'left',
...styles.smallText,
}}
data-testid="category-group-name"
>
{group.name}
</Text>
<SvgCheveronRight
style={{ flexShrink: 0, color: theme.tableTextSubdued }}
width={14}
height={14}
/>
</View>
</Button>
</View>
);
}
type IncomeGroupCellsProps = {
group: CategoryGroupEntity;
};
function IncomeGroupCells({ group }: IncomeGroupCellsProps) {
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
const format = useFormat();
const budgeted =
budgetType === 'report' ? trackingBudget.groupBudgeted(group.id) : null;
const balance =
budgetType === 'report'
? trackingBudget.groupSumAmount(group.id)
: envelopeBudget.groupSumAmount(group.id);
const columnWidth = getColumnWidth();
return (
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
paddingRight: 5,
}}
>
{budgeted && (
<CellValue<'envelope-budget' | 'tracking-budget', 'group-budget'>
binding={budgeted}
type="financial"
>
{({ type, value }) => (
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
paddingLeft: 5,
textAlign: 'right',
fontSize: 12,
fontWeight: '500',
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
)}
</CellValue>
)}
<CellValue<'envelope-budget' | 'tracking-budget', 'group-sum-amount'>
binding={balance}
type="financial"
>
{({ type, value }) => (
<View>
<PrivacyFilter>
<AutoTextSize
key={value}
as={Text}
minFontSizePx={6}
maxFontSizePx={12}
mode="oneline"
style={{
width: columnWidth,
justifyContent: 'center',
alignItems: 'flex-end',
paddingLeft: 5,
textAlign: 'right',
fontSize: 12,
fontWeight: '500',
}}
>
{format(value, type)}
</AutoTextSize>
</PrivacyFilter>
</View>
)}
</CellValue>
</View>
);
}

View File

@@ -339,7 +339,7 @@ export const deleteCategory = createAppAsyncThunk(
type MoveCategoryPayload = {
id: CategoryEntity['id'];
groupId: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'];
targetId: CategoryEntity['id'] | null;
};
export const moveCategory = createAppAsyncThunk(

View File

@@ -463,7 +463,7 @@ export function updateCategory(category) {
export async function moveCategory(
id: DbCategory['id'],
groupId: DbCategoryGroup['id'],
targetId?: DbCategory['id'],
targetId: DbCategory['id'] | null,
) {
if (!groupId) {
throw new Error('moveCategory: groupId is required');

View File

@@ -319,7 +319,7 @@ describe('Sync projections', () => {
groupId = await db.insertCategoryGroup({ id: 'group1', name: 'group1' });
await db.insertCategoryGroup({ id: 'group2', name: 'group2' });
fooId = await db.insertCategory({ name: 'foo', cat_group: 'group1' });
await db.moveCategory(fooId, 'group2');
await db.moveCategory(fooId, 'group2', null);
});
await sheet.loadSpreadsheet(db);

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
[Mobile] Drag and drop to income categories in budget page (only supports for Chromium-based browsers for now)